@cosmicdrift/kumiko-framework 0.403.0 → 0.404.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/agents/api/framework-engine.md +3 -0
- package/agents/api/renderer-web.md +1 -0
- package/agents/api/types.md +23 -0
- package/agents/examples/lane-routing.md +40 -1
- package/dist/api/redis-sse-broker.d.ts +1 -0
- package/dist/api/redis-sse-broker.js +5 -3
- package/dist/engine/feature-config-events-jobs.js +11 -7
- package/dist/engine/soft-delete-cleanup.js +2 -2
- package/dist/entrypoint/index.d.ts +5 -2
- package/dist/entrypoint/index.js +15 -6
- package/dist/entrypoint/internals.d.ts +2 -0
- package/dist/files/feature.js +1 -1
- package/dist/jobs/cron-repeat-options.d.ts +9 -0
- package/dist/jobs/cron-repeat-options.js +9 -0
- package/dist/jobs/index.d.ts +1 -0
- package/dist/jobs/index.js +1 -0
- package/dist/jobs/job-runner-context.js +4 -1
- package/dist/jobs/job-runner-types.d.ts +5 -0
- package/dist/jobs/job-runner.js +14 -5
- package/dist/jobs/maintenance-window.d.ts +7 -0
- package/dist/jobs/maintenance-window.js +14 -0
- package/dist/redis/cache-sync-bus.d.ts +1 -0
- package/dist/redis/cache-sync-bus.js +15 -6
- package/dist/redis/client.d.ts +12 -0
- package/dist/redis/client.js +30 -5
- package/dist/redis/index.d.ts +2 -2
- package/dist/redis/index.js +1 -1
- package/package.json +3 -3
- package/src/changes.json +12 -0
|
@@ -338,6 +338,7 @@ type CreateEventSourceLiveEventsOptions = {
|
|
|
338
338
|
url?: string; // URL of the SSE endpoint.
|
|
339
339
|
onSessionEnded?: () => void; // Fires once per connection when the session ended: the server refused the SSE handshake, or opened the stream with a `session-ended` frame (stale auth cookie downgraded to anonymous).
|
|
340
340
|
timers?: LiveEventTimers; // Reconnect-timer seam; defaults to the global setTimeout/clearTimeout.
|
|
341
|
+
stallTimers?: LiveEventTimers; // Stall-watchdog timer seam; defaults to the global setTimeout/clearTimeout.
|
|
341
342
|
random?: () => number; // Jitter source in [0, 1); defaults to Math.random.
|
|
342
343
|
now?: () => number; // Clock for synthetic resync events; defaults to Date.now.
|
|
343
344
|
};
|
package/agents/api/types.md
CHANGED
|
@@ -1247,6 +1247,7 @@ type JobQueuedMeta = {
|
|
|
1247
1247
|
type JobRunner = {
|
|
1248
1248
|
start(): Promise<void>;
|
|
1249
1249
|
stop(): Promise<void>;
|
|
1250
|
+
stopConsuming?(): Promise<void>;
|
|
1250
1251
|
dispatch(jobName: string, payload?: Record<string, unknown>, meta?: JobMeta): Promise<string>;
|
|
1251
1252
|
handleEvent(eventName: string, payload: Record<string, unknown>, user?: SessionUser, triggerEvent?: JobTriggerEvent): Promise<void>;
|
|
1252
1253
|
handleCatchUpEvent(jobName: string, eventName: string, payload: Record<string, unknown>, user: SessionUser, triggerEvent: JobTriggerEvent): Promise<void>;
|
|
@@ -1259,6 +1260,8 @@ type JobRunnerOptions = {
|
|
|
1259
1260
|
redisUrl: string;
|
|
1260
1261
|
consumerLane?: JobRunIn | undefined;
|
|
1261
1262
|
queueNamePrefix?: string | undefined;
|
|
1263
|
+
redisNamespace?: Pick<RedisNamespace, "keyPrefix" | "bullmqPrefix"> | undefined;
|
|
1264
|
+
maintenanceWindow?: MaintenanceWindow | undefined;
|
|
1262
1265
|
bootRedisTimeoutMs?: number | undefined;
|
|
1263
1266
|
schedulerSeenRefreshMs?: number | undefined;
|
|
1264
1267
|
jobRetention?: { completedAgeSec?: number | undefined; failedAgeSec?: number | undefined; } | undefined;
|
|
@@ -1272,6 +1275,15 @@ type JobRunnerOptions = {
|
|
|
1272
1275
|
};
|
|
1273
1276
|
```
|
|
1274
1277
|
|
|
1278
|
+
## packages/framework/src/jobs/maintenance-window.ts
|
|
1279
|
+
|
|
1280
|
+
```ts
|
|
1281
|
+
type MaintenanceWindow = {
|
|
1282
|
+
hour: number;
|
|
1283
|
+
minute: number;
|
|
1284
|
+
};
|
|
1285
|
+
```
|
|
1286
|
+
|
|
1275
1287
|
## packages/framework/src/lifecycle/lifecycle.ts
|
|
1276
1288
|
|
|
1277
1289
|
```ts
|
|
@@ -1436,6 +1448,17 @@ type SystemHooks = {
|
|
|
1436
1448
|
};
|
|
1437
1449
|
```
|
|
1438
1450
|
|
|
1451
|
+
## packages/framework/src/redis/client.ts
|
|
1452
|
+
|
|
1453
|
+
```ts
|
|
1454
|
+
type RedisNamespace = {
|
|
1455
|
+
keyPrefix: string; // ioredis keyPrefix for every non-BullMQ key; "" when unnamespaced.
|
|
1456
|
+
bullmqPrefix: string; // BullMQ `prefix` option ("bull" is BullMQ's own default).
|
|
1457
|
+
channelPrefix: string; // Prepended to every Pub/Sub channel (ioredis keyPrefix does not apply to Pub/Sub).
|
|
1458
|
+
channelSubscribePattern: string | undefined; // Set only with KUMIKO_REDIS_PREFIX.
|
|
1459
|
+
};
|
|
1460
|
+
```
|
|
1461
|
+
|
|
1439
1462
|
## packages/framework/src/secrets/dek-cache.ts
|
|
1440
1463
|
|
|
1441
1464
|
```ts
|
|
@@ -13,6 +13,7 @@ Pin jobs to deploy lanes (`api` | `worker`) with event-triggered fan-out.
|
|
|
13
13
|
## What it shows
|
|
14
14
|
|
|
15
15
|
- `r.job({ runIn: "worker" })` after an HTTP write
|
|
16
|
+
- `trigger: { maintenance: true }` for a daily housekeeping job in the app's maintenance window (`KUMIKO_MAINTENANCE_WINDOW`)
|
|
16
17
|
- End-to-end verification via `createAllInOneEntrypoint`
|
|
17
18
|
|
|
18
19
|
## Source
|
|
@@ -65,7 +66,12 @@ import {
|
|
|
65
66
|
type TestDb,
|
|
66
67
|
type TestRedis,
|
|
67
68
|
} from "@cosmicdrift/kumiko-testing/stack";
|
|
68
|
-
import {
|
|
69
|
+
import {
|
|
70
|
+
createLaneRoutingFeature,
|
|
71
|
+
pruneRuns,
|
|
72
|
+
renderedReceipts,
|
|
73
|
+
sentConfirmations,
|
|
74
|
+
} from "../feature";
|
|
69
75
|
|
|
70
76
|
const JWT = "lane-routing-sample-secret-minimum-32-chars!";
|
|
71
77
|
const adminUser = TestUsers.admin;
|
|
@@ -87,6 +93,7 @@ afterAll(async () => {
|
|
|
87
93
|
afterEach(() => {
|
|
88
94
|
renderedReceipts.length = 0;
|
|
89
95
|
sentConfirmations.length = 0;
|
|
96
|
+
pruneRuns.length = 0;
|
|
90
97
|
});
|
|
91
98
|
|
|
92
99
|
describe("lane-routing sample", () => {
|
|
@@ -132,6 +139,30 @@ describe("lane-routing sample", () => {
|
|
|
132
139
|
await entry.stop();
|
|
133
140
|
}
|
|
134
141
|
});
|
|
142
|
+
|
|
143
|
+
test("a maintenance job is registered for the daily window and runs on the worker lane", async () => {
|
|
144
|
+
const registry = createRegistry([createLaneRoutingFeature()]);
|
|
145
|
+
expect(registry.getJob("orders:job:prune-old-receipts")?.trigger).toEqual({
|
|
146
|
+
maintenance: true,
|
|
147
|
+
});
|
|
148
|
+
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
149
|
+
const entry = createAllInOneEntrypoint({
|
|
150
|
+
rowLevelSecurity: "off",
|
|
151
|
+
registry,
|
|
152
|
+
context: { db: testDb.db, redis: testRedis.redis },
|
|
153
|
+
jwtSecret: JWT,
|
|
154
|
+
redisUrl,
|
|
155
|
+
queueNamePrefix: `lane-routing-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
156
|
+
maintenanceWindow: { hour: 5, minute: 30 },
|
|
157
|
+
});
|
|
158
|
+
await entry.start();
|
|
159
|
+
try {
|
|
160
|
+
await entry.jobRunner.dispatch("orders:job:prune-old-receipts", {});
|
|
161
|
+
await waitFor(() => expect(pruneRuns).toHaveLength(1));
|
|
162
|
+
} finally {
|
|
163
|
+
await entry.stop();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
135
166
|
});
|
|
136
167
|
```
|
|
137
168
|
|
|
@@ -163,6 +194,7 @@ import { z } from "zod";
|
|
|
163
194
|
// opaque to the framework.
|
|
164
195
|
export const renderedReceipts: Array<{ customerName: string; amount: number }> = [];
|
|
165
196
|
export const sentConfirmations: Array<{ customerName: string; amount: number }> = [];
|
|
197
|
+
export const pruneRuns: number[] = [];
|
|
166
198
|
|
|
167
199
|
// The entity declaration secures the qualified-name prefix "orders:write:order:create"
|
|
168
200
|
// for the write handler below and thereby the job trigger on this event.
|
|
@@ -240,6 +272,13 @@ export function createLaneRoutingFeature(): FeatureDefinition {
|
|
|
240
272
|
});
|
|
241
273
|
},
|
|
242
274
|
);
|
|
275
|
+
|
|
276
|
+
// Housekeeping: `maintenance` runs once a day in the app's maintenance window
|
|
277
|
+
// (KUMIKO_MAINTENANCE_WINDOW, UTC, default 03:00) instead of a cron time of its own, so a
|
|
278
|
+
// sleeping app wakes once a day for all its cleanup jobs.
|
|
279
|
+
r.job("prune-old-receipts", { trigger: { maintenance: true }, runIn: "worker" }, async () => {
|
|
280
|
+
pruneRuns.push(Date.now());
|
|
281
|
+
});
|
|
243
282
|
});
|
|
244
283
|
}
|
|
245
284
|
```
|
|
@@ -4,6 +4,7 @@ export type RedisSseBrokerOptions = {
|
|
|
4
4
|
readonly redisUrl: string;
|
|
5
5
|
readonly clientOptions?: RedisClientOptions;
|
|
6
6
|
readonly channelPrefix?: string;
|
|
7
|
+
readonly channelSubscribePattern?: string;
|
|
7
8
|
};
|
|
8
9
|
export type RedisSseBroker = SseBroker & {
|
|
9
10
|
readonly kind: "redis";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
2
|
import { createFallbackLogger } from "../logging/utils.js";
|
|
3
|
-
import {
|
|
3
|
+
import { redisClientOptionsFromEnv, redisNamespaceFromEnv, } from "../redis/client.js";
|
|
4
4
|
import { createRedisPubSubSignal } from "../redis/pubsub-signal.js";
|
|
5
5
|
import { createSseBroker, readNonEmptyStringArray, } from "./sse-broker.js";
|
|
6
6
|
const log = createFallbackLogger("kumiko:sse-broker");
|
|
@@ -28,11 +28,13 @@ export function isRedisSseBroker(broker) {
|
|
|
28
28
|
// REDIS_URL-based default never ran.
|
|
29
29
|
export function createDefaultSseBroker(env = process.env) {
|
|
30
30
|
const redisUrl = env["REDIS_URL"];
|
|
31
|
+
const { channelPrefix, channelSubscribePattern } = redisNamespaceFromEnv(env);
|
|
31
32
|
const ownedRedisSseBroker = redisUrl
|
|
32
33
|
? createRedisSseBroker({
|
|
33
34
|
redisUrl,
|
|
34
35
|
clientOptions: redisClientOptionsFromEnv(env),
|
|
35
|
-
channelPrefix
|
|
36
|
+
channelPrefix,
|
|
37
|
+
...(channelSubscribePattern !== undefined && { channelSubscribePattern }),
|
|
36
38
|
})
|
|
37
39
|
: undefined;
|
|
38
40
|
return { sseBroker: ownedRedisSseBroker ?? createSseBroker(), ownedRedisSseBroker };
|
|
@@ -104,7 +106,7 @@ export function createRedisSseBroker(opts) {
|
|
|
104
106
|
const invalidationPrefix = `${opts.channelPrefix ?? ""}${INVALIDATION_PREFIX}`;
|
|
105
107
|
const signal = createRedisPubSubSignal({
|
|
106
108
|
redisUrl: opts.redisUrl,
|
|
107
|
-
channelPattern: `${opts.channelPrefix ?? ""}${PSUBSCRIBE_SUFFIX}`,
|
|
109
|
+
channelPattern: opts.channelSubscribePattern ?? `${opts.channelPrefix ?? ""}${PSUBSCRIBE_SUFFIX}`,
|
|
108
110
|
label: "sse-broker",
|
|
109
111
|
...(opts.clientOptions ? { clientOptions: opts.clientOptions } : {}),
|
|
110
112
|
});
|
|
@@ -6,6 +6,16 @@ import { createEventDef } from "./event-def.js";
|
|
|
6
6
|
import { resolveName } from "./handler-helpers.js";
|
|
7
7
|
import { requireArgument, splitNamedDefinition, unwrapArrayForm } from "./object-form.js";
|
|
8
8
|
import { QnTypes, qn, toKebab } from "./qualified-name.js";
|
|
9
|
+
function assertScheduleTrigger(featureName, jobName, trigger) {
|
|
10
|
+
if ("maintenance" in trigger && trigger.maintenance !== true) {
|
|
11
|
+
throw new Error(`[Feature ${featureName}] r.job("${jobName}") declares trigger { maintenance: ${JSON.stringify(trigger.maintenance)} } — ` +
|
|
12
|
+
"the only valid value is true; the run time comes from KUMIKO_MAINTENANCE_WINDOW.");
|
|
13
|
+
}
|
|
14
|
+
if ("cron" in trigger && trigger.tz !== undefined && !isValidIanaTimeZone(trigger.tz)) {
|
|
15
|
+
throw new Error(`[Feature ${featureName}] r.job("${jobName}") declares trigger { cron, tz: ${JSON.stringify(trigger.tz)} } — ` +
|
|
16
|
+
'tz must be an IANA time zone name such as "Europe/Berlin".');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
9
19
|
// Builds config/secrets/claims/events/jobs/notifications registrar methods.
|
|
10
20
|
export function buildConfigEventsJobsMethods(state, name) {
|
|
11
21
|
function config(arg1, arg2) {
|
|
@@ -90,13 +100,7 @@ export function buildConfigEventsJobsMethods(state, name) {
|
|
|
90
100
|
if (jobGrantsProblem) {
|
|
91
101
|
throw new Error(`[Feature ${name}] r.job("${jobName}") declares an invalid escapeHatch — ${jobGrantsProblem}`);
|
|
92
102
|
}
|
|
93
|
-
|
|
94
|
-
const tz = jobOptions.trigger.tz;
|
|
95
|
-
if (!isValidIanaTimeZone(tz)) {
|
|
96
|
-
throw new Error(`[Feature ${name}] r.job("${jobName}") declares trigger { cron, tz: ${JSON.stringify(tz)} } — ` +
|
|
97
|
-
'tz must be an IANA time zone name such as "Europe/Berlin".');
|
|
98
|
-
}
|
|
99
|
-
}
|
|
103
|
+
assertScheduleTrigger(name, jobName, jobOptions.trigger);
|
|
100
104
|
// Resolve NameOrRef(s) in trigger.on. For the multi-trigger form, arrays
|
|
101
105
|
// become arrays of resolved strings while a single value remains a string;
|
|
102
106
|
// the job runner distinguishes them with Array.isArray.
|
|
@@ -161,7 +161,7 @@ export function buildSoftDeleteCleanupJob() {
|
|
|
161
161
|
return {
|
|
162
162
|
name: SOFT_DELETE_CLEANUP_JOB,
|
|
163
163
|
handler: softDeleteCleanupJob,
|
|
164
|
-
trigger: {
|
|
164
|
+
trigger: { maintenance: true },
|
|
165
165
|
perTenant: true,
|
|
166
166
|
concurrency: "skip",
|
|
167
167
|
runIn: "worker",
|
|
@@ -171,7 +171,7 @@ export function buildSoftDeleteCleanupSystemJob() {
|
|
|
171
171
|
return {
|
|
172
172
|
name: SOFT_DELETE_CLEANUP_SYSTEM_JOB,
|
|
173
173
|
handler: softDeleteCleanupSystemJob,
|
|
174
|
-
trigger: {
|
|
174
|
+
trigger: { maintenance: true },
|
|
175
175
|
concurrency: "skip",
|
|
176
176
|
runIn: "worker",
|
|
177
177
|
escapeHatch: {
|
|
@@ -11,6 +11,9 @@ import type { Dispatcher } from "../pipeline/dispatcher.ts";
|
|
|
11
11
|
import type { EventDispatcher } from "../pipeline/index.ts";
|
|
12
12
|
import type { LifecycleHooks } from "../pipeline/lifecycle-pipeline.ts";
|
|
13
13
|
import type { AllInOneEntrypointOptions, BaseEntrypointOptions, JobsBlock, WorkerEntrypointOptions } from "./internals.ts";
|
|
14
|
+
export type EntrypointStopOptions = {
|
|
15
|
+
readonly drainTimeoutMs?: number;
|
|
16
|
+
};
|
|
14
17
|
export type ApiEntrypointOptions = BaseEntrypointOptions & {
|
|
15
18
|
readonly auth?: AuthRoutesConfig;
|
|
16
19
|
readonly anonymousAccess?: ServerOptions["anonymousAccess"];
|
|
@@ -41,7 +44,7 @@ export type ApiEntrypoint = {
|
|
|
41
44
|
readonly eventDispatcher?: EventDispatcher;
|
|
42
45
|
readonly mode: "api";
|
|
43
46
|
start(): Promise<void>;
|
|
44
|
-
stop(): Promise<void>;
|
|
47
|
+
stop(options?: EntrypointStopOptions): Promise<void>;
|
|
45
48
|
};
|
|
46
49
|
export type WorkerEntrypoint = {
|
|
47
50
|
readonly lifecycle: Lifecycle;
|
|
@@ -52,7 +55,7 @@ export type WorkerEntrypoint = {
|
|
|
52
55
|
readonly dispatcher: Dispatcher;
|
|
53
56
|
readonly mode: "worker";
|
|
54
57
|
start(): Promise<void>;
|
|
55
|
-
stop(): Promise<void>;
|
|
58
|
+
stop(options?: EntrypointStopOptions): Promise<void>;
|
|
56
59
|
};
|
|
57
60
|
export type AllInOneEntrypoint = Omit<ApiEntrypoint, "mode"> & Omit<WorkerEntrypoint, "mode"> & {
|
|
58
61
|
readonly mode: "all-in-one";
|
package/dist/entrypoint/index.js
CHANGED
|
@@ -176,6 +176,8 @@ function buildJobRunnerWithHook(registry, context, jobs, consumerLane, lifecycle
|
|
|
176
176
|
redisUrl: jobs.redisUrl,
|
|
177
177
|
consumerLane,
|
|
178
178
|
queueNamePrefix: jobs.queueNamePrefix,
|
|
179
|
+
redisNamespace: jobs.redisNamespace,
|
|
180
|
+
maintenanceWindow: jobs.maintenanceWindow,
|
|
179
181
|
getActiveTenantIds: jobs.getActiveTenantIds,
|
|
180
182
|
onJobStart: jobs.onJobStart,
|
|
181
183
|
onJobComplete: jobs.onJobComplete,
|
|
@@ -189,6 +191,13 @@ function buildJobRunnerWithHook(registry, context, jobs, consumerLane, lifecycle
|
|
|
189
191
|
});
|
|
190
192
|
return jobRunner;
|
|
191
193
|
}
|
|
194
|
+
// Before the lifecycle drain, not as a hook: runner hooks drain one after another, and every other
|
|
195
|
+
// hook (cache sync, brokers) would otherwise run first, leaving a window in which a lane still
|
|
196
|
+
// fetches new jobs after SIGTERM.
|
|
197
|
+
async function drainEntrypoint(lifecycle, runners, stopOptions) {
|
|
198
|
+
await Promise.all(runners.map((runner) => runner.stopConsuming?.()));
|
|
199
|
+
await lifecycle.drain(stopOptions?.drainTimeoutMs !== undefined ? { timeoutMs: stopOptions.drainTimeoutMs } : {});
|
|
200
|
+
}
|
|
192
201
|
// A worker-shaped process with no consumers (no SSE, no search adapter,
|
|
193
202
|
// no MSPs) has nothing to drain — that's a caller-config bug, not a
|
|
194
203
|
// usable process shape. Fail loud so ops sees it before prod takes
|
|
@@ -263,8 +272,8 @@ export function createApiEntrypoint(options) {
|
|
|
263
272
|
if (server.eventDispatcher)
|
|
264
273
|
await server.eventDispatcher.start();
|
|
265
274
|
},
|
|
266
|
-
async stop() {
|
|
267
|
-
await lifecycle
|
|
275
|
+
async stop(stopOptions) {
|
|
276
|
+
await drainEntrypoint(lifecycle, apiJobRunner ? [apiJobRunner] : [], stopOptions);
|
|
268
277
|
},
|
|
269
278
|
};
|
|
270
279
|
}
|
|
@@ -290,8 +299,8 @@ export function createWorkerEntrypoint(options) {
|
|
|
290
299
|
await eventDispatcher.start();
|
|
291
300
|
await jobRunner.start();
|
|
292
301
|
},
|
|
293
|
-
async stop() {
|
|
294
|
-
await lifecycle
|
|
302
|
+
async stop(stopOptions) {
|
|
303
|
+
await drainEntrypoint(lifecycle, [jobRunner], stopOptions);
|
|
295
304
|
},
|
|
296
305
|
};
|
|
297
306
|
}
|
|
@@ -339,8 +348,8 @@ export function createAllInOneEntrypoint(options) {
|
|
|
339
348
|
await workerJobRunner.start();
|
|
340
349
|
await apiJobRunner.start();
|
|
341
350
|
},
|
|
342
|
-
async stop() {
|
|
343
|
-
await lifecycle
|
|
351
|
+
async stop(stopOptions) {
|
|
352
|
+
await drainEntrypoint(lifecycle, [workerJobRunner, apiJobRunner], stopOptions);
|
|
344
353
|
},
|
|
345
354
|
};
|
|
346
355
|
}
|
|
@@ -27,6 +27,8 @@ export type BaseEntrypointOptions = {
|
|
|
27
27
|
export type JobsBlock = {
|
|
28
28
|
readonly redisUrl: string;
|
|
29
29
|
readonly queueNamePrefix?: string;
|
|
30
|
+
readonly redisNamespace?: JobRunnerOptions["redisNamespace"];
|
|
31
|
+
readonly maintenanceWindow?: JobRunnerOptions["maintenanceWindow"];
|
|
30
32
|
readonly getActiveTenantIds?: JobRunnerOptions["getActiveTenantIds"];
|
|
31
33
|
readonly onJobStart?: JobRunnerOptions["onJobStart"];
|
|
32
34
|
readonly onJobComplete?: JobRunnerOptions["onJobComplete"];
|
package/dist/files/feature.js
CHANGED
|
@@ -40,7 +40,7 @@ export function createFilesFeature(options = {}) {
|
|
|
40
40
|
r.storeTable(fileUploadLedgerTable, { reason: "coordination.file_upload_ledger" });
|
|
41
41
|
r.job({
|
|
42
42
|
name: "sweep-orphaned-uploads",
|
|
43
|
-
trigger: {
|
|
43
|
+
trigger: { maintenance: true },
|
|
44
44
|
perTenant: true,
|
|
45
45
|
concurrency: "skip",
|
|
46
46
|
handler: async (_payload, ctx) => {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { JobTrigger } from "@cosmicdrift/kumiko-types/config";
|
|
2
|
+
import { type MaintenanceWindow } from "./maintenance-window.ts";
|
|
1
3
|
export type CronTrigger = {
|
|
2
4
|
readonly cron: string;
|
|
3
5
|
readonly tz?: string | undefined;
|
|
@@ -6,4 +8,11 @@ export type CronRepeatOptions = {
|
|
|
6
8
|
readonly pattern: string;
|
|
7
9
|
readonly tz?: string;
|
|
8
10
|
};
|
|
11
|
+
export type ScheduledTrigger = Extract<JobTrigger, {
|
|
12
|
+
readonly cron: string;
|
|
13
|
+
}> | Extract<JobTrigger, {
|
|
14
|
+
readonly maintenance: true;
|
|
15
|
+
}>;
|
|
16
|
+
export declare function isScheduledTrigger(trigger: JobTrigger): trigger is ScheduledTrigger;
|
|
9
17
|
export declare function cronRepeatOptions(trigger: CronTrigger): CronRepeatOptions;
|
|
18
|
+
export declare function scheduleRepeatOptions(trigger: ScheduledTrigger, window?: MaintenanceWindow): CronRepeatOptions;
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
import { DEFAULT_MAINTENANCE_WINDOW } from "./maintenance-window.js";
|
|
2
|
+
export function isScheduledTrigger(trigger) {
|
|
3
|
+
return "cron" in trigger || "maintenance" in trigger;
|
|
4
|
+
}
|
|
1
5
|
export function cronRepeatOptions(trigger) {
|
|
2
6
|
return trigger.tz === undefined
|
|
3
7
|
? { pattern: trigger.cron }
|
|
4
8
|
: { pattern: trigger.cron, tz: trigger.tz };
|
|
5
9
|
}
|
|
10
|
+
export function scheduleRepeatOptions(trigger, window = DEFAULT_MAINTENANCE_WINDOW) {
|
|
11
|
+
return "cron" in trigger
|
|
12
|
+
? cronRepeatOptions(trigger)
|
|
13
|
+
: { pattern: `${window.minute} ${window.hour} * * *`, tz: "UTC" };
|
|
14
|
+
}
|
package/dist/jobs/index.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export type { JobDroppedMeta, JobLogEntry, JobMeta, JobOutcomeMeta, JobQueuedMeta, JobRunner, JobRunnerOptions, } from "./job-runner.ts";
|
|
2
2
|
export { createJobRunner, serializeJobSubject } from "./job-runner.ts";
|
|
3
|
+
export { DEFAULT_MAINTENANCE_WINDOW, MAINTENANCE_WINDOW_ENV, type MaintenanceWindow, maintenanceWindowFromEnv, } from "./maintenance-window.ts";
|
package/dist/jobs/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { configAccessorFactoryOf } from "../pipeline/config-accessor-factory.js"
|
|
|
12
12
|
import { buildContextServices } from "../pipeline/context-services.js";
|
|
13
13
|
import { createGatedIdentitySwitch } from "../pipeline/system-identity-switch.js";
|
|
14
14
|
import { buildPersonalDataGate, parseWriteOrigin } from "../pipeline/write-origin.js";
|
|
15
|
+
import { isScheduledTrigger } from "./cron-repeat-options.js";
|
|
15
16
|
import { createJobAppendEvent } from "./job-append-event.js";
|
|
16
17
|
import { attemptNumberOf, createJobLogger, handlerPayloadOf, hasDeferBudgetLeft, isFinalAttempt, isJobTriggerEvent, jobSubjectKey, missingDbProxy, tenantIdOfJobData, } from "./job-runner-helpers.js";
|
|
17
18
|
// Tenant-visible failure record (JobDefinition.tenantVisibleFailure). The
|
|
@@ -56,7 +57,9 @@ function parseJobWriteOrigin(rawData, jobName) {
|
|
|
56
57
|
function createJobEscapeHatchReporter(scope, input, systemUser) {
|
|
57
58
|
const { jobName, jobDef, tenantId, triggeredById } = input;
|
|
58
59
|
// One reporter for ctx.systemDb and ctx.db.unsafeRaw() so both dedupe in the same window.
|
|
59
|
-
const isSystemCronRun = triggeredById === null &&
|
|
60
|
+
const isSystemCronRun = triggeredById === null &&
|
|
61
|
+
jobDef.escapeHatch !== undefined &&
|
|
62
|
+
isScheduledTrigger(jobDef.trigger);
|
|
60
63
|
return createEscapeHatchReporter({
|
|
61
64
|
handler: jobName,
|
|
62
65
|
tenantId,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/// <reference types="temporal-polyfill/global" preserve="true" />
|
|
2
2
|
import type { DispatchWriteRef, JobTriggerEvent } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
3
|
import type { AppContext, JobRunIn, Registry, SessionUser, TenantId } from "../engine/types/index.ts";
|
|
4
|
+
import type { RedisNamespace } from "../redis/client.ts";
|
|
5
|
+
import type { MaintenanceWindow } from "./maintenance-window.ts";
|
|
4
6
|
export type JobLogEntry = {
|
|
5
7
|
level: "info" | "warn" | "error";
|
|
6
8
|
message: string;
|
|
@@ -40,6 +42,7 @@ export type JobSubjectValue = string | number | boolean | null;
|
|
|
40
42
|
export type JobRunner = {
|
|
41
43
|
start(): Promise<void>;
|
|
42
44
|
stop(): Promise<void>;
|
|
45
|
+
stopConsuming?(): Promise<void>;
|
|
43
46
|
dispatch(jobName: string, payload?: Record<string, unknown>, meta?: JobMeta): Promise<string>;
|
|
44
47
|
handleEvent(eventName: string, payload: Record<string, unknown>, user?: SessionUser, triggerEvent?: JobTriggerEvent): Promise<void>;
|
|
45
48
|
handleCatchUpEvent(jobName: string, eventName: string, payload: Record<string, unknown>, user: SessionUser, triggerEvent: JobTriggerEvent): Promise<void>;
|
|
@@ -52,6 +55,8 @@ export type JobRunnerOptions = {
|
|
|
52
55
|
redisUrl: string;
|
|
53
56
|
consumerLane?: JobRunIn | undefined;
|
|
54
57
|
queueNamePrefix?: string | undefined;
|
|
58
|
+
redisNamespace?: Pick<RedisNamespace, "keyPrefix" | "bullmqPrefix"> | undefined;
|
|
59
|
+
maintenanceWindow?: MaintenanceWindow | undefined;
|
|
55
60
|
bootRedisTimeoutMs?: number | undefined;
|
|
56
61
|
schedulerSeenRefreshMs?: number | undefined;
|
|
57
62
|
jobRetention?: {
|
package/dist/jobs/job-runner.js
CHANGED
|
@@ -14,7 +14,7 @@ import { registerStandardMetrics } from "../observability/standard-metrics.js";
|
|
|
14
14
|
import { attachOutsideTransaction } from "../pipeline/attach-outside-transaction.js";
|
|
15
15
|
import { createDistributedLock } from "../pipeline/distributed-lock.js";
|
|
16
16
|
import { RedisKeys } from "../pipeline/redis-keys.js";
|
|
17
|
-
import {
|
|
17
|
+
import { isScheduledTrigger, scheduleRepeatOptions } from "./cron-repeat-options.js";
|
|
18
18
|
import { createJobEventTriggers } from "./job-runner-event-triggers.js";
|
|
19
19
|
import { applyHandlerDeferral, executeJobInSpan } from "./job-runner-execution.js";
|
|
20
20
|
import { claimSequentialSlot, deferOverTenantLimit, fanOutPerTenantJob, PER_TENANT_PREFIX, perTenantTargetOf, releaseSequentialSlot, resolveJobDefinitionOrFail, startJobRun, } from "./job-runner-gates.js";
|
|
@@ -24,12 +24,15 @@ import { announceDropped, announceQueued, isOverPerTenantLimit, laneForJob, } fr
|
|
|
24
24
|
import { resolveMaxPerTenant } from "./max-per-tenant.js";
|
|
25
25
|
export { serializeJobSubject } from "./job-runner-helpers.js";
|
|
26
26
|
export { bootJobIdForJobName, ORPHAN_SCHEDULER_GRACE_MS, schedulerIdForJobName, } from "./job-runner-schedulers.js";
|
|
27
|
+
const DEFAULT_REDIS_NAMESPACE = { keyPrefix: "", bullmqPrefix: "bull" };
|
|
27
28
|
export function createJobRunner(options) {
|
|
28
29
|
const { registry, context, redisUrl, consumerLane } = options;
|
|
29
30
|
const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
|
|
30
31
|
const bootRedisTimeoutMs = options.bootRedisTimeoutMs ?? BOOT_REDIS_TIMEOUT_MS;
|
|
31
32
|
const schedulerSeenRefreshMs = options.schedulerSeenRefreshMs ?? ORPHAN_SCHEDULER_GRACE_MS / 4;
|
|
32
33
|
const redisOpts = parseRedisOpts(redisUrl);
|
|
34
|
+
// BullMQ rejects an ioredis keyPrefix on its connection, so it gets its own `prefix`; only lockRedis uses keyPrefix.
|
|
35
|
+
const { keyPrefix, bullmqPrefix } = options.redisNamespace ?? DEFAULT_REDIS_NAMESPACE;
|
|
33
36
|
// Use the context's tracer when present (observability-provider injected at
|
|
34
37
|
// boot); otherwise noop so dispatch/handleJob stay zero-cost without config.
|
|
35
38
|
const tracer = context.tracer ?? getFallbackTracer();
|
|
@@ -133,7 +136,7 @@ export function createJobRunner(options) {
|
|
|
133
136
|
let lockRedis = null;
|
|
134
137
|
let sequentialLock = null;
|
|
135
138
|
if (hasSequential) {
|
|
136
|
-
lockRedis = new Redis(redisOpts);
|
|
139
|
+
lockRedis = new Redis({ ...redisOpts, keyPrefix });
|
|
137
140
|
// Without a listener, a post-close 'error' (e.g. a teardown-race
|
|
138
141
|
// "Connection is closed") is unhandled and crashes the process — in
|
|
139
142
|
// bun:test it gets attributed to whichever test happens to run next.
|
|
@@ -161,10 +164,12 @@ export function createJobRunner(options) {
|
|
|
161
164
|
const queues = {
|
|
162
165
|
api: new Queue(queueNameFor(queueNamePrefix, "api"), {
|
|
163
166
|
connection: redisOpts,
|
|
167
|
+
prefix: bullmqPrefix,
|
|
164
168
|
defaultJobOptions: jobRetentionOpts,
|
|
165
169
|
}),
|
|
166
170
|
worker: new Queue(queueNameFor(queueNamePrefix, "worker"), {
|
|
167
171
|
connection: redisOpts,
|
|
172
|
+
prefix: bullmqPrefix,
|
|
168
173
|
defaultJobOptions: jobRetentionOpts,
|
|
169
174
|
}),
|
|
170
175
|
};
|
|
@@ -292,7 +297,7 @@ export function createJobRunner(options) {
|
|
|
292
297
|
function cronSchedulerIdsForLane(lane) {
|
|
293
298
|
const ids = new Set();
|
|
294
299
|
for (const [name, jobDef] of allJobs) {
|
|
295
|
-
if (laneForJob(jobDef) === lane &&
|
|
300
|
+
if (laneForJob(jobDef) === lane && isScheduledTrigger(jobDef.trigger)) {
|
|
296
301
|
ids.add(schedulerIdForJobName(name));
|
|
297
302
|
}
|
|
298
303
|
}
|
|
@@ -302,8 +307,8 @@ export function createJobRunner(options) {
|
|
|
302
307
|
for (const [name, jobDef] of allJobs) {
|
|
303
308
|
if (laneForJob(jobDef) !== lane)
|
|
304
309
|
continue;
|
|
305
|
-
if (
|
|
306
|
-
await queue.upsertJobScheduler(schedulerIdForJobName(name),
|
|
310
|
+
if (isScheduledTrigger(jobDef.trigger)) {
|
|
311
|
+
await queue.upsertJobScheduler(schedulerIdForJobName(name), scheduleRepeatOptions(jobDef.trigger, options.maintenanceWindow), {
|
|
307
312
|
name: jobDef.perTenant ? `${PER_TENANT_PREFIX}${name}` : name,
|
|
308
313
|
data: {},
|
|
309
314
|
// Queue-level defaultJobOptions (jobRetentionOpts) covers
|
|
@@ -370,6 +375,7 @@ export function createJobRunner(options) {
|
|
|
370
375
|
const consumerQueue = queues[consumerLane];
|
|
371
376
|
worker = new Worker(queueNameFor(queueNamePrefix, consumerLane), (job, token) => handleJob(job, token), {
|
|
372
377
|
connection: redisOpts,
|
|
378
|
+
prefix: bullmqPrefix,
|
|
373
379
|
concurrency: 5,
|
|
374
380
|
// Not consuming until the boot gates passed: a job already queued from a
|
|
375
381
|
// previous run must not execute against an unmet boot precondition.
|
|
@@ -460,6 +466,9 @@ export function createJobRunner(options) {
|
|
|
460
466
|
// Last on purpose: a start() that fails earlier must not leave a timer stamping for a runner that is not running.
|
|
461
467
|
schedulerSeenTimer = startSchedulerSeenRefresh(consumerQueue, consumerLane, expectedSchedulerIds);
|
|
462
468
|
},
|
|
469
|
+
async stopConsuming() {
|
|
470
|
+
await worker?.pause(true);
|
|
471
|
+
},
|
|
463
472
|
async stop() {
|
|
464
473
|
stopping = true;
|
|
465
474
|
if (queueDepthTimer) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type MaintenanceWindow = {
|
|
2
|
+
readonly hour: number;
|
|
3
|
+
readonly minute: number;
|
|
4
|
+
};
|
|
5
|
+
export declare const MAINTENANCE_WINDOW_ENV = "KUMIKO_MAINTENANCE_WINDOW";
|
|
6
|
+
export declare const DEFAULT_MAINTENANCE_WINDOW: MaintenanceWindow;
|
|
7
|
+
export declare function maintenanceWindowFromEnv(env?: Record<string, string | undefined>): MaintenanceWindow;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const MAINTENANCE_WINDOW_ENV = "KUMIKO_MAINTENANCE_WINDOW";
|
|
2
|
+
export const DEFAULT_MAINTENANCE_WINDOW = { hour: 3, minute: 0 };
|
|
3
|
+
const MAINTENANCE_WINDOW_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
4
|
+
// UTC wall clock, so the window does not move with the server time zone or DST.
|
|
5
|
+
export function maintenanceWindowFromEnv(env = process.env) {
|
|
6
|
+
const raw = env[MAINTENANCE_WINDOW_ENV]?.trim();
|
|
7
|
+
if (raw === undefined || raw === "")
|
|
8
|
+
return DEFAULT_MAINTENANCE_WINDOW;
|
|
9
|
+
const match = MAINTENANCE_WINDOW_PATTERN.exec(raw);
|
|
10
|
+
if (match === null) {
|
|
11
|
+
throw new Error(`${MAINTENANCE_WINDOW_ENV}="${raw}" is invalid: expected HH:MM in UTC, e.g. "03:00" or "22:45".`);
|
|
12
|
+
}
|
|
13
|
+
return { hour: Number(match[1]), minute: Number(match[2]) };
|
|
14
|
+
}
|
|
@@ -14,6 +14,7 @@ export type RedisCacheSyncBusOptions = {
|
|
|
14
14
|
readonly redisUrl: string;
|
|
15
15
|
readonly clientOptions?: RedisClientOptions;
|
|
16
16
|
readonly channelPrefix?: string;
|
|
17
|
+
readonly channelSubscribePattern?: string;
|
|
17
18
|
};
|
|
18
19
|
export declare function createRedisCacheSyncBus(opts: RedisCacheSyncBusOptions): ClosableCacheSyncBus;
|
|
19
20
|
export declare function createDefaultCacheSyncBus(envVars?: Record<string, string | undefined>): ClosableCacheSyncBus;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createFallbackLogger } from "../logging/utils.js";
|
|
2
|
-
import { redisClientOptionsFromEnv } from "./client.js";
|
|
2
|
+
import { redisClientOptionsFromEnv, redisNamespaceFromEnv, } from "./client.js";
|
|
3
3
|
import { createRedisPubSubSignal } from "./pubsub-signal.js";
|
|
4
4
|
const log = createFallbackLogger("kumiko:cache-sync");
|
|
5
5
|
const CACHE_SYNC_CHANNEL = "kumiko:cache-sync";
|
|
@@ -80,7 +80,10 @@ export function createCacheSyncBusOverSignal(signal, opts) {
|
|
|
80
80
|
const resyncMinIntervalMs = opts.resyncMinIntervalMs ?? RESYNC_MIN_INTERVAL_MS;
|
|
81
81
|
const now = opts.now ?? Date.now;
|
|
82
82
|
const fanout = createLocalFanout();
|
|
83
|
-
signal.onMessage((
|
|
83
|
+
signal.onMessage((channel, payload) => {
|
|
84
|
+
// skip: a wide subscribe pattern also delivers other features' channels
|
|
85
|
+
if (channel !== opts.channel)
|
|
86
|
+
return;
|
|
84
87
|
// skip: foreign or malformed payload on the channel
|
|
85
88
|
if (!isCacheSyncEnvelope(payload))
|
|
86
89
|
return;
|
|
@@ -133,7 +136,7 @@ export function createRedisCacheSyncBus(opts) {
|
|
|
133
136
|
const channel = `${opts.channelPrefix ?? ""}${CACHE_SYNC_CHANNEL}`;
|
|
134
137
|
const signal = createRedisPubSubSignal({
|
|
135
138
|
redisUrl: opts.redisUrl,
|
|
136
|
-
channelPattern: channel,
|
|
139
|
+
channelPattern: opts.channelSubscribePattern ?? channel,
|
|
137
140
|
label: "cache-sync",
|
|
138
141
|
...(opts.clientOptions ? { clientOptions: opts.clientOptions } : {}),
|
|
139
142
|
});
|
|
@@ -143,9 +146,15 @@ export function createRedisCacheSyncBus(opts) {
|
|
|
143
146
|
// set it. Without it invalidation stays inside this process.
|
|
144
147
|
export function createDefaultCacheSyncBus(envVars = process.env) {
|
|
145
148
|
const redisUrl = envVars["REDIS_URL"];
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
+
if (!redisUrl)
|
|
150
|
+
return createLocalCacheSyncBus();
|
|
151
|
+
const { channelPrefix, channelSubscribePattern } = redisNamespaceFromEnv(envVars);
|
|
152
|
+
return createRedisCacheSyncBus({
|
|
153
|
+
redisUrl,
|
|
154
|
+
clientOptions: redisClientOptionsFromEnv(envVars),
|
|
155
|
+
channelPrefix,
|
|
156
|
+
...(channelSubscribePattern !== undefined && { channelSubscribePattern }),
|
|
157
|
+
});
|
|
149
158
|
}
|
|
150
159
|
// An explicit bus wins (caller owns it), null opts out, undefined builds the default.
|
|
151
160
|
export function resolveCacheSyncBus(explicit) {
|
package/dist/redis/client.d.ts
CHANGED
|
@@ -8,5 +8,17 @@ export type RedisClientOptions = {
|
|
|
8
8
|
};
|
|
9
9
|
export declare function createRedisClient(url: string, options?: RedisClientOptions): Redis;
|
|
10
10
|
export declare function logRedisConnectionState(redis: Redis, logger: Pick<Logger, "warn" | "info">): void;
|
|
11
|
+
export type RedisNamespace = {
|
|
12
|
+
/** ioredis keyPrefix for every non-BullMQ key; "" when unnamespaced. */
|
|
13
|
+
readonly keyPrefix: string;
|
|
14
|
+
/** BullMQ `prefix` option ("bull" is BullMQ's own default). BullMQ rejects an ioredis keyPrefix. */
|
|
15
|
+
readonly bullmqPrefix: string;
|
|
16
|
+
/** Prepended to every Pub/Sub channel (ioredis keyPrefix does not apply to Pub/Sub). */
|
|
17
|
+
readonly channelPrefix: string;
|
|
18
|
+
/** Set only with KUMIKO_REDIS_PREFIX. Redis checks PSUBSCRIBE patterns against `&` ACL rules literally,
|
|
19
|
+
* so `P:kumiko:sse:*` is NOPERM under `&P:*`; subscribe to `P:*` and filter by channel instead. */
|
|
20
|
+
readonly channelSubscribePattern: string | undefined;
|
|
21
|
+
};
|
|
22
|
+
export declare function redisNamespaceFromEnv(env?: Readonly<Record<string, string | undefined>>): RedisNamespace;
|
|
11
23
|
export declare function redisChannelPrefixFromEnv(env?: Readonly<Record<string, string | undefined>>): string;
|
|
12
24
|
export declare function redisClientOptionsFromEnv(env?: Readonly<Record<string, string | undefined>>): RedisClientOptions;
|
package/dist/redis/client.js
CHANGED
|
@@ -46,12 +46,37 @@ export function logRedisConnectionState(redis, logger) {
|
|
|
46
46
|
logger.info("redis recovered");
|
|
47
47
|
});
|
|
48
48
|
}
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
|
|
49
|
+
const BULLMQ_DEFAULT_PREFIX = "bull";
|
|
50
|
+
// No ":" and no glob characters: the ACL rules `~<P>:*` / `&<P>:*` must not match a sibling app ("app-x" vs "app-x2:").
|
|
51
|
+
const REDIS_PREFIX_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/;
|
|
52
|
+
// KUMIKO_REDIS_PREFIX namespaces keys and channels for apps sharing one Redis behind per-app ACL users.
|
|
53
|
+
// KUMIKO_REDIS_CHANNEL_PREFIX alone keeps its older channels-only behaviour.
|
|
54
|
+
export function redisNamespaceFromEnv(env = process.env) {
|
|
55
|
+
const prefix = env["KUMIKO_REDIS_PREFIX"]?.trim();
|
|
56
|
+
const channelPrefix = env["KUMIKO_REDIS_CHANNEL_PREFIX"]?.trim();
|
|
57
|
+
if (!prefix) {
|
|
58
|
+
return {
|
|
59
|
+
keyPrefix: "",
|
|
60
|
+
bullmqPrefix: BULLMQ_DEFAULT_PREFIX,
|
|
61
|
+
channelPrefix: channelPrefix ? `${channelPrefix}:` : "",
|
|
62
|
+
channelSubscribePattern: undefined,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (!REDIS_PREFIX_PATTERN.test(prefix)) {
|
|
66
|
+
throw new Error(`KUMIKO_REDIS_PREFIX "${prefix}" is invalid: use 1-63 characters of A-Z a-z 0-9 _ - starting with a letter or digit (no ":" or glob characters).`);
|
|
67
|
+
}
|
|
68
|
+
if (channelPrefix && channelPrefix !== prefix) {
|
|
69
|
+
throw new Error(`KUMIKO_REDIS_PREFIX "${prefix}" and KUMIKO_REDIS_CHANNEL_PREFIX "${channelPrefix}" differ; unset the channel prefix or make them equal.`);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
keyPrefix: `${prefix}:`,
|
|
73
|
+
bullmqPrefix: `${prefix}:${BULLMQ_DEFAULT_PREFIX}`,
|
|
74
|
+
channelPrefix: `${prefix}:`,
|
|
75
|
+
channelSubscribePattern: `${prefix}:*`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
52
78
|
export function redisChannelPrefixFromEnv(env = process.env) {
|
|
53
|
-
|
|
54
|
-
return raw ? `${raw}:` : "";
|
|
79
|
+
return redisNamespaceFromEnv(env).channelPrefix;
|
|
55
80
|
}
|
|
56
81
|
// Env-var reader — mirrors dbConnectionOptionsFromEnv's contract. Strict:
|
|
57
82
|
// malformed values throw at boot rather than silently falling back to
|
package/dist/redis/index.d.ts
CHANGED
|
@@ -2,6 +2,6 @@ export type { CacheSyncBus, ClosableCacheSyncBus, } from "./cache-sync-bus.ts";
|
|
|
2
2
|
export { createDefaultCacheSyncBus, createLocalCacheSyncBus, createRedisCacheSyncBus, } from "./cache-sync-bus.ts";
|
|
3
3
|
export type { TenantConfigSyncMessage, TierAssignmentSyncMessage } from "./cache-sync-topics.ts";
|
|
4
4
|
export { CACHE_SYNC_TOPICS, isTierAssignmentSyncMessage, } from "./cache-sync-topics.ts";
|
|
5
|
-
export type { RedisClientOptions } from "./client.ts";
|
|
6
|
-
export { createRedisClient, logRedisConnectionState, redisChannelPrefixFromEnv, redisClientOptionsFromEnv, } from "./client.ts";
|
|
5
|
+
export type { RedisClientOptions, RedisNamespace } from "./client.ts";
|
|
6
|
+
export { createRedisClient, logRedisConnectionState, redisChannelPrefixFromEnv, redisClientOptionsFromEnv, redisNamespaceFromEnv, } from "./client.ts";
|
|
7
7
|
export { createRedisPubSubSignal } from "./pubsub-signal.ts";
|
package/dist/redis/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { createDefaultCacheSyncBus, createLocalCacheSyncBus, createRedisCacheSyncBus, } from "./cache-sync-bus.js";
|
|
2
2
|
export { CACHE_SYNC_TOPICS, isTierAssignmentSyncMessage, } from "./cache-sync-topics.js";
|
|
3
|
-
export { createRedisClient, logRedisConnectionState, redisChannelPrefixFromEnv, redisClientOptionsFromEnv, } from "./client.js";
|
|
3
|
+
export { createRedisClient, logRedisConnectionState, redisChannelPrefixFromEnv, redisClientOptionsFromEnv, redisNamespaceFromEnv, } from "./client.js";
|
|
4
4
|
export { createRedisPubSubSignal } from "./pubsub-signal.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.404.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -203,7 +203,7 @@
|
|
|
203
203
|
"./package.json": "./package.json"
|
|
204
204
|
},
|
|
205
205
|
"dependencies": {
|
|
206
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
206
|
+
"@cosmicdrift/kumiko-types": "0.404.0",
|
|
207
207
|
"bullmq": "^5.76.7",
|
|
208
208
|
"hono": "^4.13.14",
|
|
209
209
|
"i18next": "^26.1.0",
|
|
@@ -225,7 +225,7 @@
|
|
|
225
225
|
}
|
|
226
226
|
},
|
|
227
227
|
"devDependencies": {
|
|
228
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
228
|
+
"@cosmicdrift/kumiko-testing": "0.404.0",
|
|
229
229
|
"bun-types": "^1.4.4",
|
|
230
230
|
"fflate": "^0.8.3",
|
|
231
231
|
"meilisearch": "^0.58.0",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.404.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Maintenance trigger and KUMIKO_MAINTENANCE_WINDOW",
|
|
6
|
+
"detail": "The job runner registers maintenance jobs as a daily UTC scheduler at JobRunnerOptions.maintenanceWindow (default 03:00), set from KUMIKO_MAINTENANCE_WINDOW by runProdApp and runWorkerApp; an invalid value aborts the boot. maintenanceWindowFromEnv is exported from the jobs barrel. files sweep-orphaned-uploads (hourly before) and the soft-delete cleanup jobs (03:00 and 03:15 before) now run in the window. A schedule stored with the old time is replaced on the next start."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.404.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "KUMIKO_REDIS_PREFIX namespaces all Redis keys and channels",
|
|
12
|
+
"detail": "redisNamespaceFromEnv resolves keyPrefix, bullmqPrefix, channelPrefix and the subscribe pattern. Unset keeps today's layout (bull:*, unprefixed locks, rate limit, idempotency and cache keys). Set, the main ioredis client gets keyPrefix <P>:, the job runner passes prefix <P>:bull to every Queue and Worker (new JobRunnerOptions.redisNamespace, also on the entrypoint jobs block) and gives its lock client the keyPrefix, and the SSE broker, cache-sync bus and toggle signal use <P>: channels. Redis matches PSUBSCRIBE patterns literally against ACL channel rules, so with a prefix they subscribe to <P>:* and filter by channel. A value with \":\" or glob characters, or a KUMIKO_REDIS_CHANNEL_PREFIX that differs from it, aborts the boot. The cache-sync bus of runProdApp, runWorkerApp and createDefaultCacheSyncBus now also honours KUMIKO_REDIS_CHANNEL_PREFIX alone (before, only SSE and toggle channels did). JobRunner gains an optional stopConsuming(), which createJobRunner implements. The ACL rule set and the key layout for a wake-up service are in docs/reference/infra-requirements.md."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.403.0",
|
|
4
16
|
"type": "improvement",
|