@cosmicdrift/kumiko-framework 0.403.0 → 0.404.1
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.md +103 -103
- package/README.md +19 -17
- 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/apex-landing.md +3 -3
- package/agents/examples/apex-surface-auth.md +13 -9
- package/agents/examples/auth-mfa.md +2 -4
- package/agents/examples/boot-check.md +2 -2
- package/agents/examples/compliance-profiles-demo.md +5 -5
- package/agents/examples/compose-stacks.md +8 -8
- package/agents/examples/cross-feature-events.md +1 -0
- package/agents/examples/crypto-shredding-hr.md +3 -3
- package/agents/examples/custom-fields-basic.md +1 -1
- package/agents/examples/documents.md +28 -0
- package/agents/examples/encrypted-tenant-config.md +8 -8
- package/agents/examples/env-composition.md +10 -10
- package/agents/examples/error-contract.md +9 -9
- package/agents/examples/event-sourcing.md +9 -9
- package/agents/examples/lane-routing.md +40 -1
- package/agents/examples/ledger-invoicing.md +6 -6
- package/agents/examples/legal-pages.md +16 -16
- package/agents/examples/managed-config.md +15 -11
- package/agents/examples/managed-pages.md +7 -7
- package/agents/examples/pipeline-basics.md +13 -13
- package/agents/examples/public-share-token.md +4 -4
- package/agents/examples/security-baseline.md +4 -1
- package/agents/examples/session-revocation.md +1 -1
- package/agents/examples/tenant-settings.md +4 -4
- package/agents/examples/tier-admin.md +2 -2
- package/agents/examples/user-data-rights.md +21 -19
- 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/pipeline/event-retention.js +3 -1
- 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/dist/search/meilisearch-peer.js +1 -1
- package/package.json +12 -12
- package/presets/biome-app.json +1 -1
- package/src/changes.json +12 -0
|
@@ -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
|
+
}
|
|
@@ -68,7 +68,9 @@ export async function pruneEvents(db, options) {
|
|
|
68
68
|
if (candidates.length === 0) {
|
|
69
69
|
return { deletedCount: 0, cutoff, aggregateTypes, dryRun };
|
|
70
70
|
}
|
|
71
|
-
const maxCandidateId = candidates.reduce(
|
|
71
|
+
const maxCandidateId = candidates.reduce(
|
|
72
|
+
// biome-ignore lint/complexity/useMathMinMax: ids are bigint, Math.max throws a TypeError on BigInt
|
|
73
|
+
(acc, row) => (row.id > acc ? row.id : acc), candidates[0]?.id ?? 0n);
|
|
72
74
|
// Step 2 — safety guard: check every ACTIVE consumer has moved past
|
|
73
75
|
// the candidate set. Disabled consumers are intentionally excluded —
|
|
74
76
|
// ops disables them precisely to park them while pruning happens.
|
|
@@ -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.1",
|
|
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,21 +203,21 @@
|
|
|
203
203
|
"./package.json": "./package.json"
|
|
204
204
|
},
|
|
205
205
|
"dependencies": {
|
|
206
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
207
|
-
"bullmq": "^5.
|
|
206
|
+
"@cosmicdrift/kumiko-types": "0.404.1",
|
|
207
|
+
"bullmq": "^5.81.5",
|
|
208
208
|
"hono": "^4.13.14",
|
|
209
|
-
"i18next": "^26.
|
|
210
|
-
"ioredis": "^5.
|
|
211
|
-
"jose": "^6.2.
|
|
212
|
-
"pino": "^10.
|
|
209
|
+
"i18next": "^26.5.11",
|
|
210
|
+
"ioredis": "^5.11.1",
|
|
211
|
+
"jose": "^6.2.14",
|
|
212
|
+
"pino": "^10.4.0",
|
|
213
213
|
"temporal-polyfill": "^0.3.2",
|
|
214
|
-
"uuid": "^14.0.
|
|
214
|
+
"uuid": "^14.0.3",
|
|
215
215
|
"write-excel-file": "^4.1.1",
|
|
216
216
|
"zod": "^4.6.7"
|
|
217
217
|
},
|
|
218
218
|
"peerDependencies": {
|
|
219
219
|
"bun-types": "^1.4.4",
|
|
220
|
-
"meilisearch": "^0.
|
|
220
|
+
"meilisearch": "^0.62.0"
|
|
221
221
|
},
|
|
222
222
|
"peerDependenciesMeta": {
|
|
223
223
|
"meilisearch": {
|
|
@@ -225,11 +225,11 @@
|
|
|
225
225
|
}
|
|
226
226
|
},
|
|
227
227
|
"devDependencies": {
|
|
228
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
228
|
+
"@cosmicdrift/kumiko-testing": "0.404.1",
|
|
229
229
|
"bun-types": "^1.4.4",
|
|
230
230
|
"fflate": "^0.8.3",
|
|
231
|
-
"meilisearch": "^0.
|
|
232
|
-
"pino-pretty": "^13.
|
|
231
|
+
"meilisearch": "^0.62.0",
|
|
232
|
+
"pino-pretty": "^13.2.0"
|
|
233
233
|
},
|
|
234
234
|
"publishConfig": {
|
|
235
235
|
"registry": "https://registry.npmjs.org",
|
package/presets/biome-app.json
CHANGED
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",
|