@cosmicdrift/kumiko-framework 0.305.0 → 0.307.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/api/__tests__/redis-sse-broker.integration.test.ts +66 -0
- package/src/api/__tests__/server-boot-guards.test.ts +1 -0
- package/src/api/__tests__/server-error-logging.test.ts +71 -0
- package/src/api/__tests__/sse-broker.test.ts +49 -0
- package/src/api/redis-sse-broker.ts +17 -3
- package/src/api/request-context.ts +29 -4
- package/src/api/routes.ts +26 -1
- package/src/api/sse-broker.ts +29 -11
- package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
- package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
- package/src/bun-db/query.ts +42 -18
- package/src/changes.json +92 -0
- package/src/db/__tests__/pg-error.test.ts +14 -0
- package/src/db/__tests__/system-db-view-export.test.ts +107 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
- package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
- package/src/db/index.ts +1 -1
- package/src/db/pg-error.ts +13 -0
- package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
- package/src/db/queries/event-consumer.ts +57 -3
- package/src/db/queries/event-store.ts +69 -0
- package/src/db/tenant-db.ts +133 -18
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
- package/src/engine/__tests__/boot-validator.test.ts +1 -1
- package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
- package/src/engine/extension-names.ts +55 -25
- package/src/engine/extensions/storage-provider.ts +14 -41
- package/src/engine/extensions/tenant-data.ts +4 -0
- package/src/engine/extensions/tenant-resource.ts +40 -0
- package/src/engine/extensions/user-data.ts +8 -7
- package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
- package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
- package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
- package/src/engine/feature-ast/entity-field-types.ts +41 -0
- package/src/engine/feature-ast/extractors/handlers.ts +217 -84
- package/src/engine/feature-ast/extractors/hooks.ts +72 -15
- package/src/engine/feature-ast/extractors/round2.ts +21 -0
- package/src/engine/feature-ast/extractors/shared.ts +9 -0
- package/src/engine/feature-ast/index.ts +11 -1
- package/src/engine/feature-ast/patch.ts +338 -5
- package/src/engine/feature-ast/patcher.ts +2 -2
- package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
- package/src/engine/feature-ast/patterns.ts +22 -15
- package/src/engine/feature-ast/render.ts +1 -0
- package/src/engine/feature-ui-extensions.ts +8 -7
- package/src/engine/index.ts +21 -5
- package/src/engine/types/extension-options-map.ts +1 -0
- package/src/engine/types/index.ts +6 -0
- package/src/event-store/__tests__/event-attribution.integration.test.ts +53 -3
- package/src/event-store/admin-api.ts +5 -0
- package/src/event-store/event-store.ts +16 -7
- package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
- package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
- package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +3 -3
- package/src/jobs/job-runner.ts +211 -19
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
- package/src/pipeline/__tests__/event-dispatcher-commit-order.integration.test.ts +278 -0
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
- package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
- package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
- package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +261 -2
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
- package/src/pipeline/dispatch-batch.ts +102 -29
- package/src/pipeline/dispatch-stream.ts +7 -3
- package/src/pipeline/dispatcher-utils.ts +21 -2
- package/src/pipeline/dispatcher.ts +71 -6
- package/src/pipeline/event-consumer-state.ts +26 -0
- package/src/pipeline/event-dispatcher-admin.ts +32 -5
- package/src/pipeline/event-dispatcher-delivery.ts +109 -57
- package/src/pipeline/event-dispatcher.ts +167 -50
- package/src/pipeline/idempotency.ts +16 -0
- package/src/pipeline/pending-gap-ranges.ts +72 -0
- package/src/pipeline/system-hooks.ts +8 -1
- package/src/pipeline/system-identity-switch.ts +22 -4
- package/src/pipeline/write-origin.ts +31 -10
- package/src/stack/test-stack.ts +1 -1
- package/src/testing/closed-connection-error.ts +62 -0
- package/src/testing/index.ts +1 -0
- package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
2
|
+
import { runWithWriteOrigin } from "../api/request-context";
|
|
1
3
|
import type { SseBroker } from "../api/sse-broker";
|
|
2
4
|
import type { buildEntityTable } from "../db/table-builder";
|
|
3
5
|
import {
|
|
@@ -18,7 +20,7 @@ import type {
|
|
|
18
20
|
WriteResult,
|
|
19
21
|
} from "../engine/types";
|
|
20
22
|
import type { TenantId } from "../engine/types/identifiers";
|
|
21
|
-
import { reraiseAsKumikoError } from "../errors";
|
|
23
|
+
import { InternalError, reraiseAsKumikoError } from "../errors";
|
|
22
24
|
import { getFallbackMeter, getFallbackTracer, registerStandardMetrics } from "../observability";
|
|
23
25
|
import { createEscapeHatchReportWindow } from "../observability/escape-hatch-report";
|
|
24
26
|
import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership";
|
|
@@ -32,7 +34,7 @@ import type { IdempotencyGuard } from "./idempotency";
|
|
|
32
34
|
import type { LifecycleHooks } from "./lifecycle-pipeline";
|
|
33
35
|
import { createMemberReaderFn } from "./member-reader";
|
|
34
36
|
import { createTenantTimezoneCache } from "./tenant-timezone-cache";
|
|
35
|
-
import { rootWriteOrigin } from "./write-origin";
|
|
37
|
+
import { effectiveWriteOrigin, isPersonalDataGated, rootWriteOrigin } from "./write-origin";
|
|
36
38
|
|
|
37
39
|
// Re-export for callers that reach for dispatcher-adjacent types (tests,
|
|
38
40
|
// HTTP-layer stubs) — dispatch consumes these, grouping the type-surface
|
|
@@ -111,12 +113,55 @@ export type Dispatcher = {
|
|
|
111
113
|
createMemberReader(tenantId: TenantId): MemberReader;
|
|
112
114
|
};
|
|
113
115
|
|
|
116
|
+
// Kept off the public Dispatcher type: only a job may pass an inherited origin.
|
|
117
|
+
type DispatcherInternals = {
|
|
118
|
+
writeWithOrigin: (
|
|
119
|
+
type: string,
|
|
120
|
+
payload: unknown,
|
|
121
|
+
user: SessionUser,
|
|
122
|
+
inheritedOrigin: WriteOrigin,
|
|
123
|
+
) => Promise<WriteResult>;
|
|
124
|
+
queryWithOrigin: (
|
|
125
|
+
type: string,
|
|
126
|
+
payload: unknown,
|
|
127
|
+
user: SessionUser,
|
|
128
|
+
inheritedOrigin: WriteOrigin,
|
|
129
|
+
) => Promise<unknown>;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const dispatcherInternals = new WeakMap<Dispatcher, DispatcherInternals>();
|
|
133
|
+
|
|
114
134
|
// Adapts Dispatcher's (type, payload, user) call shape to DispatchWriteRef's
|
|
115
135
|
// (user, qn, payload) — JobRunner.attachDispatcher needs the latter.
|
|
116
136
|
export function dispatcherToWriteRef(dispatcher: Dispatcher): DispatchWriteRef {
|
|
137
|
+
const internals = dispatcherInternals.get(dispatcher);
|
|
117
138
|
return {
|
|
118
|
-
write: (user, qn, payload) =>
|
|
119
|
-
|
|
139
|
+
write: (user, qn, payload, inheritedOrigin) => {
|
|
140
|
+
if (inheritedOrigin && isPersonalDataGated(inheritedOrigin)) {
|
|
141
|
+
if (!internals) {
|
|
142
|
+
throw new InternalError({
|
|
143
|
+
message:
|
|
144
|
+
`JobContext.write("${qn}") carries a gated origin but this dispatcher has no ` +
|
|
145
|
+
"registered origin-aware internals — refusing to fall open to the ungated path.",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return internals.writeWithOrigin(qn, payload, user, inheritedOrigin);
|
|
149
|
+
}
|
|
150
|
+
return dispatcher.write(qn, payload, user);
|
|
151
|
+
},
|
|
152
|
+
queryAs: (user, qn, payload, inheritedOrigin) => {
|
|
153
|
+
if (inheritedOrigin && isPersonalDataGated(inheritedOrigin)) {
|
|
154
|
+
if (!internals) {
|
|
155
|
+
throw new InternalError({
|
|
156
|
+
message:
|
|
157
|
+
`JobContext.queryAs("${qn}") carries a gated origin but this dispatcher has no ` +
|
|
158
|
+
"registered origin-aware internals — refusing to fall open to the ungated path.",
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return internals.queryWithOrigin(qn, payload, user, inheritedOrigin);
|
|
162
|
+
}
|
|
163
|
+
return dispatcher.query(qn, payload, user);
|
|
164
|
+
},
|
|
120
165
|
createMemberReader: (tenantId) => dispatcher.createMemberReader(tenantId),
|
|
121
166
|
};
|
|
122
167
|
}
|
|
@@ -173,7 +218,7 @@ export function createDispatcher(
|
|
|
173
218
|
membershipQuery,
|
|
174
219
|
};
|
|
175
220
|
|
|
176
|
-
|
|
221
|
+
const dispatcher: Dispatcher = {
|
|
177
222
|
async write(typeOrRef, payload, user, requestId?) {
|
|
178
223
|
const type = resolveType(typeOrRef);
|
|
179
224
|
// Idempotency handled inside runBatch (caches BatchResult under requestId).
|
|
@@ -185,7 +230,8 @@ export function createDispatcher(
|
|
|
185
230
|
|
|
186
231
|
query: (typeOrRef, payload, user) => {
|
|
187
232
|
const type = resolveType(typeOrRef);
|
|
188
|
-
|
|
233
|
+
const origin = rootWriteOrigin(registry, type, user);
|
|
234
|
+
return runWithWriteOrigin(origin, () => executeQuery(ctx, type, payload, user, origin));
|
|
189
235
|
},
|
|
190
236
|
|
|
191
237
|
stream: (typeOrRef, payload, user) => {
|
|
@@ -210,4 +256,23 @@ export function createDispatcher(
|
|
|
210
256
|
|
|
211
257
|
createMemberReader: (tenantId) => createMemberReaderFn(ctx, tenantId),
|
|
212
258
|
};
|
|
259
|
+
|
|
260
|
+
dispatcherInternals.set(dispatcher, {
|
|
261
|
+
writeWithOrigin: async (type, payload, user, inheritedOrigin) => {
|
|
262
|
+
const batchResult = await runBatch(
|
|
263
|
+
ctx,
|
|
264
|
+
[{ type, payload }],
|
|
265
|
+
user,
|
|
266
|
+
undefined,
|
|
267
|
+
inheritedOrigin,
|
|
268
|
+
);
|
|
269
|
+
return unwrapSingle(batchResult);
|
|
270
|
+
},
|
|
271
|
+
queryWithOrigin: (type, payload, user, inheritedOrigin) => {
|
|
272
|
+
const origin = effectiveWriteOrigin(rootWriteOrigin(registry, type, user), inheritedOrigin);
|
|
273
|
+
return runWithWriteOrigin(origin, () => executeQuery(ctx, type, payload, user, origin));
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return dispatcher;
|
|
213
278
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
index,
|
|
6
6
|
instant,
|
|
7
7
|
integer,
|
|
8
|
+
jsonb,
|
|
8
9
|
table as pgTable,
|
|
9
10
|
primaryKey,
|
|
10
11
|
sql,
|
|
@@ -64,6 +65,21 @@ export const SHARED_INSTANCE_SENTINEL = "__shared__";
|
|
|
64
65
|
// The default(sql`0`) on lastProcessedEventId mirrors projection-state.ts:
|
|
65
66
|
// drizzle-kit's JSON snapshot generator can't serialise a bigint literal, so
|
|
66
67
|
// the server-side default is specified as raw SQL instead of .default(0n).
|
|
68
|
+
|
|
69
|
+
// A contiguous run of ids below the cursor invisible on some earlier turn,
|
|
70
|
+
// not yet resolved (delivered) or proven burnt (see event-dispatcher.ts's
|
|
71
|
+
// processConsumer). Ranges, not individual ids — a large historical gap
|
|
72
|
+
// (retention prune, or a new consumer starting "beginning" over pruned
|
|
73
|
+
// history) then costs O(1) entries instead of one per missing id. xmax is
|
|
74
|
+
// the pg_snapshot_xmax() captured when the range was recorded: once a later
|
|
75
|
+
// turn's xmin passes it, every xact that could still produce a row in this
|
|
76
|
+
// range has finished, so it's provably rolled back, not just slow.
|
|
77
|
+
// bigint/xid8 travel as strings — JS bigint doesn't round-trip through jsonb.
|
|
78
|
+
export type PendingGapEntry = {
|
|
79
|
+
readonly from: string;
|
|
80
|
+
readonly to: string;
|
|
81
|
+
readonly xmax: string;
|
|
82
|
+
};
|
|
67
83
|
export const eventConsumerStateTable = pgTable(
|
|
68
84
|
"kumiko_event_consumers",
|
|
69
85
|
{
|
|
@@ -82,6 +98,7 @@ export const eventConsumerStateTable = pgTable(
|
|
|
82
98
|
// poisoned), as does a manual restartConsumer()/enableConsumer()/
|
|
83
99
|
// skipPoisonEvent() — an operator vouching the consumer is healthy.
|
|
84
100
|
rearmCount: integer("rearm_count").notNull().default(0),
|
|
101
|
+
pendingGaps: jsonb("pending_gaps").$type<PendingGapEntry[]>().default([]).notNull(),
|
|
85
102
|
lastError: text("last_error"),
|
|
86
103
|
updatedAt: instant("updated_at", { precision: 3 }).notNull().default(sql`now()`),
|
|
87
104
|
},
|
|
@@ -142,6 +159,15 @@ export async function createEventConsumerStateTable(db: DbConnection): Promise<v
|
|
|
142
159
|
" NOT NULL",
|
|
143
160
|
/* ifNotExists */ true,
|
|
144
161
|
);
|
|
162
|
+
await alterTableAddColumn(
|
|
163
|
+
db,
|
|
164
|
+
"kumiko_event_consumers",
|
|
165
|
+
"pending_gaps",
|
|
166
|
+
"jsonb",
|
|
167
|
+
" DEFAULT '[]'::jsonb",
|
|
168
|
+
" NOT NULL",
|
|
169
|
+
/* ifNotExists */ true,
|
|
170
|
+
);
|
|
145
171
|
// Runs on every boot, including mid-rolling-deploy: a new pod can delete
|
|
146
172
|
// an old pod's still-in-use per-instance row out from under it. That old
|
|
147
173
|
// pod's next acquireConsumer then finds no row, delivers no more SSE/
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import type { DbConnection, DbTx } from "../db/connection";
|
|
2
2
|
import {
|
|
3
3
|
advanceConsumerPastEventReturning,
|
|
4
|
+
removePendingGapReturning,
|
|
4
5
|
updateConsumerStatusReturning,
|
|
5
6
|
} from "../db/queries/event-consumer";
|
|
6
|
-
import { selectNextEventIdAfter } from "../db/queries/event-store";
|
|
7
|
+
import { selectNextEventIdAfter, selectSmallestVisibleIdInRanges } from "../db/queries/event-store";
|
|
7
8
|
import { coerceRow, extractTableInfo, selectMany } from "../db/query";
|
|
8
9
|
import { getEventsHighWaterMark } from "../event-store";
|
|
9
10
|
import { eventConsumerStateTable, SHARED_INSTANCE_SENTINEL } from "./event-consumer-state";
|
|
10
11
|
import type { ConsumerStateRow, ConsumerStateRowShape } from "./event-dispatcher-delivery";
|
|
12
|
+
import { rangeContainsId, splitRangeExcludingIds, toIdRanges } from "./pending-gap-ranges";
|
|
11
13
|
|
|
12
14
|
// --- Ops recovery surface ---
|
|
13
15
|
//
|
|
@@ -129,10 +131,13 @@ export async function enableConsumer(
|
|
|
129
131
|
return applyConsumerStatusTransition(db, name, instanceId, "idle");
|
|
130
132
|
}
|
|
131
133
|
|
|
132
|
-
// skipPoisonEvent advances
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
134
|
+
// skipPoisonEvent advances past whatever is currently blocking the consumer.
|
|
135
|
+
// If the smallest visible id inside pending_gaps exists, THAT'S the poison —
|
|
136
|
+
// halt-on-poison walks gap ids before any id past the cursor — so it's split
|
|
137
|
+
// out of its range directly; the cursor stays put (it's already above this
|
|
138
|
+
// id). Otherwise the poison is the usual next event after the cursor. Single
|
|
139
|
+
// TX so concurrent dispatcher passes can't double-advance. Neither exists →
|
|
140
|
+
// idempotent no-op.
|
|
136
141
|
export async function skipPoisonEvent(
|
|
137
142
|
db: DbConnection,
|
|
138
143
|
name: string,
|
|
@@ -140,6 +145,28 @@ export async function skipPoisonEvent(
|
|
|
140
145
|
): Promise<ConsumerRecoveryState & { readonly skippedEventId: bigint | null }> {
|
|
141
146
|
const before = await requireConsumerRow(db, name, instanceId);
|
|
142
147
|
return db.begin(async (tx: DbTx) => {
|
|
148
|
+
const pendingGaps = before.pendingGaps;
|
|
149
|
+
const smallestVisiblePending = await selectSmallestVisibleIdInRanges(
|
|
150
|
+
tx,
|
|
151
|
+
toIdRanges(pendingGaps),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
if (smallestVisiblePending !== null) {
|
|
155
|
+
const newPendingGaps = pendingGaps.flatMap((gap) =>
|
|
156
|
+
rangeContainsId(gap, smallestVisiblePending)
|
|
157
|
+
? splitRangeExcludingIds(gap, [smallestVisiblePending])
|
|
158
|
+
: [gap],
|
|
159
|
+
);
|
|
160
|
+
const raw = await removePendingGapReturning(tx, name, instanceId, newPendingGaps);
|
|
161
|
+
const updated =
|
|
162
|
+
raw && (coerceRow(raw, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
|
|
163
|
+
if (!updated)
|
|
164
|
+
throw new Error(
|
|
165
|
+
`Consumer "${name}" (instance_id="${instanceId}") vanished mid-skip — retry.`,
|
|
166
|
+
);
|
|
167
|
+
return { ...normalizeConsumerState(updated), skippedEventId: smallestVisiblePending };
|
|
168
|
+
}
|
|
169
|
+
|
|
143
170
|
const poisonId = await selectNextEventIdAfter(tx, before.lastProcessedEventId);
|
|
144
171
|
if (poisonId === null) {
|
|
145
172
|
const [unchanged] = await selectMany<ConsumerStateRow>(tx, eventConsumerStateTable, {
|
|
@@ -12,8 +12,12 @@ import {
|
|
|
12
12
|
selectConsumerForUpdateSkipLocked,
|
|
13
13
|
updateConsumerDeliveryOutcome,
|
|
14
14
|
} from "../db/queries/event-consumer";
|
|
15
|
-
import {
|
|
16
|
-
|
|
15
|
+
import {
|
|
16
|
+
type PendingIdRange,
|
|
17
|
+
selectEventsHeadId,
|
|
18
|
+
selectPendingAndNewEventRows,
|
|
19
|
+
} from "../db/queries/event-store";
|
|
20
|
+
import { coerceRow, extractTableInfo } from "../db/query";
|
|
17
21
|
import { qnScope } from "../engine/qualified-name";
|
|
18
22
|
import type { AppContext } from "../engine/types";
|
|
19
23
|
import { eventsTable, toStoredEvent as rowToStoredEvent } from "../event-store";
|
|
@@ -26,9 +30,18 @@ import {
|
|
|
26
30
|
import {
|
|
27
31
|
ConsumerStatuses,
|
|
28
32
|
eventConsumerStateTable,
|
|
33
|
+
type PendingGapEntry,
|
|
29
34
|
SHARED_INSTANCE_SENTINEL,
|
|
30
35
|
} from "./event-consumer-state";
|
|
31
36
|
import type { EventConsumer } from "./event-dispatcher";
|
|
37
|
+
import { parseWriteOrigin } from "./write-origin";
|
|
38
|
+
|
|
39
|
+
// Fails closed without throwing: a throw would poison the event for every consumer.
|
|
40
|
+
const UNPARSEABLE_STORED_WRITE_ORIGIN = {
|
|
41
|
+
rootHandler: "<unknown>",
|
|
42
|
+
anonymousRoot: true,
|
|
43
|
+
publicIntake: false,
|
|
44
|
+
} as const;
|
|
32
45
|
|
|
33
46
|
// Per-consumer pass mechanics: acquire the state row, fetch pending events,
|
|
34
47
|
// hand them to the consumer's handler in order, persist the outcome. Split
|
|
@@ -46,6 +59,7 @@ export type ConsumerStateRowShape = {
|
|
|
46
59
|
readonly status: string;
|
|
47
60
|
readonly attempts: number;
|
|
48
61
|
readonly rearmCount: number;
|
|
62
|
+
readonly pendingGaps: readonly PendingGapEntry[];
|
|
49
63
|
readonly lastError: string | null;
|
|
50
64
|
readonly updatedAt: Temporal.Instant;
|
|
51
65
|
};
|
|
@@ -186,17 +200,20 @@ export async function markProcessing(tx: DbTx, name: string, instanceId: string)
|
|
|
186
200
|
await markConsumerProcessing(tx, name, instanceId);
|
|
187
201
|
}
|
|
188
202
|
|
|
203
|
+
// `pendingRanges` are id ranges below `cursor` the consumer is still
|
|
204
|
+
// watching as gaps (invisible on an earlier turn — see event-dispatcher.ts's
|
|
205
|
+
// processConsumer). Fetching them alongside the plain `id > cursor` window
|
|
206
|
+
// means a row that committed late becomes visible and deliverable the next
|
|
207
|
+
// time this consumer's turn runs, instead of being permanently skipped.
|
|
189
208
|
export async function fetchPendingEvents(
|
|
190
209
|
tx: DbTx,
|
|
191
210
|
cursor: bigint,
|
|
192
211
|
batchSize: number,
|
|
212
|
+
pendingRanges: readonly PendingIdRange[] = [],
|
|
193
213
|
): Promise<ReadonlyArray<StoredEventRow>> {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
{ id: { gt: cursor } },
|
|
198
|
-
{ orderBy: { col: "id", direction: "asc" }, limit: batchSize },
|
|
199
|
-
)) as ReadonlyArray<StoredEventRow>; // @cast-boundary db-row
|
|
214
|
+
const rawRows = await selectPendingAndNewEventRows(tx, cursor, pendingRanges, batchSize);
|
|
215
|
+
const info = extractTableInfo(eventsTable);
|
|
216
|
+
return rawRows.map((row) => coerceRow(row, info) as StoredEventRow); // @cast-boundary db-row
|
|
200
217
|
}
|
|
201
218
|
|
|
202
219
|
export type DeliveryOutcome = {
|
|
@@ -206,6 +223,11 @@ export type DeliveryOutcome = {
|
|
|
206
223
|
readonly deadLettered: boolean;
|
|
207
224
|
readonly processed: number;
|
|
208
225
|
readonly failed: number;
|
|
226
|
+
// Which of the *pending* ids in `events` (id <= the cursor this delivery
|
|
227
|
+
// started from) got resolved this pass — delivered or skip-applied. The
|
|
228
|
+
// caller (event-dispatcher.ts) splits exactly these out of pending_gaps;
|
|
229
|
+
// everything else in the input batch was a "new" row past the old cursor.
|
|
230
|
+
readonly resolvedPendingIds: readonly bigint[];
|
|
209
231
|
};
|
|
210
232
|
|
|
211
233
|
// Deliver events to the consumer's handler in events.id order. Halt-on-
|
|
@@ -221,83 +243,113 @@ export async function deliverEvents(
|
|
|
221
243
|
maxAttempts: number,
|
|
222
244
|
state: ConsumerStateRow,
|
|
223
245
|
): Promise<DeliveryOutcome> {
|
|
224
|
-
|
|
246
|
+
const startCursor = state.lastProcessedEventId;
|
|
247
|
+
let cursor = startCursor;
|
|
225
248
|
let attempts = state.attempts;
|
|
226
249
|
let lastError: string | null = state.lastError ?? null;
|
|
227
250
|
let deadLettered = false;
|
|
228
251
|
const effectiveMaxAttempts = consumer.errorPolicy?.maxAttempts ?? maxAttempts;
|
|
229
252
|
let processed = 0;
|
|
230
253
|
let failed = 0;
|
|
254
|
+
const resolvedPendingIds: bigint[] = [];
|
|
255
|
+
|
|
256
|
+
// A pending row sits below startCursor: it resolves its gap but never moves
|
|
257
|
+
// the cursor backward. ORDER BY id walks all pending rows first.
|
|
258
|
+
const resolve = (id: bigint): void => {
|
|
259
|
+
if (id > cursor) cursor = id;
|
|
260
|
+
if (id <= startCursor) resolvedPendingIds.push(id);
|
|
261
|
+
attempts = 0;
|
|
262
|
+
lastError = null;
|
|
263
|
+
};
|
|
231
264
|
|
|
232
265
|
for (const row of events) {
|
|
233
266
|
try {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
// inherited unchanged — it survives the hop across streams by design.
|
|
237
|
-
// requestId falls back to a fresh id because the dispatcher runs
|
|
238
|
-
// outside any HTTP request (background poll), and a stable log-
|
|
239
|
-
// correlation handle is still useful for debugging.
|
|
240
|
-
const stored = rowToStoredEvent(row);
|
|
241
|
-
const correlationId = stored.metadata.correlationId ?? requestContext.generateId();
|
|
242
|
-
const causationId = String(stored.id);
|
|
243
|
-
const requestId = requestContext.generateId();
|
|
244
|
-
// #3043 — an event this apply writes is attributed to the consumer, not
|
|
245
|
-
// to whatever wrote the triggering event; causationId already links back.
|
|
246
|
-
await requestContext.run(
|
|
247
|
-
{
|
|
248
|
-
requestId,
|
|
249
|
-
correlationId,
|
|
250
|
-
causationId,
|
|
251
|
-
handler: consumer.name,
|
|
252
|
-
feature: consumer.featureName ?? qnScope(consumer.name),
|
|
253
|
-
},
|
|
254
|
-
async () => {
|
|
255
|
-
await consumer.handler(stored, context);
|
|
256
|
-
},
|
|
257
|
-
);
|
|
258
|
-
cursor = row.id;
|
|
259
|
-
attempts = 0;
|
|
260
|
-
lastError = null;
|
|
267
|
+
await applyEvent(consumer, row, context);
|
|
268
|
+
resolve(row.id);
|
|
261
269
|
processed += 1;
|
|
262
270
|
} catch (e) {
|
|
263
271
|
const errMessage = e instanceof Error ? e.message : String(e);
|
|
272
|
+
failed += 1;
|
|
264
273
|
if (consumer.errorPolicy?.skipApplyErrors) {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
// the bad event, keep going. The consumer stays "idle", not "dead".
|
|
268
|
-
// Also emit a warn-level log line — the metric tells ops THAT events
|
|
269
|
-
// are being dropped, the log tells them WHICH events. Without this
|
|
270
|
-
// a poisoned-then-skipped event is invisible to forensic search.
|
|
271
|
-
const errorClass = e instanceof Error ? e.constructor.name : "UnknownError";
|
|
272
|
-
emitDispatcherError(context.meter ?? getFallbackMeter(), {
|
|
273
|
-
handler: consumer.name,
|
|
274
|
-
errorClass,
|
|
275
|
-
});
|
|
276
|
-
context.log?.warn(
|
|
277
|
-
`event-dispatcher: ${consumer.name} skipped event ${row.id} (${errorClass}): ${errMessage}`,
|
|
278
|
-
);
|
|
279
|
-
cursor = row.id;
|
|
280
|
-
attempts = 0;
|
|
281
|
-
lastError = null;
|
|
282
|
-
failed += 1;
|
|
274
|
+
reportSkippedEvent(consumer, row.id, e, errMessage, context);
|
|
275
|
+
resolve(row.id);
|
|
283
276
|
continue;
|
|
284
277
|
}
|
|
285
278
|
attempts += 1;
|
|
286
279
|
lastError = errMessage;
|
|
287
|
-
failed += 1;
|
|
288
280
|
if (attempts >= effectiveMaxAttempts) deadLettered = true;
|
|
289
281
|
break;
|
|
290
282
|
}
|
|
291
283
|
}
|
|
292
284
|
|
|
293
|
-
return { cursor, attempts, lastError, deadLettered, processed, failed };
|
|
285
|
+
return { cursor, attempts, lastError, deadLettered, processed, failed, resolvedPendingIds };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function applyEvent(
|
|
289
|
+
consumer: EventConsumer,
|
|
290
|
+
row: StoredEventRow,
|
|
291
|
+
context: AppContext,
|
|
292
|
+
): Promise<void> {
|
|
293
|
+
// Propagate causation: if the handler calls ctx.appendEvent, the new
|
|
294
|
+
// event should record THIS event as its cause. correlationId is
|
|
295
|
+
// inherited unchanged — it survives the hop across streams by design.
|
|
296
|
+
// requestId falls back to a fresh id because the dispatcher runs
|
|
297
|
+
// outside any HTTP request (background poll), and a stable log-
|
|
298
|
+
// correlation handle is still useful for debugging.
|
|
299
|
+
const stored = rowToStoredEvent(row);
|
|
300
|
+
const correlationId = stored.metadata.correlationId ?? requestContext.generateId();
|
|
301
|
+
const causationId = String(stored.id);
|
|
302
|
+
const requestId = requestContext.generateId();
|
|
303
|
+
// The job-trigger consumer's handleEvent stamps event-triggered jobs from this.
|
|
304
|
+
const rawStoredWriteOrigin = stored.metadata.writeOrigin;
|
|
305
|
+
const writeOrigin =
|
|
306
|
+
rawStoredWriteOrigin === undefined
|
|
307
|
+
? undefined
|
|
308
|
+
: (parseWriteOrigin(rawStoredWriteOrigin) ?? UNPARSEABLE_STORED_WRITE_ORIGIN);
|
|
309
|
+
// #3043 — an event this apply writes is attributed to the consumer, not
|
|
310
|
+
// to whatever wrote the triggering event; causationId already links back.
|
|
311
|
+
await requestContext.run(
|
|
312
|
+
{
|
|
313
|
+
requestId,
|
|
314
|
+
correlationId,
|
|
315
|
+
causationId,
|
|
316
|
+
handler: consumer.name,
|
|
317
|
+
feature: consumer.featureName ?? qnScope(consumer.name),
|
|
318
|
+
writeOrigin,
|
|
319
|
+
},
|
|
320
|
+
async () => {
|
|
321
|
+
await consumer.handler(stored, context);
|
|
322
|
+
},
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Best-effort mode: record the error on the skip counter so ops can alert on
|
|
327
|
+
// a spike of skipped events; the consumer stays "idle", not "dead". The
|
|
328
|
+
// warn-level log line tells them WHICH events — without it a
|
|
329
|
+
// poisoned-then-skipped event is invisible to forensic search.
|
|
330
|
+
function reportSkippedEvent(
|
|
331
|
+
consumer: EventConsumer,
|
|
332
|
+
eventId: bigint,
|
|
333
|
+
e: unknown,
|
|
334
|
+
errMessage: string,
|
|
335
|
+
context: AppContext,
|
|
336
|
+
): void {
|
|
337
|
+
const errorClass = e instanceof Error ? e.constructor.name : "UnknownError";
|
|
338
|
+
emitDispatcherError(context.meter ?? getFallbackMeter(), { handler: consumer.name, errorClass });
|
|
339
|
+
context.log?.warn(
|
|
340
|
+
`event-dispatcher: ${consumer.name} skipped event ${eventId} (${errorClass}): ${errMessage}`,
|
|
341
|
+
);
|
|
294
342
|
}
|
|
295
343
|
|
|
344
|
+
export type PersistedConsumerOutcome = DeliveryOutcome & {
|
|
345
|
+
readonly pendingGaps: readonly PendingGapEntry[];
|
|
346
|
+
};
|
|
347
|
+
|
|
296
348
|
export async function persistConsumerOutcome(
|
|
297
349
|
tx: DbTx,
|
|
298
350
|
name: string,
|
|
299
351
|
instanceId: string,
|
|
300
|
-
outcome:
|
|
352
|
+
outcome: PersistedConsumerOutcome,
|
|
301
353
|
): Promise<void> {
|
|
302
354
|
await updateConsumerDeliveryOutcome(tx, name, instanceId, outcome);
|
|
303
355
|
}
|