From fbe7b961384a2b42895f75a4275d592261fbe19c Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:08:50 +0200 Subject: [PATCH] fix(eventstore): prevent projections from skipping events (#12703) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved Handler/v2 projections (`users14` and every other event projection) can overlook committed events under load: - `eventstore.commands_to_events` stamped `position` with `NOW()` (transaction start). A slow create TX could commit after a faster TX that started later, so the projector stored a later position and never saw the earlier one. - Resume used `OFFSET` on a **filtered** row count. That could skip `in_tx_order = 1` at the next position (for example `user.human.added`). - Two transactions in the same microsecond both start at `in_tx_order = 1`. A cursor of `in_tx_order > last` alone would drop the other TX’s first event. This does not repair rows that are already missing from projections. # How the Problems Are Solved - Setup step 77 replaces `eventstore.commands_to_events` so `created_at = statement_timestamp()` and `position = EXTRACT(EPOCH FROM clock_timestamp())` at INSERT time. Initialise `08_events_table.sql` matches that for new databases. The function is `VOLATILE` to match `clock_timestamp()`. - The projector still drains in-flight writers with `pg_advisory_lock` / `pg_advisory_unlock` in one statement, then caps with `position <= EXTRACT(EPOCH FROM now())` (projector TX start). The exclusive lock is **not** held across the event `SELECT`. The cap must stay `now()`, not `clock_timestamp()`, or later inserts become a high-water mark. - Resume is a lexicographic `EventSortKey` on `(position, in_tx_order, instance_id, aggregate_type, aggregate_id, sequence)` via `AfterEventSortKey`. SQL `OFFSET` is no longer used for projection catch-up. - Handler and FieldHandler persist the last event’s `in_tx_order` in `current_states.filter_offset` (existing column, new meaning). Step 77 backfills matching rows from `events2`; unmatched rows keep their previous value. # Additional Changes - `eventstore.Event` exposes `InTxOrder() uint32` (BaseEvent, repository Event, v3 event, v1 models). - v3 push SELECT/RETURNING now includes `in_tx_order` so newly written events carry a real ordinal. - `WithMinPosition` stays an inclusive `PositionAtLeast` floor and does not smash the sort-key cursor. - `SearchQuery.PositionAfter(decimal)` is unchanged (used by access-token lookup). Do not confuse it with `SearchQueryBuilder.AfterEventSortKey`. - `commands_to_events` in setup 77 and initialise 08 is `VOLATILE PARALLEL SAFE`. Shipped 40/64/70 stay `STABLE` because they still stamp with `NOW()`. - Drop unused `last_owner TEXT` DECLARE from setup 77 and initialise `08_events_table.sql` (`i INTEGER` remains for the enforce-owner loop). Shipped 40/64/70 keep the leftover. - This PR’s migration is numbered 77 because `main` already has setup 76 (`76_users14_login_equality_indexes`). First-loop order is s70, then main’s 76, then this 77. # Additional Context **Reviewer map** | Start here | Why | | --- | --- | | `cmd/setup/77.sql` + `cmd/setup/77.go` | Position stamp + atomic function replace/backfill after shipped `s70` and main’s s76 | | `internal/eventstore/event_sort_key.go` + `search_query.go` | Canonical cursor and `AfterEventSortKey` | | `internal/eventstore/handler/v2/{state.go,handler.go}` | Persist/resume cursor; `eventQuery` | | `internal/eventstore/repository/sql/{query.go,postgres.go}` | Tuple `>` filter, 6-column `ORDER BY`, await cap | | `internal/eventstore/v3/push.go` / `push.sql` | Scan `in_tx_order` on write | Mechanical / low-risk: handler and query test fixtures, Event interface stubs (`event_base.go`, repository Event, mocks, v1 models). **Do not look for** a `70.sql` change — shipped migrations stay as on `main`. Setup 40/64 still use `NOW()`; step 77 overwrites the function afterward. Fresh `FirstInstance` (runs before `s70`) can still stamp `NOW()` until 77; runtime pushes after 77 use `clock_timestamp()`. Main’s `cmd/setup/76.go` is the users14 login-equality indexes, not this stamp. **Rollout** 1. Run setup so 77 replaces `commands_to_events` and backfills `filter_offset`. 2. Restart ZITADEL so projectors load the new resume path. 3. Existing missing `users14` rows are not repaired by this PR. **Tests** - `go test ./internal/eventstore/... ./cmd/setup/` - Sort-key SQL fixtures in `query_sort_key_test.go` (no `OFFSET`; aggregate-id queries keep 6-column `ORDER BY`) - `cmd/setup/77_test.go` asserts the embed has no unused `last_owner` and still uses `clock_timestamp()` / `VOLATILE PARALLEL SAFE` Related: #8509, #10480 (remove OFFSET), #10560 (advisory locks), #10710 (stable ORDER BY). Out of scope: repairing already-missing projection rows, holding the exclusive lock during SELECT, rewriting historical setup 40/64 SQL.
Open in Web Open in Cursor 
--------- Co-authored-by: Cursor Agent Co-authored-by: Silvan --- benchmark/Makefile | 4 + benchmark/src/use_cases/create_humans.ts | 24 +++ cmd/initialise/sql/08_events_table.sql | 7 +- cmd/setup/77.go | 53 +++++++ cmd/setup/77.sql | 99 ++++++++++++ cmd/setup/77_current_states.sql | 8 + cmd/setup/77_test.go | 14 ++ cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 + internal/eventstore/event.go | 2 + internal/eventstore/event_base.go | 7 + internal/eventstore/event_sort_key.go | 28 ++++ .../eventstore/handler/v2/field_handler.go | 57 +------ internal/eventstore/handler/v2/handler.go | 61 ++------ .../eventstore/handler/v2/handler_test.go | 135 ++++++++++++++++ internal/eventstore/handler/v2/state.go | 44 +++--- internal/eventstore/handler/v2/state_get.sql | 1 + internal/eventstore/handler/v2/state_test.go | 27 ++-- internal/eventstore/handler/v2/statement.go | 26 ++-- internal/eventstore/repository/event.go | 7 + .../repository/mock/repository.mock.impl.go | 4 + .../eventstore/repository/search_query.go | 2 + .../eventstore/repository/sql/postgres.go | 11 +- internal/eventstore/repository/sql/query.go | 18 ++- .../repository/sql/query_sort_key_test.go | 144 ++++++++++++++++++ .../eventstore/repository/sql/query_test.go | 14 +- internal/eventstore/search_query.go | 11 ++ internal/eventstore/v1/models/event.go | 5 + internal/eventstore/v3/event.go | 6 + internal/eventstore/v3/push.go | 4 +- internal/eventstore/v3/push.sql | 2 +- internal/eventstore/v3/push_without_func.go | 2 +- 32 files changed, 670 insertions(+), 160 deletions(-) create mode 100644 benchmark/src/use_cases/create_humans.ts create mode 100644 cmd/setup/77.go create mode 100644 cmd/setup/77.sql create mode 100644 cmd/setup/77_current_states.sql create mode 100644 cmd/setup/77_test.go create mode 100644 internal/eventstore/event_sort_key.go create mode 100644 internal/eventstore/handler/v2/handler_test.go create mode 100644 internal/eventstore/repository/sql/query_sort_key_test.go diff --git a/benchmark/Makefile b/benchmark/Makefile index 7e7974649..2589ea5b8 100644 --- a/benchmark/Makefile +++ b/benchmark/Makefile @@ -78,6 +78,10 @@ users_by_metadata_value: ensure_modules bundle users_by_login_name: ensure_modules bundle ${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/users_by_login_name.js --vus ${VUS} --duration ${DURATION} --out csv=output/users_by_login_name_${DATE}.csv +.PHONY: create_humans +create_humans: ensure_modules ensure_key_pair bundle + ${K6} run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/create_humans.js --vus ${VUS} --duration ${DURATION} + .PHONY: lint lint: npm i diff --git a/benchmark/src/use_cases/create_humans.ts b/benchmark/src/use_cases/create_humans.ts new file mode 100644 index 000000000..33fbb46e4 --- /dev/null +++ b/benchmark/src/use_cases/create_humans.ts @@ -0,0 +1,24 @@ +import { Config } from '../config'; +import { loginByUsernamePassword } from '../login_ui'; +import { createOrg, Org } from '../org'; +import { createHuman, User } from '../user'; + +type SetupData = { + tokens: { accessToken?: string }; + org: Org; +}; + +export async function setup(): Promise { + const tokens = loginByUsernamePassword(Config.admin as User); + console.info('setup: admin signed in'); + + const org = await createOrg(tokens.accessToken!); + console.info(`setup: org (${org.organizationId}) created`); + + return { tokens, org }; +} + +export default async function (data: SetupData) { + await createHuman(`zitizen3-${ __VU }-${ __ITER }`, data.org, data.tokens.accessToken!); +} + diff --git a/cmd/initialise/sql/08_events_table.sql b/cmd/initialise/sql/08_events_table.sql index c22394838..2a41cea1e 100644 --- a/cmd/initialise/sql/08_events_table.sql +++ b/cmd/initialise/sql/08_events_table.sql @@ -53,13 +53,12 @@ END $$; CREATE OR REPLACE FUNCTION eventstore.commands_to_events(commands eventstore.command2[]) RETURNS SETOF eventstore.events2 LANGUAGE 'plpgsql' - STABLE PARALLEL SAFE + VOLATILE PARALLEL SAFE ROWS 10 AS $$ DECLARE _latest_events eventstore.latest_command[]; command eventstore.command2; - last_owner TEXT; i INTEGER; BEGIN @@ -132,14 +131,14 @@ BEGIN , c.command_type AS event_type , COALESCE(e.sequence, 0) + ROW_NUMBER() OVER (PARTITION BY c.instance_id, c.aggregate_type, c.aggregate_id ORDER BY c.in_tx_order) AS sequence , c.revision - , NOW() AS created_at + , statement_timestamp() AS created_at , c.payload , c.creator , CASE WHEN c.enforce_owner THEN c.owner ELSE COALESCE(e.owner, c.owner) END AS owner - , EXTRACT(EPOCH FROM NOW()) AS position + , EXTRACT(EPOCH FROM clock_timestamp()) AS position , c.in_tx_order FROM ( SELECT c.*, CAST(ROW_NUMBER() OVER () AS INTEGER) AS in_tx_order diff --git a/cmd/setup/77.go b/cmd/setup/77.go new file mode 100644 index 000000000..25d9e51d5 --- /dev/null +++ b/cmd/setup/77.go @@ -0,0 +1,53 @@ +package setup + +import ( + "context" + _ "embed" + "fmt" + + "github.com/zitadel/zitadel/backend/v3/instrumentation/logging" + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +var ( + //go:embed 77.sql + stampEventPositionAtInsert string + //go:embed 77_current_states.sql + backfillCurrentStatesInTxOrder string +) + +type StampEventPositionAtInsert struct { + dbClient *database.DB +} + +func (mig *StampEventPositionAtInsert) Execute(ctx context.Context, _ eventstore.Event) error { + inTxOrderType, err := inTxOrderType(ctx, mig.dbClient) + if err != nil { + return err + } + + tx, err := mig.dbClient.BeginTx(ctx, nil) + if err != nil { + return err + } + stmt := fmt.Sprintf(stampEventPositionAtInsert, inTxOrderType) + _, err = tx.ExecContext(ctx, stmt) + if err == nil { + _, err = tx.ExecContext(ctx, backfillCurrentStatesInTxOrder) + } + if err = database.CloseTransaction(tx, err); err != nil { + return err + } + + // close idle connections to prevent them from using the old prepared statement + for _, conn := range mig.dbClient.Pool.AcquireAllIdle(ctx) { + logging.OnError(ctx, conn.Conn().Close(ctx)).Debug("failed to close idle connection") + conn.Release() + } + return nil +} + +func (mig *StampEventPositionAtInsert) String() string { + return "77_eventstore_position_clock_timestamp" +} diff --git a/cmd/setup/77.sql b/cmd/setup/77.sql new file mode 100644 index 000000000..733ab348a --- /dev/null +++ b/cmd/setup/77.sql @@ -0,0 +1,99 @@ +CREATE OR REPLACE FUNCTION eventstore.commands_to_events(commands eventstore.command2[]) + RETURNS SETOF eventstore.events2 + LANGUAGE 'plpgsql' + VOLATILE PARALLEL SAFE + ROWS 10 +AS $$ +DECLARE + _latest_events eventstore.latest_command[]; + command eventstore.command2; + i INTEGER; +BEGIN + + SELECT array_agg(res) INTO _latest_events + FROM ( + SELECT + ROW( + c.instance_id, + c.aggregate_type, + c.aggregate_id, + COALESCE(e.owner, c.owner), + COALESCE(e.sequence, 0) + )::eventstore.latest_command AS res + FROM ( + SELECT DISTINCT ON (c.instance_id, c.aggregate_type, c.aggregate_id) + c.instance_id, + c.aggregate_type, + c.aggregate_id, + c.owner, + c.ordinality + FROM ( + SELECT * + FROM UNNEST(commands) WITH ORDINALITY AS c + ) AS c + ORDER BY + c.instance_id, + c.aggregate_type, + c.aggregate_id, + c.ordinality + ) AS c + LEFT JOIN LATERAL ( + SELECT + e.owner, + e.sequence + FROM eventstore.events2 AS e + WHERE e.instance_id = c.instance_id + AND e.aggregate_type = c.aggregate_type + AND e.aggregate_id = c.aggregate_id + ORDER BY e.sequence DESC + LIMIT 1 + ) AS e ON TRUE + ) AS latest; + + FOR command IN SELECT * FROM UNNEST(commands) LOOP + IF NOT command.enforce_owner THEN + SELECT owner INTO command.owner + FROM UNNEST(_latest_events) AS e + WHERE e.instance_id = command.instance_id + AND e.aggregate_type = command.aggregate_type + AND e.aggregate_id = command.aggregate_id; + ELSE + FOR i IN 1..array_length(_latest_events, 1) LOOP + IF _latest_events[i].instance_id = command.instance_id + AND _latest_events[i].aggregate_type = command.aggregate_type + AND _latest_events[i].aggregate_id = command.aggregate_id + THEN + _latest_events[i].owner := command.owner; + EXIT; + END IF; + END LOOP; + END IF; + + END LOOP; + + + RETURN QUERY SELECT + c.instance_id + , c.aggregate_type + , c.aggregate_id + , c.command_type AS event_type + , COALESCE(e.sequence, 0) + ROW_NUMBER() OVER (PARTITION BY c.instance_id, c.aggregate_type, c.aggregate_id ORDER BY c.ordinality) AS sequence + , c.revision + , statement_timestamp() AS created_at + , c.payload + , c.creator + , CASE WHEN c.enforce_owner + THEN c.owner + ELSE COALESCE(e.owner, c.owner) + END AS owner + , EXTRACT(EPOCH FROM clock_timestamp()) AS position + , c.ordinality::%s AS in_tx_order + FROM UNNEST(commands) WITH ORDINALITY AS c + LEFT JOIN unnest(_latest_events) AS e + ON c.instance_id = e.instance_id + AND c.aggregate_type = e.aggregate_type + AND c.aggregate_id = e.aggregate_id + ORDER BY + in_tx_order; +END; +$$; diff --git a/cmd/setup/77_current_states.sql b/cmd/setup/77_current_states.sql new file mode 100644 index 000000000..bd5b59df7 --- /dev/null +++ b/cmd/setup/77_current_states.sql @@ -0,0 +1,8 @@ +-- filter_offset stores events2.in_tx_order for sort-key resume, not a row OFFSET. +UPDATE projections.current_states cs +SET filter_offset = e.in_tx_order +FROM eventstore.events2 e +WHERE cs.instance_id = e.instance_id + AND cs.aggregate_id = e.aggregate_id + AND cs.aggregate_type = e.aggregate_type + AND cs."sequence" = e.sequence; diff --git a/cmd/setup/77_test.go b/cmd/setup/77_test.go new file mode 100644 index 000000000..451576f23 --- /dev/null +++ b/cmd/setup/77_test.go @@ -0,0 +1,14 @@ +package setup + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStampEventPositionAtInsertSQL(t *testing.T) { + assert.NotContains(t, stampEventPositionAtInsert, "last_owner") + assert.Contains(t, stampEventPositionAtInsert, "i INTEGER") + assert.Contains(t, stampEventPositionAtInsert, "clock_timestamp()") + assert.Contains(t, stampEventPositionAtInsert, "VOLATILE PARALLEL SAFE") +} diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 0cfc5c15f..07a7d100c 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -196,6 +196,7 @@ type Steps struct { s74Apps7OIDCConfigsAddRegistrationToken *Apps7OIDCConfigsAddRegistrationToken s75Apps7OIDCConfigsAddAppLinkConfig *Apps7OIDCConfigsAddAppLinkConfig s76Users14LoginEqualityIndexes *Users14LoginEqualityIndexes + s77StampEventPositionAtInsert *StampEventPositionAtInsert RelationalTables *TransactionalTables } diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 4013b6071..b3a82ec9a 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -259,6 +259,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s74Apps7OIDCConfigsAddRegistrationToken = &Apps7OIDCConfigsAddRegistrationToken{dbClient: dbClient} steps.s75Apps7OIDCConfigsAddAppLinkConfig = &Apps7OIDCConfigsAddAppLinkConfig{dbClient: dbClient} steps.s76Users14LoginEqualityIndexes = &Users14LoginEqualityIndexes{dbClient: dbClient} + steps.s77StampEventPositionAtInsert = &StampEventPositionAtInsert{dbClient: dbClient} err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil) if err != nil { @@ -318,6 +319,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s69CacheTablesLogged, steps.s70AddEventStoreCommandEnforceOwner, steps.s76Users14LoginEqualityIndexes, + steps.s77StampEventPositionAtInsert, } { setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed") if setupErr != nil { diff --git a/internal/eventstore/event.go b/internal/eventstore/event.go index 35392c2cb..b6e028537 100644 --- a/internal/eventstore/event.go +++ b/internal/eventstore/event.go @@ -61,6 +61,8 @@ type Event interface { CreatedAt() time.Time // Position is the global position of the event Position() decimal.Decimal + // InTxOrder is the 1-based ordinal of the event inside its push transaction + InTxOrder() uint32 // Unmarshal parses the payload and stores the result // in the value pointed to by ptr. If ptr is nil or not a pointer, diff --git a/internal/eventstore/event_base.go b/internal/eventstore/event_base.go index 3c4b5a709..5dbf9adfc 100644 --- a/internal/eventstore/event_base.go +++ b/internal/eventstore/event_base.go @@ -27,6 +27,7 @@ type BaseEvent struct { Seq uint64 Pos decimal.Decimal + InTx uint32 Creation time.Time previousAggregateSequence uint64 previousAggregateTypeSequence uint64 @@ -68,6 +69,11 @@ func (e *BaseEvent) Sequence() uint64 { return e.Seq } +// InTxOrder implements Event +func (e *BaseEvent) InTxOrder() uint32 { + return e.InTx +} + // CreationDate is the time, the event is inserted into the eventstore func (e *BaseEvent) CreationDate() time.Time { return e.Creation @@ -116,6 +122,7 @@ func BaseEventFromRepo(event Event) *BaseEvent { User: event.Creator(), Data: event.DataAsBytes(), Pos: event.Position(), + InTx: event.InTxOrder(), } } diff --git a/internal/eventstore/event_sort_key.go b/internal/eventstore/event_sort_key.go new file mode 100644 index 000000000..87a8db3c0 --- /dev/null +++ b/internal/eventstore/event_sort_key.go @@ -0,0 +1,28 @@ +package eventstore + +import "github.com/shopspring/decimal" + +// EventSortKey is the events2 cursor: position, in_tx_order, instance_id, aggregate_type, aggregate_id, sequence. +type EventSortKey struct { + Position decimal.Decimal + InTxOrder uint32 + InstanceID string + AggregateType AggregateType + AggregateID string + Sequence uint64 +} + +func EventSortKeyFromEvent(e Event) EventSortKey { + return EventSortKey{ + Position: e.Position(), + InTxOrder: e.InTxOrder(), + InstanceID: e.Aggregate().InstanceID, + AggregateType: e.Aggregate().Type, + AggregateID: e.Aggregate().ID, + Sequence: e.Sequence(), + } +} + +func (k EventSortKey) IsZero() bool { + return k.Position.IsZero() +} diff --git a/internal/eventstore/handler/v2/field_handler.go b/internal/eventstore/handler/v2/field_handler.go index 957a9c0dd..f8a2f6d2a 100644 --- a/internal/eventstore/handler/v2/field_handler.go +++ b/internal/eventstore/handler/v2/field_handler.go @@ -141,17 +141,11 @@ func (h *FieldHandler) processEvents(ctx context.Context, config *triggerConfig) if err != nil { return additionalIteration, err } - // stop execution if currentState.eventTimestamp >= config.maxCreatedAt - if !config.maxPosition.IsZero() && currentState.position.GreaterThanOrEqual(config.maxPosition) { + if !config.maxPosition.IsZero() && currentState.cursor.Position.GreaterThanOrEqual(config.maxPosition) { return false, nil } - if config.minPosition.GreaterThan(decimal.NewFromInt(0)) { - currentState.position = config.minPosition - currentState.offset = 0 - } - - events, additionalIteration, err := h.fetchEvents(ctx, tx, currentState) + events, additionalIteration, err := h.fetchEvents(ctx, tx, currentState, config.minPosition) if err != nil { return additionalIteration, err } @@ -170,59 +164,20 @@ func (h *FieldHandler) processEvents(ctx context.Context, config *triggerConfig) return additionalIteration, err } -func (h *FieldHandler) fetchEvents(ctx context.Context, tx *sql.Tx, currentState *state) (_ []eventstore.FillFieldsEvent, additionalIteration bool, err error) { - events, err := h.es.Filter(ctx, h.eventQuery(currentState).SetTx(tx)) +func (h *FieldHandler) fetchEvents(ctx context.Context, tx *sql.Tx, currentState *state, minPosition decimal.Decimal) (_ []eventstore.FillFieldsEvent, additionalIteration bool, err error) { + events, err := h.es.Filter(ctx, h.eventQuery(currentState, minPosition).SetTx(tx)) if err != nil || len(events) == 0 { logging.OnError(ctx, err).Debug("filter eventstore failed") return nil, false, err } - eventAmount := len(events) - idx, offset := skipPreviouslyReducedEvents(events, currentState) - - if currentState.position.Equal(events[len(events)-1].Position()) { - offset += currentState.offset - } - currentState.position = events[len(events)-1].Position() - currentState.offset = offset - currentState.aggregateID = events[len(events)-1].Aggregate().ID - currentState.aggregateType = events[len(events)-1].Aggregate().Type - currentState.sequence = events[len(events)-1].Sequence() - currentState.eventTimestamp = events[len(events)-1].CreatedAt() - - if idx+1 == len(events) { - return nil, false, nil - } - events = events[idx+1:] - - additionalIteration = eventAmount == int(h.bulkLimit) + currentState.applyEvent(events[len(events)-1]) + additionalIteration = len(events) == int(h.bulkLimit) fillFieldsEvents := make([]eventstore.FillFieldsEvent, len(events)) - highestPosition := events[len(events)-1].Position() for i, event := range events { - if event.Position().Equal(highestPosition) { - offset++ - } fillFieldsEvents[i] = event.(eventstore.FillFieldsEvent) } return fillFieldsEvents, additionalIteration, nil } - -func skipPreviouslyReducedEvents(events []eventstore.Event, currentState *state) (index int, offset uint32) { - var position decimal.Decimal - for i, event := range events { - if !event.Position().Equal(position) { - offset = 0 - position = event.Position() - } - offset++ - if event.Position().Equal(currentState.position) && - event.Aggregate().ID == currentState.aggregateID && - event.Aggregate().Type == currentState.aggregateType && - event.Sequence() == currentState.sequence { - return i, offset - } - } - return -1, 0 -} diff --git a/internal/eventstore/handler/v2/handler.go b/internal/eventstore/handler/v2/handler.go index 6fcb7f782..2c423cbf9 100644 --- a/internal/eventstore/handler/v2/handler.go +++ b/internal/eventstore/handler/v2/handler.go @@ -583,18 +583,12 @@ func (h *Handler) processEvents(ctx context.Context, config *triggerConfig) (add if err != nil { return additionalIteration, err } - // stop execution if currentState.position >= config.maxPosition - if !config.maxPosition.IsZero() && currentState.position.GreaterThanOrEqual(config.maxPosition) { + if !config.maxPosition.IsZero() && currentState.cursor.Position.GreaterThanOrEqual(config.maxPosition) { return false, nil } - if config.minPosition.GreaterThan(decimal.NewFromInt(0)) { - currentState.position = config.minPosition - currentState.offset = 0 - } - var statements []*Statement - statements, additionalIteration, err = h.generateStatements(ctx, tx, currentState) + statements, additionalIteration, err = h.generateStatements(ctx, tx, currentState, config.minPosition) if err != nil { return additionalIteration, err } @@ -607,7 +601,7 @@ func (h *Handler) processEvents(ctx context.Context, config *triggerConfig) (add h.metrics.ProjectionEventsProcessed(ctx, h.ProjectionName(), int64(len(statements)), err == nil) - if err == nil && currentState.aggregateID != "" && len(statements) > 0 { + if err == nil && currentState.cursor.AggregateID != "" && len(statements) > 0 { // Don't update projection timing or latency unless we successfully processed events h.metrics.ProjectionUpdateTiming(ctx, h.ProjectionName(), float64(time.Since(start).Seconds())) h.metrics.ProjectionStateLatency(ctx, h.ProjectionName(), time.Since(currentState.eventTimestamp).Seconds()) @@ -627,12 +621,7 @@ func (h *Handler) processEvents(ctx context.Context, config *triggerConfig) (add return false, err } - currentState.position = statements[lastProcessedIndex].Position - currentState.offset = statements[lastProcessedIndex].offset - currentState.aggregateID = statements[lastProcessedIndex].Aggregate.ID - currentState.aggregateType = statements[lastProcessedIndex].Aggregate.Type - currentState.sequence = statements[lastProcessedIndex].Sequence - currentState.eventTimestamp = statements[lastProcessedIndex].CreationDate + currentState.applyStatement(statements[lastProcessedIndex]) setStateErr := h.setState(ctx, tx, currentState) if setStateErr != nil { @@ -642,7 +631,7 @@ func (h *Handler) processEvents(ctx context.Context, config *triggerConfig) (add return additionalIteration, err } -func (h *Handler) generateStatements(ctx context.Context, tx *sql.Tx, currentState *state) (_ []*Statement, additionalIteration bool, err error) { +func (h *Handler) generateStatements(ctx context.Context, tx *sql.Tx, currentState *state, minPosition decimal.Decimal) (_ []*Statement, additionalIteration bool, err error) { if h.triggerWithoutEvents != nil { stmt, err := h.triggerWithoutEvents(pseudo.NewScheduledEvent(ctx, time.Now(), currentState.instanceID)) if err != nil { @@ -651,31 +640,18 @@ func (h *Handler) generateStatements(ctx context.Context, tx *sql.Tx, currentSta return []*Statement{stmt}, false, nil } - events, err := h.es.Filter(ctx, h.eventQuery(currentState).SetTx(tx)) + events, err := h.es.Filter(ctx, h.eventQuery(currentState, minPosition).SetTx(tx)) if err != nil { logging.WithError(ctx, err).Debug("filter eventstore failed") return nil, false, err } eventAmount := len(events) - statements, err := h.eventsToStatements(ctx, tx, events, currentState) + statements, err := h.eventsToStatements(ctx, tx, events) if err != nil || len(statements) == 0 { return nil, false, err } - idx := skipPreviouslyReducedStatements(statements, currentState) - if idx+1 == len(statements) { - currentState.position = statements[len(statements)-1].Position - currentState.offset = statements[len(statements)-1].offset - currentState.aggregateID = statements[len(statements)-1].Aggregate.ID - currentState.aggregateType = statements[len(statements)-1].Aggregate.Type - currentState.sequence = statements[len(statements)-1].Sequence - currentState.eventTimestamp = statements[len(statements)-1].CreationDate - - return nil, false, nil - } - statements = statements[idx+1:] - additionalIteration = eventAmount == int(h.bulkLimit) if len(statements) < len(events) { // retry immediately if statements failed @@ -685,18 +661,6 @@ func (h *Handler) generateStatements(ctx context.Context, tx *sql.Tx, currentSta return statements, additionalIteration, nil } -func skipPreviouslyReducedStatements(statements []*Statement, currentState *state) int { - for i, statement := range statements { - if statement.Position.Equal(currentState.position) && - statement.Aggregate.ID == currentState.aggregateID && - statement.Aggregate.Type == currentState.aggregateType && - statement.Sequence == currentState.sequence { - return i - } - } - return -1 -} - func (h *Handler) executeStatements(ctx context.Context, tx *sql.Tx, statements []*Statement) (lastProcessedIndex int, err error) { lastProcessedIndex = -1 @@ -743,18 +707,17 @@ func (h *Handler) executeStatement(ctx context.Context, tx *sql.Tx, statement *S return nil } -func (h *Handler) eventQuery(currentState *state) *eventstore.SearchQueryBuilder { +func (h *Handler) eventQuery(currentState *state, minPosition decimal.Decimal) *eventstore.SearchQueryBuilder { builder := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). AwaitOpenTransactions(). Limit(uint64(h.bulkLimit)). OrderAsc(). InstanceID(currentState.instanceID) - if currentState.position.GreaterThan(decimal.Decimal{}) { - builder = builder.PositionAtLeast(currentState.position) - if currentState.offset > 0 { - builder = builder.Offset(currentState.offset) - } + if minPosition.GreaterThan(decimal.NewFromInt(0)) { + builder = builder.PositionAtLeast(minPosition) + } else if !currentState.cursor.IsZero() { + builder = builder.AfterEventSortKey(currentState.cursor) } if h.queryGlobal { diff --git a/internal/eventstore/handler/v2/handler_test.go b/internal/eventstore/handler/v2/handler_test.go new file mode 100644 index 000000000..50dbd0066 --- /dev/null +++ b/internal/eventstore/handler/v2/handler_test.go @@ -0,0 +1,135 @@ +package handler + +import ( + "context" + "testing" + + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/eventstore" +) + +func TestHandler_eventQuery(t *testing.T) { + h := &Handler{ + bulkLimit: 200, + eventTypes: map[eventstore.AggregateType][]eventstore.EventType{ + "user": {"user.human.added", "user.human.password.changed"}, + }, + } + + t.Run("no cursor on empty position", func(t *testing.T) { + builder := h.eventQuery(&state{instanceID: "inst"}, decimal.Decimal{}) + assert.Equal(t, uint32(0), builder.GetOffset()) + assert.Nil(t, builder.GetEventSortKeyAfter()) + assert.True(t, builder.GetPositionAtLeast().IsZero()) + }) + + t.Run("resumes after event sort key", func(t *testing.T) { + cursor := eventstore.EventSortKey{ + Position: decimal.NewFromFloat(1788336088.993079), + InTxOrder: 5, + AggregateType: "user", + AggregateID: "388963124960608862", + Sequence: 5, + } + builder := h.eventQuery(&state{ + instanceID: "inst", + cursor: cursor, + }, decimal.Decimal{}) + assert.Equal(t, uint32(0), builder.GetOffset()) + assert.True(t, builder.GetPositionAtLeast().IsZero()) + require.NotNil(t, builder.GetEventSortKeyAfter()) + assert.Equal(t, cursor, *builder.GetEventSortKeyAfter()) + }) + + t.Run("min position is an inclusive floor not a smashed cursor", func(t *testing.T) { + cursor := eventstore.EventSortKey{ + Position: decimal.NewFromInt(1), + InTxOrder: 5, + AggregateType: "user", + AggregateID: "agg-a", + Sequence: 5, + } + minPosition := decimal.NewFromInt(10) + builder := h.eventQuery(&state{ + instanceID: "inst", + cursor: cursor, + }, minPosition) + assert.Nil(t, builder.GetEventSortKeyAfter()) + assert.True(t, builder.GetPositionAtLeast().Equal(minPosition)) + assert.Equal(t, uint32(0), builder.GetOffset()) + }) +} + +func TestHandler_eventsToStatements_inTxOrder(t *testing.T) { + h := &Handler{ + projection: &projection{name: "users14"}, + } + + pos1 := decimal.NewFromFloat(1) + pos2 := decimal.NewFromFloat(1.5) + events := []eventstore.Event{ + &eventstore.BaseEvent{ + Agg: &eventstore.Aggregate{ID: "agg-a", Type: "user"}, + EventType: "user.human.added", + Seq: 1, + Pos: pos1, + InTx: 1, + }, + &eventstore.BaseEvent{ + Agg: &eventstore.Aggregate{ID: "agg-a", Type: "user"}, + EventType: "user.human.email.verified", + Seq: 2, + Pos: pos1, + InTx: 2, + }, + &eventstore.BaseEvent{ + Agg: &eventstore.Aggregate{ID: "agg-b", Type: "user"}, + EventType: "user.human.password.changed", + Seq: 10, + Pos: pos2, + InTx: 1, + }, + } + + statements, err := h.eventsToStatements(context.Background(), nil, events) + require.NoError(t, err) + require.Len(t, statements, 3) + assert.Equal(t, uint32(1), statements[0].inTxOrder) + assert.Equal(t, uint32(2), statements[1].inTxOrder) + assert.Equal(t, uint32(1), statements[2].inTxOrder) + assert.Equal(t, "agg-b", statements[2].Aggregate.ID) +} + +func TestHandler_eventsToStatements_samePositionInTxOrder(t *testing.T) { + h := &Handler{ + projection: &projection{name: "users14"}, + } + pos := decimal.NewFromInt(1) + events := []eventstore.Event{ + &eventstore.BaseEvent{ + Agg: &eventstore.Aggregate{ID: "agg-a", Type: "user"}, + EventType: "user.human.added", + Seq: 1, + Pos: pos, + InTx: 1, + }, + &eventstore.BaseEvent{ + Agg: &eventstore.Aggregate{ID: "agg-b", Type: "user"}, + EventType: "user.human.password.changed", + Seq: 1, + Pos: pos, + InTx: 1, + }, + } + + statements, err := h.eventsToStatements(context.Background(), nil, events) + require.NoError(t, err) + require.Len(t, statements, 2) + assert.Equal(t, "agg-a", statements[0].Aggregate.ID) + assert.Equal(t, "agg-b", statements[1].Aggregate.ID) + assert.Equal(t, uint32(1), statements[0].inTxOrder) + assert.Equal(t, uint32(1), statements[1].inTxOrder) +} diff --git a/internal/eventstore/handler/v2/state.go b/internal/eventstore/handler/v2/state.go index 5de452685..056c5924d 100644 --- a/internal/eventstore/handler/v2/state.go +++ b/internal/eventstore/handler/v2/state.go @@ -17,12 +17,8 @@ import ( type state struct { instanceID string - position decimal.Decimal eventTimestamp time.Time - aggregateType eventstore.AggregateType - aggregateID string - sequence uint64 - offset uint32 + cursor eventstore.EventSortKey } var ( @@ -43,7 +39,7 @@ func (h *Handler) currentState(ctx context.Context, tx *sql.Tx) (currentState *s sequence = new(sql.NullInt64) timestamp = new(sql.NullTime) position = new(decimal.NullDecimal) - offset = new(sql.NullInt64) + inTxOrder = new(sql.NullInt64) ) row := tx.QueryRow(currentStateStmt, currentState.instanceID, h.projection.Name()) @@ -53,20 +49,22 @@ func (h *Handler) currentState(ctx context.Context, tx *sql.Tx) (currentState *s sequence, timestamp, position, - offset, + inTxOrder, ) if err != nil && !errors.Is(err, sql.ErrNoRows) { logging.WithError(ctx, err).Debug("unable to query current state") return nil, err } - currentState.aggregateID = aggregateID.String - currentState.aggregateType = eventstore.AggregateType(aggregateType.String) - currentState.sequence = uint64(sequence.Int64) currentState.eventTimestamp = timestamp.Time - currentState.position = position.Decimal - // psql does not provide unsigned numbers so we work around it - currentState.offset = uint32(offset.Int64) + currentState.cursor = eventstore.EventSortKey{ + Position: position.Decimal, + InTxOrder: uint32(inTxOrder.Int64), + InstanceID: currentState.instanceID, + AggregateType: eventstore.AggregateType(aggregateType.String), + AggregateID: aggregateID.String, + Sequence: uint64(sequence.Int64), + } return currentState, nil } @@ -74,12 +72,12 @@ func (h *Handler) setState(ctx context.Context, tx *sql.Tx, updatedState *state) res, err := tx.Exec(updateStateStmt, h.projection.Name(), updatedState.instanceID, - updatedState.aggregateID, - updatedState.aggregateType, - updatedState.sequence, + updatedState.cursor.AggregateID, + updatedState.cursor.AggregateType, + updatedState.cursor.Sequence, updatedState.eventTimestamp, - updatedState.position, - updatedState.offset, + updatedState.cursor.Position, + updatedState.cursor.InTxOrder, ) if err != nil { err = zerrors.ThrowInternal(err, "V2-WF23g2", "unable to update state") @@ -93,3 +91,13 @@ func (h *Handler) setState(ctx context.Context, tx *sql.Tx, updatedState *state) } return nil } + +func (s *state) applyStatement(stmt *Statement) { + s.cursor = stmt.eventSortKey() + s.eventTimestamp = stmt.CreationDate +} + +func (s *state) applyEvent(event eventstore.Event) { + s.cursor = eventstore.EventSortKeyFromEvent(event) + s.eventTimestamp = event.CreatedAt() +} diff --git a/internal/eventstore/handler/v2/state_get.sql b/internal/eventstore/handler/v2/state_get.sql index a324dbbab..689202fb9 100644 --- a/internal/eventstore/handler/v2/state_get.sql +++ b/internal/eventstore/handler/v2/state_get.sql @@ -1,3 +1,4 @@ +-- filter_offset stores events2.in_tx_order (reused column; not a row OFFSET). SELECT aggregate_id , aggregate_type diff --git a/internal/eventstore/handler/v2/state_test.go b/internal/eventstore/handler/v2/state_test.go index f793f133f..6537c3946 100644 --- a/internal/eventstore/handler/v2/state_test.go +++ b/internal/eventstore/handler/v2/state_test.go @@ -50,7 +50,7 @@ func TestHandler_updateLastUpdated(t *testing.T) { updatedState: &state{ instanceID: "instance", eventTimestamp: time.Now(), - position: decimal.NewFromInt(42), + cursor: eventstore.EventSortKey{Position: decimal.NewFromInt(42)}, }, }, isErr: func(t *testing.T, err error) { @@ -76,7 +76,7 @@ func TestHandler_updateLastUpdated(t *testing.T) { updatedState: &state{ instanceID: "instance", eventTimestamp: time.Now(), - position: decimal.NewFromInt(42), + cursor: eventstore.EventSortKey{Position: decimal.NewFromInt(42)}, }, }, isErr: func(t *testing.T, err error) { @@ -112,10 +112,12 @@ func TestHandler_updateLastUpdated(t *testing.T) { updatedState: &state{ instanceID: "instance", eventTimestamp: time.Now(), - position: decimal.NewFromInt(42), - aggregateType: "aggregate type", - aggregateID: "aggregate id", - sequence: 42, + cursor: eventstore.EventSortKey{ + Position: decimal.NewFromInt(42), + AggregateType: "aggregate type", + AggregateID: "aggregate id", + Sequence: 42, + }, }, }, }, @@ -261,11 +263,14 @@ func TestHandler_currentState(t *testing.T) { currentState: &state{ instanceID: "instance", eventTimestamp: testTime, - position: decimal.NewFromInt(42), - aggregateType: "aggregate type", - aggregateID: "aggregate id", - sequence: 42, - offset: 10, + cursor: eventstore.EventSortKey{ + Position: decimal.NewFromInt(42), + InTxOrder: 10, + InstanceID: "instance", + AggregateType: "aggregate type", + AggregateID: "aggregate id", + Sequence: 42, + }, }, }, }, diff --git a/internal/eventstore/handler/v2/statement.go b/internal/eventstore/handler/v2/statement.go index 3aeb043c6..f960aef58 100644 --- a/internal/eventstore/handler/v2/statement.go +++ b/internal/eventstore/handler/v2/statement.go @@ -39,11 +39,9 @@ func (s *executionError) Unwrap() error { return s.parent } -func (h *Handler) eventsToStatements(ctx context.Context, tx *sql.Tx, events []eventstore.Event, currentState *state) (statements []*Statement, err error) { +func (h *Handler) eventsToStatements(ctx context.Context, tx *sql.Tx, events []eventstore.Event) (statements []*Statement, err error) { statements = make([]*Statement, 0, len(events)) - previousPosition := currentState.position - offset := currentState.offset for _, event := range events { statement, err := h.reduce(event) if err != nil { @@ -53,14 +51,6 @@ func (h *Handler) eventsToStatements(ctx context.Context, tx *sql.Tx, events []e } return statements, &executionError{err} } - offset++ - if !previousPosition.Equal(event.Position()) { - // offset is 1 because we want to skip this event - offset = 1 - } - statement.offset = offset - statement.Position = event.Position() - previousPosition = event.Position() statements = append(statements, statement) } return statements, nil @@ -87,11 +77,22 @@ type Statement struct { Position decimal.Decimal CreationDate time.Time - offset uint32 + inTxOrder uint32 Execute Exec } +func (s *Statement) eventSortKey() eventstore.EventSortKey { + return eventstore.EventSortKey{ + Position: s.Position, + InTxOrder: s.inTxOrder, + InstanceID: s.Aggregate.InstanceID, + AggregateType: s.Aggregate.Type, + AggregateID: s.Aggregate.ID, + Sequence: s.Sequence, + } +} + type Exec func(ctx context.Context, ex Executer, projectionName string) error func WithTableSuffix(name string) func(*execConfig) { @@ -112,6 +113,7 @@ func NewStatement(event eventstore.Event, e Exec) *Statement { Sequence: event.Sequence(), Position: event.Position(), CreationDate: event.CreatedAt(), + inTxOrder: event.InTxOrder(), Execute: e, } } diff --git a/internal/eventstore/repository/event.go b/internal/eventstore/repository/event.go index 12a0201b7..fbf41c751 100644 --- a/internal/eventstore/repository/event.go +++ b/internal/eventstore/repository/event.go @@ -24,6 +24,8 @@ type Event struct { Seq uint64 // Pos is the global sequence of the event multiple events can have the same sequence Pos decimal.Decimal + // InTx is the 1-based ordinal of the event inside its push transaction + InTx uint32 // CreationDate is the time the event is created // it's used for human readability. @@ -102,6 +104,11 @@ func (e *Event) Position() decimal.Decimal { return e.Pos } +// InTxOrder implements [eventstore.Event] +func (e *Event) InTxOrder() uint32 { + return e.InTx +} + // CreatedAt implements [eventstore.Event] func (e *Event) CreatedAt() time.Time { return e.CreationDate diff --git a/internal/eventstore/repository/mock/repository.mock.impl.go b/internal/eventstore/repository/mock/repository.mock.impl.go index 313f7ee5e..b58790f41 100644 --- a/internal/eventstore/repository/mock/repository.mock.impl.go +++ b/internal/eventstore/repository/mock/repository.mock.impl.go @@ -202,6 +202,10 @@ func (e *mockEvent) Position() decimal.Decimal { return decimal.Decimal{} } +func (e *mockEvent) InTxOrder() uint32 { + return 0 +} + func (e *mockEvent) CreatedAt() time.Time { return e.createdAt } diff --git a/internal/eventstore/repository/search_query.go b/internal/eventstore/repository/search_query.go index e1f848b14..5a605d6b7 100644 --- a/internal/eventstore/repository/search_query.go +++ b/internal/eventstore/repository/search_query.go @@ -31,6 +31,7 @@ type SearchQuery struct { CreatedAfter *Filter CreatedBefore *Filter ExcludeAggregateIDs []*Filter + EventSortKeyAfter *eventstore.EventSortKey } // Filter represents all fields needed to compare a field of an event with a value @@ -132,6 +133,7 @@ func QueryFromBuilder(builder *eventstore.SearchQueryBuilder) (*SearchQuery, err Tx: builder.GetTx(), AwaitOpenTransactions: builder.GetAwaitOpenTransactions(), SubQueries: make([][]*Filter, len(builder.GetQueries())), + EventSortKeyAfter: builder.GetEventSortKeyAfter(), } for _, f := range []func(builder *eventstore.SearchQueryBuilder, query *SearchQuery) *Filter{ diff --git a/internal/eventstore/repository/sql/postgres.go b/internal/eventstore/repository/sql/postgres.go index 0bd9910dc..8671ea783 100644 --- a/internal/eventstore/repository/sql/postgres.go +++ b/internal/eventstore/repository/sql/postgres.go @@ -15,7 +15,9 @@ import ( "github.com/zitadel/zitadel/internal/telemetry/tracing" ) -// awaitOpenTransactions ensures event ordering, so we don't events younger that open transactions +// awaitOpenTransactions drains in-flight writers, then caps at projector TX start (now()), +// not clock_timestamp(). A wall-clock cap at SELECT time would include rows inserted after +// BEGIN and recreate position overtake. var ( awaitOpenTransactionsV1 = ` AND created_at <= now()` awaitOpenTransactionsV2 = ` AND "position" <= EXTRACT(EPOCH FROM now())` @@ -38,6 +40,8 @@ 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"` + // 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) { ctx, span := tracing.NewSpan(ctx) @@ -91,9 +95,9 @@ func (db *Postgres) orderByEventSequence(desc, shouldOrderBySequence, useV1 bool } if desc { - return ` ORDER BY "position" DESC, in_tx_order DESC, instance_id, aggregate_type, aggregate_id` + return ` ORDER BY (` + eventSortKeySQL + `) DESC` } - return ` ORDER BY "position", in_tx_order, instance_id, aggregate_type, aggregate_id` + return ` ORDER BY ` + eventSortKeySQL } func (db *Postgres) eventQuery(useV1 bool) string { @@ -123,6 +127,7 @@ func (db *Postgres) eventQuery(useV1 bool) string { ", aggregate_type" + ", aggregate_id" + ", revision" + + ", in_tx_order" + " FROM eventstore.events2" } diff --git a/internal/eventstore/repository/sql/query.go b/internal/eventstore/repository/sql/query.go index 956396913..64c670885 100644 --- a/internal/eventstore/repository/sql/query.go +++ b/internal/eventstore/repository/sql/query.go @@ -103,7 +103,7 @@ func query(ctx context.Context, criteria querier, searchQuery *eventstore.Search // if there is only one subquery we can optimize the query ordering by sequence var shouldOrderBySequence bool - if len(q.SubQueries) == 1 { + if q.EventSortKeyAfter == nil && len(q.SubQueries) == 1 { for _, filter := range q.SubQueries[0] { if filter.Field == repository.FieldAggregateID { shouldOrderBySequence = filter.Operation == repository.OperationEquals @@ -226,6 +226,7 @@ func eventsScanner(useV1 bool) func(scanner scan, dest interface{}) (err error) &event.AggregateType, &event.AggregateID, &revision, + &event.InTx, ) event.Version = eventstore.Version("v" + strconv.Itoa(int(revision))) } @@ -279,6 +280,21 @@ func prepareConditions(criteria querier, query *repository.SearchQuery, useV1 bo args = append(args, additionalArgs...) } + if query.EventSortKeyAfter != nil && !useV1 { + if clauses != "" { + clauses += " AND " + } + clauses += `(` + eventSortKeySQL + `) > (?, ?, ?, ?, ?, ?)` + args = append(args, + query.EventSortKeyAfter.Position, + query.EventSortKeyAfter.InTxOrder, + query.EventSortKeyAfter.InstanceID, + query.EventSortKeyAfter.AggregateType, + query.EventSortKeyAfter.AggregateID, + query.EventSortKeyAfter.Sequence, + ) + } + excludeAggregateIDs := query.ExcludeAggregateIDs if len(excludeAggregateIDs) > 0 { excludeAggregateIDs = append(excludeAggregateIDs, query.InstanceID, query.InstanceIDs, query.Position, query.CreatedAfter, query.CreatedBefore) diff --git a/internal/eventstore/repository/sql/query_sort_key_test.go b/internal/eventstore/repository/sql/query_sort_key_test.go new file mode 100644 index 000000000..ed7c53df4 --- /dev/null +++ b/internal/eventstore/repository/sql/query_sort_key_test.go @@ -0,0 +1,144 @@ +package sql + +import ( + "context" + "database/sql/driver" + "regexp" + "strings" + "testing" + + "github.com/shopspring/decimal" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/repository" +) + +func Test_query_event_sort_key(t *testing.T) { + cursor := eventstore.EventSortKey{ + Position: decimal.NewFromFloat(1.5), + InTxOrder: 5, + InstanceID: "instanceID", + AggregateType: "user", + AggregateID: "agg-a", + Sequence: 5, + } + tuple := `(` + eventSortKeySQL + `) > (` + + tests := []struct { + name string + query *eventstore.SearchQueryBuilder + sql string + args []driver.Value + awaitLock bool + }{ + { + name: "after sort key with await cap", + query: eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + InstanceID("instanceID"). + OrderAsc(). + AwaitOpenTransactions(). + Limit(200). + AfterEventSortKey(cursor). + AddQuery(). + AggregateTypes("user"). + EventTypes("user.human.added"). + Builder(), + sql: `SELECT created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision, in_tx_order FROM eventstore.events2 WHERE instance_id = $1 AND aggregate_type = $2 AND event_type = $3 AND (` + eventSortKeySQL + `) > ($4, $5, $6, $7, $8, $9) AND "position" <= EXTRACT(EPOCH FROM now()) ORDER BY ` + eventSortKeySQL + ` LIMIT $10`, + args: []driver.Value{ + "instanceID", + eventstore.AggregateType("user"), + eventstore.EventType("user.human.added"), + cursor.Position, + cursor.InTxOrder, + cursor.InstanceID, + cursor.AggregateType, + cursor.AggregateID, + cursor.Sequence, + uint64(200), + }, + awaitLock: true, + }, + { + name: "after sort key without await", + query: eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + InstanceID("instanceID"). + OrderAsc(). + Limit(200). + AfterEventSortKey(cursor). + AddQuery(). + AggregateTypes("user"). + Builder(), + sql: `SELECT created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision, in_tx_order FROM eventstore.events2 WHERE instance_id = $1 AND aggregate_type = $2 AND (` + eventSortKeySQL + `) > ($3, $4, $5, $6, $7, $8) ORDER BY ` + eventSortKeySQL + ` LIMIT $9`, + args: []driver.Value{ + "instanceID", + eventstore.AggregateType("user"), + cursor.Position, + cursor.InTxOrder, + cursor.InstanceID, + cursor.AggregateType, + cursor.AggregateID, + cursor.Sequence, + uint64(200), + }, + }, + { + name: "after sort key with aggregate id still uses sort-key order", + query: eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + InstanceID("instanceID"). + OrderAsc(). + Limit(200). + AfterEventSortKey(cursor). + AddQuery(). + AggregateTypes("user"). + AggregateIDs("agg-a"). + Builder(), + sql: `SELECT created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision, in_tx_order FROM eventstore.events2 WHERE instance_id = $1 AND aggregate_type = $2 AND aggregate_id = $3 AND (` + eventSortKeySQL + `) > ($4, $5, $6, $7, $8, $9) ORDER BY ` + eventSortKeySQL + ` LIMIT $10`, + args: []driver.Value{ + "instanceID", + eventstore.AggregateType("user"), + "agg-a", + cursor.Position, + cursor.InTxOrder, + cursor.InstanceID, + cursor.AggregateType, + cursor.AggregateID, + cursor.Sequence, + uint64(200), + }, + }, + } + + client := NewPostgres(&database.DB{Database: new(testDB)}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if strings.Contains(tt.sql, "OFFSET") { + t.Fatal("sort-key resume must not use OFFSET") + } + if strings.Contains(tt.sql, `ORDER BY "sequence"`) { + t.Fatal("sort-key resume must not order by sequence") + } + if !strings.Contains(tt.sql, tuple) { + t.Fatalf("expected sort-key tuple %s in SQL", tuple) + } + + mock := newMockClient(t) + if tt.awaitLock { + mock = mock.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"}, + ) + } + mock = mock.expectQuery(regexp.QuoteMeta(tt.sql), tt.args) + client.DB.DB = mock.client + + err := query(context.Background(), client, tt.query, &[]*repository.Event{}, false) + if err != nil { + t.Fatalf("query() error = %v", err) + } + if err := mock.mock.ExpectationsWereMet(); err != nil { + t.Errorf("not all expectations met: %v", err) + } + }) + } +} diff --git a/internal/eventstore/repository/sql/query_test.go b/internal/eventstore/repository/sql/query_test.go index 16e2ad66f..c00eb7f78 100644 --- a/internal/eventstore/repository/sql/query_test.go +++ b/internal/eventstore/repository/sql/query_test.go @@ -181,13 +181,13 @@ func Test_prepareColumns(t *testing.T) { }), }, res: res{ - query: `SELECT created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2`, + query: `SELECT created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision, in_tx_order FROM eventstore.events2`, expected: []eventstore.Event{ - &repository.Event{AggregateID: "hodor", AggregateType: "user", Seq: 5, Pos: decimal.NewFromInt(42), Data: nil, Version: "v1"}, + &repository.Event{AggregateID: "hodor", AggregateType: "user", Seq: 5, Pos: decimal.NewFromInt(42), Data: nil, Version: "v1", InTx: 3}, }, }, fields: fields{ - dbRow: []interface{}{time.Time{}, eventstore.EventType(""), uint64(5), decimal.NewNullDecimal(decimal.NewFromInt(42)), sql.RawBytes(nil), "", sql.NullString{}, "", eventstore.AggregateType("user"), "hodor", uint8(1)}, + dbRow: []interface{}{time.Time{}, eventstore.EventType(""), uint64(5), decimal.NewNullDecimal(decimal.NewFromInt(42)), sql.RawBytes(nil), "", sql.NullString{}, "", eventstore.AggregateType("user"), "hodor", uint8(1), uint32(3)}, }, }, { @@ -200,13 +200,13 @@ func Test_prepareColumns(t *testing.T) { }), }, res: res{ - query: `SELECT created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2`, + query: `SELECT created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision, in_tx_order FROM eventstore.events2`, expected: []eventstore.Event{ &repository.Event{AggregateID: "hodor", AggregateType: "user", Seq: 5, Pos: decimal.Decimal{}, Data: nil, Version: "v1"}, }, }, fields: fields{ - dbRow: []interface{}{time.Time{}, eventstore.EventType(""), uint64(5), decimal.NullDecimal{}, sql.RawBytes(nil), "", sql.NullString{}, "", eventstore.AggregateType("user"), "hodor", uint8(1)}, + dbRow: []interface{}{time.Time{}, eventstore.EventType(""), uint64(5), decimal.NullDecimal{}, sql.RawBytes(nil), "", sql.NullString{}, "", eventstore.AggregateType("user"), "hodor", uint8(1), uint32(0)}, }, }, { @@ -904,7 +904,7 @@ func Test_query_events_mocked(t *testing.T) { }, fields: fields{ mock: newMockClient(t).expectQuery( - regexp.QuoteMeta(`SELECT created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $1 AND aggregate_type = $2 AND event_type = $3 AND "position" >= $4 AND aggregate_id NOT IN (SELECT aggregate_id FROM eventstore.events2 WHERE aggregate_type = $5 AND event_type = ANY($6) AND instance_id = $7 AND "position" >= $8) ORDER BY "position" DESC, in_tx_order DESC, instance_id, aggregate_type, aggregate_id LIMIT $9`), + regexp.QuoteMeta(`SELECT created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision, in_tx_order FROM eventstore.events2 WHERE instance_id = $1 AND aggregate_type = $2 AND event_type = $3 AND "position" >= $4 AND aggregate_id NOT IN (SELECT aggregate_id FROM eventstore.events2 WHERE aggregate_type = $5 AND event_type = ANY($6) AND instance_id = $7 AND "position" >= $8) ORDER BY (`+eventSortKeySQL+`) DESC LIMIT $9`), []driver.Value{"instanceID", eventstore.AggregateType("notify"), eventstore.EventType("notify.foo.bar"), decimal.NewFromFloat(123.456), eventstore.AggregateType("notify"), []eventstore.EventType{"notification.failed", "notification.success"}, "instanceID", decimal.NewFromFloat(123.456), uint64(5)}, ), }, @@ -933,7 +933,7 @@ func Test_query_events_mocked(t *testing.T) { }, fields: fields{ mock: newMockClient(t).expectQuery( - regexp.QuoteMeta(`SELECT created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $1 AND aggregate_type = $2 AND event_type = $3 AND created_at > $4 AND aggregate_id NOT IN (SELECT aggregate_id FROM eventstore.events2 WHERE aggregate_type = $5 AND event_type = ANY($6) AND instance_id = $7 AND created_at > $8) ORDER BY "position" DESC, in_tx_order DESC, instance_id, aggregate_type, aggregate_id LIMIT $9`), + regexp.QuoteMeta(`SELECT created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision, in_tx_order FROM eventstore.events2 WHERE instance_id = $1 AND aggregate_type = $2 AND event_type = $3 AND created_at > $4 AND aggregate_id NOT IN (SELECT aggregate_id FROM eventstore.events2 WHERE aggregate_type = $5 AND event_type = ANY($6) AND instance_id = $7 AND created_at > $8) ORDER BY (`+eventSortKeySQL+`) DESC LIMIT $9`), []driver.Value{"instanceID", eventstore.AggregateType("notify"), eventstore.EventType("notify.foo.bar"), time.Unix(123, 456), eventstore.AggregateType("notify"), []eventstore.EventType{"notification.failed", "notification.success"}, "instanceID", time.Unix(123, 456), uint64(5)}, ), }, diff --git a/internal/eventstore/search_query.go b/internal/eventstore/search_query.go index d431c4dcb..2fe16dcff 100644 --- a/internal/eventstore/search_query.go +++ b/internal/eventstore/search_query.go @@ -26,6 +26,7 @@ type SearchQueryBuilder struct { excludeAggregateIDs *ExclusionQuery tx *sql.Tx positionAtLeast decimal.Decimal + eventSortKeyAfter *EventSortKey awaitOpenTransactions bool creationDateAfter time.Time creationDateBefore time.Time @@ -80,6 +81,10 @@ func (b SearchQueryBuilder) GetPositionAtLeast() decimal.Decimal { return b.positionAtLeast } +func (b SearchQueryBuilder) GetEventSortKeyAfter() *EventSortKey { + return b.eventSortKeyAfter +} + func (b SearchQueryBuilder) GetAwaitOpenTransactions() bool { return b.awaitOpenTransactions } @@ -286,6 +291,12 @@ func (builder *SearchQueryBuilder) PositionAtLeast(position decimal.Decimal) *Se return builder } +// AfterEventSortKey resumes after the given event using a lexicographic row comparison. +func (builder *SearchQueryBuilder) AfterEventSortKey(key EventSortKey) *SearchQueryBuilder { + builder.eventSortKeyAfter = &key + return builder +} + // AwaitOpenTransactions filters for events which are older than the oldest transaction of the database func (builder *SearchQueryBuilder) AwaitOpenTransactions() *SearchQueryBuilder { builder.awaitOpenTransactions = true diff --git a/internal/eventstore/v1/models/event.go b/internal/eventstore/v1/models/event.go index ab2b60887..0c4f2142b 100644 --- a/internal/eventstore/v1/models/event.go +++ b/internal/eventstore/v1/models/event.go @@ -86,6 +86,11 @@ func (e *Event) Position() decimal.Decimal { return e.Pos } +// InTxOrder implements [eventstore.Event]. The v1 events table has no in_tx_order column. +func (e *Event) InTxOrder() uint32 { + return 0 +} + // Type implements [eventstore.action] func (e *Event) Type() eventstore.EventType { return e.Typ diff --git a/internal/eventstore/v3/event.go b/internal/eventstore/v3/event.go index 38e2247bd..1f04cf44f 100644 --- a/internal/eventstore/v3/event.go +++ b/internal/eventstore/v3/event.go @@ -45,6 +45,7 @@ type event struct { createdAt time.Time sequence uint64 position decimal.Decimal + inTxOrder uint32 } // TODO: remove on v3 @@ -160,6 +161,11 @@ func (e *event) Position() decimal.Decimal { return e.position } +// InTxOrder implements [eventstore.Event] +func (e *event) InTxOrder() uint32 { + return e.inTxOrder +} + // Unmarshal implements [eventstore.Event] func (e *event) Unmarshal(ptr any) error { if len(e.command.Payload) == 0 { diff --git a/internal/eventstore/v3/push.go b/internal/eventstore/v3/push.go index ffae4246e..980679719 100644 --- a/internal/eventstore/v3/push.go +++ b/internal/eventstore/v3/push.go @@ -85,14 +85,14 @@ func writeEvents(ctx context.Context, tx database.Transaction, commands []events commandMapping = "eventstore.command" } - rows, err := tx.Query(ctx, fmt.Sprintf(`select owner, created_at, "sequence", position from eventstore.push($1::%s[])`, commandMapping), cmds) + rows, err := tx.Query(ctx, fmt.Sprintf(`select owner, created_at, "sequence", position, in_tx_order from eventstore.push($1::%s[])`, commandMapping), cmds) if err != nil { return nil, err } defer rows.Close() for i := 0; rows.Next(); i++ { - err = rows.Scan(&events[i].(*event).command.Owner, &events[i].(*event).createdAt, &events[i].(*event).sequence, &events[i].(*event).position) + err = rows.Scan(&events[i].(*event).command.Owner, &events[i].(*event).createdAt, &events[i].(*event).sequence, &events[i].(*event).position, &events[i].(*event).inTxOrder) if err != nil { logging.WithError(err).Warn("failed to scan events") return nil, err diff --git a/internal/eventstore/v3/push.sql b/internal/eventstore/v3/push.sql index 025094895..671cb78f5 100644 --- a/internal/eventstore/v3/push.sql +++ b/internal/eventstore/v3/push.sql @@ -15,4 +15,4 @@ INSERT INTO eventstore.events2 ( , in_tx_order ) VALUES %s -RETURNING created_at, "position"; \ No newline at end of file +RETURNING created_at, "position", in_tx_order; \ No newline at end of file diff --git a/internal/eventstore/v3/push_without_func.go b/internal/eventstore/v3/push_without_func.go index 3386e4f9b..2488ace53 100644 --- a/internal/eventstore/v3/push_without_func.go +++ b/internal/eventstore/v3/push_without_func.go @@ -82,7 +82,7 @@ func (es *Eventstore) writeEventsOld(ctx context.Context, tx database.Transactio defer rows.Close() for i := 0; rows.Next(); i++ { - err = rows.Scan(&events[i].(*event).createdAt, &events[i].(*event).position) + err = rows.Scan(&events[i].(*event).createdAt, &events[i].(*event).position, &events[i].(*event).inTxOrder) if err != nil { logging.WithError(err).Warn("failed to scan events") return nil, err