@cosmicdrift/kumiko-framework 0.146.4 → 0.147.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 +6 -2
- package/src/api/auth-routes.ts +32 -67
- package/src/api/routes.ts +1 -3
- package/src/bun-db/__tests__/PATTERN.md +0 -1
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +29 -3
- package/src/db/__tests__/schema-inspection.test.ts +7 -0
- package/src/db/event-store-executor-context.ts +398 -0
- package/src/db/event-store-executor-read.ts +276 -0
- package/src/db/event-store-executor-write.ts +615 -0
- package/src/db/event-store-executor.ts +29 -1166
- package/src/db/queries/shadow-swap.ts +3 -1
- package/src/db/schema-inspection.ts +1 -1
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +10 -0
- package/src/engine/__tests__/engine.test.ts +45 -1
- package/src/engine/__tests__/event-migration-declarative.test.ts +6 -0
- package/src/engine/__tests__/membership-roles.test.ts +4 -10
- package/src/engine/__tests__/screen.test.ts +26 -0
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +1 -1
- package/src/engine/boot-validator/index.ts +12 -8
- package/src/engine/boot-validator/nav.ts +120 -0
- package/src/engine/boot-validator/{screens-nav.ts → screens.ts} +12 -190
- package/src/engine/boot-validator/workspaces.ts +68 -0
- package/src/engine/define-feature.ts +77 -1011
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/feature.ts +9 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-registrar/screens.ts +4 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +18 -8
- package/src/engine/feature-ast/__tests__/parse.test.ts +291 -2
- package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +73 -0
- package/src/engine/feature-ast/extractors/round1.ts +3 -3
- package/src/engine/feature-ast/extractors/round4.ts +45 -10
- package/src/engine/feature-ast/extractors/shared.ts +64 -6
- package/src/engine/feature-ast/parse.ts +123 -10
- package/src/engine/feature-ast/patterns.ts +14 -7
- package/src/engine/feature-ast/render.ts +28 -7
- package/src/engine/feature-builder-state.ts +165 -0
- package/src/engine/feature-config-events-jobs.ts +303 -0
- package/src/engine/feature-entity-handlers.ts +161 -0
- package/src/engine/feature-ui-extensions.ts +413 -0
- package/src/engine/index.ts +0 -2
- package/src/engine/pattern-library/library.ts +44 -1131
- package/src/engine/pattern-library/mixed-schemas.ts +484 -0
- package/src/engine/pattern-library/opaque-schemas.ts +124 -0
- package/src/engine/pattern-library/shared-fields.ts +80 -0
- package/src/engine/pattern-library/static-schemas.ts +456 -0
- package/src/engine/registry-facade.ts +349 -0
- package/src/engine/registry-ingest.ts +473 -0
- package/src/engine/registry-state.ts +388 -0
- package/src/engine/registry-validate.ts +660 -0
- package/src/engine/registry.ts +79 -1695
- package/src/engine/types/screen.ts +4 -2
- package/src/engine/types/tree-node.ts +2 -5
- package/src/event-store/snapshot.ts +7 -7
- package/src/i18n/required-surface-keys.ts +2 -0
- package/src/jobs/job-runner.ts +1 -1
- package/src/observability/standard-metrics.ts +4 -3
- package/src/pipeline/dispatch-batch.ts +3 -3
- package/src/pipeline/dispatch-shared.ts +17 -10
- package/src/pipeline/event-dispatcher-admin.ts +289 -0
- package/src/pipeline/event-dispatcher-delivery.ts +264 -0
- package/src/pipeline/event-dispatcher.ts +28 -540
- package/src/pipeline/projection-rebuild.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +95 -0
- package/src/stack/test-stack.ts +46 -1
- package/src/testing/boot-validator-fixture.ts +1 -2
- package/src/engine/__tests__/deep-link.test.ts +0 -35
- package/src/engine/deep-link.ts +0 -23
|
@@ -1,27 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { DbConnection, DbTx, PgClient } from "../db/connection";
|
|
3
|
-
import {
|
|
4
|
-
advanceConsumerPastEventReturning,
|
|
5
|
-
insertConsumerIfAbsent,
|
|
6
|
-
markConsumerProcessing,
|
|
7
|
-
selectConsumerForUpdateSkipLocked,
|
|
8
|
-
updateConsumerDeliveryOutcome,
|
|
9
|
-
updateConsumerStatusReturning,
|
|
10
|
-
} from "../db/queries/event-consumer";
|
|
11
|
-
import { selectEventsHeadId, selectNextEventIdAfter } from "../db/queries/event-store";
|
|
12
|
-
import { coerceRow, extractTableInfo, selectMany } from "../db/query";
|
|
1
|
+
import type { DbTx, PgClient } from "../db/connection";
|
|
13
2
|
import type { AppContext } from "../engine/types";
|
|
14
3
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
|
|
4
|
+
import { EVENTS_PUBSUB_CHANNEL, type StoredEvent } from "../event-store";
|
|
15
5
|
import {
|
|
16
|
-
EVENTS_PUBSUB_CHANNEL,
|
|
17
|
-
eventsTable,
|
|
18
|
-
getEventsHighWaterMark,
|
|
19
|
-
toStoredEvent as rowToStoredEvent,
|
|
20
|
-
type StoredEvent,
|
|
21
|
-
} from "../event-store";
|
|
22
|
-
import {
|
|
23
|
-
emitDispatcherError,
|
|
24
|
-
emitEventConsumerLag,
|
|
25
6
|
emitEventConsumerPassOutcome,
|
|
26
7
|
emitEventDispatcherListenConnected,
|
|
27
8
|
getFallbackMeter,
|
|
@@ -29,11 +10,17 @@ import {
|
|
|
29
10
|
type Meter,
|
|
30
11
|
type Tracer,
|
|
31
12
|
} from "../observability";
|
|
13
|
+
import { SHARED_INSTANCE_SENTINEL } from "./event-consumer-state";
|
|
32
14
|
import {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
15
|
+
acquireConsumerState,
|
|
16
|
+
consumerInstanceId,
|
|
17
|
+
deliverEvents,
|
|
18
|
+
emitLagFromTx,
|
|
19
|
+
fetchPendingEvents,
|
|
20
|
+
markProcessing,
|
|
21
|
+
persistConsumerOutcome,
|
|
22
|
+
preRegisterConsumers,
|
|
23
|
+
} from "./event-dispatcher-delivery";
|
|
37
24
|
|
|
38
25
|
// Async event-dispatcher — the "AsyncDaemon"-pendant for Kumiko.
|
|
39
26
|
//
|
|
@@ -64,6 +51,11 @@ import {
|
|
|
64
51
|
// Delivery semantics: **at-least-once**. If a handler runs but the cursor
|
|
65
52
|
// update fails (crash mid-pass), the same event is delivered again next pass.
|
|
66
53
|
// Handlers MUST be idempotent.
|
|
54
|
+
//
|
|
55
|
+
// Delivery-loop mechanics (acquire/fetch/deliver/persist) live in
|
|
56
|
+
// event-dispatcher-delivery.ts; the ops recovery surface (restart/disable/
|
|
57
|
+
// enable/skipPoisonEvent/progress) lives in event-dispatcher-admin.ts. This
|
|
58
|
+
// file is the facade: public types + the createEventDispatcher() factory.
|
|
67
59
|
|
|
68
60
|
export type EventConsumerHandler = (event: StoredEvent, ctx: AppContext) => Promise<void>;
|
|
69
61
|
|
|
@@ -131,7 +123,7 @@ export type EventDispatcher = {
|
|
|
131
123
|
};
|
|
132
124
|
|
|
133
125
|
export type EventDispatcherOptions = {
|
|
134
|
-
readonly db: DbConnection;
|
|
126
|
+
readonly db: import("../db/connection").DbConnection;
|
|
135
127
|
readonly consumers: readonly EventConsumer[];
|
|
136
128
|
readonly context: AppContext;
|
|
137
129
|
readonly batchSize?: number;
|
|
@@ -159,242 +151,6 @@ const DEFAULT_BATCH_SIZE = 200;
|
|
|
159
151
|
const DEFAULT_POLL_MS = 100;
|
|
160
152
|
const DEFAULT_MAX_ATTEMPTS = 10;
|
|
161
153
|
|
|
162
|
-
// --- processConsumer helpers ---
|
|
163
|
-
// Free functions (not closures) so they're independently readable and the
|
|
164
|
-
// dispatcher's main pass logic stays under ~50 LOC. Every helper takes an
|
|
165
|
-
// explicit `tx` — none of them use the outer dispatcher's closure state.
|
|
166
|
-
|
|
167
|
-
type ConsumerStateRowShape = {
|
|
168
|
-
readonly name: string;
|
|
169
|
-
readonly instanceId: string;
|
|
170
|
-
readonly lastProcessedEventId: bigint;
|
|
171
|
-
readonly status: string;
|
|
172
|
-
readonly attempts: number;
|
|
173
|
-
readonly lastError: string | null;
|
|
174
|
-
readonly updatedAt: Temporal.Instant;
|
|
175
|
-
};
|
|
176
|
-
type ConsumerStateRow = ConsumerStateRowShape;
|
|
177
|
-
|
|
178
|
-
type StoredEventRow = {
|
|
179
|
-
readonly id: bigint;
|
|
180
|
-
readonly aggregateId: string;
|
|
181
|
-
readonly aggregateType: string;
|
|
182
|
-
readonly tenantId: string;
|
|
183
|
-
readonly version: number;
|
|
184
|
-
readonly type: string;
|
|
185
|
-
readonly eventVersion: number;
|
|
186
|
-
readonly payload: Record<string, unknown>;
|
|
187
|
-
readonly metadata: import("../event-store/event-store").EventMetadata;
|
|
188
|
-
readonly createdAt: Temporal.Instant;
|
|
189
|
-
readonly createdBy: string;
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
type AcquireOutcome =
|
|
193
|
-
| { readonly state: ConsumerStateRow; readonly skip: null }
|
|
194
|
-
| {
|
|
195
|
-
readonly state: null;
|
|
196
|
-
readonly skip: "locked_by_other_instance" | "disabled" | "dead" | "not_registered";
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
// Lock the consumer's state row with SKIP LOCKED. Strict: no in-tx bootstrap.
|
|
200
|
-
// The row must exist — start() pre-registers every consumer up front so
|
|
201
|
-
// prune (event-retention) sees their cursors as soon as the process is up,
|
|
202
|
-
// closing the race where a lazy-bootstrapped consumer's cursor is absent
|
|
203
|
-
// during prune and its events are silently deleted.
|
|
204
|
-
//
|
|
205
|
-
// skip="not_registered" signals a row-missing-despite-start condition.
|
|
206
|
-
// Production shouldn't hit this — it means either start() wasn't called
|
|
207
|
-
// (runOnce() guards against that) or the state row was deleted externally
|
|
208
|
-
// (a test TRUNCATE without subsequent ensureRegistered(), or an operator
|
|
209
|
-
// intervention). Skipping quietly preserves the dispatcher's other
|
|
210
|
-
// consumers and surfaces the issue via the metrics pass-outcome.
|
|
211
|
-
async function acquireConsumerState(
|
|
212
|
-
tx: DbTx,
|
|
213
|
-
name: string,
|
|
214
|
-
instanceId: string,
|
|
215
|
-
): Promise<AcquireOutcome> {
|
|
216
|
-
const rawState = await selectConsumerForUpdateSkipLocked(tx, name, instanceId);
|
|
217
|
-
|
|
218
|
-
if (!rawState) {
|
|
219
|
-
return { state: null, skip: "not_registered" };
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const state = coerceRow(rawState, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow;
|
|
223
|
-
|
|
224
|
-
if (!state) {
|
|
225
|
-
// Either the row never existed (no pre-reg, no ensureRegistered) or
|
|
226
|
-
// another instance currently holds the lock with SKIP LOCKED filtering
|
|
227
|
-
// us out. We can't distinguish here in a single query, so return
|
|
228
|
-
// "not_registered" — ops sees a skip-reason instead of silent delivery
|
|
229
|
-
// loss. Under normal operation (start() called, no external tampering)
|
|
230
|
-
// this path is never taken.
|
|
231
|
-
return { state: null, skip: "not_registered" };
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (state.status === ConsumerStatuses.disabled) return { state: null, skip: "disabled" };
|
|
235
|
-
if (state.status === ConsumerStatuses.dead) return { state: null, skip: "dead" };
|
|
236
|
-
return { state, skip: null };
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Shared pre-registration: one row per (consumer, shard), cursor = 0,
|
|
240
|
-
// status = idle. Shared-delivery consumers use SHARED_INSTANCE_SENTINEL;
|
|
241
|
-
// per-instance consumers use the dispatcher's instanceId. Idempotent
|
|
242
|
-
// under restart and concurrent start-calls via ON CONFLICT DO NOTHING
|
|
243
|
-
// on the composite PK — never clobbers an existing cursor.
|
|
244
|
-
async function preRegisterConsumers(
|
|
245
|
-
db: DbConnection,
|
|
246
|
-
consumers: readonly EventConsumer[],
|
|
247
|
-
dispatcherInstanceId: string | undefined,
|
|
248
|
-
): Promise<void> {
|
|
249
|
-
for (const consumer of consumers) {
|
|
250
|
-
const instanceId = consumerInstanceId(consumer, dispatcherInstanceId);
|
|
251
|
-
await insertConsumerIfAbsent(db, consumer.name, instanceId);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Resolve the instance_id column value for one consumer on this dispatcher.
|
|
256
|
-
// Shared stays at the sentinel; per-instance rides the dispatcher's id.
|
|
257
|
-
// Throws when a per-instance consumer is registered without an instanceId
|
|
258
|
-
// — missing at boot is the sharp-edge to catch, not at first delivery.
|
|
259
|
-
function consumerInstanceId(
|
|
260
|
-
consumer: EventConsumer,
|
|
261
|
-
dispatcherInstanceId: string | undefined,
|
|
262
|
-
): string {
|
|
263
|
-
if (consumer.delivery !== "per-instance") return SHARED_INSTANCE_SENTINEL;
|
|
264
|
-
if (!dispatcherInstanceId) {
|
|
265
|
-
throw new Error(
|
|
266
|
-
`EventConsumer "${consumer.name}" has delivery="per-instance" but the dispatcher was created without an instanceId — ` +
|
|
267
|
-
`pass EventDispatcherOptions.instanceId (typically from ServerOptions.instanceId / KUMIKO_INSTANCE_ID).`,
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
return dispatcherInstanceId;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// Mark the consumer row as "processing" for ops visibility. The SKIP LOCKED
|
|
274
|
-
// lock already guarantees single-writer semantics; this is purely
|
|
275
|
-
// informational (and resets on commit to idle/dead via persistConsumerOutcome).
|
|
276
|
-
async function markProcessing(tx: DbTx, name: string, instanceId: string): Promise<void> {
|
|
277
|
-
await markConsumerProcessing(tx, name, instanceId);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
async function fetchPendingEvents(
|
|
281
|
-
tx: DbTx,
|
|
282
|
-
cursor: bigint,
|
|
283
|
-
batchSize: number,
|
|
284
|
-
): Promise<ReadonlyArray<StoredEventRow>> {
|
|
285
|
-
return (await selectMany(
|
|
286
|
-
tx,
|
|
287
|
-
eventsTable,
|
|
288
|
-
{ id: { gt: cursor } },
|
|
289
|
-
{ orderBy: { col: "id", direction: "asc" }, limit: batchSize },
|
|
290
|
-
)) as ReadonlyArray<StoredEventRow>; // @cast-boundary db-row
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
type DeliveryOutcome = {
|
|
294
|
-
readonly cursor: bigint;
|
|
295
|
-
readonly attempts: number;
|
|
296
|
-
readonly lastError: string | null;
|
|
297
|
-
readonly deadLettered: boolean;
|
|
298
|
-
readonly processed: number;
|
|
299
|
-
readonly failed: number;
|
|
300
|
-
};
|
|
301
|
-
|
|
302
|
-
// Deliver events to the consumer's handler in events.id order. Halt-on-
|
|
303
|
-
// poison: a throw breaks the loop, the cursor stays at the last successful
|
|
304
|
-
// event, and attempts climb. At maxAttempts the caller persists status=
|
|
305
|
-
// "dead" and the consumer is parked until ops intervenes (see
|
|
306
|
-
// restartConsumer / skipPoisonEvent).
|
|
307
|
-
async function deliverEvents(
|
|
308
|
-
consumer: EventConsumer,
|
|
309
|
-
events: ReadonlyArray<StoredEventRow>,
|
|
310
|
-
context: AppContext,
|
|
311
|
-
maxAttempts: number,
|
|
312
|
-
state: ConsumerStateRow,
|
|
313
|
-
): Promise<DeliveryOutcome> {
|
|
314
|
-
let cursor = state.lastProcessedEventId;
|
|
315
|
-
let attempts = state.attempts;
|
|
316
|
-
let lastError: string | null = state.lastError ?? null;
|
|
317
|
-
let deadLettered = false;
|
|
318
|
-
let processed = 0;
|
|
319
|
-
let failed = 0;
|
|
320
|
-
|
|
321
|
-
for (const row of events) {
|
|
322
|
-
try {
|
|
323
|
-
// Propagate causation: if the handler calls ctx.appendEvent, the new
|
|
324
|
-
// event should record THIS event as its cause. correlationId is
|
|
325
|
-
// inherited unchanged — it survives the hop across streams by design.
|
|
326
|
-
// requestId falls back to a fresh id because the dispatcher runs
|
|
327
|
-
// outside any HTTP request (background poll), and a stable log-
|
|
328
|
-
// correlation handle is still useful for debugging.
|
|
329
|
-
const stored = rowToStoredEvent(row);
|
|
330
|
-
const correlationId = stored.metadata.correlationId ?? requestContext.generateId();
|
|
331
|
-
const causationId = String(stored.id);
|
|
332
|
-
const requestId = requestContext.generateId();
|
|
333
|
-
await requestContext.run({ requestId, correlationId, causationId }, async () => {
|
|
334
|
-
await consumer.handler(stored, context);
|
|
335
|
-
});
|
|
336
|
-
cursor = row.id;
|
|
337
|
-
attempts = 0;
|
|
338
|
-
lastError = null;
|
|
339
|
-
processed += 1;
|
|
340
|
-
} catch (e) {
|
|
341
|
-
const errMessage = e instanceof Error ? e.message : String(e);
|
|
342
|
-
if (consumer.errorPolicy?.skipApplyErrors) {
|
|
343
|
-
// Best-effort mode: record the error on the skip counter so ops
|
|
344
|
-
// can alert on a spike of skipped events, advance the cursor past
|
|
345
|
-
// the bad event, keep going. The consumer stays "idle", not "dead".
|
|
346
|
-
// Also emit a warn-level log line — the metric tells ops THAT events
|
|
347
|
-
// are being dropped, the log tells them WHICH events. Without this
|
|
348
|
-
// a poisoned-then-skipped event is invisible to forensic search.
|
|
349
|
-
const errorClass = e instanceof Error ? e.constructor.name : "UnknownError";
|
|
350
|
-
emitDispatcherError(context.meter ?? getFallbackMeter(), {
|
|
351
|
-
handler: consumer.name,
|
|
352
|
-
errorClass,
|
|
353
|
-
});
|
|
354
|
-
context.log?.warn(
|
|
355
|
-
`event-dispatcher: ${consumer.name} skipped event ${row.id} (${errorClass}): ${errMessage}`,
|
|
356
|
-
);
|
|
357
|
-
cursor = row.id;
|
|
358
|
-
attempts = 0;
|
|
359
|
-
lastError = null;
|
|
360
|
-
failed += 1;
|
|
361
|
-
continue;
|
|
362
|
-
}
|
|
363
|
-
attempts += 1;
|
|
364
|
-
lastError = errMessage;
|
|
365
|
-
failed += 1;
|
|
366
|
-
if (attempts >= maxAttempts) deadLettered = true;
|
|
367
|
-
break;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
return { cursor, attempts, lastError, deadLettered, processed, failed };
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
async function persistConsumerOutcome(
|
|
375
|
-
tx: DbTx,
|
|
376
|
-
name: string,
|
|
377
|
-
instanceId: string,
|
|
378
|
-
outcome: DeliveryOutcome,
|
|
379
|
-
): Promise<void> {
|
|
380
|
-
await updateConsumerDeliveryOutcome(tx, name, instanceId, outcome);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// Emit the lag gauge inside the consumer pass's tx so ops sees a snapshot
|
|
384
|
-
// consistent with the cursor we just advanced to. `MAX(id)` on the events
|
|
385
|
-
// table is an O(1) reverse-index scan — cheap even under load.
|
|
386
|
-
async function emitLagFromTx(
|
|
387
|
-
tx: DbTx,
|
|
388
|
-
consumerName: string,
|
|
389
|
-
instanceId: string,
|
|
390
|
-
cursor: bigint,
|
|
391
|
-
meter: Meter,
|
|
392
|
-
): Promise<void> {
|
|
393
|
-
const head = await selectEventsHeadId(tx);
|
|
394
|
-
const lag = head > cursor ? Number(head - cursor) : 0;
|
|
395
|
-
emitEventConsumerLag(meter, { consumer: consumerName, instanceId }, lag);
|
|
396
|
-
}
|
|
397
|
-
|
|
398
154
|
export function createEventDispatcher(options: EventDispatcherOptions): EventDispatcher {
|
|
399
155
|
const {
|
|
400
156
|
db,
|
|
@@ -671,281 +427,13 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
671
427
|
};
|
|
672
428
|
}
|
|
673
429
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
// disableConsumer status=* → "disabled". Dispatcher skips this consumer
|
|
685
|
-
// until enableConsumer() flips it back.
|
|
686
|
-
// enableConsumer status="disabled" → "idle". No-op on any other state.
|
|
687
|
-
// skipPoisonEvent cursor advances past the first event after the
|
|
688
|
-
// current cursor (the one that's failing). attempts=0,
|
|
689
|
-
// lastError=null, status="idle". For events that will
|
|
690
|
-
// never succeed (broken payload, removed feature code).
|
|
691
|
-
|
|
692
|
-
function normalizeConsumerState(row: ConsumerStateRowShape): ConsumerRecoveryState {
|
|
693
|
-
return {
|
|
694
|
-
name: row.name,
|
|
695
|
-
instanceId: row.instanceId,
|
|
696
|
-
status: row.status,
|
|
697
|
-
lastProcessedEventId: row.lastProcessedEventId,
|
|
698
|
-
attempts: row.attempts,
|
|
699
|
-
lastError: row.lastError,
|
|
700
|
-
updatedAt: row.updatedAt,
|
|
701
|
-
};
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
export type ConsumerRecoveryState = {
|
|
705
|
-
readonly name: string;
|
|
706
|
-
readonly instanceId: string;
|
|
707
|
-
readonly status: string;
|
|
708
|
-
readonly lastProcessedEventId: bigint;
|
|
709
|
-
readonly attempts: number;
|
|
710
|
-
readonly lastError: string | null;
|
|
711
|
-
readonly updatedAt: Temporal.Instant;
|
|
712
|
-
};
|
|
713
|
-
|
|
714
|
-
// Ops calls default to the SHARED_INSTANCE_SENTINEL row — that's the only
|
|
715
|
-
// row shared-delivery consumers have, so legacy CLI invocations without
|
|
716
|
-
// --instance-id keep working. Per-instance consumers require an explicit
|
|
717
|
-
// instanceId: picking one of N shards arbitrarily ("first row wins") or
|
|
718
|
-
// mutating all shards simultaneously ("bounce every instance") are both
|
|
719
|
-
// worse than a loud missing-arg error on the CLI.
|
|
720
|
-
async function requireConsumerRow(
|
|
721
|
-
db: DbConnection,
|
|
722
|
-
name: string,
|
|
723
|
-
instanceId: string,
|
|
724
|
-
): Promise<ConsumerStateRowShape> {
|
|
725
|
-
const [row] = await selectMany<ConsumerStateRow>(db, eventConsumerStateTable, {
|
|
726
|
-
name,
|
|
727
|
-
instanceId,
|
|
728
|
-
});
|
|
729
|
-
if (!row) {
|
|
730
|
-
throw new Error(
|
|
731
|
-
`Consumer "${name}" (instance_id="${instanceId}") has no state row — it hasn't run yet, the name is misspelled, or the instance is misspelled. ` +
|
|
732
|
-
`For per-instance consumers pass the instance_id explicitly; shared consumers use the default.`,
|
|
733
|
-
);
|
|
734
|
-
}
|
|
735
|
-
return row;
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
async function applyConsumerStatusTransition(
|
|
739
|
-
db: DbConnection,
|
|
740
|
-
name: string,
|
|
741
|
-
instanceId: string,
|
|
742
|
-
targetStatus: "idle" | "disabled",
|
|
743
|
-
): Promise<ConsumerRecoveryState> {
|
|
744
|
-
const raw = await updateConsumerStatusReturning(db, name, instanceId, targetStatus);
|
|
745
|
-
const updated =
|
|
746
|
-
raw && (coerceRow(raw, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
|
|
747
|
-
if (!updated) {
|
|
748
|
-
throw new Error(
|
|
749
|
-
`Consumer "${name}" (instance_id="${instanceId}") vanished between read and write — retry.`,
|
|
750
|
-
);
|
|
751
|
-
}
|
|
752
|
-
return normalizeConsumerState(updated);
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
export async function restartConsumer(
|
|
756
|
-
db: DbConnection,
|
|
757
|
-
name: string,
|
|
758
|
-
instanceId: string = SHARED_INSTANCE_SENTINEL,
|
|
759
|
-
): Promise<ConsumerRecoveryState> {
|
|
760
|
-
const before = await requireConsumerRow(db, name, instanceId);
|
|
761
|
-
if (before.status !== "dead") {
|
|
762
|
-
throw new Error(
|
|
763
|
-
`Consumer "${name}" (instance_id="${instanceId}") is not dead (status="${before.status}"). Restart only applies to dead consumers; use "enable" for a disabled one.`,
|
|
764
|
-
);
|
|
765
|
-
}
|
|
766
|
-
return applyConsumerStatusTransition(db, name, instanceId, "idle");
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
export async function disableConsumer(
|
|
770
|
-
db: DbConnection,
|
|
771
|
-
name: string,
|
|
772
|
-
instanceId: string = SHARED_INSTANCE_SENTINEL,
|
|
773
|
-
): Promise<ConsumerRecoveryState> {
|
|
774
|
-
await requireConsumerRow(db, name, instanceId);
|
|
775
|
-
return applyConsumerStatusTransition(db, name, instanceId, "disabled");
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
export async function enableConsumer(
|
|
779
|
-
db: DbConnection,
|
|
780
|
-
name: string,
|
|
781
|
-
instanceId: string = SHARED_INSTANCE_SENTINEL,
|
|
782
|
-
): Promise<ConsumerRecoveryState> {
|
|
783
|
-
const before = await requireConsumerRow(db, name, instanceId);
|
|
784
|
-
if (before.status !== "disabled") {
|
|
785
|
-
throw new Error(
|
|
786
|
-
`Consumer "${name}" (instance_id="${instanceId}") is not disabled (status="${before.status}"). Enable only flips disabled → idle; use "restart" for a dead consumer.`,
|
|
787
|
-
);
|
|
788
|
-
}
|
|
789
|
-
return applyConsumerStatusTransition(db, name, instanceId, "idle");
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
// skipPoisonEvent advances the cursor past the first event after the
|
|
793
|
-
// current cursor. Single TX so concurrent dispatcher passes can't double-
|
|
794
|
-
// advance. If no event exists past the cursor, there is nothing to skip —
|
|
795
|
-
// treat as idempotent no-op (cursor already at head).
|
|
796
|
-
export async function skipPoisonEvent(
|
|
797
|
-
db: DbConnection,
|
|
798
|
-
name: string,
|
|
799
|
-
instanceId: string = SHARED_INSTANCE_SENTINEL,
|
|
800
|
-
): Promise<ConsumerRecoveryState & { readonly skippedEventId: bigint | null }> {
|
|
801
|
-
const before = await requireConsumerRow(db, name, instanceId);
|
|
802
|
-
return db.begin(async (tx: DbTx) => {
|
|
803
|
-
const poisonId = await selectNextEventIdAfter(tx, before.lastProcessedEventId);
|
|
804
|
-
if (poisonId === null) {
|
|
805
|
-
const [unchanged] = await selectMany<ConsumerStateRow>(tx, eventConsumerStateTable, {
|
|
806
|
-
name,
|
|
807
|
-
instanceId,
|
|
808
|
-
});
|
|
809
|
-
if (!unchanged)
|
|
810
|
-
throw new Error(`Consumer "${name}" (instance_id="${instanceId}") vanished — retry.`);
|
|
811
|
-
return { ...normalizeConsumerState(unchanged), skippedEventId: null };
|
|
812
|
-
}
|
|
813
|
-
const raw = await advanceConsumerPastEventReturning(tx, name, instanceId, poisonId);
|
|
814
|
-
const updated =
|
|
815
|
-
raw && (coerceRow(raw, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
|
|
816
|
-
if (!updated)
|
|
817
|
-
throw new Error(
|
|
818
|
-
`Consumer "${name}" (instance_id="${instanceId}") vanished mid-skip — retry.`,
|
|
819
|
-
);
|
|
820
|
-
return { ...normalizeConsumerState(updated), skippedEventId: poisonId };
|
|
821
|
-
});
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
// Read-only status for one consumer shard — CLI surface.
|
|
825
|
-
export async function getConsumerState(
|
|
826
|
-
db: DbConnection,
|
|
827
|
-
name: string,
|
|
828
|
-
instanceId: string = SHARED_INSTANCE_SENTINEL,
|
|
829
|
-
): Promise<{
|
|
830
|
-
readonly name: string;
|
|
831
|
-
readonly instanceId: string;
|
|
832
|
-
readonly status: string;
|
|
833
|
-
readonly lastProcessedEventId: bigint;
|
|
834
|
-
readonly attempts: number;
|
|
835
|
-
readonly lastError: string | null;
|
|
836
|
-
readonly updatedAt: Temporal.Instant;
|
|
837
|
-
} | null> {
|
|
838
|
-
const [row] = await selectMany<ConsumerStateRow>(db, eventConsumerStateTable, {
|
|
839
|
-
name,
|
|
840
|
-
instanceId,
|
|
841
|
-
});
|
|
842
|
-
if (!row) return null;
|
|
843
|
-
return {
|
|
844
|
-
name: row.name,
|
|
845
|
-
instanceId: row.instanceId,
|
|
846
|
-
status: row.status,
|
|
847
|
-
lastProcessedEventId: row.lastProcessedEventId,
|
|
848
|
-
attempts: row.attempts,
|
|
849
|
-
lastError: row.lastError,
|
|
850
|
-
updatedAt: row.updatedAt,
|
|
851
|
-
};
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
// List every consumer the registry knows about, joined with all shard rows
|
|
855
|
-
// from the state table. One entry per (name, instance_id) shard. Consumers
|
|
856
|
-
// that have never run appear with status="never-run" and instance_id =
|
|
857
|
-
// SHARED_INSTANCE_SENTINEL — a placeholder, because without a running
|
|
858
|
-
// dispatcher we can't know the instance-ids of per-instance consumers yet.
|
|
859
|
-
// Mirrors listProjectionsWithState — the registry (not the DB) is the
|
|
860
|
-
// source-of-truth for which consumer-names exist; the DB is the source-
|
|
861
|
-
// of-truth for which instance-shards have been seen.
|
|
862
|
-
export async function listConsumersWithState(
|
|
863
|
-
db: DbConnection,
|
|
864
|
-
registeredNames: readonly string[],
|
|
865
|
-
): Promise<
|
|
866
|
-
ReadonlyArray<{
|
|
867
|
-
readonly name: string;
|
|
868
|
-
readonly instanceId: string;
|
|
869
|
-
readonly status: string;
|
|
870
|
-
readonly lastProcessedEventId: bigint;
|
|
871
|
-
readonly attempts: number;
|
|
872
|
-
readonly lastError: string | null;
|
|
873
|
-
}>
|
|
874
|
-
> {
|
|
875
|
-
const stateRows = await selectMany<ConsumerStateRow>(db, eventConsumerStateTable);
|
|
876
|
-
const registered = new Set(registeredNames);
|
|
877
|
-
|
|
878
|
-
// Materialize one output row per (name, instance_id). Registered names
|
|
879
|
-
// without any shard (never-run) get a placeholder row so ops can still
|
|
880
|
-
// see the name exists.
|
|
881
|
-
const out: Array<{
|
|
882
|
-
name: string;
|
|
883
|
-
instanceId: string;
|
|
884
|
-
status: string;
|
|
885
|
-
lastProcessedEventId: bigint;
|
|
886
|
-
attempts: number;
|
|
887
|
-
lastError: string | null;
|
|
888
|
-
}> = [];
|
|
889
|
-
|
|
890
|
-
const seenNames = new Set<string>();
|
|
891
|
-
for (const r of stateRows) {
|
|
892
|
-
if (!registered.has(r.name)) continue; // stale row from an older deploy
|
|
893
|
-
seenNames.add(r.name);
|
|
894
|
-
out.push({
|
|
895
|
-
name: r.name,
|
|
896
|
-
instanceId: r.instanceId,
|
|
897
|
-
status: r.status,
|
|
898
|
-
lastProcessedEventId: r.lastProcessedEventId,
|
|
899
|
-
attempts: r.attempts,
|
|
900
|
-
lastError: r.lastError,
|
|
901
|
-
});
|
|
902
|
-
}
|
|
903
|
-
for (const name of registeredNames) {
|
|
904
|
-
if (seenNames.has(name)) continue;
|
|
905
|
-
out.push({
|
|
906
|
-
name,
|
|
907
|
-
instanceId: SHARED_INSTANCE_SENTINEL,
|
|
908
|
-
status: "never-run",
|
|
909
|
-
lastProcessedEventId: 0n,
|
|
910
|
-
attempts: 0,
|
|
911
|
-
lastError: null,
|
|
912
|
-
});
|
|
913
|
-
}
|
|
914
|
-
return out;
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
export type ConsumerProgress = {
|
|
918
|
-
readonly name: string;
|
|
919
|
-
readonly instanceId: string;
|
|
920
|
-
readonly status: string;
|
|
921
|
-
readonly lastProcessedEventId: bigint;
|
|
922
|
-
readonly attempts: number;
|
|
923
|
-
readonly lastError: string | null;
|
|
924
|
-
// Global MAX(events.id) at query time.
|
|
925
|
-
readonly highWaterMark: bigint;
|
|
926
|
-
// HWM - cursor. 0n when caught-up. Disabled consumers often show high
|
|
927
|
-
// lag intentionally (ops parks them before pruning).
|
|
928
|
-
readonly lag: bigint;
|
|
929
|
-
};
|
|
930
|
-
|
|
931
|
-
// Like listConsumersWithState, but also returns HWM + lag per consumer.
|
|
932
|
-
// Async consumers (MSPs) lag behind inline projections because they run
|
|
933
|
-
// post-commit — lag is the primary signal for backpressure, dead consumers,
|
|
934
|
-
// or dispatcher stalls. Programmatic callers can map the result to a
|
|
935
|
-
// `kumiko_consumer_lag{name}` Prometheus gauge.
|
|
936
|
-
// guard:dup-ok — intentionale Parallele zu getAllProjectionProgress; Consumer ≠ Projection (verschiedene Subsysteme)
|
|
937
|
-
export async function getAllConsumerProgress(
|
|
938
|
-
db: DbConnection,
|
|
939
|
-
registeredNames: readonly string[],
|
|
940
|
-
): Promise<readonly ConsumerProgress[]> {
|
|
941
|
-
const [consumers, highWaterMark] = await Promise.all([
|
|
942
|
-
listConsumersWithState(db, registeredNames),
|
|
943
|
-
getEventsHighWaterMark(db),
|
|
944
|
-
]);
|
|
945
|
-
|
|
946
|
-
return consumers.map((c) => ({
|
|
947
|
-
...c,
|
|
948
|
-
highWaterMark,
|
|
949
|
-
lag: highWaterMark - c.lastProcessedEventId,
|
|
950
|
-
}));
|
|
951
|
-
}
|
|
430
|
+
export type { ConsumerProgress, ConsumerRecoveryState } from "./event-dispatcher-admin";
|
|
431
|
+
export {
|
|
432
|
+
disableConsumer,
|
|
433
|
+
enableConsumer,
|
|
434
|
+
getAllConsumerProgress,
|
|
435
|
+
getConsumerState,
|
|
436
|
+
listConsumersWithState,
|
|
437
|
+
restartConsumer,
|
|
438
|
+
skipPoisonEvent,
|
|
439
|
+
} from "./event-dispatcher-admin";
|
|
@@ -309,7 +309,7 @@ export async function rebuildProjection(
|
|
|
309
309
|
while ((await drainBatch(tx)) === REBUILD_BATCH_SIZE) {
|
|
310
310
|
// re-replay the full log into the fresh shadow
|
|
311
311
|
}
|
|
312
|
-
//
|
|
312
|
+
// drainBatch increments eventsProcessed for every drained row
|
|
313
313
|
// regardless of quarantine (skipApplyErrors), so this must hold
|
|
314
314
|
// exactly under the fence — a mismatch means drainBatch silently
|
|
315
315
|
// dropped a row instead of counting it, which the rest of this
|