@cosmicdrift/kumiko-framework 0.289.0 → 0.291.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__/field-access.integration.test.ts +7 -3
- package/src/__tests__/ownership-where-write-path.integration.test.ts +1 -1
- package/src/__tests__/ownership.integration.test.ts +1 -1
- package/src/__tests__/pii-personal-migration-report-codemod.test.ts +160 -0
- package/src/api/__tests__/server-boot-guards.test.ts +42 -0
- package/src/api/__tests__/server-error-logging.test.ts +168 -17
- package/src/api/request-context.ts +31 -0
- package/src/api/request-id-middleware.ts +2 -1
- package/src/api/routes.ts +35 -3
- package/src/api/server.ts +30 -4
- package/src/changes.json +69 -0
- package/src/crypto/__tests__/blind-index.test.ts +1 -1
- package/src/crypto/__tests__/event-pii.test.ts +110 -9
- package/src/crypto/__tests__/pii-field-encryption.test.ts +2 -2
- package/src/crypto/__tests__/subject-resolver.test.ts +25 -4
- package/src/crypto/subject-resolver.ts +25 -8
- package/src/db/__tests__/blind-index.integration.test.ts +1 -1
- package/src/db/__tests__/eagerload.integration.test.ts +12 -2
- package/src/db/__tests__/entity-field-encryption.test.ts +2 -2
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
- package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +7 -2
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +3 -1
- package/src/db/__tests__/event-store-executor.integration.test.ts +15 -5
- package/src/db/__tests__/list-filter-field-access.integration.test.ts +6 -2
- package/src/db/queries/shadow-swap.ts +35 -0
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +32 -0
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +148 -15
- package/src/engine/__tests__/boot-validator.test.ts +226 -0
- package/src/engine/__tests__/build-app-schema.test.ts +18 -0
- package/src/engine/__tests__/engine.test.ts +87 -0
- package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +6 -2
- package/src/engine/__tests__/factories-long-text.test.ts +6 -1
- package/src/engine/__tests__/form-money-currency-types.test.ts +90 -0
- package/src/engine/boot-validator/__tests__/record-owned.test.ts +12 -2
- package/src/engine/boot-validator/action-wiring.ts +2 -1
- package/src/engine/boot-validator/entity-handler.ts +44 -0
- package/src/engine/boot-validator/index.ts +7 -2
- package/src/engine/boot-validator/pii-retention.ts +8 -0
- package/src/engine/boot-validator/screens.ts +54 -12
- package/src/engine/create-app.ts +54 -0
- package/src/engine/feature-config-events-jobs.ts +19 -0
- package/src/engine/index.ts +2 -0
- package/src/engine/qualified-name.ts +9 -0
- package/src/engine/screen-helpers.ts +1 -0
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +32 -8
- package/src/event-store/__tests__/event-attribution.integration.test.ts +186 -0
- package/src/event-store/event-store.ts +23 -2
- package/src/i18n/required-surface-keys.ts +1 -0
- package/src/jobs/__tests__/job-last-success.integration.test.ts +135 -0
- package/src/jobs/index.ts +7 -1
- package/src/jobs/job-runner.ts +104 -6
- package/src/logging/utils.ts +14 -1
- package/src/observability/index.ts +1 -0
- package/src/observability/standard-metrics.ts +20 -0
- package/src/pipeline/__tests__/blind-index-rebuild-guard.integration.test.ts +96 -0
- package/src/pipeline/active-membership.ts +10 -4
- package/src/pipeline/append-event-core.ts +2 -11
- package/src/pipeline/dispatch-shared.ts +17 -5
- package/src/pipeline/event-dispatcher-delivery.ts +15 -3
- package/src/pipeline/projection-rebuild.ts +7 -0
- package/src/schema-cli.ts +21 -0
- package/src/scripts/codemod/pii-personal-migration.ts +242 -2
- package/src/stack/__tests__/ownership-boot-guard.integration.test.ts +1 -1
- package/src/testing/__tests__/e2e-generator.test.ts +50 -0
- package/src/testing/e2e-generator.ts +4 -3
- package/src/ui-types/index.ts +2 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// #3043 — event attribution. append() stamps metadata.feature +
|
|
2
|
+
// metadata.handler from the ambient execution scope, below every envelope
|
|
3
|
+
// builder, so no writer can forget it.
|
|
4
|
+
//
|
|
5
|
+
// Claims pinned here:
|
|
6
|
+
// 1. Dispatch path: an event a write-handler appends carries the handler's
|
|
7
|
+
// qualified name and its owning feature.
|
|
8
|
+
// 2. In-process path: the entity-executor's CRUD event carries the SAME
|
|
9
|
+
// attribution although nothing was passed through its signature.
|
|
10
|
+
// 3. MSP-apply: an event an apply writes is attributed to the consumer, not
|
|
11
|
+
// to the handler that started the chain.
|
|
12
|
+
// 4. No scope: a bare append() stamps the sentinel, never a guess.
|
|
13
|
+
// 5. appendRaw stays unstamped — historical rows keep their metadata verbatim
|
|
14
|
+
// and stay readable without the new fields.
|
|
15
|
+
|
|
16
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
17
|
+
import { UNATTRIBUTED_ORIGIN } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
20
|
+
import { selectMany } from "../../db/query";
|
|
21
|
+
import { buildEntityTable } from "../../db/table-builder";
|
|
22
|
+
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
23
|
+
import {
|
|
24
|
+
resetEventStore,
|
|
25
|
+
setupTestStack,
|
|
26
|
+
type TestStack,
|
|
27
|
+
TestUsers,
|
|
28
|
+
unsafeCreateEntityTable,
|
|
29
|
+
} from "../../stack";
|
|
30
|
+
import { generateId as uuid } from "../../utils";
|
|
31
|
+
import { appendRaw } from "../admin-api";
|
|
32
|
+
import { append } from "../event-store";
|
|
33
|
+
import { eventsTable } from "../events-schema";
|
|
34
|
+
|
|
35
|
+
const orderEntity = createEntity({
|
|
36
|
+
table: "read_attribution_orders",
|
|
37
|
+
fields: {
|
|
38
|
+
item: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const orderTable = buildEntityTable("attr-order", orderEntity);
|
|
43
|
+
|
|
44
|
+
const PLACED = "attribution:event:placed";
|
|
45
|
+
const CONFIRMED = "attribution:event:confirmed";
|
|
46
|
+
const PLACE_HANDLER = "attribution:write:order:place";
|
|
47
|
+
const CONFIRMER_MSP = "attribution:projection:confirmer";
|
|
48
|
+
|
|
49
|
+
const attributionFeature = defineFeature("attribution", (r) => {
|
|
50
|
+
r.entity("attr-order", orderEntity);
|
|
51
|
+
|
|
52
|
+
const placed = r.defineEvent("placed", z.object({ orderId: z.uuid() }), { piiFields: "none" });
|
|
53
|
+
const confirmed = r.defineEvent("confirmed", z.object({ orderId: z.uuid() }), {
|
|
54
|
+
piiFields: "none",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const orderExecutor = createEventStoreExecutor(orderTable, orderEntity, {
|
|
58
|
+
entityName: "attr-order",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
r.writeHandler(
|
|
62
|
+
"order:place",
|
|
63
|
+
z.object({ item: z.string() }),
|
|
64
|
+
async (event, ctx) => {
|
|
65
|
+
const created = await orderExecutor.create({ item: event.payload.item }, event.user, ctx.db);
|
|
66
|
+
if (!created.isSuccess) return created;
|
|
67
|
+
await ctx.unsafeAppendEvent({
|
|
68
|
+
aggregateId: String(created.data.id),
|
|
69
|
+
aggregateType: "attr-order",
|
|
70
|
+
type: placed.name,
|
|
71
|
+
payload: { orderId: String(created.data.id) },
|
|
72
|
+
});
|
|
73
|
+
return created;
|
|
74
|
+
},
|
|
75
|
+
{ access: { roles: ["Admin"] } },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
r.multiStreamProjection({
|
|
79
|
+
name: "confirmer",
|
|
80
|
+
apply: {
|
|
81
|
+
[placed.name]: async (event, _tx, ctx) => {
|
|
82
|
+
if (!ctx) throw new Error("MSP-apply ctx missing — regression of C.2b wiring");
|
|
83
|
+
await ctx.unsafeAppendEvent({
|
|
84
|
+
aggregateId: event.aggregateId,
|
|
85
|
+
aggregateType: "attr-order",
|
|
86
|
+
type: confirmed.name,
|
|
87
|
+
payload: { orderId: event.aggregateId },
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
let stack: TestStack;
|
|
95
|
+
const admin = TestUsers.admin;
|
|
96
|
+
|
|
97
|
+
beforeAll(async () => {
|
|
98
|
+
stack = await setupTestStack({ features: [attributionFeature], systemHooks: [] });
|
|
99
|
+
await unsafeCreateEntityTable(stack.db, orderEntity, "attr-order");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterAll(async () => {
|
|
103
|
+
await stack.cleanup();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterEach(async () => {
|
|
107
|
+
await resetEventStore(stack, ["read_attribution_orders"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
type Origin = { feature?: string; handler?: string };
|
|
111
|
+
|
|
112
|
+
async function originOf(type: string): Promise<Origin> {
|
|
113
|
+
const rows = await selectMany(stack.db, eventsTable);
|
|
114
|
+
const row = rows.find((r: Record<string, unknown>) => r["type"] === type);
|
|
115
|
+
expect(row).toBeDefined();
|
|
116
|
+
return (row?.["metadata"] ?? {}) as Origin;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe("#3043 — event attribution from the execution scope", () => {
|
|
120
|
+
test("dispatch path: ctx.appendEvent stamps the handler and its feature", async () => {
|
|
121
|
+
await stack.http.writeOk(PLACE_HANDLER, { item: "widget" }, admin);
|
|
122
|
+
|
|
123
|
+
expect(await originOf(PLACED)).toMatchObject({
|
|
124
|
+
feature: "attribution",
|
|
125
|
+
handler: PLACE_HANDLER,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("in-process path: the entity-executor CRUD event carries the same attribution", async () => {
|
|
130
|
+
await stack.http.writeOk(PLACE_HANDLER, { item: "sprocket" }, admin);
|
|
131
|
+
|
|
132
|
+
// Nothing is threaded through createEntityExecutor → EventStoreExecutor.write —
|
|
133
|
+
// the write runs inside the handler's scope, so the stamp finds it anyway.
|
|
134
|
+
expect(await originOf("attr-order.created")).toMatchObject({
|
|
135
|
+
feature: "attribution",
|
|
136
|
+
handler: PLACE_HANDLER,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("MSP-apply: the follow-up event is attributed to the consumer, not the trigger", async () => {
|
|
141
|
+
await stack.http.writeOk(PLACE_HANDLER, { item: "gasket" }, admin);
|
|
142
|
+
await stack.eventDispatcher?.runOnce();
|
|
143
|
+
|
|
144
|
+
expect(await originOf(CONFIRMED)).toMatchObject({
|
|
145
|
+
feature: "attribution",
|
|
146
|
+
handler: CONFIRMER_MSP,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("no scope: a bare append() stamps the sentinel instead of guessing", async () => {
|
|
151
|
+
const aggregateId = uuid();
|
|
152
|
+
await append(stack.db, {
|
|
153
|
+
aggregateId,
|
|
154
|
+
aggregateType: "attr-order",
|
|
155
|
+
tenantId: admin.tenantId,
|
|
156
|
+
expectedVersion: 0,
|
|
157
|
+
type: PLACED,
|
|
158
|
+
payload: { orderId: aggregateId },
|
|
159
|
+
metadata: { userId: admin.id },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(await originOf(PLACED)).toMatchObject({
|
|
163
|
+
feature: UNATTRIBUTED_ORIGIN,
|
|
164
|
+
handler: UNATTRIBUTED_ORIGIN,
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("appendRaw keeps historical metadata verbatim and stays readable", async () => {
|
|
169
|
+
const aggregateId = uuid();
|
|
170
|
+
await appendRaw(stack.db, {
|
|
171
|
+
aggregateId,
|
|
172
|
+
aggregateType: "attr-order",
|
|
173
|
+
tenantId: admin.tenantId,
|
|
174
|
+
expectedVersion: 0,
|
|
175
|
+
type: CONFIRMED,
|
|
176
|
+
payload: { orderId: aggregateId },
|
|
177
|
+
metadata: { userId: admin.id },
|
|
178
|
+
createdAt: Temporal.Instant.from("2023-01-15T10:00:00Z"),
|
|
179
|
+
createdBy: admin.id,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const origin = await originOf(CONFIRMED);
|
|
183
|
+
expect(origin.feature).toBeUndefined();
|
|
184
|
+
expect(origin.handler).toBeUndefined();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
type EventMetadata,
|
|
3
|
+
type StoredEvent,
|
|
4
|
+
UNATTRIBUTED_ORIGIN,
|
|
5
|
+
} from "@cosmicdrift/kumiko-types/event-store-types";
|
|
2
6
|
// Value-only import, aliased to avoid shadowing the ambient global
|
|
3
7
|
// `Temporal` TYPE this file's other Temporal.Instant annotations resolve
|
|
4
8
|
// against (StoredEvent.createdAt et al. — importing the bare name here
|
|
@@ -6,6 +10,7 @@ import type { EventMetadata, StoredEvent } from "@cosmicdrift/kumiko-types/event
|
|
|
6
10
|
// as a runtime value on globalThis, so the un-aliased call below crashed
|
|
7
11
|
// with "Temporal is not defined" outside boot paths that install it (#1480).
|
|
8
12
|
import { Temporal as TemporalPolyfill } from "temporal-polyfill";
|
|
13
|
+
import { requestContext } from "../api/request-context";
|
|
9
14
|
import { encryptEventPayloadPii } from "../crypto/event-pii";
|
|
10
15
|
import type { DbRunner } from "../db";
|
|
11
16
|
import { constraintOf, isUniqueViolation } from "../db/pg-error";
|
|
@@ -83,7 +88,7 @@ export async function append(db: DbRunner, event: EventToAppend): Promise<Stored
|
|
|
83
88
|
aggregateType: event.aggregateType,
|
|
84
89
|
aggregateId: event.aggregateId,
|
|
85
90
|
});
|
|
86
|
-
const toStore = payload === event.payload ? event : { ...event, payload };
|
|
91
|
+
const toStore = stampOrigin(payload === event.payload ? event : { ...event, payload });
|
|
87
92
|
const newVersion = toStore.expectedVersion + 1;
|
|
88
93
|
const eventVersion = toStore.eventVersion ?? 1;
|
|
89
94
|
|
|
@@ -116,6 +121,22 @@ export async function append(db: DbRunner, event: EventToAppend): Promise<Stored
|
|
|
116
121
|
}
|
|
117
122
|
}
|
|
118
123
|
|
|
124
|
+
// #3043 — attribution is derived from the execution scope, never taken from
|
|
125
|
+
// the caller: a passed-in value can lie about who wrote the row, a derived
|
|
126
|
+
// one cannot. Hence overwrite rather than merge. appendRaw/appendRawBatch
|
|
127
|
+
// bypass this deliberately — they replay historical rows verbatim.
|
|
128
|
+
function stampOrigin(event: EventToAppend): EventToAppend {
|
|
129
|
+
const origin = requestContext.get();
|
|
130
|
+
return {
|
|
131
|
+
...event,
|
|
132
|
+
metadata: {
|
|
133
|
+
...event.metadata,
|
|
134
|
+
feature: origin?.feature ?? UNATTRIBUTED_ORIGIN,
|
|
135
|
+
handler: origin?.handler ?? UNATTRIBUTED_ORIGIN,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
119
140
|
type InsertReturn = { id: bigint; createdAt: Temporal.Instant };
|
|
120
141
|
|
|
121
142
|
async function insertFirstEvent(
|
|
@@ -328,6 +328,7 @@ export function requiredKeysFromScreen(
|
|
|
328
328
|
if (isWriteFormEditSection(section)) {
|
|
329
329
|
pushKey(out, section.title);
|
|
330
330
|
pushKey(out, section.submitLabel);
|
|
331
|
+
// kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
|
|
331
332
|
for (const f of section.fields) {
|
|
332
333
|
const fieldName = editFieldName(f);
|
|
333
334
|
out.add(fieldLabelKey(featureName, WRITE_FORM_SECTION_ENTITY, fieldName));
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildServer } from "../../api/server";
|
|
3
|
+
import { createRegistry, defineFeature } from "../../engine";
|
|
4
|
+
import type { AppContext, Registry } from "../../engine/types";
|
|
5
|
+
import {
|
|
6
|
+
createNoopProvider,
|
|
7
|
+
createPrometheusMeter,
|
|
8
|
+
registerStandardMetrics,
|
|
9
|
+
} from "../../observability";
|
|
10
|
+
import { createTestRedis, type TestRedis } from "../../stack";
|
|
11
|
+
import { waitFor } from "../../testing";
|
|
12
|
+
import { createJobRunner } from "../job-runner";
|
|
13
|
+
|
|
14
|
+
const JWT = "job-last-success-test-secret-minimum-32-chars!!";
|
|
15
|
+
const SUCCEEDS = "liveness:job:succeeds";
|
|
16
|
+
const FAILS = "liveness:job:fails-always";
|
|
17
|
+
|
|
18
|
+
let testRedis: TestRedis;
|
|
19
|
+
let redisUrl: string;
|
|
20
|
+
|
|
21
|
+
const livenessFeature = defineFeature("liveness", (r) => {
|
|
22
|
+
r.job("succeeds", { trigger: { manual: true } }, async () => {});
|
|
23
|
+
r.job("failsAlways", { trigger: { manual: true } }, async () => {
|
|
24
|
+
throw new Error("intentional failure");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
testRedis = await createTestRedis();
|
|
30
|
+
redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterAll(async () => {
|
|
34
|
+
await testRedis.cleanup();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function slotFor(meter: ReturnType<typeof createPrometheusMeter>, job: string) {
|
|
38
|
+
return meter
|
|
39
|
+
.snapshot()
|
|
40
|
+
.get("kumiko_job_last_success_timestamp_seconds")
|
|
41
|
+
?.slots.find((s) => s.labels?.["job"] === job);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function withRunner(
|
|
45
|
+
meter: ReturnType<typeof createPrometheusMeter>,
|
|
46
|
+
fn: (runner: ReturnType<typeof createJobRunner>, failures: string[]) => Promise<void>,
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
const registry: Registry = createRegistry([livenessFeature]);
|
|
49
|
+
const context: AppContext = { meter };
|
|
50
|
+
const failures: string[] = [];
|
|
51
|
+
const queueNamePrefix = `kumiko-test-ls-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
52
|
+
const runner = createJobRunner({
|
|
53
|
+
registry,
|
|
54
|
+
context,
|
|
55
|
+
redisUrl,
|
|
56
|
+
consumerLane: "worker",
|
|
57
|
+
queueNamePrefix,
|
|
58
|
+
onJobFailed: (jobName) => {
|
|
59
|
+
failures.push(jobName);
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
await runner.start();
|
|
63
|
+
try {
|
|
64
|
+
await fn(runner, failures);
|
|
65
|
+
} finally {
|
|
66
|
+
await runner.stop();
|
|
67
|
+
const keys = await testRedis.redis.keys(`bull:${queueNamePrefix}-worker:*`);
|
|
68
|
+
if (keys.length > 0) await testRedis.redis.del(...keys);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("job-runner — kumiko_job_last_success_timestamp_seconds", () => {
|
|
73
|
+
test("a successful run stamps the gauge, a failing run leaves no series", async () => {
|
|
74
|
+
const meter = createPrometheusMeter();
|
|
75
|
+
registerStandardMetrics(meter);
|
|
76
|
+
|
|
77
|
+
await withRunner(meter, async (runner, failures) => {
|
|
78
|
+
const before = Date.now() / 1000;
|
|
79
|
+
await runner.dispatch(SUCCEEDS, {});
|
|
80
|
+
await waitFor(() => slotFor(meter, SUCCEEDS) !== undefined);
|
|
81
|
+
|
|
82
|
+
const stamped = slotFor(meter, SUCCEEDS) as { value: number };
|
|
83
|
+
expect(stamped.value).toBeGreaterThanOrEqual(before);
|
|
84
|
+
expect(stamped.value).toBeLessThanOrEqual(Date.now() / 1000 + 1);
|
|
85
|
+
|
|
86
|
+
// Separate job name, so the "failure must not stamp" assertion cannot
|
|
87
|
+
// pass just because a prior success left a value inside the same second.
|
|
88
|
+
await runner.dispatch(FAILS, {});
|
|
89
|
+
await waitFor(() => failures.includes(FAILS));
|
|
90
|
+
expect(slotFor(meter, FAILS)).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a later success advances the stamp", async () => {
|
|
95
|
+
const meter = createPrometheusMeter();
|
|
96
|
+
registerStandardMetrics(meter);
|
|
97
|
+
|
|
98
|
+
await withRunner(meter, async (runner) => {
|
|
99
|
+
await runner.dispatch(SUCCEEDS, {});
|
|
100
|
+
await waitFor(() => slotFor(meter, SUCCEEDS) !== undefined);
|
|
101
|
+
const first = (slotFor(meter, SUCCEEDS) as { value: number }).value;
|
|
102
|
+
|
|
103
|
+
await runner.dispatch(SUCCEEDS, {});
|
|
104
|
+
await waitFor(() => (slotFor(meter, SUCCEEDS) as { value: number }).value > first, {
|
|
105
|
+
delays: [250, 1000, 3000],
|
|
106
|
+
});
|
|
107
|
+
expect((slotFor(meter, SUCCEEDS) as { value: number }).value).toBeGreaterThan(first);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("the stamp reaches the real /metrics scrape output", async () => {
|
|
112
|
+
const meter = createPrometheusMeter();
|
|
113
|
+
registerStandardMetrics(meter);
|
|
114
|
+
|
|
115
|
+
await withRunner(meter, async (runner) => {
|
|
116
|
+
await runner.dispatch(SUCCEEDS, {});
|
|
117
|
+
await waitFor(() => slotFor(meter, SUCCEEDS) !== undefined);
|
|
118
|
+
|
|
119
|
+
// Same meter instance the runner wrote into — that sharing is what
|
|
120
|
+
// buildServer + job-runner do in a real process (fw#1046).
|
|
121
|
+
const { app } = buildServer({
|
|
122
|
+
registry: createRegistry([livenessFeature]),
|
|
123
|
+
context: {},
|
|
124
|
+
jwtSecret: JWT,
|
|
125
|
+
observability: { ...createNoopProvider(), meter },
|
|
126
|
+
metrics: {},
|
|
127
|
+
});
|
|
128
|
+
const res = await app.request("/metrics");
|
|
129
|
+
expect(res.status).toBe(200);
|
|
130
|
+
const body = await res.text();
|
|
131
|
+
expect(body).toContain("# TYPE kumiko_job_last_success_timestamp_seconds gauge");
|
|
132
|
+
expect(body).toContain(`kumiko_job_last_success_timestamp_seconds{job="${SUCCEEDS}"} `);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
package/src/jobs/index.ts
CHANGED
package/src/jobs/job-runner.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { requestContext } from "../api/request-context";
|
|
|
4
4
|
import type { DbConnection, DbRow } from "../db/connection";
|
|
5
5
|
import { createTenantDb, createUncheckedSystemDb, type TenantDb } from "../db/tenant-db";
|
|
6
6
|
import { createDerivativesContext } from "../derivatives/derivatives-context";
|
|
7
|
+
import { qnScope } from "../engine/qualified-name";
|
|
7
8
|
import { createSystemUser } from "../engine/system-user";
|
|
8
9
|
import {
|
|
9
10
|
type AppContext,
|
|
@@ -17,10 +18,12 @@ import {
|
|
|
17
18
|
SYSTEM_TENANT_ID,
|
|
18
19
|
type TenantId,
|
|
19
20
|
} from "../engine/types";
|
|
21
|
+
import { isKumikoError } from "../errors/kumiko-error";
|
|
20
22
|
import { createFileContext } from "../files/file-handle";
|
|
21
23
|
import { createFallbackLogger } from "../logging";
|
|
22
24
|
import type { Logger } from "../logging/types";
|
|
23
25
|
import {
|
|
26
|
+
emitJobLastSuccess,
|
|
24
27
|
emitJobQueueDepth,
|
|
25
28
|
getFallbackTracer,
|
|
26
29
|
type Meter,
|
|
@@ -138,6 +141,55 @@ export type JobMeta = {
|
|
|
138
141
|
priority?: number | undefined;
|
|
139
142
|
};
|
|
140
143
|
|
|
144
|
+
// What a finished run tells the run-logger about the tenant-visible failure
|
|
145
|
+
// record (`JobDefinition.tenantVisibleFailure`). `tenantVisible` is set only
|
|
146
|
+
// when the job opted in; `messageKey` is null on the success path, where the
|
|
147
|
+
// record is cleared rather than written.
|
|
148
|
+
export type JobOutcomeMeta = {
|
|
149
|
+
readonly tenantId: string;
|
|
150
|
+
readonly finalAttempt: boolean;
|
|
151
|
+
readonly tenantVisible?:
|
|
152
|
+
| { readonly subject: string | null; readonly messageKey: string | null }
|
|
153
|
+
| undefined;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Stable identity for the declared payload fields: sorted keys, so two runs
|
|
157
|
+
// with the same subject values produce the same string and the later one
|
|
158
|
+
// replaces the earlier record. Non-primitives would serialize into something
|
|
159
|
+
// no caller can reconstruct for a lookup, so they fail the run loudly.
|
|
160
|
+
function jobSubjectKey(
|
|
161
|
+
jobName: string,
|
|
162
|
+
payload: Record<string, unknown>,
|
|
163
|
+
fields: readonly string[] | undefined,
|
|
164
|
+
): string | null {
|
|
165
|
+
if (fields === undefined || fields.length === 0) return null;
|
|
166
|
+
const entries: [string, string | number | boolean | null][] = [];
|
|
167
|
+
for (const field of [...fields].sort()) {
|
|
168
|
+
const value = payload[field] ?? null;
|
|
169
|
+
if (value !== null && typeof value === "object") {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`Job "${jobName}": tenantVisibleFailure.subjectFields["${field}"] must be a primitive, got ${typeof value}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
entries.push([field, value as string | number | boolean | null]);
|
|
175
|
+
}
|
|
176
|
+
return JSON.stringify(entries);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Only a translation key ever travels to the tenant: the thrown error's own
|
|
180
|
+
// i18nKey when it carries one, otherwise the key declared at the job. The
|
|
181
|
+
// error message itself can echo provider or payload content and stays on the
|
|
182
|
+
// run row and in the run log.
|
|
183
|
+
function tenantFailureMessageKey(
|
|
184
|
+
err: unknown,
|
|
185
|
+
declaration: JobDefinition["tenantVisibleFailure"],
|
|
186
|
+
): string | null {
|
|
187
|
+
// skip: job did not opt in — nothing tenant-visible to record
|
|
188
|
+
if (!declaration) return null;
|
|
189
|
+
if (isKumikoError(err) && err.i18nKey.length > 0) return err.i18nKey;
|
|
190
|
+
return declaration.messageKey;
|
|
191
|
+
}
|
|
192
|
+
|
|
141
193
|
export type JobRunner = {
|
|
142
194
|
start(): Promise<void>;
|
|
143
195
|
stop(): Promise<void>;
|
|
@@ -175,8 +227,20 @@ export type JobRunnerOptions = {
|
|
|
175
227
|
bootRedisTimeoutMs?: number | undefined;
|
|
176
228
|
getActiveTenantIds?: () => Promise<TenantId[]>;
|
|
177
229
|
onJobStart?: (jobName: string, jobId: string, meta: JobMeta) => void;
|
|
178
|
-
onJobComplete?: (
|
|
179
|
-
|
|
230
|
+
onJobComplete?: (
|
|
231
|
+
jobName: string,
|
|
232
|
+
jobId: string,
|
|
233
|
+
duration: number,
|
|
234
|
+
logs: JobLogEntry[],
|
|
235
|
+
outcome?: JobOutcomeMeta,
|
|
236
|
+
) => void;
|
|
237
|
+
onJobFailed?: (
|
|
238
|
+
jobName: string,
|
|
239
|
+
jobId: string,
|
|
240
|
+
error: string,
|
|
241
|
+
logs: JobLogEntry[],
|
|
242
|
+
outcome?: JobOutcomeMeta,
|
|
243
|
+
) => void;
|
|
180
244
|
};
|
|
181
245
|
|
|
182
246
|
// Serialized trace context lives under this key in the BullMQ job data.
|
|
@@ -526,6 +590,24 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
526
590
|
SYSTEM_TENANT_ID;
|
|
527
591
|
const triggeredById = (rawData["_triggeredById"] as string | undefined) ?? null; // @cast-boundary dynamic-key
|
|
528
592
|
|
|
593
|
+
// Tenant-visible failure record (JobDefinition.tenantVisibleFailure). The
|
|
594
|
+
// subject is read from the handler payload, so it is resolved here where
|
|
595
|
+
// the payload is built, not in the callbacks.
|
|
596
|
+
const tenantVisibleDecl = jobDef.tenantVisibleFailure;
|
|
597
|
+
const tenantVisibleSubject = tenantVisibleDecl
|
|
598
|
+
? jobSubjectKey(jobName, payload, tenantVisibleDecl.subjectFields)
|
|
599
|
+
: null;
|
|
600
|
+
// BullMQ stops retrying once attemptsMade reaches the configured attempts
|
|
601
|
+
// (`retries + 1`), so this is the attempt whose failure is final.
|
|
602
|
+
const finalAttempt = bullJob.attemptsMade + 1 >= (jobDef.retries ?? 0) + 1;
|
|
603
|
+
const outcomeMeta = (messageKey: string | null): JobOutcomeMeta => ({
|
|
604
|
+
tenantId,
|
|
605
|
+
finalAttempt,
|
|
606
|
+
...(tenantVisibleDecl && {
|
|
607
|
+
tenantVisible: { subject: tenantVisibleSubject, messageKey },
|
|
608
|
+
}),
|
|
609
|
+
});
|
|
610
|
+
|
|
529
611
|
// Carry `_triggerName` from rawData when set — handleEvent injects it on
|
|
530
612
|
// multi-trigger dispatch; exposed as jobContext.triggerName so handlers
|
|
531
613
|
// don't dig through the raw payload themselves.
|
|
@@ -665,15 +747,31 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
665
747
|
|
|
666
748
|
const runInSpan = async (): Promise<void> => {
|
|
667
749
|
try {
|
|
668
|
-
await requestContext.run(
|
|
669
|
-
|
|
750
|
+
await requestContext.run(
|
|
751
|
+
{
|
|
752
|
+
requestId: jobRequestId,
|
|
753
|
+
correlationId: jobCorrelationId,
|
|
754
|
+
// #3043 — events a job writes carry the job as their origin.
|
|
755
|
+
handler: jobName,
|
|
756
|
+
feature: qnScope(jobName),
|
|
757
|
+
},
|
|
758
|
+
() => jobDef.handler(payload, jobContext),
|
|
670
759
|
);
|
|
760
|
+
// Stamped before the observer hook: a throwing onJobComplete must not
|
|
761
|
+
// make a run that actually succeeded look dead to the liveness alert.
|
|
762
|
+
if (context.meter) emitJobLastSuccess(context.meter, jobName);
|
|
671
763
|
const duration = Date.now() - startTime;
|
|
672
|
-
await options.onJobComplete?.(jobName, jobId, duration, logs);
|
|
764
|
+
await options.onJobComplete?.(jobName, jobId, duration, logs, outcomeMeta(null));
|
|
673
765
|
} catch (err) {
|
|
674
766
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
675
767
|
logs.push({ level: "error", message: errorMsg, timestamp: Temporal.Now.instant() });
|
|
676
|
-
await options.onJobFailed?.(
|
|
768
|
+
await options.onJobFailed?.(
|
|
769
|
+
jobName,
|
|
770
|
+
jobId,
|
|
771
|
+
errorMsg,
|
|
772
|
+
logs,
|
|
773
|
+
outcomeMeta(tenantFailureMessageKey(err, tenantVisibleDecl)),
|
|
774
|
+
);
|
|
677
775
|
throw err;
|
|
678
776
|
}
|
|
679
777
|
};
|
package/src/logging/utils.ts
CHANGED
|
@@ -2,18 +2,27 @@ import type { Logger } from "./types";
|
|
|
2
2
|
|
|
3
3
|
type FallbackLogger = {
|
|
4
4
|
error(msg: string, data?: Record<string, unknown>): void;
|
|
5
|
+
warn(msg: string, data?: Record<string, unknown>): void;
|
|
5
6
|
debug(msg: string, data?: Record<string, unknown>): void;
|
|
6
7
|
};
|
|
7
8
|
|
|
8
9
|
export function createFallbackLogger(
|
|
9
10
|
namespace: string,
|
|
10
|
-
logger?: (Pick<Logger, "error"> & Partial<Pick<Logger, "debug">>) | undefined,
|
|
11
|
+
logger?: (Pick<Logger, "error"> & Partial<Pick<Logger, "warn" | "debug">>) | undefined,
|
|
11
12
|
): FallbackLogger {
|
|
12
13
|
if (logger) {
|
|
13
14
|
return {
|
|
14
15
|
error(msg, data) {
|
|
15
16
|
logger.error(`[${namespace}] ${msg}`, data);
|
|
16
17
|
},
|
|
18
|
+
warn(msg, data) {
|
|
19
|
+
if (logger.warn) {
|
|
20
|
+
logger.warn(`[${namespace}] ${msg}`, data);
|
|
21
|
+
} else {
|
|
22
|
+
// biome-ignore lint/suspicious/noConsole: ops-visible fallback when the wrapped logger has no warn method
|
|
23
|
+
console.warn(`[${namespace}] ${msg}`, data);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
17
26
|
debug(msg, data) {
|
|
18
27
|
if (logger.debug) {
|
|
19
28
|
logger.debug(`[${namespace}] ${msg}`, data);
|
|
@@ -29,6 +38,10 @@ export function createFallbackLogger(
|
|
|
29
38
|
// biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
|
|
30
39
|
console.error(`[${namespace}] ${msg}`, data);
|
|
31
40
|
},
|
|
41
|
+
warn(msg, data) {
|
|
42
|
+
// biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
|
|
43
|
+
console.warn(`[${namespace}] ${msg}`, data);
|
|
44
|
+
},
|
|
32
45
|
debug(msg, data) {
|
|
33
46
|
// biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
|
|
34
47
|
console.debug(`[${namespace}] ${msg}`, data);
|
|
@@ -123,6 +123,19 @@ export const STANDARD_METRIC_DEFS: readonly MetricDefinition[] = [
|
|
|
123
123
|
description: "BullMQ job counts per lane and state.",
|
|
124
124
|
labels: ["lane", "state"],
|
|
125
125
|
},
|
|
126
|
+
// Dead-man for in-process jobs: the k8s CronJob alerts never fire for them
|
|
127
|
+
// (no CronJob object exists), and a gauge the job itself sets freezes on its
|
|
128
|
+
// last value when the job dies, so neither `absent()` nor a threshold
|
|
129
|
+
// catches a silently stopped cron. This one is stamped by the runner, so
|
|
130
|
+
// `time() - kumiko_job_last_success_timestamp_seconds{job="…"} > interval`
|
|
131
|
+
// is a true liveness check. Absent until the first success after a restart —
|
|
132
|
+
// see docs/reference/job-liveness-metric.md for the `for:` that implies.
|
|
133
|
+
{
|
|
134
|
+
name: "kumiko_job_last_success_timestamp_seconds",
|
|
135
|
+
type: "gauge",
|
|
136
|
+
description: "Unix timestamp of the last successful run, per registered job.",
|
|
137
|
+
labels: ["job"],
|
|
138
|
+
},
|
|
126
139
|
] as const;
|
|
127
140
|
|
|
128
141
|
export function registerStandardMetrics(meter: Meter): void {
|
|
@@ -273,3 +286,10 @@ export function emitJobQueueDepth(
|
|
|
273
286
|
meter.gauge("kumiko_job_queue_depth").set(count, { lane, state });
|
|
274
287
|
}
|
|
275
288
|
}
|
|
289
|
+
|
|
290
|
+
// `job` is the registry-declared job name — handleJob rejects an unknown name
|
|
291
|
+
// before it can reach here, so the label set is bounded by the app's r.job
|
|
292
|
+
// registrations and carries no tenant or user input.
|
|
293
|
+
export function emitJobLastSuccess(meter: Meter, job: string): void {
|
|
294
|
+
meter.gauge("kumiko_job_last_success_timestamp_seconds").set(Date.now() / 1000, { job });
|
|
295
|
+
}
|