@cosmicdrift/kumiko-bundled-features 0.209.0 → 0.210.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 +8 -8
- package/src/auth-mfa/schema/user-mfa.ts +8 -5
- package/src/auth-mfa-user-data/hooks.ts +9 -8
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +2 -2
- package/src/billing-foundation/entities.ts +5 -3
- package/src/billing-foundation/tenant-destroy-hook.ts +1 -1
- package/src/compliance-profiles/schema/profile-selection.ts +2 -1
- package/src/config/table.ts +4 -1
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +4 -4
- package/src/custom-fields/feature.ts +2 -2
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +12 -4
- package/src/data-retention/schema/tenant-retention-override.ts +6 -3
- package/src/delivery/tables.ts +1 -1
- package/src/document-ingest-foundation/entity.ts +6 -3
- package/src/files/README.md +11 -11
- package/src/form-draft/entity.ts +6 -3
- package/src/inbound-mail-foundation/entities.ts +13 -11
- package/src/inbound-mail-foundation/feature.ts +5 -5
- package/src/jobs/__tests__/jobs-events.integration.test.ts +65 -81
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +21 -18
- package/src/jobs/__tests__/jobs-retention.integration.test.ts +124 -0
- package/src/jobs/db/queries/retention.ts +44 -0
- package/src/jobs/events.ts +5 -8
- package/src/jobs/feature.ts +26 -146
- package/src/jobs/handlers/retention-cleanup.job.ts +21 -0
- package/src/jobs/index.ts +1 -1
- package/src/jobs/job-run-logger.ts +112 -59
- package/src/jobs/job-run-table.ts +36 -28
- package/src/ledger/entity.ts +5 -3
- package/src/managed-pages/screens/branding-screen.ts +2 -1
- package/src/managed-pages/table.ts +3 -2
- package/src/notes-history/entity.ts +8 -4
- package/src/notes-history-user-data/hooks.ts +6 -5
- package/src/personal-access-tokens/schema/api-token.ts +2 -1
- package/src/sessions/feature.ts +0 -4
- package/src/sessions/schema/user-session.ts +4 -2
- package/src/tags/entity.ts +11 -5
- package/src/template-resolver/__tests__/collection-ownership.integration.test.ts +1 -1
- package/src/template-resolver/table.ts +4 -1
- package/src/template-resolver/user-content-table.ts +5 -2
- package/src/template-resolver-user-data/hooks.ts +6 -5
- package/src/tenant/invitation-table.ts +3 -3
- package/src/tenant-lifecycle/boot-checks.ts +2 -2
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +58 -0
- package/src/tenant-settings/feature.ts +12 -0
- package/src/user/schema/user.ts +5 -5
- package/src/user-data-rights/__tests__/boot-checks.test.ts +73 -9
- package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +2 -2
- package/src/user-data-rights/boot-checks.ts +2 -2
- package/src/user-data-rights/feature.ts +4 -4
- package/src/user-data-rights/schema/download-token.ts +12 -10
- package/src/user-data-rights/schema/export-job.ts +5 -4
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +1 -1
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// (
|
|
5
|
-
//
|
|
1
|
+
// Direct-write shape contract for job runs (#2243). Pins onJobStart/
|
|
2
|
+
// -Complete/-Failed against the tables they write — jobRunsTable status
|
|
3
|
+
// transitions and jobRunLogsTable batched log rows. A silent shape drift
|
|
4
|
+
// (missing field, wrong status string) fails here instead of breaking the
|
|
5
|
+
// operator UI silently.
|
|
6
6
|
//
|
|
7
7
|
// The jobs integration test (jobs-feature.integration.ts) covers the
|
|
8
|
-
// projection side (list + detail queries). This file covers the
|
|
9
|
-
// side — complementary coverage, minimal overlap.
|
|
8
|
+
// projection side (list + detail queries). This file covers the
|
|
9
|
+
// write side — complementary coverage, minimal overlap.
|
|
10
10
|
|
|
11
11
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
12
12
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
13
|
-
import { createRegistry
|
|
13
|
+
import { createRegistry } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
14
|
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
15
15
|
import {
|
|
16
16
|
createTestDb,
|
|
@@ -20,14 +20,8 @@ import {
|
|
|
20
20
|
unsafePushTables,
|
|
21
21
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
22
22
|
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
23
|
-
import { runCompletedSchema, runFailedSchema, runStartedSchema } from "../events";
|
|
24
23
|
import { createJobsFeature } from "../feature";
|
|
25
|
-
import {
|
|
26
|
-
createJobRunLogger,
|
|
27
|
-
JOB_RUN_COMPLETED_EVENT,
|
|
28
|
-
JOB_RUN_FAILED_EVENT,
|
|
29
|
-
JOB_RUN_STARTED_EVENT,
|
|
30
|
-
} from "../job-run-logger";
|
|
24
|
+
import { createJobRunLogger } from "../job-run-logger";
|
|
31
25
|
import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
32
26
|
|
|
33
27
|
let testDb: TestDb;
|
|
@@ -40,6 +34,8 @@ beforeAll(async () => {
|
|
|
40
34
|
testRedis = await createTestRedis();
|
|
41
35
|
registry = createRegistry([createJobsFeature()]);
|
|
42
36
|
await unsafePushTables(testDb.db, { jobRunsTable, jobRunLogsTable });
|
|
37
|
+
// Kept only for the negative assertion below (no jobs:event:run-* rows) —
|
|
38
|
+
// the write path itself no longer touches the event store.
|
|
43
39
|
await createEventsTable(testDb.db);
|
|
44
40
|
logger = createJobRunLogger({ db: testDb.db, registry });
|
|
45
41
|
});
|
|
@@ -53,114 +49,102 @@ beforeEach(async () => {
|
|
|
53
49
|
await resetTestTables(testDb.db, [eventsTable, jobRunsTable, jobRunLogsTable]);
|
|
54
50
|
});
|
|
55
51
|
|
|
56
|
-
describe("jobRun
|
|
57
|
-
test("
|
|
58
|
-
// Guard against silent rename. Tests that subscribe via string-match
|
|
59
|
-
// (MSPs written in userland, audit export tools) break without this.
|
|
60
|
-
expect(JOB_RUN_STARTED_EVENT).toBe("jobs:event:run-started");
|
|
61
|
-
expect(JOB_RUN_COMPLETED_EVENT).toBe("jobs:event:run-completed");
|
|
62
|
-
expect(JOB_RUN_FAILED_EVENT).toBe("jobs:event:run-failed");
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test("onJobStart writes a run-started event on the jobRun aggregate", async () => {
|
|
52
|
+
describe("jobRun direct writes", () => {
|
|
53
|
+
test("onJobStart inserts a row into jobRunsTable", async () => {
|
|
66
54
|
await logger.onJobStart?.("example:job:import", "bull-42", {
|
|
67
55
|
triggeredById: "u-99",
|
|
68
56
|
payload: JSON.stringify({ foo: 1 }),
|
|
69
57
|
attempt: 1,
|
|
70
58
|
});
|
|
71
59
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
expect(
|
|
75
|
-
const
|
|
76
|
-
expect(
|
|
77
|
-
expect(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
expect(() => runStartedSchema.parse(e?.payload)).not.toThrow();
|
|
81
|
-
const p = runStartedSchema.parse(e?.payload);
|
|
82
|
-
expect(p.jobName).toBe("example:job:import");
|
|
83
|
-
expect(p.bullJobId).toBe("bull-42");
|
|
84
|
-
expect(p.triggeredById).toBe("u-99");
|
|
85
|
-
expect(p.attempt).toBe(1);
|
|
60
|
+
const runs = await selectMany(testDb.db, jobRunsTable, { bullJobId: "bull-42" });
|
|
61
|
+
|
|
62
|
+
expect(runs.length).toBe(1);
|
|
63
|
+
const run = runs[0];
|
|
64
|
+
expect(run?.jobName).toBe("example:job:import");
|
|
65
|
+
expect(run?.status).toBe("running");
|
|
66
|
+
expect(run?.triggeredById).toBe("u-99");
|
|
67
|
+
expect(run?.attempt).toBe(1);
|
|
86
68
|
});
|
|
87
69
|
|
|
88
|
-
test("onJobComplete
|
|
70
|
+
test("onJobComplete updates the row to completed with batched logs", async () => {
|
|
89
71
|
await logger.onJobStart?.("example:job:export", "bull-1", {});
|
|
90
72
|
await logger.onJobComplete?.("example:job:export", "bull-1", 123, [
|
|
91
73
|
{ level: "info", message: "started", timestamp: Temporal.Now.instant() },
|
|
92
74
|
{ level: "info", message: "done", timestamp: Temporal.Now.instant() },
|
|
93
75
|
]);
|
|
94
76
|
|
|
95
|
-
const
|
|
77
|
+
const runs = await selectMany(testDb.db, jobRunsTable, { bullJobId: "bull-1" });
|
|
78
|
+
expect(runs.length).toBe(1);
|
|
79
|
+
expect(runs[0]?.status).toBe("completed");
|
|
80
|
+
expect(runs[0]?.duration).toBe(123);
|
|
96
81
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
expect(
|
|
100
|
-
expect(p.logs).toHaveLength(2);
|
|
101
|
-
expect(p.logs[0]?.level).toBe("info");
|
|
82
|
+
const logs = await selectMany(testDb.db, jobRunLogsTable, { runId: runs[0]?.id as string });
|
|
83
|
+
expect(logs).toHaveLength(2);
|
|
84
|
+
expect(logs[0]?.level).toBe("info");
|
|
102
85
|
});
|
|
103
86
|
|
|
104
|
-
test("onJobFailed
|
|
87
|
+
test("onJobFailed updates the row to failed with error + logs", async () => {
|
|
105
88
|
await logger.onJobStart?.("example:job:fragile", "bull-9", {});
|
|
106
89
|
await logger.onJobFailed?.("example:job:fragile", "bull-9", "boom", [
|
|
107
90
|
{ level: "error", message: "kaboom", timestamp: Temporal.Now.instant() },
|
|
108
91
|
]);
|
|
109
92
|
|
|
110
|
-
const
|
|
93
|
+
const runs = await selectMany(testDb.db, jobRunsTable, { bullJobId: "bull-9" });
|
|
94
|
+
expect(runs.length).toBe(1);
|
|
95
|
+
expect(runs[0]?.status).toBe("failed");
|
|
96
|
+
expect(runs[0]?.error).toBe("boom");
|
|
111
97
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
expect(p.error).toBe("boom");
|
|
115
|
-
expect(p.logs).toHaveLength(1);
|
|
98
|
+
const logs = await selectMany(testDb.db, jobRunLogsTable, { runId: runs[0]?.id as string });
|
|
99
|
+
expect(logs).toHaveLength(1);
|
|
116
100
|
});
|
|
117
101
|
|
|
118
|
-
test("start + complete both
|
|
102
|
+
test("start + complete both act on the SAME row", async () => {
|
|
119
103
|
await logger.onJobStart?.("example:job:stream", "bull-99", {});
|
|
120
104
|
await logger.onJobComplete?.("example:job:stream", "bull-99", 10, []);
|
|
121
105
|
|
|
122
|
-
//
|
|
123
|
-
// the
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
expect(events.length).toBe(2);
|
|
128
|
-
const ids = new Set(events.map((e) => e.aggregateId));
|
|
129
|
-
expect(ids.size).toBe(1);
|
|
106
|
+
// Exactly one row for this bullJobId — the complete-callback updated
|
|
107
|
+
// the row onJobStart created, it did not insert a second one.
|
|
108
|
+
const runs = await selectMany(testDb.db, jobRunsTable, { bullJobId: "bull-99" });
|
|
109
|
+
expect(runs.length).toBe(1);
|
|
110
|
+
expect(runs[0]?.status).toBe("completed");
|
|
130
111
|
});
|
|
131
112
|
|
|
132
|
-
test("complete/fail without a prior start skips — does not forge a
|
|
133
|
-
// State-loss path: worker restart with empty cache AND no
|
|
134
|
-
//
|
|
135
|
-
//
|
|
113
|
+
test("complete/fail without a prior start skips — does not forge a run row", async () => {
|
|
114
|
+
// State-loss path: worker restart with empty cache AND no row for this
|
|
115
|
+
// bullJobId. Dropping the terminal write is intentional — forging a
|
|
116
|
+
// row from scratch would invent a run that never started.
|
|
136
117
|
await logger.onJobComplete?.("example:job:orphan", "bull-orphan-complete", 50, []);
|
|
137
118
|
await logger.onJobFailed?.("example:job:orphan", "bull-orphan-failed", "boom", []);
|
|
138
119
|
|
|
139
|
-
const completed = await selectMany(testDb.db, eventsTable, { type: JOB_RUN_COMPLETED_EVENT });
|
|
140
|
-
const failed = await selectMany(testDb.db, eventsTable, { type: JOB_RUN_FAILED_EVENT });
|
|
141
120
|
const runs = await selectMany(testDb.db, jobRunsTable);
|
|
142
|
-
|
|
143
|
-
expect(completed).toHaveLength(0);
|
|
144
|
-
expect(failed).toHaveLength(0);
|
|
145
121
|
expect(runs).toHaveLength(0);
|
|
146
122
|
});
|
|
147
123
|
|
|
148
|
-
test("complete after cache loss recovers runId from
|
|
149
|
-
// Simulates worker process restart: in-memory bullJobId→runId cache is
|
|
150
|
-
// but the run-started
|
|
151
|
-
// must DB-lookup and
|
|
124
|
+
test("complete after cache loss recovers runId from jobRunsTable (same row)", async () => {
|
|
125
|
+
// Simulates worker process restart: in-memory bullJobId→runId cache is
|
|
126
|
+
// gone, but the run-started row still has bull_job_id. A fresh logger
|
|
127
|
+
// must DB-lookup and update the original row — not insert a second one.
|
|
152
128
|
await logger.onJobStart?.("example:job:restart", "bull-restart-1", {});
|
|
153
|
-
const started = await selectMany(testDb.db,
|
|
129
|
+
const started = await selectMany(testDb.db, jobRunsTable, { bullJobId: "bull-restart-1" });
|
|
154
130
|
expect(started).toHaveLength(1);
|
|
155
|
-
const
|
|
156
|
-
expect(
|
|
131
|
+
const originalId = started[0]?.id;
|
|
132
|
+
expect(originalId).toBeTruthy();
|
|
157
133
|
|
|
158
134
|
const coldLogger = createJobRunLogger({ db: testDb.db, registry });
|
|
159
135
|
await coldLogger.onJobComplete?.("example:job:restart", "bull-restart-1", 42, []);
|
|
160
136
|
|
|
161
|
-
const all = await selectMany(testDb.db,
|
|
162
|
-
expect(all).toHaveLength(
|
|
163
|
-
expect(
|
|
164
|
-
expect(all
|
|
137
|
+
const all = await selectMany(testDb.db, jobRunsTable, { bullJobId: "bull-restart-1" });
|
|
138
|
+
expect(all).toHaveLength(1);
|
|
139
|
+
expect(all[0]?.id).toBe(originalId);
|
|
140
|
+
expect(all[0]?.status).toBe("completed");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("no jobs:event:run-* rows ever land in the event store", async () => {
|
|
144
|
+
await logger.onJobStart?.("example:job:no-events", "bull-no-events", {});
|
|
145
|
+
await logger.onJobComplete?.("example:job:no-events", "bull-no-events", 5, []);
|
|
146
|
+
|
|
147
|
+
const events = await selectMany(testDb.db, eventsTable, { aggregateType: "jobRun" });
|
|
148
|
+
expect(events).toHaveLength(0);
|
|
165
149
|
});
|
|
166
150
|
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
//
|
|
2
|
-
// arbitrary user data and is written
|
|
3
|
-
//
|
|
4
|
-
// With a KMS active the stored
|
|
5
|
-
// ciphertext under the triggering user's DEK; erasing that key
|
|
6
|
-
// payload unreadable ([[erased]]) without touching the
|
|
1
|
+
// PII on the jobs run-logger, direct-write path (#2243, formerly #799):
|
|
2
|
+
// run-started payload can carry arbitrary user data and is written straight
|
|
3
|
+
// into jobRunsTable (no event store, no event-PII catalog — #2243 removed
|
|
4
|
+
// the jobRun r.defineEvent registrations). With a KMS active the stored row
|
|
5
|
+
// carries ciphertext under the triggering user's DEK; erasing that key
|
|
6
|
+
// makes the payload unreadable ([[erased]]) without touching the row.
|
|
7
7
|
|
|
8
8
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
9
|
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
26
26
|
import { resetPiiSubjectKmsForTests, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
27
27
|
import { createJobsFeature } from "../feature";
|
|
28
|
-
import { createJobRunLogger
|
|
28
|
+
import { createJobRunLogger } from "../job-run-logger";
|
|
29
29
|
import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
30
30
|
|
|
31
31
|
let testDb: TestDb;
|
|
@@ -39,10 +39,10 @@ const SECRET_PAYLOAD = JSON.stringify({ iban: "DE89370400440532013000" });
|
|
|
39
39
|
beforeAll(async () => {
|
|
40
40
|
testDb = await createTestDb();
|
|
41
41
|
testRedis = await createTestRedis();
|
|
42
|
-
// createRegistry publishes the event-PII catalog as a module singleton —
|
|
43
|
-
// the logger's low-level append() picks it up without further wiring.
|
|
44
42
|
const registry = createRegistry([createJobsFeature()]);
|
|
45
43
|
await unsafePushTables(testDb.db, { jobRunsTable, jobRunLogsTable });
|
|
44
|
+
// Kept only so the "no event store involved" assertion below has a table
|
|
45
|
+
// to assert against — the write path itself never touches it.
|
|
46
46
|
await createEventsTable(testDb.db);
|
|
47
47
|
logger = createJobRunLogger({ db: testDb.db, registry });
|
|
48
48
|
});
|
|
@@ -63,25 +63,28 @@ afterEach(() => {
|
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
describe("jobs run-started payload under KMS", () => {
|
|
66
|
-
test("stored
|
|
66
|
+
test("stored row carries ciphertext payload, plaintext triggeredById — no event-store write", async () => {
|
|
67
67
|
await logger.onJobStart?.("app:job:export", "bull-1", {
|
|
68
68
|
triggeredById: USER_ID,
|
|
69
69
|
payload: SECRET_PAYLOAD,
|
|
70
70
|
attempt: 1,
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
const
|
|
74
|
-
expect(
|
|
75
|
-
|
|
76
|
-
expect(
|
|
77
|
-
expect(String(payload["payload"])).toContain(`user:${USER_ID}`);
|
|
78
|
-
expect(payload["triggeredById"]).toBe(USER_ID);
|
|
73
|
+
const row = await fetchOne(testDb.db, jobRunsTable, { bullJobId: "bull-1" });
|
|
74
|
+
expect(isPiiCiphertext(row?.["payload"])).toBe(true);
|
|
75
|
+
expect(String(row?.["payload"])).toContain(`user:${USER_ID}`);
|
|
76
|
+
expect(row?.["triggeredById"]).toBe(USER_ID);
|
|
79
77
|
|
|
80
|
-
const back = await decryptPiiFieldValues(payload, ["payload"], kms, {
|
|
78
|
+
const back = await decryptPiiFieldValues({ payload: row?.["payload"] }, ["payload"], kms, {
|
|
79
|
+
requestId: "t",
|
|
80
|
+
});
|
|
81
81
|
expect(back["payload"]).toBe(SECRET_PAYLOAD);
|
|
82
|
+
|
|
83
|
+
const events = await selectMany(testDb.db, eventsTable, { aggregateType: "jobRun" });
|
|
84
|
+
expect(events).toHaveLength(0);
|
|
82
85
|
});
|
|
83
86
|
|
|
84
|
-
test("
|
|
87
|
+
test("erase subject key → row payload decrypts to [[erased]]", async () => {
|
|
85
88
|
await logger.onJobStart?.("app:job:export", "bull-2", {
|
|
86
89
|
triggeredById: USER_ID,
|
|
87
90
|
payload: SECRET_PAYLOAD,
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Integration test for the job-run retention-cleanup job (#2243). Dispatches
|
|
2
|
+
// jobs:job:retention-cleanup through the real jobRunner (setupTestStack +
|
|
3
|
+
// BullMQ worker) — the same enqueue path production's cron trigger uses —
|
|
4
|
+
// instead of hand-building a JobContext and casting into the handler. Each
|
|
5
|
+
// test seeds a row past the active cutoff alongside one that must survive,
|
|
6
|
+
// so waitFor's completion signal (the stale row's count dropping) is only
|
|
7
|
+
// reached once the real dispatch has actually run; the survivor's fate is
|
|
8
|
+
// decided by that same DELETE and can be asserted right after.
|
|
9
|
+
|
|
10
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
12
|
+
import { sql } from "@cosmicdrift/kumiko-framework/db";
|
|
13
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import {
|
|
15
|
+
setupTestStack,
|
|
16
|
+
type TestStack,
|
|
17
|
+
unsafePushTables,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
19
|
+
import { seedRow, waitFor } from "@cosmicdrift/kumiko-framework/testing";
|
|
20
|
+
import { createJobsFeature } from "../feature";
|
|
21
|
+
import { DEFAULT_JOB_RUN_RETENTION_DAYS } from "../handlers/retention-cleanup.job";
|
|
22
|
+
import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
23
|
+
|
|
24
|
+
const RETENTION_JOB = "jobs:job:retention-cleanup";
|
|
25
|
+
|
|
26
|
+
let stack: TestStack | undefined;
|
|
27
|
+
|
|
28
|
+
async function bootStack(retentionDays?: number): Promise<TestStack> {
|
|
29
|
+
const s = await setupTestStack({
|
|
30
|
+
features: [createJobsFeature(retentionDays !== undefined ? { retentionDays } : {})],
|
|
31
|
+
jobs: { consumerLane: "worker" },
|
|
32
|
+
});
|
|
33
|
+
await unsafePushTables(s.db, { jobRunsTable, jobRunLogsTable });
|
|
34
|
+
return s;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
if (stack) await stack.cleanup();
|
|
39
|
+
stack = undefined;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function currentStack(): TestStack {
|
|
43
|
+
if (!stack) throw new Error("stack not booted");
|
|
44
|
+
return stack;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function seedRun(opts: { id: string; ageDays: number }): Promise<void> {
|
|
48
|
+
const insertedAt = sql`now() - ${sql.raw(`interval '${opts.ageDays} days'`)}`;
|
|
49
|
+
await seedRow(currentStack().db, jobRunsTable, {
|
|
50
|
+
id: opts.id,
|
|
51
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
52
|
+
insertedAt,
|
|
53
|
+
jobName: "example:job:retention-probe",
|
|
54
|
+
bullJobId: `bull-${opts.id}`,
|
|
55
|
+
status: "completed",
|
|
56
|
+
attempt: 1,
|
|
57
|
+
startedAt: insertedAt,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function countRuns(): Promise<number> {
|
|
62
|
+
return (await selectMany(currentStack().db, jobRunsTable)).length;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function countLogs(): Promise<number> {
|
|
66
|
+
return (await selectMany(currentStack().db, jobRunLogsTable)).length;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Dispatches the real job through jobRunner and blocks until the queued
|
|
70
|
+
// worker has actually applied it, proven by the run count reaching
|
|
71
|
+
// `expectedRuns` (not merely "some time has passed").
|
|
72
|
+
async function dispatchAndWaitForRunCount(expectedRuns: number): Promise<void> {
|
|
73
|
+
await currentStack().jobRunner?.dispatch(RETENTION_JOB);
|
|
74
|
+
await waitFor(async () => {
|
|
75
|
+
expect(await countRuns()).toBe(expectedRuns);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("jobs:job:retention-cleanup", () => {
|
|
80
|
+
test("default window (30d): older-than-cutoff runs go, recent ones stay", async () => {
|
|
81
|
+
stack = await bootStack();
|
|
82
|
+
await seedRun({
|
|
83
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
84
|
+
ageDays: DEFAULT_JOB_RUN_RETENTION_DAYS + 1,
|
|
85
|
+
});
|
|
86
|
+
await seedRun({ id: "22222222-2222-4222-8222-222222222222", ageDays: 1 });
|
|
87
|
+
expect(await countRuns()).toBe(2);
|
|
88
|
+
|
|
89
|
+
await dispatchAndWaitForRunCount(1);
|
|
90
|
+
|
|
91
|
+
const remaining = await selectMany(currentStack().db, jobRunsTable);
|
|
92
|
+
expect(remaining[0]?.id).toBe("22222222-2222-4222-8222-222222222222");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("associated log rows are deleted along with their run", async () => {
|
|
96
|
+
stack = await bootStack();
|
|
97
|
+
const runId = "33333333-3333-4333-8333-333333333333";
|
|
98
|
+
const ageDays = DEFAULT_JOB_RUN_RETENTION_DAYS + 5;
|
|
99
|
+
await seedRun({ id: runId, ageDays });
|
|
100
|
+
await seedRow(currentStack().db, jobRunLogsTable, {
|
|
101
|
+
runId,
|
|
102
|
+
level: "info",
|
|
103
|
+
message: "old log line",
|
|
104
|
+
timestamp: sql`now() - ${sql.raw(`interval '${ageDays} days'`)}`,
|
|
105
|
+
});
|
|
106
|
+
expect(await countLogs()).toBe(1);
|
|
107
|
+
|
|
108
|
+
await dispatchAndWaitForRunCount(0);
|
|
109
|
+
|
|
110
|
+
expect(await countLogs()).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a custom retentionDays option is honored: past-cutoff runs go, in-window runs stay", async () => {
|
|
114
|
+
stack = await bootStack(3);
|
|
115
|
+
await seedRun({ id: "44444444-4444-4444-8444-444444444444", ageDays: 5 });
|
|
116
|
+
await seedRun({ id: "55555555-5555-4555-8555-555555555555", ageDays: 1 });
|
|
117
|
+
expect(await countRuns()).toBe(2);
|
|
118
|
+
|
|
119
|
+
await dispatchAndWaitForRunCount(1);
|
|
120
|
+
|
|
121
|
+
const remaining = await selectMany(currentStack().db, jobRunsTable);
|
|
122
|
+
expect(remaining[0]?.id).toBe("55555555-5555-4555-8555-555555555555");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Job-run retention (#2243): store_job_runs/store_job_run_logs are
|
|
2
|
+
// direct-write stores now, not event-sourced projections — nothing else
|
|
3
|
+
// purges them, so without this query they grow forever with the run count
|
|
4
|
+
// instead of the runtime. Not the retention-cleanup executor: that executor
|
|
5
|
+
// is entity/tenant-scoped and jobRun has neither (system-scoped direct-write
|
|
6
|
+
// store). Each table is purged by its own timestamp column independently —
|
|
7
|
+
// run_id isn't a real FK constraint (see job-run-table.ts), so there is no
|
|
8
|
+
// ordering requirement between the two deletes.
|
|
9
|
+
//
|
|
10
|
+
// deleteManyBatched (typed query API, no raw SQL in this file) chunks each
|
|
11
|
+
// delete so a large backlog doesn't hold one lock for the whole sweep.
|
|
12
|
+
|
|
13
|
+
import { deleteManyBatched } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
15
|
+
import { jobRunLogsTable, jobRunsTable } from "../../job-run-table";
|
|
16
|
+
|
|
17
|
+
const RETENTION_DELETE_BATCH_SIZE = 500;
|
|
18
|
+
|
|
19
|
+
export type JobRunRetentionResult = {
|
|
20
|
+
readonly runsDeleted: number;
|
|
21
|
+
readonly logsDeleted: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export async function deleteStaleJobRuns(
|
|
25
|
+
db: DbConnection,
|
|
26
|
+
retentionDays: number,
|
|
27
|
+
): Promise<JobRunRetentionResult> {
|
|
28
|
+
const cutoff = Temporal.Now.instant().subtract({ hours: retentionDays * 24 });
|
|
29
|
+
|
|
30
|
+
const logsResult = await deleteManyBatched(
|
|
31
|
+
db,
|
|
32
|
+
jobRunLogsTable,
|
|
33
|
+
{ timestamp: { lt: cutoff } },
|
|
34
|
+
{ limit: RETENTION_DELETE_BATCH_SIZE },
|
|
35
|
+
);
|
|
36
|
+
const runsResult = await deleteManyBatched(
|
|
37
|
+
db,
|
|
38
|
+
jobRunsTable,
|
|
39
|
+
{ insertedAt: { lt: cutoff } },
|
|
40
|
+
{ limit: RETENTION_DELETE_BATCH_SIZE },
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return { runsDeleted: runsResult.deleted, logsDeleted: logsResult.deleted };
|
|
44
|
+
}
|
package/src/jobs/events.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// Keeping them in a separate module avoids the circular import between
|
|
8
|
-
// jobs-feature.ts (imports the logger) and job-run-logger.ts.
|
|
1
|
+
// Payload schemas for the jobRun direct-write path (#2243). Pre-#2243 these
|
|
2
|
+
// backed jobRun's event-store payloads (r.defineEvent + inline projections);
|
|
3
|
+
// now job-run-logger.ts parses against them before writing straight into
|
|
4
|
+
// jobRunsTable/jobRunLogsTable, so an out-of-dispatcher write still gets the
|
|
5
|
+
// same validation guarantee ctx.appendEvent used to give it.
|
|
9
6
|
|
|
10
7
|
import { z } from "zod";
|
|
11
8
|
|