mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
perf(eventstore): read projection events per event type (#12753)
# Which Problems Are Solved - A projection that subscribes to several event types gets slower the further it falls behind. - The handler reads its events with one query: `aggregate_type = ANY(...) AND event_type = ANY(...)`, ordered by position and limited to the bulk limit. - The `es_projection` index returns each (aggregate type, event type) range in position order, but Postgres cannot merge those ranges. - So each batch reads every matching event after the projection's position, sorts them all, and keeps only the bulk limit. - A projection that is N events behind therefore reads about N rows per batch. Catching up costs about N² in total, and once events arrive faster than such batches can process them, the projection does not recover. - Every query that triggers the projection waits for that catch-up. Examples are `GetSession` and the notification handlers. # How the Problems Are Solved - **New opt-in, `SearchQueryBuilder.ScanEventTypesSeparately()`.** It applies to event queries for a single instance, ascending and limited, whose sub-queries filter only by aggregate and event types. - For such a query, the SQL layer reads each distinct (aggregate type, event type) combination in its own `ORDER BY … LIMIT` subquery, using `UNNEST … CROSS JOIN LATERAL`. - It then merges the results with the same sort key and limit. - Each subquery is one `es_projection` range read in position order, so it stops at the limit. A batch reads at most `limit × combinations` rows, however far behind the projection is. - Every other query renders exactly as before. - **The projection handler** now passes its real combinations, one sub-query per aggregate type instead of the cross product of all types, and sets the opt-in. - **Results are identical to the single query.** - The sort key (`position, in_tx_order, instance_id, aggregate_type, aggregate_id, sequence`) is a total order, and the global top n is contained in the union of each combination's top n. - The cursor from #12703 and the open-transaction cap apply inside every subquery. - A test against a real Postgres compares both shapes from every cursor and every position, for several limits. The test events include positions shared by several transactions, whose `in_tx_order` restarts. # Additional Changes This is a separate commit and can be split into its own PR: - `cmd/defaults.yaml` sets `BulkLimit: 1000` for the `sessions` projection. - `projection_state_latency` gets histogram buckets up to one week. Until now, every projection more than 30 minutes behind was reported in the same top bucket. # Additional Context These numbers come from a local, CPU-pinned sandbox, for comparing builds only. - **Database:** Postgres 17 in a VM with 8 vCPUs and 16 GiB. The `Eventstore.Autovacuum` and `Projections` settings mirror a production-like setup. - **Starting state:** `sessions8` 4.8M events behind, restored byte-identically for every run. Batch query for `sessions8`, `EXPLAIN ANALYZE`, median of 5 runs, parallel workers at the default: | | before | after | | --- | ---: | ---: | | at the backlog, limit 1000 | 832 ms | 4.0 ms | | at the backlog, limit 200 | 832 ms | 1.0 ms | | at the head (idle poll) | 0.14 ms | 0.19 ms | | generic plan (`plan_cache_mode=force_generic_plan`), at the backlog | 1,042 ms | 4.0 ms | Draining `sessions8` from that backlog for 600 s, 3 runs per build (median, with the range in brackets): | | before | after | | --- | ---: | ---: | | events/s | 975 (932-1,032) | 7,811 (7,523-7,955) | | mean batch time, including writes | 1.03 s | 0.13 s | | still behind after 600 s | 4.2M | 17k-277k | A new index `(instance_id, position, in_tx_order, aggregate_type, event_type)` with the old query was evaluated as an alternative and rejected. Under a generic plan, Postgres chose it for projections whose events are rare and walked the whole event stream: 430 ms instead of 0.15 ms. This is the behaviour that led to dropping `es_instance_position` in #10626. - Follow-up for #12703 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <[email protected]> Co-authored-by: Copilot Autofix powered by AI <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
Copilot Autofix powered by AI
parent
98272ab5d1
commit
4bc484dac9
@@ -549,6 +549,11 @@ Projections:
|
|||||||
Customizations:
|
Customizations:
|
||||||
custom_texts:
|
custom_texts:
|
||||||
BulkLimit: 400
|
BulkLimit: 400
|
||||||
|
# The sessions projection processes the highest event volume.
|
||||||
|
# A higher bulk limit amortizes the fixed cost per batch over more events,
|
||||||
|
# which shortens catch-up after the projection fell behind.
|
||||||
|
sessions:
|
||||||
|
BulkLimit: 1000 # ZITADEL_PROJECTIONS_CUSTOMIZATIONS_SESSIONS_BULKLIMIT
|
||||||
project_grant_fields:
|
project_grant_fields:
|
||||||
TransactionDuration: 0s
|
TransactionDuration: 0s
|
||||||
BulkLimit: 2000
|
BulkLimit: 2000
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"maps"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -724,14 +725,24 @@ func (h *Handler) eventQuery(currentState *state, minPosition decimal.Decimal) *
|
|||||||
return builder
|
return builder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reading each event type separately keeps the cost of a batch bounded by the bulk limit.
|
||||||
|
// A single query for all event types must read every matching event after the position before it can sort and limit them,
|
||||||
|
// so a projection that falls behind would get slower the further behind it is.
|
||||||
|
// Handlers which subscribe to all events of an aggregate (no event types) keep the single query.
|
||||||
|
if !slices.ContainsFunc(slices.Collect(maps.Values(h.eventTypes)), func(eventTypes []eventstore.EventType) bool { return len(eventTypes) == 0 }) {
|
||||||
|
builder = builder.ScanEventTypesSeparately()
|
||||||
|
for _, aggregate := range slices.Sorted(maps.Keys(h.eventTypes)) {
|
||||||
|
builder = builder.AddQuery().AggregateTypes(aggregate).EventTypes(h.eventTypes[aggregate]...).Builder()
|
||||||
|
}
|
||||||
|
return builder
|
||||||
|
}
|
||||||
|
|
||||||
aggregateTypes := make([]eventstore.AggregateType, 0, len(h.eventTypes))
|
aggregateTypes := make([]eventstore.AggregateType, 0, len(h.eventTypes))
|
||||||
eventTypes := make([]eventstore.EventType, 0, len(h.eventTypes))
|
eventTypes := make([]eventstore.EventType, 0, len(h.eventTypes))
|
||||||
|
|
||||||
for aggregate, events := range h.eventTypes {
|
for aggregate, events := range h.eventTypes {
|
||||||
aggregateTypes = append(aggregateTypes, aggregate)
|
aggregateTypes = append(aggregateTypes, aggregate)
|
||||||
eventTypes = append(eventTypes, events...)
|
eventTypes = append(eventTypes, events...)
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.AddQuery().AggregateTypes(aggregateTypes...).EventTypes(eventTypes...).Builder()
|
return builder.AddQuery().AggregateTypes(aggregateTypes...).EventTypes(eventTypes...).Builder()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,39 @@ func TestHandler_eventQuery(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.Run("scans every aggregate's event types separately", func(t *testing.T) {
|
||||||
|
h := &Handler{
|
||||||
|
bulkLimit: 200,
|
||||||
|
eventTypes: map[eventstore.AggregateType][]eventstore.EventType{
|
||||||
|
"user": {"user.human.added", "user.locked"},
|
||||||
|
"org": {"org.removed"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
builder := h.eventQuery(&state{instanceID: "inst"}, decimal.Decimal{})
|
||||||
|
assert.True(t, builder.GetScanEventTypesSeparately())
|
||||||
|
queries := builder.GetQueries()
|
||||||
|
require.Len(t, queries, 2)
|
||||||
|
assert.Equal(t, []eventstore.AggregateType{"org"}, queries[0].GetAggregateTypes())
|
||||||
|
assert.Equal(t, []eventstore.EventType{"org.removed"}, queries[0].GetEventTypes())
|
||||||
|
assert.Equal(t, []eventstore.AggregateType{"user"}, queries[1].GetAggregateTypes())
|
||||||
|
assert.Equal(t, []eventstore.EventType{"user.human.added", "user.locked"}, queries[1].GetEventTypes())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("aggregate without event types keeps the single query", func(t *testing.T) {
|
||||||
|
h := &Handler{
|
||||||
|
bulkLimit: 200,
|
||||||
|
eventTypes: map[eventstore.AggregateType][]eventstore.EventType{
|
||||||
|
"org": nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
builder := h.eventQuery(&state{instanceID: "inst"}, decimal.Decimal{})
|
||||||
|
assert.False(t, builder.GetScanEventTypesSeparately())
|
||||||
|
queries := builder.GetQueries()
|
||||||
|
require.Len(t, queries, 1)
|
||||||
|
assert.Equal(t, []eventstore.AggregateType{"org"}, queries[0].GetAggregateTypes())
|
||||||
|
assert.Empty(t, queries[0].GetEventTypes())
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("no cursor on empty position", func(t *testing.T) {
|
t.Run("no cursor on empty position", func(t *testing.T) {
|
||||||
builder := h.eventQuery(&state{instanceID: "inst"}, decimal.Decimal{})
|
builder := h.eventQuery(&state{instanceID: "inst"}, decimal.Decimal{})
|
||||||
assert.Equal(t, uint32(0), builder.GetOffset())
|
assert.Equal(t, uint32(0), builder.GetOffset())
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ func newProjectionMetrics(ctx context.Context, m metrics.Metrics) *ProjectionMet
|
|||||||
ProjectionStateLatencyMetric,
|
ProjectionStateLatencyMetric,
|
||||||
"When finishing processing a batch of events, this track the age of the last events seen from current time",
|
"When finishing processing a batch of events, this track the age of the last events seen from current time",
|
||||||
"s",
|
"s",
|
||||||
[]float64{0.1, 0.5, 1, 5, 10, 30, 60, 300, 600, 1800},
|
// the upper bounds up to a week make a projection that falls hours or days behind visible,
|
||||||
|
// instead of reporting every latency above 30 minutes as the 1800s bucket
|
||||||
|
[]float64{0.1, 0.5, 1, 5, 10, 30, 60, 300, 600, 1800, 3600, 21600, 86400, 604800},
|
||||||
)
|
)
|
||||||
logging.OnError(ctx, err).Error("failed to register projection state latency metric")
|
logging.OnError(ctx, err).Error("failed to register projection state latency metric")
|
||||||
return projectionMetrics
|
return projectionMetrics
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
|
|
||||||
@@ -32,6 +34,16 @@ type SearchQuery struct {
|
|||||||
CreatedBefore *Filter
|
CreatedBefore *Filter
|
||||||
ExcludeAggregateIDs []*Filter
|
ExcludeAggregateIDs []*Filter
|
||||||
EventSortKeyAfter *eventstore.EventSortKey
|
EventSortKeyAfter *eventstore.EventSortKey
|
||||||
|
// EventTypeScans contains the combinations of aggregate type and event type to read separately,
|
||||||
|
// see [eventstore.SearchQueryBuilder.ScanEventTypesSeparately].
|
||||||
|
// It is empty if the sub queries cannot be read that way.
|
||||||
|
EventTypeScans []EventTypeScan
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventTypeScan is a combination of aggregate type and event type, which is read separately.
|
||||||
|
type EventTypeScan struct {
|
||||||
|
AggregateType eventstore.AggregateType
|
||||||
|
EventType eventstore.EventType
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter represents all fields needed to compare a field of an event with a value
|
// Filter represents all fields needed to compare a field of an event with a value
|
||||||
@@ -135,6 +147,11 @@ func QueryFromBuilder(builder *eventstore.SearchQueryBuilder) (*SearchQuery, err
|
|||||||
SubQueries: make([][]*Filter, len(builder.GetQueries())),
|
SubQueries: make([][]*Filter, len(builder.GetQueries())),
|
||||||
EventSortKeyAfter: builder.GetEventSortKeyAfter(),
|
EventSortKeyAfter: builder.GetEventSortKeyAfter(),
|
||||||
}
|
}
|
||||||
|
var err error
|
||||||
|
query.EventTypeScans, err = eventTypeScans(builder)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
for _, f := range []func(builder *eventstore.SearchQueryBuilder, query *SearchQuery) *Filter{
|
for _, f := range []func(builder *eventstore.SearchQueryBuilder, query *SearchQuery) *Filter{
|
||||||
instanceIDFilter,
|
instanceIDFilter,
|
||||||
@@ -192,6 +209,35 @@ func QueryFromBuilder(builder *eventstore.SearchQueryBuilder) (*SearchQuery, err
|
|||||||
return query, nil
|
return query, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eventTypeScans returns the distinct combinations of aggregate type and event type of all sub queries,
|
||||||
|
// if [eventstore.SearchQueryBuilder.ScanEventTypesSeparately] was set.
|
||||||
|
// Each sub query must filter a single aggregate type and at least one event type, and nothing else,
|
||||||
|
// otherwise an error is returned. A single aggregate type is required because event types belong to an aggregate type:
|
||||||
|
// a sub query with several aggregate types would combine event types with aggregate types they do not belong to.
|
||||||
|
func eventTypeScans(builder *eventstore.SearchQueryBuilder) ([]EventTypeScan, error) {
|
||||||
|
if !builder.GetScanEventTypesSeparately() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var scans []EventTypeScan
|
||||||
|
for _, query := range builder.GetQueries() {
|
||||||
|
if len(query.GetAggregateTypes()) != 1 ||
|
||||||
|
len(query.GetEventTypes()) == 0 ||
|
||||||
|
len(query.GetAggregateIDs()) > 0 ||
|
||||||
|
len(query.GetEventData()) > 0 ||
|
||||||
|
!query.GetPositionAfter().IsZero() {
|
||||||
|
return nil, zerrors.ThrowInvalidArgument(nil, "REPO-Ahx6e", "scanning event types separately requires sub queries with one aggregate type and event types only")
|
||||||
|
}
|
||||||
|
for _, eventType := range query.GetEventTypes() {
|
||||||
|
scans = append(scans, EventTypeScan{AggregateType: query.GetAggregateTypes()[0], EventType: eventType})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// sub queries are OR-connected, so a combination listed twice must still be read once
|
||||||
|
slices.SortFunc(scans, func(a, b EventTypeScan) int {
|
||||||
|
return cmp.Or(cmp.Compare(a.AggregateType, b.AggregateType), cmp.Compare(a.EventType, b.EventType))
|
||||||
|
})
|
||||||
|
return slices.Compact(scans), nil
|
||||||
|
}
|
||||||
|
|
||||||
func eventSequenceGreaterFilter(builder *eventstore.SearchQueryBuilder, query *SearchQuery) *Filter {
|
func eventSequenceGreaterFilter(builder *eventstore.SearchQueryBuilder, query *SearchQuery) *Filter {
|
||||||
if builder.GetEventSequenceGreater() == 0 {
|
if builder.GetEventSequenceGreater() == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/zitadel/zitadel/internal/eventstore"
|
"github.com/zitadel/zitadel/internal/eventstore"
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewFilter(t *testing.T) {
|
func TestNewFilter(t *testing.T) {
|
||||||
@@ -144,3 +148,66 @@ func TestColumns_Validate(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQueryFromBuilder_eventTypeScans(t *testing.T) {
|
||||||
|
base := func() *eventstore.SearchQueryBuilder {
|
||||||
|
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).ScanEventTypesSeparately()
|
||||||
|
}
|
||||||
|
errScan := zerrors.ThrowInvalidArgument(nil, "REPO-Ahx6e", "")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
builder *eventstore.SearchQueryBuilder
|
||||||
|
want []EventTypeScan
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "not requested",
|
||||||
|
builder: eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
|
||||||
|
AddQuery().AggregateTypes("user").EventTypes("user.locked").Builder(),
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "event types of each sub query, sorted and distinct",
|
||||||
|
builder: base().
|
||||||
|
AddQuery().AggregateTypes("user").EventTypes("user.locked", "user.removed").Builder().
|
||||||
|
AddQuery().AggregateTypes("org").EventTypes("org.removed").Builder().
|
||||||
|
AddQuery().AggregateTypes("user").EventTypes("user.locked").Builder(),
|
||||||
|
want: []EventTypeScan{
|
||||||
|
{AggregateType: "org", EventType: "org.removed"},
|
||||||
|
{AggregateType: "user", EventType: "user.locked"},
|
||||||
|
{AggregateType: "user", EventType: "user.removed"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sub query with several aggregate types",
|
||||||
|
builder: base().AddQuery().AggregateTypes("user", "org").EventTypes("user.locked", "org.removed").Builder(),
|
||||||
|
wantErr: errScan,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sub query without event types",
|
||||||
|
builder: base().AddQuery().AggregateTypes("user").Builder(),
|
||||||
|
wantErr: errScan,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sub query with aggregate ids",
|
||||||
|
builder: base().AddQuery().AggregateTypes("user").AggregateIDs("id").EventTypes("user.locked").Builder(),
|
||||||
|
wantErr: errScan,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sub query with event data",
|
||||||
|
builder: base().AddQuery().AggregateTypes("user").EventTypes("user.locked").EventData(map[string]any{"key": "value"}).Builder(),
|
||||||
|
wantErr: errScan,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
query, err := QueryFromBuilder(tt.builder)
|
||||||
|
if tt.wantErr != nil {
|
||||||
|
require.ErrorIs(t, err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, query.EventTypeScans)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ func NewPostgres(client *database.DB) *Postgres {
|
|||||||
|
|
||||||
func (db *Postgres) Health(ctx context.Context) error { return db.Ping() }
|
func (db *Postgres) Health(ctx context.Context) error { return db.Ping() }
|
||||||
|
|
||||||
const eventSortKeySQL = `"position", in_tx_order, instance_id, aggregate_type, aggregate_id, "sequence"`
|
const (
|
||||||
|
eventSortKeySQL = `"position", in_tx_order, instance_id, aggregate_type, aggregate_id, "sequence"`
|
||||||
|
eventColumnsSQL = `created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision, in_tx_order`
|
||||||
|
)
|
||||||
|
|
||||||
// FilterToReducer finds all events matching the given search query and passes them to the reduce function.
|
// FilterToReducer finds all events matching the given search query and passes them to the reduce function.
|
||||||
func (psql *Postgres) FilterToReducer(ctx context.Context, searchQuery *eventstore.SearchQueryBuilder, reduce eventstore.Reducer) (err error) {
|
func (psql *Postgres) FilterToReducer(ctx context.Context, searchQuery *eventstore.SearchQueryBuilder, reduce eventstore.Reducer) (err error) {
|
||||||
@@ -115,20 +118,7 @@ func (db *Postgres) eventQuery(useV1 bool) string {
|
|||||||
", aggregate_version" +
|
", aggregate_version" +
|
||||||
" FROM eventstore.events"
|
" FROM eventstore.events"
|
||||||
}
|
}
|
||||||
return "SELECT" +
|
return "SELECT " + eventColumnsSQL + " FROM eventstore.events2"
|
||||||
" created_at" +
|
|
||||||
", event_type" +
|
|
||||||
`, "sequence"` +
|
|
||||||
`, "position"` +
|
|
||||||
", payload" +
|
|
||||||
", creator" +
|
|
||||||
`, "owner"` +
|
|
||||||
", instance_id" +
|
|
||||||
", aggregate_type" +
|
|
||||||
", aggregate_id" +
|
|
||||||
", revision" +
|
|
||||||
", in_tx_order" +
|
|
||||||
" FROM eventstore.events2"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *Postgres) maxPositionQuery(useV1 bool) string {
|
func (db *Postgres) maxPositionQuery(useV1 bool) string {
|
||||||
|
|||||||
@@ -92,39 +92,44 @@ func query(ctx context.Context, criteria querier, searchQuery *eventstore.Search
|
|||||||
|
|
||||||
where += awaitOpenTransactions(useV1)
|
where += awaitOpenTransactions(useV1)
|
||||||
}
|
}
|
||||||
query += where
|
|
||||||
|
|
||||||
// instead of using the max function of the database (which doesn't work for postgres)
|
if canScanEventTypesSeparately(q, useV1) {
|
||||||
// we select the most recent row
|
query, values = prepareEventTypeScans(q)
|
||||||
if q.Columns == eventstore.ColumnsMaxPosition {
|
} else {
|
||||||
q.Limit = 1
|
query += where
|
||||||
q.Desc = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// if there is only one subquery we can optimize the query ordering by sequence
|
// instead of using the max function of the database (which doesn't work for postgres)
|
||||||
var shouldOrderBySequence bool
|
// we select the most recent row
|
||||||
if q.EventSortKeyAfter == nil && len(q.SubQueries) == 1 {
|
if q.Columns == eventstore.ColumnsMaxPosition {
|
||||||
for _, filter := range q.SubQueries[0] {
|
q.Limit = 1
|
||||||
if filter.Field == repository.FieldAggregateID {
|
q.Desc = true
|
||||||
shouldOrderBySequence = filter.Operation == repository.OperationEquals
|
}
|
||||||
|
|
||||||
|
// if there is only one subquery we can optimize the query ordering by sequence
|
||||||
|
var shouldOrderBySequence bool
|
||||||
|
if q.EventSortKeyAfter == nil && len(q.SubQueries) == 1 {
|
||||||
|
for _, filter := range q.SubQueries[0] {
|
||||||
|
if filter.Field == repository.FieldAggregateID {
|
||||||
|
shouldOrderBySequence = filter.Operation == repository.OperationEquals
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
switch q.Columns {
|
switch q.Columns {
|
||||||
case eventstore.ColumnsEvent,
|
case eventstore.ColumnsEvent,
|
||||||
eventstore.ColumnsMaxPosition:
|
eventstore.ColumnsMaxPosition:
|
||||||
query += criteria.orderByEventSequence(q.Desc, shouldOrderBySequence, useV1)
|
query += criteria.orderByEventSequence(q.Desc, shouldOrderBySequence, useV1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if q.Limit > 0 {
|
if q.Limit > 0 {
|
||||||
values = append(values, q.Limit)
|
values = append(values, q.Limit)
|
||||||
query += " LIMIT ?"
|
query += " LIMIT ?"
|
||||||
}
|
}
|
||||||
|
|
||||||
if q.Offset > 0 {
|
if q.Offset > 0 {
|
||||||
values = append(values, q.Offset)
|
values = append(values, q.Offset)
|
||||||
query += " OFFSET ?"
|
query += " OFFSET ?"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
query = criteria.placeholder(query)
|
query = criteria.placeholder(query)
|
||||||
@@ -147,6 +152,68 @@ func query(ctx context.Context, criteria querier, searchQuery *eventstore.Search
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// canScanEventTypesSeparately reports if the events of the combinations in [repository.SearchQuery.EventTypeScans]
|
||||||
|
// can be read one combination at a time: only the instance, the position or sort key and the await of open transactions
|
||||||
|
// may restrict the query besides the combinations, and it must be ordered ascending with a limit.
|
||||||
|
func canScanEventTypesSeparately(q *repository.SearchQuery, useV1 bool) bool {
|
||||||
|
return !useV1 &&
|
||||||
|
len(q.EventTypeScans) > 0 &&
|
||||||
|
q.Columns == eventstore.ColumnsEvent &&
|
||||||
|
q.Limit > 0 &&
|
||||||
|
!q.Desc &&
|
||||||
|
q.Offset == 0 &&
|
||||||
|
q.InstanceID != nil &&
|
||||||
|
q.InstanceIDs == nil &&
|
||||||
|
q.ExcludedInstances == nil &&
|
||||||
|
q.Creator == nil &&
|
||||||
|
q.Owner == nil &&
|
||||||
|
q.Sequence == nil &&
|
||||||
|
q.CreatedAfter == nil &&
|
||||||
|
q.CreatedBefore == nil &&
|
||||||
|
len(q.ExcludeAggregateIDs) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareEventTypeScans reads the events of every combination of aggregate type and event type
|
||||||
|
// in sort key order up to the limit, and merges them.
|
||||||
|
// Each combination is a range of the es_projection index (instance_id, aggregate_type, event_type, position),
|
||||||
|
// which returns its events in position order, so every scan stops at the limit
|
||||||
|
// and the query reads at most limit events per combination, however many events match after the position.
|
||||||
|
func prepareEventTypeScans(q *repository.SearchQuery) (string, []any) {
|
||||||
|
aggregateTypes := make(database.TextArray[eventstore.AggregateType], len(q.EventTypeScans))
|
||||||
|
eventTypes := make(database.TextArray[eventstore.EventType], len(q.EventTypeScans))
|
||||||
|
for i, typeScan := range q.EventTypeScans {
|
||||||
|
aggregateTypes[i] = typeScan.AggregateType
|
||||||
|
eventTypes[i] = typeScan.EventType
|
||||||
|
}
|
||||||
|
values := []any{aggregateTypes, eventTypes, q.InstanceID.Value}
|
||||||
|
|
||||||
|
conditions := "instance_id = ? AND aggregate_type = scans.scan_aggregate_type AND event_type = scans.scan_event_type"
|
||||||
|
if q.Position != nil {
|
||||||
|
conditions += ` AND "position" >= ?`
|
||||||
|
values = append(values, q.Position.Value)
|
||||||
|
}
|
||||||
|
if q.EventSortKeyAfter != nil {
|
||||||
|
conditions += ` AND (` + eventSortKeySQL + `) > (?, ?, ?, ?, ?, ?)`
|
||||||
|
values = append(values,
|
||||||
|
q.EventSortKeyAfter.Position,
|
||||||
|
q.EventSortKeyAfter.InTxOrder,
|
||||||
|
q.EventSortKeyAfter.InstanceID,
|
||||||
|
q.EventSortKeyAfter.AggregateType,
|
||||||
|
q.EventSortKeyAfter.AggregateID,
|
||||||
|
q.EventSortKeyAfter.Sequence,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if q.AwaitOpenTransactions {
|
||||||
|
conditions += awaitOpenTransactions(false)
|
||||||
|
}
|
||||||
|
values = append(values, q.Limit, q.Limit)
|
||||||
|
|
||||||
|
return "SELECT events.* FROM UNNEST(?::TEXT[], ?::TEXT[]) AS scans(scan_aggregate_type, scan_event_type)" +
|
||||||
|
" CROSS JOIN LATERAL (SELECT " + eventColumnsSQL + " FROM eventstore.events2 WHERE " + conditions +
|
||||||
|
" ORDER BY " + eventSortKeySQL + " LIMIT ?) AS events" +
|
||||||
|
" ORDER BY " + eventSortKeySQL + " LIMIT ?", values
|
||||||
|
}
|
||||||
|
|
||||||
func prepareColumns(criteria querier, columns eventstore.Columns, useV1 bool) (string, func(s scan, dest interface{}) error) {
|
func prepareColumns(criteria querier, columns eventstore.Columns, useV1 bool) (string, func(s scan, dest interface{}) error) {
|
||||||
switch columns {
|
switch columns {
|
||||||
case eventstore.ColumnsMaxPosition:
|
case eventstore.ColumnsMaxPosition:
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package sql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql/driver"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/internal/database"
|
||||||
|
"github.com/zitadel/zitadel/internal/eventstore"
|
||||||
|
"github.com/zitadel/zitadel/internal/eventstore/repository"
|
||||||
|
"github.com/zitadel/zitadel/internal/zerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_query_event_type_scans(t *testing.T) {
|
||||||
|
cursor := eventstore.EventSortKey{
|
||||||
|
Position: decimal.NewFromFloat(1.5),
|
||||||
|
InTxOrder: 5,
|
||||||
|
InstanceID: "instanceID",
|
||||||
|
AggregateType: "user",
|
||||||
|
AggregateID: "agg-a",
|
||||||
|
Sequence: 5,
|
||||||
|
}
|
||||||
|
scanSQL := func(positionCondition string) string {
|
||||||
|
return `SELECT events.* FROM UNNEST($1::TEXT[], $2::TEXT[]) AS scans(scan_aggregate_type, scan_event_type)` +
|
||||||
|
` CROSS JOIN LATERAL (SELECT ` + eventColumnsSQL + ` FROM eventstore.events2` +
|
||||||
|
` WHERE instance_id = $3 AND aggregate_type = scans.scan_aggregate_type AND event_type = scans.scan_event_type` +
|
||||||
|
positionCondition +
|
||||||
|
` AND "position" <= EXTRACT(EPOCH FROM now()) ORDER BY ` + eventSortKeySQL + ` LIMIT $%d) AS events` +
|
||||||
|
` ORDER BY ` + eventSortKeySQL + ` LIMIT $%d`
|
||||||
|
}
|
||||||
|
projectionQuery := func() *eventstore.SearchQueryBuilder {
|
||||||
|
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
|
||||||
|
InstanceID("instanceID").
|
||||||
|
OrderAsc().
|
||||||
|
AwaitOpenTransactions().
|
||||||
|
Limit(200).
|
||||||
|
ScanEventTypesSeparately()
|
||||||
|
}
|
||||||
|
aggregateTypes := database.TextArray[eventstore.AggregateType]{"org", "user", "user"}
|
||||||
|
eventTypes := database.TextArray[eventstore.EventType]{"org.removed", "user.human.added", "user.locked"}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query *eventstore.SearchQueryBuilder
|
||||||
|
sql string
|
||||||
|
args []driver.Value
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "after sort key, one scan per aggregate and event type",
|
||||||
|
query: projectionQuery().
|
||||||
|
AfterEventSortKey(cursor).
|
||||||
|
AddQuery().AggregateTypes("user").EventTypes("user.locked", "user.human.added").Builder().
|
||||||
|
AddQuery().AggregateTypes("org").EventTypes("org.removed").Builder(),
|
||||||
|
sql: fmt.Sprintf(scanSQL(` AND (`+eventSortKeySQL+`) > ($4, $5, $6, $7, $8, $9)`), 10, 11),
|
||||||
|
args: []driver.Value{
|
||||||
|
aggregateTypes, eventTypes, "instanceID",
|
||||||
|
cursor.Position, cursor.InTxOrder, cursor.InstanceID, cursor.AggregateType, cursor.AggregateID, cursor.Sequence,
|
||||||
|
uint64(200), uint64(200),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "position at least, combinations listed twice are scanned once",
|
||||||
|
query: projectionQuery().
|
||||||
|
PositionAtLeast(decimal.NewFromInt(10)).
|
||||||
|
AddQuery().AggregateTypes("user").EventTypes("user.locked", "user.human.added").Builder().
|
||||||
|
AddQuery().AggregateTypes("org").EventTypes("org.removed").Builder().
|
||||||
|
AddQuery().AggregateTypes("user").EventTypes("user.locked").Builder(),
|
||||||
|
sql: fmt.Sprintf(scanSQL(` AND "position" >= $4`), 5, 6),
|
||||||
|
args: []driver.Value{
|
||||||
|
aggregateTypes, eventTypes, "instanceID",
|
||||||
|
decimal.NewFromInt(10),
|
||||||
|
uint64(200), uint64(200),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "descending order is not scanned separately",
|
||||||
|
query: eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
|
||||||
|
InstanceID("instanceID").
|
||||||
|
OrderDesc().
|
||||||
|
AwaitOpenTransactions().
|
||||||
|
Limit(200).
|
||||||
|
ScanEventTypesSeparately().
|
||||||
|
AddQuery().AggregateTypes("org").EventTypes("org.removed").Builder(),
|
||||||
|
sql: `SELECT ` + eventColumnsSQL + ` FROM eventstore.events2 WHERE instance_id = $1 AND aggregate_type = $2 AND event_type = $3 AND "position" <= EXTRACT(EPOCH FROM now()) ORDER BY (` + eventSortKeySQL + `) DESC LIMIT $4`,
|
||||||
|
args: []driver.Value{
|
||||||
|
"instanceID", eventstore.AggregateType("org"), eventstore.EventType("org.removed"),
|
||||||
|
uint64(200),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewPostgres(&database.DB{Database: new(testDB)})
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
mock := newMockClient(t).
|
||||||
|
expectExec(regexp.QuoteMeta(
|
||||||
|
`select pg_advisory_lock('eventstore.events2'::REGCLASS::OID::INTEGER, hashtext($1)), pg_advisory_unlock('eventstore.events2'::REGCLASS::OID::INTEGER, hashtext($1))`),
|
||||||
|
[]driver.Value{"instanceID"},
|
||||||
|
).
|
||||||
|
expectQuery("^"+regexp.QuoteMeta(tt.sql)+"$", tt.args)
|
||||||
|
client.DB.DB = mock.client
|
||||||
|
|
||||||
|
require.NoError(t, query(t.Context(), client, tt.query, &[]*repository.Event{}, false))
|
||||||
|
assert.NoError(t, mock.mock.ExpectationsWereMet())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("sub query filtering by aggregate id returns an error", func(t *testing.T) {
|
||||||
|
mock := newMockClient(t)
|
||||||
|
client.DB.DB = mock.client
|
||||||
|
|
||||||
|
err := query(t.Context(), client,
|
||||||
|
projectionQuery().AddQuery().AggregateTypes("user").AggregateIDs("agg-a").EventTypes("user.locked").Builder(),
|
||||||
|
&[]*repository.Event{}, false,
|
||||||
|
)
|
||||||
|
require.ErrorIs(t, err, zerrors.ThrowInvalidArgument(nil, "REPO-Ahx6e", ""))
|
||||||
|
assert.NoError(t, mock.mock.ExpectationsWereMet())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test_query_event_type_scans_postgres checks against a real database that reading every combination of
|
||||||
|
// aggregate type and event type separately returns exactly the events, in exactly the order,
|
||||||
|
// of the single query for all event types, from any cursor and for any limit.
|
||||||
|
// The events include positions shared by several transactions, whose in_tx_order restarts at 1,
|
||||||
|
// which is the situation where resuming from a cursor has skipped events before.
|
||||||
|
func Test_query_event_type_scans_postgres(t *testing.T) {
|
||||||
|
ctx := t.Context()
|
||||||
|
instanceID := "event-type-scans-" + t.Name()
|
||||||
|
client := &Postgres{DB: &database.DB{DB: testClient, Database: new(testDB)}}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, err := testClient.ExecContext(context.Background(), "DELETE FROM eventstore.events2 WHERE instance_id = $1", instanceID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
type aggregateEvents struct {
|
||||||
|
aggregateType eventstore.AggregateType
|
||||||
|
eventTypes []eventstore.EventType
|
||||||
|
}
|
||||||
|
// the last event type of every aggregate is written but not subscribed to
|
||||||
|
aggregates := []aggregateEvents{
|
||||||
|
{"user", []eventstore.EventType{"user.human.added", "user.locked", "user.human.changed"}},
|
||||||
|
{"org", []eventstore.EventType{"org.removed", "org.added"}},
|
||||||
|
{"session", []eventstore.EventType{"session.added", "session.token.set", "session.terminated"}},
|
||||||
|
}
|
||||||
|
sequences := make(map[string]uint64)
|
||||||
|
for tx := range 90 {
|
||||||
|
// every three transactions share a position, each numbering its events from 1
|
||||||
|
position := decimal.NewFromInt(1_000_000).Add(decimal.NewFromInt(int64(tx / 3)))
|
||||||
|
for inTxOrder := range tx%4 + 1 {
|
||||||
|
agg := aggregates[(tx+inTxOrder)%len(aggregates)]
|
||||||
|
eventType := agg.eventTypes[(tx*7+inTxOrder)%len(agg.eventTypes)]
|
||||||
|
aggregateID := fmt.Sprintf("agg-%d", (tx*3+inTxOrder)%5)
|
||||||
|
key := string(agg.aggregateType) + aggregateID
|
||||||
|
sequences[key]++
|
||||||
|
_, err := testClient.ExecContext(ctx,
|
||||||
|
`INSERT INTO eventstore.events2 (instance_id, aggregate_type, aggregate_id, event_type, "sequence", revision, created_at, payload, creator, "owner", "position", in_tx_order)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 1, now(), NULL, 'creator', 'owner', $6, $7)`,
|
||||||
|
instanceID, agg.aggregateType, aggregateID, eventType, sequences[key], position, inTxOrder+1,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe := func(builder *eventstore.SearchQueryBuilder) *eventstore.SearchQueryBuilder {
|
||||||
|
for _, agg := range aggregates {
|
||||||
|
builder = builder.AddQuery().AggregateTypes(agg.aggregateType).EventTypes(agg.eventTypes[:len(agg.eventTypes)-1]...).Builder()
|
||||||
|
}
|
||||||
|
return builder
|
||||||
|
}
|
||||||
|
filter := func(t *testing.T, scanSeparately bool, limit uint64, cursor *eventstore.EventSortKey, minPosition decimal.Decimal) []eventstore.EventSortKey {
|
||||||
|
builder := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
|
||||||
|
InstanceID(instanceID).
|
||||||
|
AwaitOpenTransactions().
|
||||||
|
OrderAsc().
|
||||||
|
Limit(limit)
|
||||||
|
if scanSeparately {
|
||||||
|
builder = builder.ScanEventTypesSeparately()
|
||||||
|
}
|
||||||
|
if cursor != nil {
|
||||||
|
builder = builder.AfterEventSortKey(*cursor)
|
||||||
|
}
|
||||||
|
if !minPosition.IsZero() {
|
||||||
|
builder = builder.PositionAtLeast(minPosition)
|
||||||
|
}
|
||||||
|
keys := []eventstore.EventSortKey{}
|
||||||
|
err := query(ctx, client, subscribe(builder), eventstore.Reducer(func(event eventstore.Event) error {
|
||||||
|
keys = append(keys, eventstore.EventSortKey{
|
||||||
|
Position: event.Position(),
|
||||||
|
InTxOrder: event.InTxOrder(),
|
||||||
|
InstanceID: event.Aggregate().InstanceID,
|
||||||
|
AggregateType: event.Aggregate().Type,
|
||||||
|
AggregateID: event.Aggregate().ID,
|
||||||
|
Sequence: event.Sequence(),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}), false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
all := filter(t, false, 10_000, nil, decimal.Decimal{})
|
||||||
|
require.Len(t, filter(t, true, 10_000, nil, decimal.Decimal{}), len(all))
|
||||||
|
require.Greater(t, len(all), 100, "the generated events must include subscribed events")
|
||||||
|
|
||||||
|
for _, limit := range []uint64{1, 2, 3, 7, 50} {
|
||||||
|
t.Run(fmt.Sprintf("limit %d", limit), func(t *testing.T) {
|
||||||
|
// from the start
|
||||||
|
assert.Equal(t, filter(t, false, limit, nil, decimal.Decimal{}), filter(t, true, limit, nil, decimal.Decimal{}))
|
||||||
|
// from after every event
|
||||||
|
for i := range all {
|
||||||
|
cursor := all[i]
|
||||||
|
want := filter(t, false, limit, &cursor, decimal.Decimal{})
|
||||||
|
require.Equal(t, all[i+1:min(len(all), i+1+int(limit))], want, "the single query must page through all events")
|
||||||
|
assert.Equal(t, want, filter(t, true, limit, &cursor, decimal.Decimal{}), "cursor %d", i)
|
||||||
|
}
|
||||||
|
// from every position, inclusive
|
||||||
|
for i := range all {
|
||||||
|
assert.Equal(t,
|
||||||
|
filter(t, false, limit, nil, all[i].Position),
|
||||||
|
filter(t, true, limit, nil, all[i].Position),
|
||||||
|
"position %s", all[i].Position,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ type SearchQueryBuilder struct {
|
|||||||
positionAtLeast decimal.Decimal
|
positionAtLeast decimal.Decimal
|
||||||
eventSortKeyAfter *EventSortKey
|
eventSortKeyAfter *EventSortKey
|
||||||
awaitOpenTransactions bool
|
awaitOpenTransactions bool
|
||||||
|
scanEventTypes bool
|
||||||
creationDateAfter time.Time
|
creationDateAfter time.Time
|
||||||
creationDateBefore time.Time
|
creationDateBefore time.Time
|
||||||
eventSequenceGreater uint64
|
eventSequenceGreater uint64
|
||||||
@@ -89,6 +90,10 @@ func (b SearchQueryBuilder) GetAwaitOpenTransactions() bool {
|
|||||||
return b.awaitOpenTransactions
|
return b.awaitOpenTransactions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b SearchQueryBuilder) GetScanEventTypesSeparately() bool {
|
||||||
|
return b.scanEventTypes
|
||||||
|
}
|
||||||
|
|
||||||
func (q SearchQueryBuilder) GetEventSequenceGreater() uint64 {
|
func (q SearchQueryBuilder) GetEventSequenceGreater() uint64 {
|
||||||
return q.eventSequenceGreater
|
return q.eventSequenceGreater
|
||||||
}
|
}
|
||||||
@@ -303,6 +308,19 @@ func (builder *SearchQueryBuilder) AwaitOpenTransactions() *SearchQueryBuilder {
|
|||||||
return builder
|
return builder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScanEventTypesSeparately reads the events of each combination of aggregate type and event type of the sub queries
|
||||||
|
// separately, in sort key order up to the limit, and merges the results.
|
||||||
|
// Without it, a query for multiple types cannot stop reading at the limit:
|
||||||
|
// it reads every matching event after the position and sorts them, so its cost grows with the number of matching events.
|
||||||
|
// With it, the cost is bounded by the limit and the number of combinations.
|
||||||
|
//
|
||||||
|
// It is only applied to queries for events of a single instance, ordered ascending with a limit,
|
||||||
|
// whose sub queries filter by aggregate types and event types only. Other queries ignore it.
|
||||||
|
func (builder *SearchQueryBuilder) ScanEventTypesSeparately() *SearchQueryBuilder {
|
||||||
|
builder.scanEventTypes = true
|
||||||
|
return builder
|
||||||
|
}
|
||||||
|
|
||||||
// SequenceGreater filters for events with sequence greater the requested sequence
|
// SequenceGreater filters for events with sequence greater the requested sequence
|
||||||
func (builder *SearchQueryBuilder) SequenceGreater(sequence uint64) *SearchQueryBuilder {
|
func (builder *SearchQueryBuilder) SequenceGreater(sequence uint64) *SearchQueryBuilder {
|
||||||
builder.eventSequenceGreater = sequence
|
builder.eventSequenceGreater = sequence
|
||||||
|
|||||||
Reference in New Issue
Block a user