mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-05 22:02:15 +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
@@ -40,7 +40,10 @@ func NewPostgres(client *database.DB) *Postgres {
|
||||
|
||||
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.
|
||||
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" +
|
||||
" FROM eventstore.events"
|
||||
}
|
||||
return "SELECT" +
|
||||
" created_at" +
|
||||
", event_type" +
|
||||
`, "sequence"` +
|
||||
`, "position"` +
|
||||
", payload" +
|
||||
", creator" +
|
||||
`, "owner"` +
|
||||
", instance_id" +
|
||||
", aggregate_type" +
|
||||
", aggregate_id" +
|
||||
", revision" +
|
||||
", in_tx_order" +
|
||||
" FROM eventstore.events2"
|
||||
return "SELECT " + eventColumnsSQL + " FROM eventstore.events2"
|
||||
}
|
||||
|
||||
func (db *Postgres) maxPositionQuery(useV1 bool) string {
|
||||
|
||||
@@ -92,39 +92,44 @@ func query(ctx context.Context, criteria querier, searchQuery *eventstore.Search
|
||||
|
||||
where += awaitOpenTransactions(useV1)
|
||||
}
|
||||
query += where
|
||||
|
||||
// instead of using the max function of the database (which doesn't work for postgres)
|
||||
// we select the most recent row
|
||||
if q.Columns == eventstore.ColumnsMaxPosition {
|
||||
q.Limit = 1
|
||||
q.Desc = true
|
||||
}
|
||||
if canScanEventTypesSeparately(q, useV1) {
|
||||
query, values = prepareEventTypeScans(q)
|
||||
} else {
|
||||
query += where
|
||||
|
||||
// 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
|
||||
// instead of using the max function of the database (which doesn't work for postgres)
|
||||
// we select the most recent row
|
||||
if q.Columns == eventstore.ColumnsMaxPosition {
|
||||
q.Limit = 1
|
||||
q.Desc = true
|
||||
}
|
||||
|
||||
// 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 {
|
||||
case eventstore.ColumnsEvent,
|
||||
eventstore.ColumnsMaxPosition:
|
||||
query += criteria.orderByEventSequence(q.Desc, shouldOrderBySequence, useV1)
|
||||
}
|
||||
switch q.Columns {
|
||||
case eventstore.ColumnsEvent,
|
||||
eventstore.ColumnsMaxPosition:
|
||||
query += criteria.orderByEventSequence(q.Desc, shouldOrderBySequence, useV1)
|
||||
}
|
||||
|
||||
if q.Limit > 0 {
|
||||
values = append(values, q.Limit)
|
||||
query += " LIMIT ?"
|
||||
}
|
||||
if q.Limit > 0 {
|
||||
values = append(values, q.Limit)
|
||||
query += " LIMIT ?"
|
||||
}
|
||||
|
||||
if q.Offset > 0 {
|
||||
values = append(values, q.Offset)
|
||||
query += " OFFSET ?"
|
||||
if q.Offset > 0 {
|
||||
values = append(values, q.Offset)
|
||||
query += " OFFSET ?"
|
||||
}
|
||||
}
|
||||
|
||||
query = criteria.placeholder(query)
|
||||
@@ -147,6 +152,68 @@ func query(ctx context.Context, criteria querier, searchQuery *eventstore.Search
|
||||
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) {
|
||||
switch columns {
|
||||
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,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user