@cosmicdrift/kumiko-framework 0.322.0 → 0.324.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/api/auth-routes.ts +43 -1
- package/src/changes.json +32 -0
- package/src/crypto/pii-field-encryption.ts +16 -1
- package/src/db/event-store-executor-write.ts +15 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +0 -122
- package/src/engine/__tests__/entity-handlers.test.ts +62 -1
- package/src/engine/__tests__/factories-long-text.test.ts +5 -3
- package/src/engine/__tests__/factories-personal.test.ts +99 -13
- package/src/engine/boot-validator/pii-retention.ts +0 -8
- package/src/engine/entity-handlers.ts +40 -7
- package/src/engine/factories.ts +75 -2
- package/src/engine/instruction-fields.ts +11 -0
- package/src/errors/i18n/de.yaml +14 -0
- package/src/errors/i18n/en.yaml +14 -0
- package/src/errors/reasons.ts +6 -0
- package/src/jobs/__tests__/job-backoff.integration.test.ts +7 -11
- package/src/jobs/job-runner.ts +27 -0
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +1 -0
- package/src/pipeline/__tests__/instruction-field-gate.integration.test.ts +213 -0
- package/src/pipeline/index.ts +1 -0
- package/src/pipeline/irreversible-operation-gate.ts +54 -12
- package/src/stack/__tests__/drain-jobs.integration.test.ts +159 -0
- package/src/stack/drain-jobs.ts +129 -0
- package/src/stack/test-stack.ts +19 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// fw#3360: stack.drainJobs() waits deterministically for every in-flight
|
|
2
|
+
// job AND its follow-up event-consumer / job-trigger cascade — replaces
|
|
3
|
+
// `waitFor` polling on a specific job's side effect.
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import * as z from "zod";
|
|
7
|
+
import { defineFeature } from "../../engine";
|
|
8
|
+
import { SYSTEM_ROLE } from "../../engine/system-user";
|
|
9
|
+
import { setupTestStack, type TestStack } from "../test-stack";
|
|
10
|
+
import { TestUsers } from "../test-users";
|
|
11
|
+
|
|
12
|
+
const chainResults: Array<{ value: string }> = [];
|
|
13
|
+
|
|
14
|
+
// Job A → write handler that appends an r.defineEvent event → the async
|
|
15
|
+
// job-trigger event-consumer → Job B. Exercises exactly the gap drainJobs
|
|
16
|
+
// exists for: a job-completion that only cascades into a follow-up job via
|
|
17
|
+
// the event-dispatcher, not the synchronous write-handler dispatch path.
|
|
18
|
+
const chainFeature = defineFeature("draintestchain", (r) => {
|
|
19
|
+
const relayed = r.defineEvent("relayed", z.object({ value: z.string() }), {
|
|
20
|
+
piiFields: "none",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
r.writeHandler(
|
|
24
|
+
"start",
|
|
25
|
+
z.object({ value: z.string() }),
|
|
26
|
+
async () => ({ isSuccess: true as const, data: {} }),
|
|
27
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
r.writeHandler(
|
|
31
|
+
"emit-relayed",
|
|
32
|
+
z.object({ value: z.string() }),
|
|
33
|
+
async (event, ctx) => {
|
|
34
|
+
await ctx.unsafeAppendEvent({
|
|
35
|
+
aggregateId: crypto.randomUUID(),
|
|
36
|
+
aggregateType: "draintestchain",
|
|
37
|
+
type: relayed.name,
|
|
38
|
+
payload: { value: event.payload.value },
|
|
39
|
+
});
|
|
40
|
+
return { isSuccess: true as const, data: {} };
|
|
41
|
+
},
|
|
42
|
+
{ access: { roles: [SYSTEM_ROLE] } },
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
r.job(
|
|
46
|
+
"job-a",
|
|
47
|
+
{ trigger: { on: "draintestchain:write:start" }, retries: 0 },
|
|
48
|
+
async (payload, ctx) => {
|
|
49
|
+
await ctx.write("draintestchain:write:emit-relayed", { value: payload["value"] });
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
r.job("job-b", { trigger: { on: relayed.name }, retries: 0 }, async (payload) => {
|
|
54
|
+
chainResults.push({ value: payload["value"] as string }); // @cast-boundary test-fixture
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const alwaysFailsRuns: string[] = [];
|
|
59
|
+
const retrySucceedsAttempts: number[] = [];
|
|
60
|
+
|
|
61
|
+
const failureFeature = defineFeature("draintestfailure", (r) => {
|
|
62
|
+
r.job("always-fails", { trigger: { manual: true }, retries: 0 }, async () => {
|
|
63
|
+
alwaysFailsRuns.push("ran");
|
|
64
|
+
throw new Error("job-c always fails");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
r.job(
|
|
68
|
+
"retry-succeeds",
|
|
69
|
+
{ trigger: { manual: true }, retries: 1, backoff: { type: "fixed", delayMs: 10 } },
|
|
70
|
+
async () => {
|
|
71
|
+
retrySucceedsAttempts.push(Date.now());
|
|
72
|
+
if (retrySucceedsAttempts.length === 1) throw new Error("first attempt fails on purpose");
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
let stack: TestStack | undefined;
|
|
78
|
+
|
|
79
|
+
afterEach(async () => {
|
|
80
|
+
if (stack) await stack.cleanup();
|
|
81
|
+
stack = undefined;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("stack.drainJobs() waits for a job's async event-trigger cascade", () => {
|
|
85
|
+
test("job A → event → job B settles before drainJobs() resolves", async () => {
|
|
86
|
+
chainResults.length = 0;
|
|
87
|
+
stack = await setupTestStack({
|
|
88
|
+
features: [chainFeature],
|
|
89
|
+
jobs: { consumerLane: "worker" },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await stack.http.writeOk("draintestchain:write:start", { value: "hi" }, TestUsers.admin);
|
|
93
|
+
await stack.drainJobs();
|
|
94
|
+
|
|
95
|
+
expect(chainResults).toEqual([{ value: "hi" }]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a huge event-dispatcher poll interval doesn't stop drainJobs() from driving the cascade itself", async () => {
|
|
99
|
+
chainResults.length = 0;
|
|
100
|
+
stack = await setupTestStack({
|
|
101
|
+
features: [chainFeature],
|
|
102
|
+
jobs: { consumerLane: "worker" },
|
|
103
|
+
});
|
|
104
|
+
// stop() tears down both the poll timer AND the LISTEN/NOTIFY
|
|
105
|
+
// subscription (idempotent — cleanup()'s own stop() later is a no-op).
|
|
106
|
+
// runOnce() still works afterward (event-dispatcher.ts's own contract),
|
|
107
|
+
// so this leaves drainJobs() as the ONLY thing that can advance the
|
|
108
|
+
// job-trigger consumer past job A's emitted event — without this, the
|
|
109
|
+
// always-on pgClient wake-up races the assertion regardless of
|
|
110
|
+
// eventDispatcherPollIntervalMs.
|
|
111
|
+
await stack.eventDispatcher?.stop();
|
|
112
|
+
|
|
113
|
+
await stack.http.writeOk("draintestchain:write:start", { value: "race" }, TestUsers.admin);
|
|
114
|
+
await stack.drainJobs();
|
|
115
|
+
|
|
116
|
+
expect(chainResults).toEqual([{ value: "race" }]);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("stack.drainJobs() surfaces final job failures", () => {
|
|
121
|
+
test("a job that always throws rejects drainJobs() with its name and error", async () => {
|
|
122
|
+
alwaysFailsRuns.length = 0;
|
|
123
|
+
stack = await setupTestStack({
|
|
124
|
+
features: [failureFeature],
|
|
125
|
+
jobs: { consumerLane: "worker" },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await stack.jobRunner?.dispatch("draintestfailure:job:always-fails");
|
|
129
|
+
|
|
130
|
+
await expect(stack.drainJobs()).rejects.toThrow(
|
|
131
|
+
/draintestfailure:job:always-fails.*job-c always fails/s,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// The tracker is cleared after a rejection — a later drainJobs() call
|
|
135
|
+
// (e.g. the next test's setup) must not still see this failure.
|
|
136
|
+
await expect(stack.drainJobs()).resolves.toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("a job with retries:1 that fails once then succeeds resolves drainJobs()", async () => {
|
|
140
|
+
retrySucceedsAttempts.length = 0;
|
|
141
|
+
stack = await setupTestStack({
|
|
142
|
+
features: [failureFeature],
|
|
143
|
+
jobs: { consumerLane: "worker" },
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await stack.jobRunner?.dispatch("draintestfailure:job:retry-succeeds");
|
|
147
|
+
|
|
148
|
+
await expect(stack.drainJobs()).resolves.toBeUndefined();
|
|
149
|
+
expect(retrySucceedsAttempts).toHaveLength(2);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("stack.drainJobs() without a jobRunner", () => {
|
|
154
|
+
test("rejects with a clear message when the stack has no `jobs` opt-in", async () => {
|
|
155
|
+
stack = await setupTestStack({ features: [chainFeature] });
|
|
156
|
+
|
|
157
|
+
await expect(stack.drainJobs()).rejects.toThrow(/jobRunner is undefined/);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { DbConnection } from "../db";
|
|
2
|
+
import { getEventsHighWaterMark } from "../event-store";
|
|
3
|
+
import type { JobRunner } from "../jobs";
|
|
4
|
+
import { type EventDispatcher, getConsumerState } from "../pipeline";
|
|
5
|
+
|
|
6
|
+
// Gap between idle-checks while waiting for a job hook to fire. Short enough
|
|
7
|
+
// that a test never feels it, long enough that consecutive idle passes don't
|
|
8
|
+
// hammer Postgres/Redis with pointless polling between hook-driven wakeups.
|
|
9
|
+
const DRAIN_IDLE_POLL_MS = 25;
|
|
10
|
+
// Two consecutive idle passes (not one) before declaring the stack drained —
|
|
11
|
+
// a single "pending===0 and consumers caught up" pass can be a race: a job
|
|
12
|
+
// hook fires and enqueues a follow-up between the countPendingJobs() read
|
|
13
|
+
// and the consumer check of the SAME pass. A DRAIN_IDLE_POLL_MS wait sits
|
|
14
|
+
// between every pass, including the first and second idle one, so an
|
|
15
|
+
// enqueue that lands just after the first idle read still gets picked up
|
|
16
|
+
// by the second pass instead of racing straight past it.
|
|
17
|
+
const REQUIRED_IDLE_PASSES = 2;
|
|
18
|
+
|
|
19
|
+
function sleep(ms: number): Promise<void> {
|
|
20
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type JobFailureTracker = {
|
|
24
|
+
readonly onJobComplete: (jobName: string, jobId: string) => void;
|
|
25
|
+
readonly onJobFailed: (jobName: string, jobId: string, error: string) => void;
|
|
26
|
+
waitForActivity(): Promise<void>;
|
|
27
|
+
readonly failures: ReadonlyMap<string, { readonly jobName: string; readonly error: string }>;
|
|
28
|
+
clear(): void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function createJobFailureTracker(): JobFailureTracker {
|
|
32
|
+
const failures = new Map<string, { jobName: string; error: string }>();
|
|
33
|
+
let wake: (() => void) | undefined;
|
|
34
|
+
let armed: Promise<void> | undefined;
|
|
35
|
+
|
|
36
|
+
function fire(): void {
|
|
37
|
+
wake?.();
|
|
38
|
+
armed = undefined;
|
|
39
|
+
wake = undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
onJobComplete(_jobName, jobId) {
|
|
44
|
+
// A retry that eventually succeeds must not leave a stale failure
|
|
45
|
+
// behind from its earlier attempt(s).
|
|
46
|
+
failures.delete(jobId);
|
|
47
|
+
fire();
|
|
48
|
+
},
|
|
49
|
+
onJobFailed(jobName, jobId, error) {
|
|
50
|
+
failures.set(jobId, { jobName, error });
|
|
51
|
+
fire();
|
|
52
|
+
},
|
|
53
|
+
waitForActivity() {
|
|
54
|
+
armed ??= new Promise<void>((resolve) => {
|
|
55
|
+
wake = resolve;
|
|
56
|
+
});
|
|
57
|
+
return armed;
|
|
58
|
+
},
|
|
59
|
+
failures,
|
|
60
|
+
clear() {
|
|
61
|
+
failures.clear();
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type DrainJobsStack = {
|
|
67
|
+
readonly db: DbConnection;
|
|
68
|
+
readonly eventDispatcher?: EventDispatcher;
|
|
69
|
+
readonly jobRunner?: JobRunner;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
async function sharedConsumersCaughtUp(
|
|
73
|
+
db: DbConnection,
|
|
74
|
+
consumerNames: readonly string[],
|
|
75
|
+
): Promise<boolean> {
|
|
76
|
+
// skip: no shared consumers wired — nothing to catch up on.
|
|
77
|
+
if (consumerNames.length === 0) return true;
|
|
78
|
+
const target = await getEventsHighWaterMark(db);
|
|
79
|
+
let allCaughtUp = true;
|
|
80
|
+
for (const name of consumerNames) {
|
|
81
|
+
const state = await getConsumerState(db, name);
|
|
82
|
+
if (!state) {
|
|
83
|
+
throw new Error(`drainJobs: consumer "${name}" not registered or per-instance`);
|
|
84
|
+
}
|
|
85
|
+
if (state.status === "dead") {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`drainJobs: consumer "${state.name}" is dead — lastError: ${state.lastError ?? "unknown"}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (state.lastProcessedEventId < target) allCaughtUp = false;
|
|
91
|
+
}
|
|
92
|
+
return allCaughtUp;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function drainJobs(stack: DrainJobsStack, tracker: JobFailureTracker): Promise<void> {
|
|
96
|
+
const { db, eventDispatcher, jobRunner } = stack;
|
|
97
|
+
if (!jobRunner) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
"drainJobs: stack.jobRunner is undefined — pass `jobs: {...}` to setupTestStack and " +
|
|
100
|
+
"register at least one r.job(...) so a runner is built.",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const sharedConsumerNames = (eventDispatcher?.consumers ?? [])
|
|
105
|
+
.filter((consumer) => (consumer.delivery ?? "shared") === "shared")
|
|
106
|
+
.map((consumer) => consumer.name);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
let consecutiveIdlePasses = 0;
|
|
110
|
+
while (consecutiveIdlePasses < REQUIRED_IDLE_PASSES) {
|
|
111
|
+
await eventDispatcher?.runOnce();
|
|
112
|
+
const pending = await jobRunner.countPendingJobs();
|
|
113
|
+
const caughtUp = await sharedConsumersCaughtUp(db, sharedConsumerNames);
|
|
114
|
+
consecutiveIdlePasses = pending === 0 && caughtUp ? consecutiveIdlePasses + 1 : 0;
|
|
115
|
+
if (consecutiveIdlePasses < REQUIRED_IDLE_PASSES) {
|
|
116
|
+
await Promise.race([tracker.waitForActivity(), sleep(DRAIN_IDLE_POLL_MS)]);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (tracker.failures.size > 0) {
|
|
121
|
+
const details = [...tracker.failures.values()]
|
|
122
|
+
.map((failure) => `${failure.jobName}: ${failure.error}`)
|
|
123
|
+
.join("; ");
|
|
124
|
+
throw new Error(`drainJobs: job(s) failed: ${details}`);
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
tracker.clear();
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { createRateLimitResolver } from "../rate-limit";
|
|
|
22
22
|
import { createInMemorySearchAdapter } from "../search";
|
|
23
23
|
import type { SearchAdapter } from "../search/types";
|
|
24
24
|
import { createTestDb } from "./db";
|
|
25
|
+
import { createJobFailureTracker, drainJobs } from "./drain-jobs";
|
|
25
26
|
import { createEventCollector, type EventCollector } from "./event-collector";
|
|
26
27
|
import { createTestRedis, type TestRedis } from "./redis";
|
|
27
28
|
import { createRequestHelper, type RequestHelper } from "./request-helper";
|
|
@@ -64,6 +65,16 @@ export type TestStack = {
|
|
|
64
65
|
// registered in the mounted features. Lets integration tests call
|
|
65
66
|
// `stack.jobRunner.dispatch(...)` directly, mirroring `ctx.jobRunner`.
|
|
66
67
|
jobRunner?: JobRunner;
|
|
68
|
+
// Waits for every in-flight job and its follow-up event-consumer /
|
|
69
|
+
// job-trigger cascade to settle — instead of `waitFor` polling on a
|
|
70
|
+
// specific job's side effect. Rejects with the failing job's name and
|
|
71
|
+
// error once a job exhausts its retries, or with a clear error when this
|
|
72
|
+
// stack has no jobRunner at all. Tracked failures accumulate since the
|
|
73
|
+
// PREVIOUS drainJobs() call (or setup, if none yet) — on a shared stack
|
|
74
|
+
// (e.g. beforeAll), a test that deliberately fails a job and never calls
|
|
75
|
+
// drainJobs() itself leaves that failure for the next test's drainJobs()
|
|
76
|
+
// call to reject on.
|
|
77
|
+
drainJobs: () => Promise<void>;
|
|
67
78
|
cleanup: () => Promise<void>;
|
|
68
79
|
};
|
|
69
80
|
|
|
@@ -361,6 +372,11 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
361
372
|
// context = appContext + tracer/meter, mirroring the prod entrypoint's
|
|
362
373
|
// `contextWithObservability(options.context, observability)`.
|
|
363
374
|
let jobRunner: JobRunner | undefined;
|
|
375
|
+
// Fed to createJobRunner's onJobComplete/onJobFailed below so drainJobs()
|
|
376
|
+
// can tell a job's final outcome apart from an attempt that will retry —
|
|
377
|
+
// built before createJobRunner regardless of whether a runner ends up
|
|
378
|
+
// existing, so the returned drainJobs() closure always has one to read.
|
|
379
|
+
const jobFailureTracker = createJobFailureTracker();
|
|
364
380
|
if (options.jobs && registry.getAllJobs().size > 0) {
|
|
365
381
|
jobRunner = createJobRunner({
|
|
366
382
|
registry,
|
|
@@ -374,6 +390,8 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
374
390
|
...(options.jobs.getActiveTenantIds !== undefined && {
|
|
375
391
|
getActiveTenantIds: options.jobs.getActiveTenantIds,
|
|
376
392
|
}),
|
|
393
|
+
onJobComplete: (jobName, jobId) => jobFailureTracker.onJobComplete(jobName, jobId),
|
|
394
|
+
onJobFailed: (jobName, jobId, error) => jobFailureTracker.onJobFailed(jobName, jobId, error),
|
|
377
395
|
});
|
|
378
396
|
}
|
|
379
397
|
|
|
@@ -540,6 +558,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
540
558
|
...(eventDispatcher ? { eventDispatcher } : {}),
|
|
541
559
|
...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
|
|
542
560
|
...(jobRunner ? { jobRunner } : {}),
|
|
561
|
+
drainJobs: () => drainJobs({ db: testDb.db, eventDispatcher, jobRunner }, jobFailureTracker),
|
|
543
562
|
cleanup: async () => {
|
|
544
563
|
if (jobRunner) await jobRunner.stop();
|
|
545
564
|
if (eventDispatcher) await eventDispatcher.stop();
|