@cosmicdrift/kumiko-framework 0.306.0 → 0.308.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/__tests__/anonymous-access.integration.test.ts +1 -1
- package/src/__tests__/error-contract.integration.test.ts +1 -1
- package/src/__tests__/field-access.integration.test.ts +1 -1
- package/src/__tests__/fixtures/migrate-open-to-all/manual-only.expected.ts +1 -1
- package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +1 -1
- package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +1 -1
- package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +1 -1
- package/src/__tests__/full-stack.integration.test.ts +1 -1
- package/src/__tests__/transition-guard.integration.test.ts +1 -1
- package/src/api/__tests__/api.test.ts +1 -1
- package/src/api/__tests__/batch.integration.test.ts +1 -1
- package/src/api/__tests__/body-limit.test.ts +1 -1
- package/src/api/__tests__/dispatcher-live.integration.test.ts +1 -1
- package/src/api/__tests__/extra-routes.integration.test.ts +1 -1
- package/src/api/__tests__/http-route-rate-limit.integration.test.ts +1 -1
- package/src/api/__tests__/nested-write-ownership.integration.test.ts +1 -1
- package/src/api/__tests__/nested-write.integration.test.ts +1 -1
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +1 -1
- package/src/api/__tests__/redis-sse-broker.integration.test.ts +133 -0
- package/src/api/__tests__/request-locale.integration.test.ts +1 -1
- package/src/api/__tests__/server-boot-guards.test.ts +1 -1
- package/src/api/__tests__/server-error-logging.test.ts +1 -1
- package/src/api/__tests__/sse-broker.test.ts +144 -1
- package/src/api/__tests__/stream-escape-hatch.integration.test.ts +1 -1
- package/src/api/auth-routes.ts +1 -1
- package/src/api/redis-sse-broker.ts +57 -3
- package/src/api/request-context.ts +24 -0
- package/src/api/server.ts +11 -0
- package/src/api/sse-broker.ts +69 -12
- package/src/changes.json +63 -0
- package/src/compliance/override-schema.ts +1 -1
- package/src/crypto/__tests__/event-pii.test.ts +1 -1
- package/src/crypto/kms-adapter.ts +1 -1
- package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +1 -1
- package/src/db/queries/event-consumer.ts +155 -9
- package/src/db/queries/event-store.ts +69 -0
- package/src/db/tenant-db.ts +43 -5
- package/src/derivatives/__tests__/derivatives-context.integration.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-nav-area.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-query-refs.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-ref-entity.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +1 -1
- package/src/engine/__tests__/boot-validator.test.ts +35 -1
- package/src/engine/__tests__/build-app-schema.test.ts +1 -1
- package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +1 -1
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +1 -1
- package/src/engine/__tests__/engine.test.ts +1 -1
- package/src/engine/__tests__/event-helpers.test.ts +1 -1
- package/src/engine/__tests__/event-migration-declarative.test.ts +1 -1
- package/src/engine/__tests__/extend-entity-projection.test.ts +1 -1
- package/src/engine/__tests__/extends-registrar.test.ts +1 -1
- package/src/engine/__tests__/hook-phases.test.ts +1 -1
- package/src/engine/__tests__/lifecycle-hooks.test.ts +1 -1
- package/src/engine/__tests__/pipeline-engine.test.ts +1 -1
- package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
- package/src/engine/__tests__/pipeline-observability.integration.test.ts +1 -1
- package/src/engine/__tests__/pipeline-performance.integration.test.ts +1 -1
- package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +1 -1
- package/src/engine/__tests__/post-query-hook.test.ts +1 -1
- package/src/engine/__tests__/projection-detail-actions.test.ts +1 -1
- package/src/engine/__tests__/projection-detail-singleton.test.ts +1 -1
- package/src/engine/__tests__/projection-detail-tabs.test.ts +1 -1
- package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +1 -1
- package/src/engine/__tests__/registry.test.ts +1 -1
- package/src/engine/__tests__/screen.test.ts +1 -1
- package/src/engine/__tests__/validate-projection-allowlist.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/action-icon.test.ts +81 -0
- package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/parent-ref.test.ts +1 -1
- package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +1 -1
- package/src/engine/boot-validator/screens.ts +83 -0
- package/src/engine/define-handler.ts +2 -1
- package/src/engine/entity-handlers.ts +2 -1
- package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +1 -1
- package/src/engine/feature-ast/pattern-change-schema.ts +1 -1
- package/src/engine/feature-builder-state.ts +2 -1
- package/src/engine/feature-config-events-jobs.ts +2 -1
- package/src/engine/feature-entity-handlers.ts +2 -1
- package/src/engine/schema-builder.ts +1 -1
- package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +1 -1
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
- package/src/env/__tests__/compose-env-schema.test.ts +1 -1
- package/src/env/__tests__/dry-run.test.ts +1 -1
- package/src/env/__tests__/parse-env-dry-run.test.ts +1 -1
- package/src/env/_zod-introspect.ts +1 -1
- package/src/env/dry-run.ts +1 -1
- package/src/env/index.ts +1 -1
- package/src/errors/__tests__/classes.test.ts +1 -1
- package/src/es-ops/__tests__/runner.integration.test.ts +1 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +1 -1
- package/src/event-store/__tests__/event-attribution.integration.test.ts +54 -4
- package/src/event-store/__tests__/upcaster.integration.test.ts +1 -1
- package/src/event-store/admin-api.ts +5 -0
- package/src/event-store/event-store.ts +16 -7
- package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-event-trigger.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
- package/src/jobs/__tests__/job-retention.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-write-as.integration.test.ts +1 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +1 -1
- package/src/jobs/job-runner.ts +61 -6
- package/src/observability/__tests__/metrics-wiring.test.ts +1 -1
- package/src/observability/__tests__/observability.integration.test.ts +1 -1
- package/src/observability/metrics-wiring.ts +1 -1
- package/src/pipeline/__tests__/archive-stream.integration.test.ts +1 -1
- package/src/pipeline/__tests__/causation-chain.integration.test.ts +1 -1
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +1 -1
- package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +1 -1
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +1 -1
- package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +1 -1
- package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +1 -1
- package/src/pipeline/__tests__/escape-hatch.integration.test.ts +1 -1
- package/src/pipeline/__tests__/event-dedup.integration.test.ts +1 -1
- package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +1 -1
- package/src/pipeline/__tests__/event-dispatcher-batch-delivery.test.ts +178 -0
- package/src/pipeline/__tests__/event-dispatcher-batch-handler.integration.test.ts +143 -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-idle.integration.test.ts +94 -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__/event-dispatcher-start-from.integration.test.ts +103 -3
- package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +1 -1
- package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +1 -1
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +1 -1
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
- package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +1 -1
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +116 -1
- package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +1 -1
- package/src/pipeline/__tests__/pending-gap-ranges.test.ts +94 -0
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +1 -1
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +262 -3
- package/src/pipeline/__tests__/query-projection.integration.test.ts +1 -1
- package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +1 -1
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +1 -1
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +1 -1
- package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +1 -1
- package/src/pipeline/__tests__/try-append-event.integration.test.ts +1 -1
- package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +1 -1
- package/src/pipeline/dispatch-batch.ts +49 -19
- package/src/pipeline/dispatch-stream.ts +12 -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 +212 -65
- package/src/pipeline/event-dispatcher.ts +253 -49
- package/src/pipeline/index.ts +1 -0
- package/src/pipeline/msp-rebuild.ts +39 -1
- package/src/pipeline/pending-gap-ranges.ts +120 -0
- package/src/pipeline/system-hooks.ts +226 -64
- package/src/pipeline/write-origin.ts +31 -10
- package/src/rate-limit/__tests__/dispatcher-l3.integration.test.ts +1 -1
- package/src/search/__tests__/derive-search-adapter-config.test.ts +65 -0
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +91 -0
- package/src/search/__tests__/search-consumer-batch.integration.test.ts +306 -0
- package/src/search/derive-search-adapter-config.ts +27 -0
- package/src/search/index.ts +1 -0
- package/src/search/meilisearch-adapter.ts +96 -14
- package/src/secrets/__tests__/contains-secret.test.ts +1 -1
- package/src/stack/__tests__/request-helper.integration.test.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +1 -1
- package/src/stack/test-stack.ts +1 -1
- package/src/testing/__tests__/e2e-generator.test.ts +1 -1
- package/src/testing/e2e-generator.ts +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// `Temporal` TYPE that ConsumerStateRow.updatedAt/StoredEventRow.createdAt
|
|
3
3
|
// resolve against (same #1438 dual-package-hazard pattern as event-store.ts).
|
|
4
4
|
import { Temporal as TemporalPolyfill } from "temporal-polyfill";
|
|
5
|
-
import { requestContext } from "../api/request-context";
|
|
5
|
+
import { type RequestContextData, requestContext } from "../api/request-context";
|
|
6
6
|
import type { DbConnection, DbTx } from "../db/connection";
|
|
7
7
|
import {
|
|
8
8
|
insertConsumerIfAbsent,
|
|
@@ -10,13 +10,18 @@ import {
|
|
|
10
10
|
rearmDeadConsumer,
|
|
11
11
|
recordConsumerPassFailure,
|
|
12
12
|
selectConsumerForUpdateSkipLocked,
|
|
13
|
+
selectProvablyIdleConsumerPairs,
|
|
13
14
|
updateConsumerDeliveryOutcome,
|
|
14
15
|
} from "../db/queries/event-consumer";
|
|
15
|
-
import {
|
|
16
|
-
|
|
16
|
+
import {
|
|
17
|
+
type PendingIdRange,
|
|
18
|
+
selectEventsHeadId,
|
|
19
|
+
selectPendingAndNewEventRows,
|
|
20
|
+
} from "../db/queries/event-store";
|
|
21
|
+
import { coerceRow, extractTableInfo } from "../db/query";
|
|
17
22
|
import { qnScope } from "../engine/qualified-name";
|
|
18
23
|
import type { AppContext } from "../engine/types";
|
|
19
|
-
import { eventsTable, toStoredEvent as rowToStoredEvent } from "../event-store";
|
|
24
|
+
import { eventsTable, toStoredEvent as rowToStoredEvent, type StoredEvent } from "../event-store";
|
|
20
25
|
import {
|
|
21
26
|
emitDispatcherError,
|
|
22
27
|
emitEventConsumerLag,
|
|
@@ -26,9 +31,18 @@ import {
|
|
|
26
31
|
import {
|
|
27
32
|
ConsumerStatuses,
|
|
28
33
|
eventConsumerStateTable,
|
|
34
|
+
type PendingGapEntry,
|
|
29
35
|
SHARED_INSTANCE_SENTINEL,
|
|
30
36
|
} from "./event-consumer-state";
|
|
31
37
|
import type { EventConsumer } from "./event-dispatcher";
|
|
38
|
+
import { parseWriteOrigin } from "./write-origin";
|
|
39
|
+
|
|
40
|
+
// Fails closed without throwing: a throw would poison the event for every consumer.
|
|
41
|
+
const UNPARSEABLE_STORED_WRITE_ORIGIN = {
|
|
42
|
+
rootHandler: "<unknown>",
|
|
43
|
+
anonymousRoot: true,
|
|
44
|
+
publicIntake: false,
|
|
45
|
+
} as const;
|
|
32
46
|
|
|
33
47
|
// Per-consumer pass mechanics: acquire the state row, fetch pending events,
|
|
34
48
|
// hand them to the consumer's handler in order, persist the outcome. Split
|
|
@@ -46,6 +60,7 @@ export type ConsumerStateRowShape = {
|
|
|
46
60
|
readonly status: string;
|
|
47
61
|
readonly attempts: number;
|
|
48
62
|
readonly rearmCount: number;
|
|
63
|
+
readonly pendingGaps: readonly PendingGapEntry[];
|
|
49
64
|
readonly lastError: string | null;
|
|
50
65
|
readonly updatedAt: Temporal.Instant;
|
|
51
66
|
};
|
|
@@ -145,6 +160,39 @@ export async function acquireConsumerState(
|
|
|
145
160
|
return { state, skip: null };
|
|
146
161
|
}
|
|
147
162
|
|
|
163
|
+
// Read-only pre-check run once per doPass, before any consumer's turn opens
|
|
164
|
+
// a transaction (event-dispatcher.ts's doPass/runConsumerTurn). Keyed like
|
|
165
|
+
// consumerBackoff/inFlightTurns: `${name}:${instanceId}`. Consumers not
|
|
166
|
+
// provably idle (missing row, dead, pending gaps, or an event past cursor)
|
|
167
|
+
// simply don't appear in the result — the caller falls through to the
|
|
168
|
+
// existing acquireConsumerState/FOR UPDATE SKIP LOCKED path unchanged.
|
|
169
|
+
//
|
|
170
|
+
// Race: an event that commits between this query and the next tick CAN
|
|
171
|
+
// make this snapshot stale — that's expected, not a bug. What it can't do
|
|
172
|
+
// is get silently lost: the append fires NOTIFY (or the next poll tick
|
|
173
|
+
// fires regardless), triggering a fresh doPass. That pass's own pre-check
|
|
174
|
+
// sees the new head id and excludes the consumer from "idle"; a new
|
|
175
|
+
// pending_gap only ever gets created when a later turn's fetch (id > the
|
|
176
|
+
// old cursor) walks past the row that committed late, and this same
|
|
177
|
+
// pre-check reads pending_gaps live on every pass, so it observes that gap
|
|
178
|
+
// too. In short: this can under-report idleness for one tick, never
|
|
179
|
+
// over-report it.
|
|
180
|
+
export async function selectIdleConsumerKeys(
|
|
181
|
+
db: DbConnection,
|
|
182
|
+
pairs: ReadonlyArray<{ readonly name: string; readonly instanceId: string }>,
|
|
183
|
+
): Promise<ReadonlySet<string>> {
|
|
184
|
+
if (pairs.length === 0) return new Set();
|
|
185
|
+
const names = pairs.map((p) => p.name);
|
|
186
|
+
const instanceIds = pairs.map((p) => p.instanceId);
|
|
187
|
+
const idlePairs = await selectProvablyIdleConsumerPairs(
|
|
188
|
+
db,
|
|
189
|
+
names,
|
|
190
|
+
instanceIds,
|
|
191
|
+
ConsumerStatuses.dead,
|
|
192
|
+
);
|
|
193
|
+
return new Set(idlePairs.map((p) => `${p.name}:${p.instanceId}`));
|
|
194
|
+
}
|
|
195
|
+
|
|
148
196
|
// Shared pre-registration: one row per (consumer, shard), cursor = 0,
|
|
149
197
|
// status = idle. Shared-delivery consumers use SHARED_INSTANCE_SENTINEL;
|
|
150
198
|
// per-instance consumers use the dispatcher's instanceId. Idempotent
|
|
@@ -186,17 +234,20 @@ export async function markProcessing(tx: DbTx, name: string, instanceId: string)
|
|
|
186
234
|
await markConsumerProcessing(tx, name, instanceId);
|
|
187
235
|
}
|
|
188
236
|
|
|
237
|
+
// `pendingRanges` are id ranges below `cursor` the consumer is still
|
|
238
|
+
// watching as gaps (invisible on an earlier turn — see event-dispatcher.ts's
|
|
239
|
+
// processConsumer). Fetching them alongside the plain `id > cursor` window
|
|
240
|
+
// means a row that committed late becomes visible and deliverable the next
|
|
241
|
+
// time this consumer's turn runs, instead of being permanently skipped.
|
|
189
242
|
export async function fetchPendingEvents(
|
|
190
243
|
tx: DbTx,
|
|
191
244
|
cursor: bigint,
|
|
192
245
|
batchSize: number,
|
|
246
|
+
pendingRanges: readonly PendingIdRange[] = [],
|
|
193
247
|
): 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
|
|
248
|
+
const rawRows = await selectPendingAndNewEventRows(tx, cursor, pendingRanges, batchSize);
|
|
249
|
+
const info = extractTableInfo(eventsTable);
|
|
250
|
+
return rawRows.map((row) => coerceRow(row, info) as StoredEventRow); // @cast-boundary db-row
|
|
200
251
|
}
|
|
201
252
|
|
|
202
253
|
export type DeliveryOutcome = {
|
|
@@ -206,14 +257,22 @@ export type DeliveryOutcome = {
|
|
|
206
257
|
readonly deadLettered: boolean;
|
|
207
258
|
readonly processed: number;
|
|
208
259
|
readonly failed: number;
|
|
260
|
+
// Which of the *pending* ids in `events` (id <= the cursor this delivery
|
|
261
|
+
// started from) got resolved this pass — delivered or skip-applied. The
|
|
262
|
+
// caller (event-dispatcher.ts) splits exactly these out of pending_gaps;
|
|
263
|
+
// everything else in the input batch was a "new" row past the old cursor.
|
|
264
|
+
readonly resolvedPendingIds: readonly bigint[];
|
|
209
265
|
};
|
|
210
266
|
|
|
211
|
-
// Deliver events to the consumer's handler in events.id order
|
|
212
|
-
//
|
|
213
|
-
// event,
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
267
|
+
// Deliver events to the consumer's handler in events.id order, or — when
|
|
268
|
+
// the consumer wires a batchHandler — through it in one call first. Halt-
|
|
269
|
+
// on-poison: a throw breaks the per-event loop, the cursor stays at the
|
|
270
|
+
// last successful event, and attempts climb. At the consumer's
|
|
271
|
+
// effectiveMaxAttempts (errorPolicy.maxAttempts ?? maxAttempts) the caller
|
|
272
|
+
// persists status="dead" and the consumer is parked until ops intervenes
|
|
273
|
+
// (see restartConsumer / skipPoisonEvent). A batch that throws falls back
|
|
274
|
+
// to the same per-event loop for the same events — the batch failure
|
|
275
|
+
// itself does not bump attempts, only a per-event failure does.
|
|
217
276
|
export async function deliverEvents(
|
|
218
277
|
consumer: EventConsumer,
|
|
219
278
|
events: ReadonlyArray<StoredEventRow>,
|
|
@@ -221,83 +280,171 @@ export async function deliverEvents(
|
|
|
221
280
|
maxAttempts: number,
|
|
222
281
|
state: ConsumerStateRow,
|
|
223
282
|
): Promise<DeliveryOutcome> {
|
|
224
|
-
|
|
283
|
+
const startCursor = state.lastProcessedEventId;
|
|
284
|
+
let cursor = startCursor;
|
|
225
285
|
let attempts = state.attempts;
|
|
226
286
|
let lastError: string | null = state.lastError ?? null;
|
|
227
287
|
let deadLettered = false;
|
|
228
288
|
const effectiveMaxAttempts = consumer.errorPolicy?.maxAttempts ?? maxAttempts;
|
|
229
289
|
let processed = 0;
|
|
230
290
|
let failed = 0;
|
|
291
|
+
const resolvedPendingIds: bigint[] = [];
|
|
292
|
+
|
|
293
|
+
// A pending row sits below startCursor: it resolves its gap but never moves
|
|
294
|
+
// the cursor backward. ORDER BY id walks all pending rows first.
|
|
295
|
+
const resolve = (id: bigint): void => {
|
|
296
|
+
if (id > cursor) cursor = id;
|
|
297
|
+
if (id <= startCursor) resolvedPendingIds.push(id);
|
|
298
|
+
attempts = 0;
|
|
299
|
+
lastError = null;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
if (consumer.batchHandler && events.length > 0) {
|
|
303
|
+
const batchSucceeded = await tryApplyBatch(consumer, events, context);
|
|
304
|
+
if (batchSucceeded) {
|
|
305
|
+
for (const row of events) resolve(row.id);
|
|
306
|
+
processed = events.length;
|
|
307
|
+
return { cursor, attempts, lastError, deadLettered, processed, failed, resolvedPendingIds };
|
|
308
|
+
}
|
|
309
|
+
// fall through to the per-event loop below; the batch failure itself
|
|
310
|
+
// does NOT bump attempts — only a per-event failure does.
|
|
311
|
+
}
|
|
231
312
|
|
|
232
313
|
for (const row of events) {
|
|
233
314
|
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;
|
|
315
|
+
await applyEvent(consumer, row, context);
|
|
316
|
+
resolve(row.id);
|
|
261
317
|
processed += 1;
|
|
262
318
|
} catch (e) {
|
|
263
319
|
const errMessage = e instanceof Error ? e.message : String(e);
|
|
320
|
+
failed += 1;
|
|
264
321
|
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;
|
|
322
|
+
reportSkippedEvent(consumer, row.id, e, errMessage, context);
|
|
323
|
+
resolve(row.id);
|
|
283
324
|
continue;
|
|
284
325
|
}
|
|
285
326
|
attempts += 1;
|
|
286
327
|
lastError = errMessage;
|
|
287
|
-
failed += 1;
|
|
288
328
|
if (attempts >= effectiveMaxAttempts) deadLettered = true;
|
|
289
329
|
break;
|
|
290
330
|
}
|
|
291
331
|
}
|
|
292
332
|
|
|
293
|
-
return { cursor, attempts, lastError, deadLettered, processed, failed };
|
|
333
|
+
return { cursor, attempts, lastError, deadLettered, processed, failed, resolvedPendingIds };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Shared requestContext scope for one apply — event-derived fields
|
|
337
|
+
// (correlationId, causationId, writeOrigin) come from `causationSource`;
|
|
338
|
+
// applyEvent uses the event itself, applyBatch uses the LAST event of the
|
|
339
|
+
// turn (see applyBatch for why). requestId falls back to a fresh id
|
|
340
|
+
// because the dispatcher runs outside any HTTP request (background poll),
|
|
341
|
+
// and a stable log-correlation handle is still useful for debugging.
|
|
342
|
+
// #3043 — an event this apply writes is attributed to the consumer, not
|
|
343
|
+
// to whatever wrote the triggering event; causationId already links back.
|
|
344
|
+
function buildConsumerRequestScope(
|
|
345
|
+
consumer: EventConsumer,
|
|
346
|
+
causationSource: StoredEvent,
|
|
347
|
+
): RequestContextData {
|
|
348
|
+
const correlationId = causationSource.metadata.correlationId ?? requestContext.generateId();
|
|
349
|
+
const causationId = String(causationSource.id);
|
|
350
|
+
const requestId = requestContext.generateId();
|
|
351
|
+
// The job-trigger consumer's handleEvent stamps event-triggered jobs from this.
|
|
352
|
+
const rawStoredWriteOrigin = causationSource.metadata.writeOrigin;
|
|
353
|
+
const writeOrigin =
|
|
354
|
+
rawStoredWriteOrigin === undefined
|
|
355
|
+
? undefined
|
|
356
|
+
: (parseWriteOrigin(rawStoredWriteOrigin) ?? UNPARSEABLE_STORED_WRITE_ORIGIN);
|
|
357
|
+
return {
|
|
358
|
+
requestId,
|
|
359
|
+
correlationId,
|
|
360
|
+
causationId,
|
|
361
|
+
handler: consumer.name,
|
|
362
|
+
feature: consumer.featureName ?? qnScope(consumer.name),
|
|
363
|
+
writeOrigin,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function applyEvent(
|
|
368
|
+
consumer: EventConsumer,
|
|
369
|
+
row: StoredEventRow,
|
|
370
|
+
context: AppContext,
|
|
371
|
+
): Promise<void> {
|
|
372
|
+
const stored = rowToStoredEvent(row);
|
|
373
|
+
await requestContext.run(buildConsumerRequestScope(consumer, stored), async () => {
|
|
374
|
+
await consumer.handler(stored, context);
|
|
375
|
+
});
|
|
294
376
|
}
|
|
295
377
|
|
|
378
|
+
// Runs the consumer's batchHandler for the whole turn under one
|
|
379
|
+
// requestContext scope, attempted before the per-event loop. Returns
|
|
380
|
+
// false on failure instead of rethrowing — the caller falls back to
|
|
381
|
+
// per-event delivery for the exact same events, so a batch failure must
|
|
382
|
+
// not itself count as an applyEvent failure or bump attempts.
|
|
383
|
+
async function tryApplyBatch(
|
|
384
|
+
consumer: EventConsumer,
|
|
385
|
+
events: ReadonlyArray<StoredEventRow>,
|
|
386
|
+
context: AppContext,
|
|
387
|
+
): Promise<boolean> {
|
|
388
|
+
try {
|
|
389
|
+
await applyBatch(consumer, events, context);
|
|
390
|
+
return true;
|
|
391
|
+
} catch (e) {
|
|
392
|
+
const errMessage = e instanceof Error ? e.message : String(e);
|
|
393
|
+
context.log?.warn(
|
|
394
|
+
`event-dispatcher: ${consumer.name} batch of ${events.length} failed, falling back to per-event delivery: ${errMessage}`,
|
|
395
|
+
);
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function applyBatch(
|
|
401
|
+
consumer: EventConsumer,
|
|
402
|
+
events: ReadonlyArray<StoredEventRow>,
|
|
403
|
+
context: AppContext,
|
|
404
|
+
): Promise<void> {
|
|
405
|
+
const batchHandler = consumer.batchHandler;
|
|
406
|
+
// skip: deliverEvents only calls this for consumers that wire a batchHandler.
|
|
407
|
+
if (!batchHandler) return;
|
|
408
|
+
const stored = events.map((row) => rowToStoredEvent(row));
|
|
409
|
+
// correlationId/causationId/writeOrigin come from the LAST row — a
|
|
410
|
+
// batch-appended event's causation is attributed to the turn's most
|
|
411
|
+
// recent input, same as chaining N applyEvent calls would leave the
|
|
412
|
+
// requestContext at after the final one.
|
|
413
|
+
const lastStored = stored.at(-1);
|
|
414
|
+
// skip: deliverEvents never calls this with an empty turn.
|
|
415
|
+
if (!lastStored) return;
|
|
416
|
+
await requestContext.run(buildConsumerRequestScope(consumer, lastStored), async () => {
|
|
417
|
+
await batchHandler(stored, context);
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Best-effort mode: record the error on the skip counter so ops can alert on
|
|
422
|
+
// a spike of skipped events; the consumer stays "idle", not "dead". The
|
|
423
|
+
// warn-level log line tells them WHICH events — without it a
|
|
424
|
+
// poisoned-then-skipped event is invisible to forensic search.
|
|
425
|
+
function reportSkippedEvent(
|
|
426
|
+
consumer: EventConsumer,
|
|
427
|
+
eventId: bigint,
|
|
428
|
+
e: unknown,
|
|
429
|
+
errMessage: string,
|
|
430
|
+
context: AppContext,
|
|
431
|
+
): void {
|
|
432
|
+
const errorClass = e instanceof Error ? e.constructor.name : "UnknownError";
|
|
433
|
+
emitDispatcherError(context.meter ?? getFallbackMeter(), { handler: consumer.name, errorClass });
|
|
434
|
+
context.log?.warn(
|
|
435
|
+
`event-dispatcher: ${consumer.name} skipped event ${eventId} (${errorClass}): ${errMessage}`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export type PersistedConsumerOutcome = DeliveryOutcome & {
|
|
440
|
+
readonly pendingGaps: readonly PendingGapEntry[];
|
|
441
|
+
};
|
|
442
|
+
|
|
296
443
|
export async function persistConsumerOutcome(
|
|
297
444
|
tx: DbTx,
|
|
298
445
|
name: string,
|
|
299
446
|
instanceId: string,
|
|
300
|
-
outcome:
|
|
447
|
+
outcome: PersistedConsumerOutcome,
|
|
301
448
|
): Promise<void> {
|
|
302
449
|
await updateConsumerDeliveryOutcome(tx, name, instanceId, outcome);
|
|
303
450
|
}
|