@cosmicdrift/kumiko-framework 0.131.0 → 0.133.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 +2 -2
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +139 -0
- package/src/db/queries/shadow-swap.ts +62 -0
- package/src/engine/__tests__/deep-link.test.ts +35 -0
- package/src/engine/deep-link.ts +23 -0
- package/src/engine/index.ts +2 -0
- package/src/jobs/__tests__/job-queue-depth.integration.test.ts +82 -0
- package/src/jobs/job-runner.ts +41 -1
- package/src/observability/index.ts +1 -0
- package/src/observability/standard-metrics.ts +28 -0
- package/src/pipeline/projection-rebuild.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.133.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -189,7 +189,7 @@
|
|
|
189
189
|
"zod": "^4.4.3"
|
|
190
190
|
},
|
|
191
191
|
"devDependencies": {
|
|
192
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
192
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.133.0",
|
|
193
193
|
"bun-types": "^1.3.13",
|
|
194
194
|
"pino-pretty": "^13.1.3"
|
|
195
195
|
},
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Runtime guard #722: before the rebuild swaps the shadow over the live table,
|
|
2
|
+
// abort if a live row has NO event in the projection's source streams — a row
|
|
3
|
+
// no replay can reconstruct (#498 ghost, direct-inserted without a .created
|
|
4
|
+
// event), which the swap would silently drop.
|
|
5
|
+
//
|
|
6
|
+
// The guard is deliberately narrow (event EXISTENCE only, not a column diff):
|
|
7
|
+
// the framework legitimately makes live diverge from a fresh replay in shipped
|
|
8
|
+
// ways (blind-index erase→NULL, sensitive-strip, archived-stream wipe, the #494
|
|
9
|
+
// backfill flow). Those rows all HAVE an event, so they are not ghosts and the
|
|
10
|
+
// guard leaves them alone — proven by the "direct column-write" test below.
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
14
|
+
import { asRawClient, insertOne, selectMany } from "../../db/query";
|
|
15
|
+
import { createBooleanField, createEntity, createTextField, defineFeature } from "../../engine";
|
|
16
|
+
import { createRegistry } from "../../engine/registry";
|
|
17
|
+
import { createEventsTable } from "../../event-store";
|
|
18
|
+
import { rebuildProjection } from "../../pipeline";
|
|
19
|
+
import { createProjectionStateTable } from "../../pipeline/projection-state";
|
|
20
|
+
import { TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
21
|
+
import { ensureTemporalPolyfill } from "../../time/polyfill";
|
|
22
|
+
import { createEventStoreExecutor } from "../event-store-executor";
|
|
23
|
+
import { buildEntityTable } from "../table-builder";
|
|
24
|
+
import { createTenantDb, type TenantDb } from "../tenant-db";
|
|
25
|
+
|
|
26
|
+
const userEntity = createEntity({
|
|
27
|
+
table: "read_unreachable_users",
|
|
28
|
+
fields: {
|
|
29
|
+
email: createTextField({ required: true }),
|
|
30
|
+
firstName: createTextField(),
|
|
31
|
+
isEnabled: createBooleanField({ default: true }),
|
|
32
|
+
},
|
|
33
|
+
softDelete: true,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const userFeature = defineFeature("unreachabletest", (r) => {
|
|
37
|
+
r.entity("user", userEntity);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const userTable = buildEntityTable("user", userEntity);
|
|
41
|
+
const projectionName = "unreachabletest:projection:user-entity";
|
|
42
|
+
const registry = createRegistry([userFeature]);
|
|
43
|
+
|
|
44
|
+
let testDb: BunTestDb;
|
|
45
|
+
let tdb: TenantDb;
|
|
46
|
+
const adminUser = TestUsers.admin;
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
await ensureTemporalPolyfill();
|
|
50
|
+
testDb = await createTestDb();
|
|
51
|
+
await unsafeCreateEntityTable(testDb.db, userEntity, "user");
|
|
52
|
+
await createEventsTable(testDb.db);
|
|
53
|
+
await createProjectionStateTable(testDb.db);
|
|
54
|
+
tdb = createTenantDb(testDb.db, adminUser.tenantId);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll(async () => {
|
|
58
|
+
await testDb.cleanup();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
beforeEach(async () => {
|
|
62
|
+
await asRawClient(testDb.db).unsafe(
|
|
63
|
+
`TRUNCATE kumiko_events, read_unreachable_users, kumiko_projections RESTART IDENTITY CASCADE`,
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
async function snapshotTable(): Promise<readonly Record<string, unknown>[]> {
|
|
68
|
+
const rows = await selectMany(
|
|
69
|
+
testDb.db,
|
|
70
|
+
userTable,
|
|
71
|
+
{},
|
|
72
|
+
{ orderBy: { col: "id", direction: "asc" } },
|
|
73
|
+
);
|
|
74
|
+
return rows as readonly Record<string, unknown>[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("assert-no-unreachable-live-rows / #722 ghost-row guard", () => {
|
|
78
|
+
test("clean executor-only projection rebuilds without firing the guard", async () => {
|
|
79
|
+
const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
80
|
+
await crud.create({ email: "a@test.de", firstName: "Alice" }, adminUser, tdb);
|
|
81
|
+
await crud.create({ email: "b@test.de", firstName: "Bob" }, adminUser, tdb);
|
|
82
|
+
const before = await snapshotTable();
|
|
83
|
+
|
|
84
|
+
const result = await rebuildProjection(projectionName, { db: testDb.db, registry });
|
|
85
|
+
|
|
86
|
+
expect(result.eventsProcessed).toBe(2);
|
|
87
|
+
expect(await snapshotTable()).toEqual(before);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("ghost row (no backing event) → swap aborts, live untouched", async () => {
|
|
91
|
+
const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
92
|
+
const a = await crud.create({ email: "a@test.de", firstName: "Alice" }, adminUser, tdb);
|
|
93
|
+
if (!a.isSuccess) throw new Error("setup failed");
|
|
94
|
+
|
|
95
|
+
// Drift: clone the event-backed row into a second id WITHOUT appending an
|
|
96
|
+
// event — the #498 ghost. No replay can ever reconstruct it.
|
|
97
|
+
const [liveRow] = await selectMany(testDb.db, userTable, { id: a.data.id as string });
|
|
98
|
+
if (!liveRow) throw new Error("live row missing");
|
|
99
|
+
const ghostId = crypto.randomUUID();
|
|
100
|
+
// Deliberately bypass the EXECUTOR_ONLY brand: the whole point is to write
|
|
101
|
+
// the entity table WITHOUT going through the executor — the production bug
|
|
102
|
+
// the guard defends against.
|
|
103
|
+
const writable = userTable as unknown as Parameters<typeof insertOne>[1];
|
|
104
|
+
await insertOne(tdb, writable, { ...liveRow, id: ghostId, email: "ghost@test.de" });
|
|
105
|
+
expect(await snapshotTable()).toHaveLength(2);
|
|
106
|
+
|
|
107
|
+
await expect(rebuildProjection(projectionName, { db: testDb.db, registry })).rejects.toThrow(
|
|
108
|
+
/have no\s+event in the projection's source streams/,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// Swap never ran: both rows — including the ghost — survive.
|
|
112
|
+
const after = await snapshotTable();
|
|
113
|
+
expect(after).toHaveLength(2);
|
|
114
|
+
expect(after.map((r) => r["id"])).toContain(ghostId);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("direct column-write on an event-backed row does NOT trip the guard", async () => {
|
|
118
|
+
// The row has a .created event, so it is not a ghost. Its column state was
|
|
119
|
+
// direct-written without an event (the #494 / blind-index-erase class) — the
|
|
120
|
+
// guard is event-existence-only and leaves it alone; the replay overwrites
|
|
121
|
+
// the column from the event, which is the intended, shipped behavior.
|
|
122
|
+
const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
123
|
+
const a = await crud.create({ email: "a@test.de", firstName: "Alice" }, adminUser, tdb);
|
|
124
|
+
if (!a.isSuccess) throw new Error("setup failed");
|
|
125
|
+
|
|
126
|
+
await asRawClient(testDb.db).unsafe(
|
|
127
|
+
`UPDATE read_unreachable_users SET first_name = 'DirectWrite' WHERE id = $1`,
|
|
128
|
+
[a.data.id],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// No throw — the row is event-backed. Rebuild replays the create event.
|
|
132
|
+
await rebuildProjection(projectionName, { db: testDb.db, registry });
|
|
133
|
+
|
|
134
|
+
const [rebuilt] = await selectMany(testDb.db, userTable, { id: a.data.id as string });
|
|
135
|
+
expect(rebuilt?.["email"]).toBe("a@test.de");
|
|
136
|
+
// The direct write is overwritten by the replay — deliberately allowed.
|
|
137
|
+
expect(rebuilt?.["firstName"]).toBe("Alice");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -149,6 +149,68 @@ export async function fenceLiveTable(
|
|
|
149
149
|
await raw.unsafe(`LOCK TABLE public.${quoteTableIdent(tableName)} IN ACCESS EXCLUSIVE MODE`);
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
// Ids reported when the swap is aborted — enough to locate the ghost rows
|
|
153
|
+
// without dumping an unbounded set into the log.
|
|
154
|
+
const UNREACHABLE_SAMPLE_LIMIT = 20;
|
|
155
|
+
|
|
156
|
+
// Runs INSIDE the rebuild tx, under the fence, before swapShadowIntoLive. A live
|
|
157
|
+
// row whose aggregate id has NO event in the projection's source streams is
|
|
158
|
+
// UNREACHABLE: no replay can ever reconstruct it, so the swap would silently
|
|
159
|
+
// drop it. That is the #498 ghost — a row direct-inserted without ever emitting
|
|
160
|
+
// a .created event. The static CI guard cannot see it in data that already
|
|
161
|
+
// exists in production, or on table identifiers it couldn't resolve; this
|
|
162
|
+
// catches it at cutover and aborts (tx rolls back, live untouched).
|
|
163
|
+
//
|
|
164
|
+
// Deliberately NARROW — event EXISTENCE only, not a column or row-vs-shadow
|
|
165
|
+
// diff. The framework legitimately makes live diverge from a fresh replay in
|
|
166
|
+
// several SHIPPED ways, none of which is drift:
|
|
167
|
+
// - a blind-index column recomputed to NULL after the subject's key is
|
|
168
|
+
// shredded (GDPR erase) — the NULL is the intended end state;
|
|
169
|
+
// - a `sensitive` column stripped from the event log by design;
|
|
170
|
+
// - an archived stream that stops replaying (fw#832) — the row's wipe is the
|
|
171
|
+
// intended tombstone behavior, reported via backfill's `failed` list;
|
|
172
|
+
// - a legacy column direct-written before its handler emitted events, healed
|
|
173
|
+
// by the #494 backfill-then-rebuild flow.
|
|
174
|
+
// Checking event existence INCLUDING archived streams leaves every one of them
|
|
175
|
+
// alone: those rows all have a real event, so they are not ghosts. Column-level
|
|
176
|
+
// drift detection (fail-hard vs. graceful repair) is the open question deferred
|
|
177
|
+
// from #722.
|
|
178
|
+
//
|
|
179
|
+
// Implicit projections only (caller-gated). aggregate_id and the entity id are
|
|
180
|
+
// both uuid, so the anti-join probes the events index without a cast.
|
|
181
|
+
export async function assertNoUnreachableLiveRows(
|
|
182
|
+
tx: AnyDb,
|
|
183
|
+
projectionName: string,
|
|
184
|
+
tableName: string,
|
|
185
|
+
aggregateTypes: readonly string[],
|
|
186
|
+
): Promise<void> {
|
|
187
|
+
// skip: no source streams → no events could back any row anyway; a rebuild
|
|
188
|
+
// of a subscription-less projection swaps an empty shadow (handled upstream).
|
|
189
|
+
if (aggregateTypes.length === 0) return;
|
|
190
|
+
const raw = asRawClient(tx);
|
|
191
|
+
const t = quoteTableIdent(tableName);
|
|
192
|
+
const ghosts = await raw.unsafe<{ id: unknown }>(
|
|
193
|
+
`SELECT l."id" FROM public.${t} l
|
|
194
|
+
WHERE NOT EXISTS (
|
|
195
|
+
SELECT 1 FROM "kumiko_events" e
|
|
196
|
+
WHERE e."aggregate_id" = l."id" AND e."aggregate_type" = ANY($1::text[])
|
|
197
|
+
)
|
|
198
|
+
LIMIT ${UNREACHABLE_SAMPLE_LIMIT}`,
|
|
199
|
+
[aggregateTypes],
|
|
200
|
+
);
|
|
201
|
+
// skip: every live row has a backing event — nothing unreachable, swap is safe
|
|
202
|
+
if (ghosts.length === 0) return;
|
|
203
|
+
const ids = ghosts.map((r) => String(r.id));
|
|
204
|
+
throw new Error(
|
|
205
|
+
`projection-rebuild "${projectionName}": ${ids.length}+ live rows in "${tableName}" have no ` +
|
|
206
|
+
`event in the projection's source streams and cannot be reconstructed by replay — the swap ` +
|
|
207
|
+
`would silently drop them (ids: ${ids.join(", ")}). A handler direct-inserted these rows ` +
|
|
208
|
+
`without emitting a .created event. Fix: register the table with r.unmanagedTable(meta, ` +
|
|
209
|
+
`{ reason }) to opt out of rebuild, or emit the missing events. See ` +
|
|
210
|
+
`docs/reference/entity-write-patterns.md. Rebuild aborted; live table untouched.`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
152
214
|
// Atomic swap, INSIDE the rebuild tx, AFTER replay. Schema-qualified so the
|
|
153
215
|
// active shadow search_path can't redirect them. DROP without CASCADE: if any
|
|
154
216
|
// object depends on the live table the swap fails loud and the whole rebuild
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildDeepLinkUrl } from "../deep-link";
|
|
3
|
+
|
|
4
|
+
describe("buildDeepLinkUrl()", () => {
|
|
5
|
+
test("screenId only", () => {
|
|
6
|
+
expect(buildDeepLinkUrl("https://app.example.com", { screenId: "jobs" })).toBe(
|
|
7
|
+
"https://app.example.com/jobs",
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("screenId + entityId", () => {
|
|
12
|
+
expect(
|
|
13
|
+
buildDeepLinkUrl("https://app.example.com", { screenId: "jobs", entityId: "job-1" }),
|
|
14
|
+
).toBe("https://app.example.com/jobs/job-1");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("workspaceId + screenId + entityId, in path order", () => {
|
|
18
|
+
expect(
|
|
19
|
+
buildDeepLinkUrl("https://app.example.com", {
|
|
20
|
+
workspaceId: "admin",
|
|
21
|
+
screenId: "jobs",
|
|
22
|
+
entityId: "job-1",
|
|
23
|
+
}),
|
|
24
|
+
).toBe("https://app.example.com/admin/jobs/job-1");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("strips trailing slash(es) from baseUrl", () => {
|
|
28
|
+
expect(buildDeepLinkUrl("https://app.example.com/", { screenId: "jobs" })).toBe(
|
|
29
|
+
"https://app.example.com/jobs",
|
|
30
|
+
);
|
|
31
|
+
expect(buildDeepLinkUrl("https://app.example.com//", { screenId: "jobs" })).toBe(
|
|
32
|
+
"https://app.example.com/jobs",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Deep-Link-URL-Builder für Notification-Templates (#449, Lazy-Scope: nur
|
|
2
|
+
// Notification→Screen, kein Permalink-Sharing-Layer). Server-seitig nutzbar
|
|
3
|
+
// (kein React) — der Renderer hat mit `formatPath`
|
|
4
|
+
// (packages/renderer/src/app/nav.tsx) dasselbe Pfad-Format fürs Client-Routing,
|
|
5
|
+
// hier bewusst dupliziert statt importiert: `renderer` zieht React als
|
|
6
|
+
// Dependency, Notification-Data-Fns laufen server-seitig im Write-Handler.
|
|
7
|
+
//
|
|
8
|
+
// baseUrl kommt vom App-Autor (analog `AuthMailOptions.baseUrl` /
|
|
9
|
+
// magic-link-mail.ts appendToken) — kein Auto-Detect, kein Env-Var-Read hier.
|
|
10
|
+
|
|
11
|
+
export type DeepLinkTarget = {
|
|
12
|
+
readonly screenId: string;
|
|
13
|
+
readonly entityId?: string;
|
|
14
|
+
readonly workspaceId?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function buildDeepLinkUrl(baseUrl: string, target: DeepLinkTarget): string {
|
|
18
|
+
const segments: string[] = [];
|
|
19
|
+
if (target.workspaceId !== undefined) segments.push(target.workspaceId);
|
|
20
|
+
segments.push(target.screenId);
|
|
21
|
+
if (target.entityId !== undefined) segments.push(target.entityId);
|
|
22
|
+
return `${baseUrl.replace(/\/+$/, "")}/${segments.join("/")}`;
|
|
23
|
+
}
|
package/src/engine/index.ts
CHANGED
|
@@ -38,6 +38,8 @@ export {
|
|
|
38
38
|
export type { App, AppConfig } from "./create-app";
|
|
39
39
|
export { createApp } from "./create-app";
|
|
40
40
|
export { crossTenantOverrideDenied } from "./cross-tenant";
|
|
41
|
+
export type { DeepLinkTarget } from "./deep-link";
|
|
42
|
+
export { buildDeepLinkUrl } from "./deep-link";
|
|
41
43
|
export { defineFeature } from "./define-feature";
|
|
42
44
|
export type {
|
|
43
45
|
QueryHandlerDefinition,
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createRegistry, defineFeature } from "../../engine";
|
|
3
|
+
import type { AppContext, Registry } from "../../engine/types";
|
|
4
|
+
import { createPrometheusMeter, registerStandardMetrics } from "../../observability";
|
|
5
|
+
import { createTestRedis, type TestRedis } from "../../stack";
|
|
6
|
+
import { sleep } from "../../testing";
|
|
7
|
+
import { createJobRunner } from "../job-runner";
|
|
8
|
+
|
|
9
|
+
let testRedis: TestRedis;
|
|
10
|
+
let redisUrl: string;
|
|
11
|
+
|
|
12
|
+
const testFeature = defineFeature("test-queue-depth", (r) => {
|
|
13
|
+
r.job("noop", { trigger: { manual: true } }, async () => {});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
testRedis = await createTestRedis();
|
|
18
|
+
redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterAll(async () => {
|
|
22
|
+
await testRedis.cleanup();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("job-runner — kumiko_job_queue_depth", () => {
|
|
26
|
+
test("start() polls BullMQ counts into the gauge, stop() tears the poller down", async () => {
|
|
27
|
+
const registry: Registry = createRegistry([testFeature]);
|
|
28
|
+
const meter = createPrometheusMeter();
|
|
29
|
+
registerStandardMetrics(meter);
|
|
30
|
+
const context: AppContext = { meter };
|
|
31
|
+
const queueNamePrefix = `kumiko-test-qd-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
32
|
+
|
|
33
|
+
const runner = createJobRunner({
|
|
34
|
+
registry,
|
|
35
|
+
context,
|
|
36
|
+
redisUrl,
|
|
37
|
+
consumerLane: "worker",
|
|
38
|
+
queueNamePrefix,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await runner.start();
|
|
43
|
+
|
|
44
|
+
const snapshot = meter.snapshot().get("kumiko_job_queue_depth");
|
|
45
|
+
expect(snapshot).toBeDefined();
|
|
46
|
+
const waitingSlot = snapshot?.slots.find(
|
|
47
|
+
(s) => s.labels?.["lane"] === "worker" && s.labels?.["state"] === "waiting",
|
|
48
|
+
);
|
|
49
|
+
expect(waitingSlot).toBeDefined();
|
|
50
|
+
expect((waitingSlot as { value: number }).value).toBe(0);
|
|
51
|
+
// BullMQ's fresh Worker connection is still settling right after
|
|
52
|
+
// start() returns (no boot/cron job here to have already warmed it
|
|
53
|
+
// up, unlike every other scenario in this suite) — stop() immediately
|
|
54
|
+
// after start() races the Worker's own connection teardown and throws
|
|
55
|
+
// an unrelated "Connection is closed" from ioredis. Production runners
|
|
56
|
+
// live far longer than this; the race is a test-only artifact of
|
|
57
|
+
// start()+stop() with zero work in between.
|
|
58
|
+
await sleep(50);
|
|
59
|
+
} finally {
|
|
60
|
+
await runner.stop();
|
|
61
|
+
const keys = await testRedis.redis.keys(`bull:${queueNamePrefix}-worker:*`);
|
|
62
|
+
if (keys.length > 0) await testRedis.redis.del(...keys);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("enqueuer-only runner (no consumerLane) never polls — no gauge slots", async () => {
|
|
67
|
+
const registry: Registry = createRegistry([testFeature]);
|
|
68
|
+
const meter = createPrometheusMeter();
|
|
69
|
+
registerStandardMetrics(meter);
|
|
70
|
+
const context: AppContext = { meter };
|
|
71
|
+
const queueNamePrefix = `kumiko-test-qd-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
72
|
+
|
|
73
|
+
const runner = createJobRunner({ registry, context, redisUrl, queueNamePrefix });
|
|
74
|
+
try {
|
|
75
|
+
await runner.start();
|
|
76
|
+
const snapshot = meter.snapshot().get("kumiko_job_queue_depth");
|
|
77
|
+
expect(snapshot?.slots.length ?? 0).toBe(0);
|
|
78
|
+
} finally {
|
|
79
|
+
await runner.stop();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -11,7 +11,13 @@ import {
|
|
|
11
11
|
SYSTEM_TENANT_ID,
|
|
12
12
|
} from "../engine/types";
|
|
13
13
|
import type { Logger } from "../logging/types";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
emitJobQueueDepth,
|
|
16
|
+
getFallbackTracer,
|
|
17
|
+
type Meter,
|
|
18
|
+
type SerializedTraceContext,
|
|
19
|
+
type Tracer,
|
|
20
|
+
} from "../observability";
|
|
15
21
|
import { createDistributedLock, type DistributedLock } from "../pipeline/distributed-lock";
|
|
16
22
|
import { RedisKeys } from "../pipeline/redis-keys";
|
|
17
23
|
|
|
@@ -119,6 +125,29 @@ function captureTraceContext(tracer: Tracer): SerializedTraceContext | undefined
|
|
|
119
125
|
return { traceId: span.traceId, spanId: span.spanId };
|
|
120
126
|
}
|
|
121
127
|
|
|
128
|
+
const QUEUE_DEPTH_POLL_INTERVAL_MS = 15_000;
|
|
129
|
+
|
|
130
|
+
// kumiko_job_queue_depth: only the lane's own consumer polls — the count is
|
|
131
|
+
// a global Redis-backed value (BullMQ, not per-process), so one reporter per
|
|
132
|
+
// lane is enough. Fires once immediately (metrics exist before the first
|
|
133
|
+
// poll tick) then on an interval; the caller stores + clears the handle.
|
|
134
|
+
async function startQueueDepthPolling(
|
|
135
|
+
queue: Queue,
|
|
136
|
+
lane: JobRunIn,
|
|
137
|
+
meter: Meter,
|
|
138
|
+
): Promise<ReturnType<typeof setInterval>> {
|
|
139
|
+
const pollQueueDepth = async (): Promise<void> => {
|
|
140
|
+
try {
|
|
141
|
+
const counts = await queue.getJobCounts();
|
|
142
|
+
emitJobQueueDepth(meter, lane, counts);
|
|
143
|
+
} catch {
|
|
144
|
+
// skip: transient Redis hiccup — next poll retries
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
await pollQueueDepth();
|
|
148
|
+
return setInterval(() => void pollQueueDepth(), QUEUE_DEPTH_POLL_INTERVAL_MS);
|
|
149
|
+
}
|
|
150
|
+
|
|
122
151
|
function parseRedisOpts(url: string): { host: string; port: number; db?: number | undefined } {
|
|
123
152
|
const parsed = new URL(url);
|
|
124
153
|
const result: { host: string; port: number; db?: number | undefined } = {
|
|
@@ -181,6 +210,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
181
210
|
worker: new Queue(queueNameFor(queueNamePrefix, "worker"), { connection: redisOpts }),
|
|
182
211
|
};
|
|
183
212
|
let worker: Worker | null = null;
|
|
213
|
+
let queueDepthTimer: ReturnType<typeof setInterval> | null = null;
|
|
184
214
|
// Forward reference to the runner's own API, exposed on the job-handler ctx
|
|
185
215
|
// so a handler can dispatch a follow-up job (job→job chaining, e.g.
|
|
186
216
|
// delivery.render → delivery.send). Assigned just before return; reads happen
|
|
@@ -431,9 +461,19 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
431
461
|
await consumerQueue.add(bootName, {}, { jobId: `boot-${name.replace(/\./g, "-")}` });
|
|
432
462
|
}
|
|
433
463
|
}
|
|
464
|
+
|
|
465
|
+
// Skipped when no meter is wired (context.meter is optional, e.g. in
|
|
466
|
+
// tests without an observability provider).
|
|
467
|
+
if (context.meter) {
|
|
468
|
+
queueDepthTimer = await startQueueDepthPolling(consumerQueue, consumerLane, context.meter);
|
|
469
|
+
}
|
|
434
470
|
},
|
|
435
471
|
|
|
436
472
|
async stop(): Promise<void> {
|
|
473
|
+
if (queueDepthTimer) {
|
|
474
|
+
clearInterval(queueDepthTimer);
|
|
475
|
+
queueDepthTimer = null;
|
|
476
|
+
}
|
|
437
477
|
if (worker) {
|
|
438
478
|
await worker.close();
|
|
439
479
|
worker = null;
|
|
@@ -94,6 +94,21 @@ export const STANDARD_METRIC_DEFS: readonly MetricDefinition[] = [
|
|
|
94
94
|
"1 if the event-dispatcher holds an active PG LISTEN subscription on the events channel, 0 otherwise.",
|
|
95
95
|
labels: [],
|
|
96
96
|
},
|
|
97
|
+
// BullMQ backlog per lane + state (waiting/active/delayed/failed/…). Only
|
|
98
|
+
// the process that owns a lane's consumerLane polls it (job-runner.ts
|
|
99
|
+
// start()) — the count is a global Redis-backed value, no per-instance
|
|
100
|
+
// label needed. A growing "waiting" count means the lane's worker can't
|
|
101
|
+
// keep up with dispatch; "failed" trending up means jobs are erroring
|
|
102
|
+
// silently. Motivated by apps that run every job on the "api" lane
|
|
103
|
+
// (no dedicated worker consumer) — fanout/reconcile jobs then share
|
|
104
|
+
// Redis-queue capacity with request-handling, and contention is
|
|
105
|
+
// otherwise invisible until requests slow down.
|
|
106
|
+
{
|
|
107
|
+
name: "kumiko_job_queue_depth",
|
|
108
|
+
type: "gauge",
|
|
109
|
+
description: "BullMQ job counts per lane and state.",
|
|
110
|
+
labels: ["lane", "state"],
|
|
111
|
+
},
|
|
97
112
|
] as const;
|
|
98
113
|
|
|
99
114
|
export function registerStandardMetrics(meter: Meter): void {
|
|
@@ -211,3 +226,16 @@ export function emitEventConsumerPassOutcome(
|
|
|
211
226
|
});
|
|
212
227
|
}
|
|
213
228
|
}
|
|
229
|
+
|
|
230
|
+
// counts: BullMQ's Queue.getJobCounts() result — a state → count record
|
|
231
|
+
// (waiting/active/delayed/failed/completed/…), passed through unmodified so
|
|
232
|
+
// this stays agnostic of exactly which states BullMQ reports.
|
|
233
|
+
export function emitJobQueueDepth(
|
|
234
|
+
meter: Meter,
|
|
235
|
+
lane: string,
|
|
236
|
+
counts: Readonly<Record<string, number>>,
|
|
237
|
+
): void {
|
|
238
|
+
for (const [state, count] of Object.entries(counts)) {
|
|
239
|
+
meter.gauge("kumiko_job_queue_depth").set(count, { lane, state });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "../db/queries/projection-rebuild";
|
|
9
9
|
import {
|
|
10
10
|
assertLiveColumnsMatchMeta,
|
|
11
|
+
assertNoUnreachableLiveRows,
|
|
11
12
|
buildShadowTable,
|
|
12
13
|
ensureRebuildSchema,
|
|
13
14
|
fenceLiveTable,
|
|
@@ -326,6 +327,12 @@ export async function rebuildProjection(
|
|
|
326
327
|
if (skipped.length > 0) {
|
|
327
328
|
await recordRebuildDeadLetters(tx, projectionName, skipped);
|
|
328
329
|
}
|
|
330
|
+
// Guard the swap: abort if the live table holds a row no event can
|
|
331
|
+
// reconstruct (#498 ghost — direct-inserted without a .created event),
|
|
332
|
+
// which the swap would silently drop. Implicit projections only.
|
|
333
|
+
if (projection.isImplicit === true) {
|
|
334
|
+
await assertNoUnreachableLiveRows(tx, projectionName, meta.tableName, sourcesList);
|
|
335
|
+
}
|
|
329
336
|
await finalizeProjectionRebuild(tx, projectionName, lastProcessedEventId);
|
|
330
337
|
await swapShadowIntoLive(tx, meta.tableName);
|
|
331
338
|
});
|