mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
fix(eventstore): prevent projections from skipping events (#12703)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
# 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.
<!-- CURSOR_AGENT_PR_BODY_END -->
<div><a
href="https://cursor.com/agents/bc-3da6eb51-11d4-4a8a-8ba0-e190f116df05?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a
href="https://cursor.com/background-agent?bcId=bc-3da6eb51-11d4-4a8a-8ba0-e190f116df05&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div>
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Silvan <[email protected]>
This commit is contained in:
co-authored by
Cursor Agent
Silvan
parent
684de2e691
commit
fbe7b96138
@@ -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
|
||||
|
||||
@@ -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<SetupData> {
|
||||
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!);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
@@ -196,6 +196,7 @@ type Steps struct {
|
||||
s74Apps7OIDCConfigsAddRegistrationToken *Apps7OIDCConfigsAddRegistrationToken
|
||||
s75Apps7OIDCConfigsAddAppLinkConfig *Apps7OIDCConfigsAddAppLinkConfig
|
||||
s76Users14LoginEqualityIndexes *Users14LoginEqualityIndexes
|
||||
s77StampEventPositionAtInsert *StampEventPositionAtInsert
|
||||
RelationalTables *TransactionalTables
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
-- filter_offset stores events2.in_tx_order (reused column; not a row OFFSET).
|
||||
SELECT
|
||||
aggregate_id
|
||||
, aggregate_type
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)},
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,4 +15,4 @@ INSERT INTO eventstore.events2 (
|
||||
, in_tx_order
|
||||
) VALUES
|
||||
%s
|
||||
RETURNING created_at, "position";
|
||||
RETURNING created_at, "position", in_tx_order;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user