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
|
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
|
${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
|
.PHONY: lint
|
||||||
lint:
|
lint:
|
||||||
npm i
|
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[])
|
CREATE OR REPLACE FUNCTION eventstore.commands_to_events(commands eventstore.command2[])
|
||||||
RETURNS SETOF eventstore.events2
|
RETURNS SETOF eventstore.events2
|
||||||
LANGUAGE 'plpgsql'
|
LANGUAGE 'plpgsql'
|
||||||
STABLE PARALLEL SAFE
|
VOLATILE PARALLEL SAFE
|
||||||
ROWS 10
|
ROWS 10
|
||||||
AS $$
|
AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
_latest_events eventstore.latest_command[];
|
_latest_events eventstore.latest_command[];
|
||||||
command eventstore.command2;
|
command eventstore.command2;
|
||||||
last_owner TEXT;
|
|
||||||
i INTEGER;
|
i INTEGER;
|
||||||
BEGIN
|
BEGIN
|
||||||
|
|
||||||
@@ -132,14 +131,14 @@ BEGIN
|
|||||||
, c.command_type AS event_type
|
, 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
|
, 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
|
, c.revision
|
||||||
, NOW() AS created_at
|
, statement_timestamp() AS created_at
|
||||||
, c.payload
|
, c.payload
|
||||||
, c.creator
|
, c.creator
|
||||||
, CASE WHEN c.enforce_owner
|
, CASE WHEN c.enforce_owner
|
||||||
THEN c.owner
|
THEN c.owner
|
||||||
ELSE COALESCE(e.owner, c.owner)
|
ELSE COALESCE(e.owner, c.owner)
|
||||||
END AS owner
|
END AS owner
|
||||||
, EXTRACT(EPOCH FROM NOW()) AS position
|
, EXTRACT(EPOCH FROM clock_timestamp()) AS position
|
||||||
, c.in_tx_order
|
, c.in_tx_order
|
||||||
FROM (
|
FROM (
|
||||||
SELECT c.*, CAST(ROW_NUMBER() OVER () AS INTEGER) AS in_tx_order
|
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
|
s74Apps7OIDCConfigsAddRegistrationToken *Apps7OIDCConfigsAddRegistrationToken
|
||||||
s75Apps7OIDCConfigsAddAppLinkConfig *Apps7OIDCConfigsAddAppLinkConfig
|
s75Apps7OIDCConfigsAddAppLinkConfig *Apps7OIDCConfigsAddAppLinkConfig
|
||||||
s76Users14LoginEqualityIndexes *Users14LoginEqualityIndexes
|
s76Users14LoginEqualityIndexes *Users14LoginEqualityIndexes
|
||||||
|
s77StampEventPositionAtInsert *StampEventPositionAtInsert
|
||||||
RelationalTables *TransactionalTables
|
RelationalTables *TransactionalTables
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
|
|||||||
steps.s74Apps7OIDCConfigsAddRegistrationToken = &Apps7OIDCConfigsAddRegistrationToken{dbClient: dbClient}
|
steps.s74Apps7OIDCConfigsAddRegistrationToken = &Apps7OIDCConfigsAddRegistrationToken{dbClient: dbClient}
|
||||||
steps.s75Apps7OIDCConfigsAddAppLinkConfig = &Apps7OIDCConfigsAddAppLinkConfig{dbClient: dbClient}
|
steps.s75Apps7OIDCConfigsAddAppLinkConfig = &Apps7OIDCConfigsAddAppLinkConfig{dbClient: dbClient}
|
||||||
steps.s76Users14LoginEqualityIndexes = &Users14LoginEqualityIndexes{dbClient: dbClient}
|
steps.s76Users14LoginEqualityIndexes = &Users14LoginEqualityIndexes{dbClient: dbClient}
|
||||||
|
steps.s77StampEventPositionAtInsert = &StampEventPositionAtInsert{dbClient: dbClient}
|
||||||
|
|
||||||
err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil)
|
err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -318,6 +319,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
|
|||||||
steps.s69CacheTablesLogged,
|
steps.s69CacheTablesLogged,
|
||||||
steps.s70AddEventStoreCommandEnforceOwner,
|
steps.s70AddEventStoreCommandEnforceOwner,
|
||||||
steps.s76Users14LoginEqualityIndexes,
|
steps.s76Users14LoginEqualityIndexes,
|
||||||
|
steps.s77StampEventPositionAtInsert,
|
||||||
} {
|
} {
|
||||||
setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed")
|
setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed")
|
||||||
if setupErr != nil {
|
if setupErr != nil {
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ type Event interface {
|
|||||||
CreatedAt() time.Time
|
CreatedAt() time.Time
|
||||||
// Position is the global position of the event
|
// Position is the global position of the event
|
||||||
Position() decimal.Decimal
|
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
|
// Unmarshal parses the payload and stores the result
|
||||||
// in the value pointed to by ptr. If ptr is nil or not a pointer,
|
// in the value pointed to by ptr. If ptr is nil or not a pointer,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type BaseEvent struct {
|
|||||||
|
|
||||||
Seq uint64
|
Seq uint64
|
||||||
Pos decimal.Decimal
|
Pos decimal.Decimal
|
||||||
|
InTx uint32
|
||||||
Creation time.Time
|
Creation time.Time
|
||||||
previousAggregateSequence uint64
|
previousAggregateSequence uint64
|
||||||
previousAggregateTypeSequence uint64
|
previousAggregateTypeSequence uint64
|
||||||
@@ -68,6 +69,11 @@ func (e *BaseEvent) Sequence() uint64 {
|
|||||||
return e.Seq
|
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
|
// CreationDate is the time, the event is inserted into the eventstore
|
||||||
func (e *BaseEvent) CreationDate() time.Time {
|
func (e *BaseEvent) CreationDate() time.Time {
|
||||||
return e.Creation
|
return e.Creation
|
||||||
@@ -116,6 +122,7 @@ func BaseEventFromRepo(event Event) *BaseEvent {
|
|||||||
User: event.Creator(),
|
User: event.Creator(),
|
||||||
Data: event.DataAsBytes(),
|
Data: event.DataAsBytes(),
|
||||||
Pos: event.Position(),
|
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 {
|
if err != nil {
|
||||||
return additionalIteration, err
|
return additionalIteration, err
|
||||||
}
|
}
|
||||||
// stop execution if currentState.eventTimestamp >= config.maxCreatedAt
|
if !config.maxPosition.IsZero() && currentState.cursor.Position.GreaterThanOrEqual(config.maxPosition) {
|
||||||
if !config.maxPosition.IsZero() && currentState.position.GreaterThanOrEqual(config.maxPosition) {
|
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.minPosition.GreaterThan(decimal.NewFromInt(0)) {
|
events, additionalIteration, err := h.fetchEvents(ctx, tx, currentState, config.minPosition)
|
||||||
currentState.position = config.minPosition
|
|
||||||
currentState.offset = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
events, additionalIteration, err := h.fetchEvents(ctx, tx, currentState)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return additionalIteration, err
|
return additionalIteration, err
|
||||||
}
|
}
|
||||||
@@ -170,59 +164,20 @@ func (h *FieldHandler) processEvents(ctx context.Context, config *triggerConfig)
|
|||||||
return additionalIteration, err
|
return additionalIteration, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *FieldHandler) fetchEvents(ctx context.Context, tx *sql.Tx, currentState *state) (_ []eventstore.FillFieldsEvent, additionalIteration bool, err error) {
|
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).SetTx(tx))
|
events, err := h.es.Filter(ctx, h.eventQuery(currentState, minPosition).SetTx(tx))
|
||||||
if err != nil || len(events) == 0 {
|
if err != nil || len(events) == 0 {
|
||||||
logging.OnError(ctx, err).Debug("filter eventstore failed")
|
logging.OnError(ctx, err).Debug("filter eventstore failed")
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
eventAmount := len(events)
|
|
||||||
|
|
||||||
idx, offset := skipPreviouslyReducedEvents(events, currentState)
|
currentState.applyEvent(events[len(events)-1])
|
||||||
|
additionalIteration = len(events) == int(h.bulkLimit)
|
||||||
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)
|
|
||||||
|
|
||||||
fillFieldsEvents := make([]eventstore.FillFieldsEvent, len(events))
|
fillFieldsEvents := make([]eventstore.FillFieldsEvent, len(events))
|
||||||
highestPosition := events[len(events)-1].Position()
|
|
||||||
for i, event := range events {
|
for i, event := range events {
|
||||||
if event.Position().Equal(highestPosition) {
|
|
||||||
offset++
|
|
||||||
}
|
|
||||||
fillFieldsEvents[i] = event.(eventstore.FillFieldsEvent)
|
fillFieldsEvents[i] = event.(eventstore.FillFieldsEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fillFieldsEvents, additionalIteration, nil
|
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 {
|
if err != nil {
|
||||||
return additionalIteration, err
|
return additionalIteration, err
|
||||||
}
|
}
|
||||||
// stop execution if currentState.position >= config.maxPosition
|
if !config.maxPosition.IsZero() && currentState.cursor.Position.GreaterThanOrEqual(config.maxPosition) {
|
||||||
if !config.maxPosition.IsZero() && currentState.position.GreaterThanOrEqual(config.maxPosition) {
|
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.minPosition.GreaterThan(decimal.NewFromInt(0)) {
|
|
||||||
currentState.position = config.minPosition
|
|
||||||
currentState.offset = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
var statements []*Statement
|
var statements []*Statement
|
||||||
statements, additionalIteration, err = h.generateStatements(ctx, tx, currentState)
|
statements, additionalIteration, err = h.generateStatements(ctx, tx, currentState, config.minPosition)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return additionalIteration, err
|
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)
|
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
|
// 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.ProjectionUpdateTiming(ctx, h.ProjectionName(), float64(time.Since(start).Seconds()))
|
||||||
h.metrics.ProjectionStateLatency(ctx, h.ProjectionName(), time.Since(currentState.eventTimestamp).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
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
currentState.position = statements[lastProcessedIndex].Position
|
currentState.applyStatement(statements[lastProcessedIndex])
|
||||||
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
|
|
||||||
|
|
||||||
setStateErr := h.setState(ctx, tx, currentState)
|
setStateErr := h.setState(ctx, tx, currentState)
|
||||||
if setStateErr != nil {
|
if setStateErr != nil {
|
||||||
@@ -642,7 +631,7 @@ func (h *Handler) processEvents(ctx context.Context, config *triggerConfig) (add
|
|||||||
return additionalIteration, err
|
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 {
|
if h.triggerWithoutEvents != nil {
|
||||||
stmt, err := h.triggerWithoutEvents(pseudo.NewScheduledEvent(ctx, time.Now(), currentState.instanceID))
|
stmt, err := h.triggerWithoutEvents(pseudo.NewScheduledEvent(ctx, time.Now(), currentState.instanceID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -651,31 +640,18 @@ func (h *Handler) generateStatements(ctx context.Context, tx *sql.Tx, currentSta
|
|||||||
return []*Statement{stmt}, false, nil
|
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 {
|
if err != nil {
|
||||||
logging.WithError(ctx, err).Debug("filter eventstore failed")
|
logging.WithError(ctx, err).Debug("filter eventstore failed")
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
eventAmount := len(events)
|
eventAmount := len(events)
|
||||||
|
|
||||||
statements, err := h.eventsToStatements(ctx, tx, events, currentState)
|
statements, err := h.eventsToStatements(ctx, tx, events)
|
||||||
if err != nil || len(statements) == 0 {
|
if err != nil || len(statements) == 0 {
|
||||||
return nil, false, err
|
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)
|
additionalIteration = eventAmount == int(h.bulkLimit)
|
||||||
if len(statements) < len(events) {
|
if len(statements) < len(events) {
|
||||||
// retry immediately if statements failed
|
// retry immediately if statements failed
|
||||||
@@ -685,18 +661,6 @@ func (h *Handler) generateStatements(ctx context.Context, tx *sql.Tx, currentSta
|
|||||||
return statements, additionalIteration, nil
|
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) {
|
func (h *Handler) executeStatements(ctx context.Context, tx *sql.Tx, statements []*Statement) (lastProcessedIndex int, err error) {
|
||||||
lastProcessedIndex = -1
|
lastProcessedIndex = -1
|
||||||
|
|
||||||
@@ -743,18 +707,17 @@ func (h *Handler) executeStatement(ctx context.Context, tx *sql.Tx, statement *S
|
|||||||
return nil
|
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).
|
builder := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
|
||||||
AwaitOpenTransactions().
|
AwaitOpenTransactions().
|
||||||
Limit(uint64(h.bulkLimit)).
|
Limit(uint64(h.bulkLimit)).
|
||||||
OrderAsc().
|
OrderAsc().
|
||||||
InstanceID(currentState.instanceID)
|
InstanceID(currentState.instanceID)
|
||||||
|
|
||||||
if currentState.position.GreaterThan(decimal.Decimal{}) {
|
if minPosition.GreaterThan(decimal.NewFromInt(0)) {
|
||||||
builder = builder.PositionAtLeast(currentState.position)
|
builder = builder.PositionAtLeast(minPosition)
|
||||||
if currentState.offset > 0 {
|
} else if !currentState.cursor.IsZero() {
|
||||||
builder = builder.Offset(currentState.offset)
|
builder = builder.AfterEventSortKey(currentState.cursor)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.queryGlobal {
|
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 {
|
type state struct {
|
||||||
instanceID string
|
instanceID string
|
||||||
position decimal.Decimal
|
|
||||||
eventTimestamp time.Time
|
eventTimestamp time.Time
|
||||||
aggregateType eventstore.AggregateType
|
cursor eventstore.EventSortKey
|
||||||
aggregateID string
|
|
||||||
sequence uint64
|
|
||||||
offset uint32
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -43,7 +39,7 @@ func (h *Handler) currentState(ctx context.Context, tx *sql.Tx) (currentState *s
|
|||||||
sequence = new(sql.NullInt64)
|
sequence = new(sql.NullInt64)
|
||||||
timestamp = new(sql.NullTime)
|
timestamp = new(sql.NullTime)
|
||||||
position = new(decimal.NullDecimal)
|
position = new(decimal.NullDecimal)
|
||||||
offset = new(sql.NullInt64)
|
inTxOrder = new(sql.NullInt64)
|
||||||
)
|
)
|
||||||
|
|
||||||
row := tx.QueryRow(currentStateStmt, currentState.instanceID, h.projection.Name())
|
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,
|
sequence,
|
||||||
timestamp,
|
timestamp,
|
||||||
position,
|
position,
|
||||||
offset,
|
inTxOrder,
|
||||||
)
|
)
|
||||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
logging.WithError(ctx, err).Debug("unable to query current state")
|
logging.WithError(ctx, err).Debug("unable to query current state")
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
currentState.aggregateID = aggregateID.String
|
|
||||||
currentState.aggregateType = eventstore.AggregateType(aggregateType.String)
|
|
||||||
currentState.sequence = uint64(sequence.Int64)
|
|
||||||
currentState.eventTimestamp = timestamp.Time
|
currentState.eventTimestamp = timestamp.Time
|
||||||
currentState.position = position.Decimal
|
currentState.cursor = eventstore.EventSortKey{
|
||||||
// psql does not provide unsigned numbers so we work around it
|
Position: position.Decimal,
|
||||||
currentState.offset = uint32(offset.Int64)
|
InTxOrder: uint32(inTxOrder.Int64),
|
||||||
|
InstanceID: currentState.instanceID,
|
||||||
|
AggregateType: eventstore.AggregateType(aggregateType.String),
|
||||||
|
AggregateID: aggregateID.String,
|
||||||
|
Sequence: uint64(sequence.Int64),
|
||||||
|
}
|
||||||
return currentState, nil
|
return currentState, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,12 +72,12 @@ func (h *Handler) setState(ctx context.Context, tx *sql.Tx, updatedState *state)
|
|||||||
res, err := tx.Exec(updateStateStmt,
|
res, err := tx.Exec(updateStateStmt,
|
||||||
h.projection.Name(),
|
h.projection.Name(),
|
||||||
updatedState.instanceID,
|
updatedState.instanceID,
|
||||||
updatedState.aggregateID,
|
updatedState.cursor.AggregateID,
|
||||||
updatedState.aggregateType,
|
updatedState.cursor.AggregateType,
|
||||||
updatedState.sequence,
|
updatedState.cursor.Sequence,
|
||||||
updatedState.eventTimestamp,
|
updatedState.eventTimestamp,
|
||||||
updatedState.position,
|
updatedState.cursor.Position,
|
||||||
updatedState.offset,
|
updatedState.cursor.InTxOrder,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = zerrors.ThrowInternal(err, "V2-WF23g2", "unable to update state")
|
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
|
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
|
SELECT
|
||||||
aggregate_id
|
aggregate_id
|
||||||
, aggregate_type
|
, aggregate_type
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func TestHandler_updateLastUpdated(t *testing.T) {
|
|||||||
updatedState: &state{
|
updatedState: &state{
|
||||||
instanceID: "instance",
|
instanceID: "instance",
|
||||||
eventTimestamp: time.Now(),
|
eventTimestamp: time.Now(),
|
||||||
position: decimal.NewFromInt(42),
|
cursor: eventstore.EventSortKey{Position: decimal.NewFromInt(42)},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
isErr: func(t *testing.T, err error) {
|
isErr: func(t *testing.T, err error) {
|
||||||
@@ -76,7 +76,7 @@ func TestHandler_updateLastUpdated(t *testing.T) {
|
|||||||
updatedState: &state{
|
updatedState: &state{
|
||||||
instanceID: "instance",
|
instanceID: "instance",
|
||||||
eventTimestamp: time.Now(),
|
eventTimestamp: time.Now(),
|
||||||
position: decimal.NewFromInt(42),
|
cursor: eventstore.EventSortKey{Position: decimal.NewFromInt(42)},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
isErr: func(t *testing.T, err error) {
|
isErr: func(t *testing.T, err error) {
|
||||||
@@ -112,10 +112,12 @@ func TestHandler_updateLastUpdated(t *testing.T) {
|
|||||||
updatedState: &state{
|
updatedState: &state{
|
||||||
instanceID: "instance",
|
instanceID: "instance",
|
||||||
eventTimestamp: time.Now(),
|
eventTimestamp: time.Now(),
|
||||||
position: decimal.NewFromInt(42),
|
cursor: eventstore.EventSortKey{
|
||||||
aggregateType: "aggregate type",
|
Position: decimal.NewFromInt(42),
|
||||||
aggregateID: "aggregate id",
|
AggregateType: "aggregate type",
|
||||||
sequence: 42,
|
AggregateID: "aggregate id",
|
||||||
|
Sequence: 42,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -261,11 +263,14 @@ func TestHandler_currentState(t *testing.T) {
|
|||||||
currentState: &state{
|
currentState: &state{
|
||||||
instanceID: "instance",
|
instanceID: "instance",
|
||||||
eventTimestamp: testTime,
|
eventTimestamp: testTime,
|
||||||
position: decimal.NewFromInt(42),
|
cursor: eventstore.EventSortKey{
|
||||||
aggregateType: "aggregate type",
|
Position: decimal.NewFromInt(42),
|
||||||
aggregateID: "aggregate id",
|
InTxOrder: 10,
|
||||||
sequence: 42,
|
InstanceID: "instance",
|
||||||
offset: 10,
|
AggregateType: "aggregate type",
|
||||||
|
AggregateID: "aggregate id",
|
||||||
|
Sequence: 42,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -39,11 +39,9 @@ func (s *executionError) Unwrap() error {
|
|||||||
return s.parent
|
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))
|
statements = make([]*Statement, 0, len(events))
|
||||||
|
|
||||||
previousPosition := currentState.position
|
|
||||||
offset := currentState.offset
|
|
||||||
for _, event := range events {
|
for _, event := range events {
|
||||||
statement, err := h.reduce(event)
|
statement, err := h.reduce(event)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -53,14 +51,6 @@ func (h *Handler) eventsToStatements(ctx context.Context, tx *sql.Tx, events []e
|
|||||||
}
|
}
|
||||||
return statements, &executionError{err}
|
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)
|
statements = append(statements, statement)
|
||||||
}
|
}
|
||||||
return statements, nil
|
return statements, nil
|
||||||
@@ -87,11 +77,22 @@ type Statement struct {
|
|||||||
Position decimal.Decimal
|
Position decimal.Decimal
|
||||||
CreationDate time.Time
|
CreationDate time.Time
|
||||||
|
|
||||||
offset uint32
|
inTxOrder uint32
|
||||||
|
|
||||||
Execute Exec
|
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
|
type Exec func(ctx context.Context, ex Executer, projectionName string) error
|
||||||
|
|
||||||
func WithTableSuffix(name string) func(*execConfig) {
|
func WithTableSuffix(name string) func(*execConfig) {
|
||||||
@@ -112,6 +113,7 @@ func NewStatement(event eventstore.Event, e Exec) *Statement {
|
|||||||
Sequence: event.Sequence(),
|
Sequence: event.Sequence(),
|
||||||
Position: event.Position(),
|
Position: event.Position(),
|
||||||
CreationDate: event.CreatedAt(),
|
CreationDate: event.CreatedAt(),
|
||||||
|
inTxOrder: event.InTxOrder(),
|
||||||
Execute: e,
|
Execute: e,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ type Event struct {
|
|||||||
Seq uint64
|
Seq uint64
|
||||||
// Pos is the global sequence of the event multiple events can have the same sequence
|
// Pos is the global sequence of the event multiple events can have the same sequence
|
||||||
Pos decimal.Decimal
|
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
|
// CreationDate is the time the event is created
|
||||||
// it's used for human readability.
|
// it's used for human readability.
|
||||||
@@ -102,6 +104,11 @@ func (e *Event) Position() decimal.Decimal {
|
|||||||
return e.Pos
|
return e.Pos
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InTxOrder implements [eventstore.Event]
|
||||||
|
func (e *Event) InTxOrder() uint32 {
|
||||||
|
return e.InTx
|
||||||
|
}
|
||||||
|
|
||||||
// CreatedAt implements [eventstore.Event]
|
// CreatedAt implements [eventstore.Event]
|
||||||
func (e *Event) CreatedAt() time.Time {
|
func (e *Event) CreatedAt() time.Time {
|
||||||
return e.CreationDate
|
return e.CreationDate
|
||||||
|
|||||||
@@ -202,6 +202,10 @@ func (e *mockEvent) Position() decimal.Decimal {
|
|||||||
return decimal.Decimal{}
|
return decimal.Decimal{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *mockEvent) InTxOrder() uint32 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func (e *mockEvent) CreatedAt() time.Time {
|
func (e *mockEvent) CreatedAt() time.Time {
|
||||||
return e.createdAt
|
return e.createdAt
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type SearchQuery struct {
|
|||||||
CreatedAfter *Filter
|
CreatedAfter *Filter
|
||||||
CreatedBefore *Filter
|
CreatedBefore *Filter
|
||||||
ExcludeAggregateIDs []*Filter
|
ExcludeAggregateIDs []*Filter
|
||||||
|
EventSortKeyAfter *eventstore.EventSortKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter represents all fields needed to compare a field of an event with a value
|
// Filter represents all fields needed to compare a field of an event with a value
|
||||||
@@ -132,6 +133,7 @@ func QueryFromBuilder(builder *eventstore.SearchQueryBuilder) (*SearchQuery, err
|
|||||||
Tx: builder.GetTx(),
|
Tx: builder.GetTx(),
|
||||||
AwaitOpenTransactions: builder.GetAwaitOpenTransactions(),
|
AwaitOpenTransactions: builder.GetAwaitOpenTransactions(),
|
||||||
SubQueries: make([][]*Filter, len(builder.GetQueries())),
|
SubQueries: make([][]*Filter, len(builder.GetQueries())),
|
||||||
|
EventSortKeyAfter: builder.GetEventSortKeyAfter(),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, f := range []func(builder *eventstore.SearchQueryBuilder, query *SearchQuery) *Filter{
|
for _, f := range []func(builder *eventstore.SearchQueryBuilder, query *SearchQuery) *Filter{
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import (
|
|||||||
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
"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 (
|
var (
|
||||||
awaitOpenTransactionsV1 = ` AND created_at <= now()`
|
awaitOpenTransactionsV1 = ` AND created_at <= now()`
|
||||||
awaitOpenTransactionsV2 = ` AND "position" <= EXTRACT(EPOCH FROM 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() }
|
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.
|
// FilterToReducer finds all events matching the given search query and passes them to the reduce function.
|
||||||
func (psql *Postgres) FilterToReducer(ctx context.Context, searchQuery *eventstore.SearchQueryBuilder, reduce eventstore.Reducer) (err error) {
|
func (psql *Postgres) FilterToReducer(ctx context.Context, searchQuery *eventstore.SearchQueryBuilder, reduce eventstore.Reducer) (err error) {
|
||||||
ctx, span := tracing.NewSpan(ctx)
|
ctx, span := tracing.NewSpan(ctx)
|
||||||
@@ -91,9 +95,9 @@ func (db *Postgres) orderByEventSequence(desc, shouldOrderBySequence, useV1 bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
if desc {
|
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 {
|
func (db *Postgres) eventQuery(useV1 bool) string {
|
||||||
@@ -123,6 +127,7 @@ func (db *Postgres) eventQuery(useV1 bool) string {
|
|||||||
", aggregate_type" +
|
", aggregate_type" +
|
||||||
", aggregate_id" +
|
", aggregate_id" +
|
||||||
", revision" +
|
", revision" +
|
||||||
|
", in_tx_order" +
|
||||||
" FROM eventstore.events2"
|
" 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
|
// if there is only one subquery we can optimize the query ordering by sequence
|
||||||
var shouldOrderBySequence bool
|
var shouldOrderBySequence bool
|
||||||
if len(q.SubQueries) == 1 {
|
if q.EventSortKeyAfter == nil && len(q.SubQueries) == 1 {
|
||||||
for _, filter := range q.SubQueries[0] {
|
for _, filter := range q.SubQueries[0] {
|
||||||
if filter.Field == repository.FieldAggregateID {
|
if filter.Field == repository.FieldAggregateID {
|
||||||
shouldOrderBySequence = filter.Operation == repository.OperationEquals
|
shouldOrderBySequence = filter.Operation == repository.OperationEquals
|
||||||
@@ -226,6 +226,7 @@ func eventsScanner(useV1 bool) func(scanner scan, dest interface{}) (err error)
|
|||||||
&event.AggregateType,
|
&event.AggregateType,
|
||||||
&event.AggregateID,
|
&event.AggregateID,
|
||||||
&revision,
|
&revision,
|
||||||
|
&event.InTx,
|
||||||
)
|
)
|
||||||
event.Version = eventstore.Version("v" + strconv.Itoa(int(revision)))
|
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...)
|
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
|
excludeAggregateIDs := query.ExcludeAggregateIDs
|
||||||
if len(excludeAggregateIDs) > 0 {
|
if len(excludeAggregateIDs) > 0 {
|
||||||
excludeAggregateIDs = append(excludeAggregateIDs, query.InstanceID, query.InstanceIDs, query.Position, query.CreatedAfter, query.CreatedBefore)
|
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{
|
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{
|
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{
|
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{
|
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{
|
expected: []eventstore.Event{
|
||||||
&repository.Event{AggregateID: "hodor", AggregateType: "user", Seq: 5, Pos: decimal.Decimal{}, Data: nil, Version: "v1"},
|
&repository.Event{AggregateID: "hodor", AggregateType: "user", Seq: 5, Pos: decimal.Decimal{}, Data: nil, Version: "v1"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fields: fields{
|
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{
|
fields: fields{
|
||||||
mock: newMockClient(t).expectQuery(
|
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)},
|
[]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{
|
fields: fields{
|
||||||
mock: newMockClient(t).expectQuery(
|
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)},
|
[]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
|
excludeAggregateIDs *ExclusionQuery
|
||||||
tx *sql.Tx
|
tx *sql.Tx
|
||||||
positionAtLeast decimal.Decimal
|
positionAtLeast decimal.Decimal
|
||||||
|
eventSortKeyAfter *EventSortKey
|
||||||
awaitOpenTransactions bool
|
awaitOpenTransactions bool
|
||||||
creationDateAfter time.Time
|
creationDateAfter time.Time
|
||||||
creationDateBefore time.Time
|
creationDateBefore time.Time
|
||||||
@@ -80,6 +81,10 @@ func (b SearchQueryBuilder) GetPositionAtLeast() decimal.Decimal {
|
|||||||
return b.positionAtLeast
|
return b.positionAtLeast
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b SearchQueryBuilder) GetEventSortKeyAfter() *EventSortKey {
|
||||||
|
return b.eventSortKeyAfter
|
||||||
|
}
|
||||||
|
|
||||||
func (b SearchQueryBuilder) GetAwaitOpenTransactions() bool {
|
func (b SearchQueryBuilder) GetAwaitOpenTransactions() bool {
|
||||||
return b.awaitOpenTransactions
|
return b.awaitOpenTransactions
|
||||||
}
|
}
|
||||||
@@ -286,6 +291,12 @@ func (builder *SearchQueryBuilder) PositionAtLeast(position decimal.Decimal) *Se
|
|||||||
return builder
|
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
|
// AwaitOpenTransactions filters for events which are older than the oldest transaction of the database
|
||||||
func (builder *SearchQueryBuilder) AwaitOpenTransactions() *SearchQueryBuilder {
|
func (builder *SearchQueryBuilder) AwaitOpenTransactions() *SearchQueryBuilder {
|
||||||
builder.awaitOpenTransactions = true
|
builder.awaitOpenTransactions = true
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ func (e *Event) Position() decimal.Decimal {
|
|||||||
return e.Pos
|
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]
|
// Type implements [eventstore.action]
|
||||||
func (e *Event) Type() eventstore.EventType {
|
func (e *Event) Type() eventstore.EventType {
|
||||||
return e.Typ
|
return e.Typ
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type event struct {
|
|||||||
createdAt time.Time
|
createdAt time.Time
|
||||||
sequence uint64
|
sequence uint64
|
||||||
position decimal.Decimal
|
position decimal.Decimal
|
||||||
|
inTxOrder uint32
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: remove on v3
|
// TODO: remove on v3
|
||||||
@@ -160,6 +161,11 @@ func (e *event) Position() decimal.Decimal {
|
|||||||
return e.position
|
return e.position
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InTxOrder implements [eventstore.Event]
|
||||||
|
func (e *event) InTxOrder() uint32 {
|
||||||
|
return e.inTxOrder
|
||||||
|
}
|
||||||
|
|
||||||
// Unmarshal implements [eventstore.Event]
|
// Unmarshal implements [eventstore.Event]
|
||||||
func (e *event) Unmarshal(ptr any) error {
|
func (e *event) Unmarshal(ptr any) error {
|
||||||
if len(e.command.Payload) == 0 {
|
if len(e.command.Payload) == 0 {
|
||||||
|
|||||||
@@ -85,14 +85,14 @@ func writeEvents(ctx context.Context, tx database.Transaction, commands []events
|
|||||||
commandMapping = "eventstore.command"
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
for i := 0; rows.Next(); i++ {
|
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 {
|
if err != nil {
|
||||||
logging.WithError(err).Warn("failed to scan events")
|
logging.WithError(err).Warn("failed to scan events")
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -15,4 +15,4 @@ INSERT INTO eventstore.events2 (
|
|||||||
, in_tx_order
|
, in_tx_order
|
||||||
) VALUES
|
) VALUES
|
||||||
%s
|
%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()
|
defer rows.Close()
|
||||||
|
|
||||||
for i := 0; rows.Next(); i++ {
|
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 {
|
if err != nil {
|
||||||
logging.WithError(err).Warn("failed to scan events")
|
logging.WithError(err).Warn("failed to scan events")
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
Reference in New Issue
Block a user