@pattern-stack/codegen 0.4.1 → 0.4.3
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/CHANGELOG.md +6 -0
- package/dist/runtime/subsystems/bridge/bridge.module.d.ts +1 -0
- package/dist/runtime/subsystems/bridge/bridge.module.js +38 -21
- package/dist/runtime/subsystems/bridge/bridge.module.js.map +1 -1
- package/dist/runtime/subsystems/bridge/index.d.ts +1 -0
- package/dist/runtime/subsystems/bridge/index.js +29 -12
- package/dist/runtime/subsystems/bridge/index.js.map +1 -1
- package/dist/runtime/subsystems/index.js +31 -14
- package/dist/runtime/subsystems/index.js.map +1 -1
- package/dist/runtime/subsystems/jobs/index.d.ts +1 -0
- package/dist/runtime/subsystems/jobs/index.js +27 -10
- package/dist/runtime/subsystems/jobs/index.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.d.ts +3 -1
- package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js +9 -4
- package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-worker.d.ts +3 -1
- package/dist/runtime/subsystems/jobs/job-worker.js +6 -2
- package/dist/runtime/subsystems/jobs/job-worker.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +3 -1
- package/dist/runtime/subsystems/jobs/job-worker.module.js +27 -10
- package/dist/runtime/subsystems/jobs/job-worker.module.js.map +1 -1
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js +9 -4
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js.map +1 -1
- package/dist/src/cli/index.js +29 -2
- package/dist/src/cli/index.js.map +1 -1
- package/package.json +2 -1
- package/runtime/analytics/index.ts +31 -0
- package/runtime/analytics/metrics.ts +85 -0
- package/runtime/analytics/packs/crm-entity-measures.ts +20 -0
- package/runtime/analytics/packs/index.ts +5 -0
- package/runtime/analytics/packs/monetary-measures.ts +20 -0
- package/runtime/analytics/specs.ts +54 -0
- package/runtime/analytics/types.ts +105 -0
- package/runtime/base-classes/activity-entity-repository.ts +50 -0
- package/runtime/base-classes/activity-entity-service.ts +48 -0
- package/runtime/base-classes/base-read-use-cases.ts +88 -0
- package/runtime/base-classes/base-repository.ts +289 -0
- package/runtime/base-classes/base-service.ts +183 -0
- package/runtime/base-classes/index.ts +38 -0
- package/runtime/base-classes/knowledge-entity-repository.ts +12 -0
- package/runtime/base-classes/knowledge-entity-service.ts +14 -0
- package/runtime/base-classes/lifecycle-events.ts +152 -0
- package/runtime/base-classes/metadata-entity-repository.ts +80 -0
- package/runtime/base-classes/metadata-entity-service.ts +48 -0
- package/runtime/base-classes/synced-entity-repository.ts +57 -0
- package/runtime/base-classes/synced-entity-service.ts +50 -0
- package/runtime/base-classes/with-analytics.ts +22 -0
- package/runtime/constants/tokens.ts +29 -0
- package/runtime/eav-helpers.ts +74 -0
- package/runtime/pipes/zod-validation.pipe.ts +64 -0
- package/runtime/shared/openapi/error-response.dto.ts +24 -0
- package/runtime/shared/openapi/errors.ts +39 -0
- package/runtime/shared/openapi/index.ts +20 -0
- package/runtime/shared/openapi/registry.tokens.ts +13 -0
- package/runtime/shared/openapi/registry.ts +151 -0
- package/runtime/subsystems/analytics/analytics-query.protocol.ts +37 -0
- package/runtime/subsystems/analytics/analytics.module.ts +64 -0
- package/runtime/subsystems/analytics/analytics.tokens.ts +24 -0
- package/runtime/subsystems/analytics/cube-backend.ts +75 -0
- package/runtime/subsystems/analytics/index.ts +15 -0
- package/runtime/subsystems/analytics/noop-backend.ts +27 -0
- package/runtime/subsystems/auth/auth.module.ts +91 -0
- package/runtime/subsystems/auth/auth.tokens.ts +27 -0
- package/runtime/subsystems/auth/backends/encryption-key/env.ts +76 -0
- package/runtime/subsystems/auth/backends/oauth-state-store/in-memory.ts +42 -0
- package/runtime/subsystems/auth/index.ts +77 -0
- package/runtime/subsystems/auth/protocols/auth-strategy.ts +46 -0
- package/runtime/subsystems/auth/protocols/encryption-key.ts +21 -0
- package/runtime/subsystems/auth/protocols/integration-store.ts +66 -0
- package/runtime/subsystems/auth/protocols/oauth-state-store.ts +16 -0
- package/runtime/subsystems/auth/runtime/integration-broken.error.ts +21 -0
- package/runtime/subsystems/auth/runtime/oauth2-refresh.strategy.ts +189 -0
- package/runtime/subsystems/auth/runtime/session-expired.error.ts +39 -0
- package/runtime/subsystems/auth/runtime/with-auth-retry.ts +50 -0
- package/runtime/subsystems/bridge/assert-tenant-id.ts +57 -0
- package/runtime/subsystems/bridge/bridge-delivery-handler.ts +220 -0
- package/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.ts +149 -0
- package/runtime/subsystems/bridge/bridge-delivery.memory-backend.ts +140 -0
- package/runtime/subsystems/bridge/bridge-delivery.schema.ts +142 -0
- package/runtime/subsystems/bridge/bridge-errors.ts +112 -0
- package/runtime/subsystems/bridge/bridge-outbox-drain-hook.ts +175 -0
- package/runtime/subsystems/bridge/bridge.module.ts +160 -0
- package/runtime/subsystems/bridge/bridge.protocol.ts +351 -0
- package/runtime/subsystems/bridge/bridge.tokens.ts +68 -0
- package/runtime/subsystems/bridge/event-flow.service.ts +175 -0
- package/runtime/subsystems/bridge/generated/.gitkeep +0 -0
- package/runtime/subsystems/bridge/generated/registry.ts +6 -0
- package/runtime/subsystems/bridge/index.ts +84 -0
- package/runtime/subsystems/bridge/reserved-pools.ts +36 -0
- package/runtime/subsystems/cache/cache.drizzle-backend.ts +150 -0
- package/runtime/subsystems/cache/cache.memory-backend.ts +116 -0
- package/runtime/subsystems/cache/cache.module.ts +115 -0
- package/runtime/subsystems/cache/cache.protocol.ts +45 -0
- package/runtime/subsystems/cache/cache.schema.ts +27 -0
- package/runtime/subsystems/cache/cache.tokens.ts +17 -0
- package/runtime/subsystems/cache/index.ts +22 -0
- package/runtime/subsystems/events/domain-events.schema.ts +77 -0
- package/runtime/subsystems/events/event-bus.drizzle-backend.ts +327 -0
- package/runtime/subsystems/events/event-bus.memory-backend.ts +142 -0
- package/runtime/subsystems/events/event-bus.protocol.ts +86 -0
- package/runtime/subsystems/events/event-bus.redis-backend.ts +304 -0
- package/runtime/subsystems/events/events-errors.ts +30 -0
- package/runtime/subsystems/events/events.module.ts +230 -0
- package/runtime/subsystems/events/events.tokens.ts +62 -0
- package/runtime/subsystems/events/generated/bus.ts +103 -0
- package/runtime/subsystems/events/generated/index.ts +7 -0
- package/runtime/subsystems/events/generated/registry.ts +84 -0
- package/runtime/subsystems/events/generated/schemas.ts +59 -0
- package/runtime/subsystems/events/generated/types.ts +94 -0
- package/runtime/subsystems/events/index.ts +21 -0
- package/runtime/subsystems/index.ts +63 -0
- package/runtime/subsystems/jobs/generated/job-orchestration.schema.multi-tenant.ts +217 -0
- package/runtime/subsystems/jobs/generated/job-orchestration.schema.single-tenant.ts +217 -0
- package/runtime/subsystems/jobs/generated/scope-entity-type.ts +10 -0
- package/runtime/subsystems/jobs/index.ts +120 -0
- package/runtime/subsystems/jobs/job-handler.base.ts +206 -0
- package/runtime/subsystems/jobs/job-orchestration.schema.ts +217 -0
- package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +536 -0
- package/runtime/subsystems/jobs/job-orchestrator.memory-backend.ts +860 -0
- package/runtime/subsystems/jobs/job-orchestrator.protocol.ts +179 -0
- package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +171 -0
- package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +165 -0
- package/runtime/subsystems/jobs/job-run-service.protocol.ts +79 -0
- package/runtime/subsystems/jobs/job-step-service.drizzle-backend.ts +66 -0
- package/runtime/subsystems/jobs/job-step-service.memory-backend.ts +119 -0
- package/runtime/subsystems/jobs/job-step-service.protocol.ts +53 -0
- package/runtime/subsystems/jobs/job-worker.module.ts +312 -0
- package/runtime/subsystems/jobs/job-worker.ts +624 -0
- package/runtime/subsystems/jobs/jobs-domain.module.ts +119 -0
- package/runtime/subsystems/jobs/jobs-domain.tokens.ts +30 -0
- package/runtime/subsystems/jobs/jobs-errors.ts +150 -0
- package/runtime/subsystems/jobs/memory-job-store.ts +35 -0
- package/runtime/subsystems/jobs/pool-config.loader.ts +218 -0
- package/runtime/subsystems/storage/index.ts +18 -0
- package/runtime/subsystems/storage/storage.local-backend.ts +113 -0
- package/runtime/subsystems/storage/storage.memory-backend.ts +78 -0
- package/runtime/subsystems/storage/storage.module.ts +60 -0
- package/runtime/subsystems/storage/storage.protocol.ts +78 -0
- package/runtime/subsystems/storage/storage.tokens.ts +9 -0
- package/runtime/subsystems/storage/storage.utils.ts +20 -0
- package/runtime/subsystems/sync/deep-equal.differ.ts +198 -0
- package/runtime/subsystems/sync/execute-sync.use-case.ts +334 -0
- package/runtime/subsystems/sync/index.ts +98 -0
- package/runtime/subsystems/sync/sync-audit.schema.ts +300 -0
- package/runtime/subsystems/sync/sync-change-source.protocol.ts +99 -0
- package/runtime/subsystems/sync/sync-cursor-store.drizzle-backend.ts +104 -0
- package/runtime/subsystems/sync/sync-cursor-store.memory-backend.ts +64 -0
- package/runtime/subsystems/sync/sync-cursor-store.protocol.ts +53 -0
- package/runtime/subsystems/sync/sync-errors.ts +54 -0
- package/runtime/subsystems/sync/sync-field-diff.protocol.ts +61 -0
- package/runtime/subsystems/sync/sync-loopback.protocol.ts +33 -0
- package/runtime/subsystems/sync/sync-run-recorder.drizzle-backend.ts +123 -0
- package/runtime/subsystems/sync/sync-run-recorder.memory-backend.ts +143 -0
- package/runtime/subsystems/sync/sync-run-recorder.protocol.ts +86 -0
- package/runtime/subsystems/sync/sync-sink.protocol.ts +55 -0
- package/runtime/subsystems/sync/sync.module.ts +156 -0
- package/runtime/subsystems/sync/sync.tokens.ts +57 -0
- package/runtime/types/drizzle.ts +23 -0
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryJobOrchestrator — in-process implementation of `IJobOrchestrator`
|
|
3
|
+
* (ADR-022, JOB-4).
|
|
4
|
+
*
|
|
5
|
+
* Exists solely for the unit test suite: reproduces the Drizzle backend's
|
|
6
|
+
* observable behaviour (claim ordering, collision modes, dedupe collapse,
|
|
7
|
+
* memoization cache, replay row-clearing, cascade cancel) without a
|
|
8
|
+
* database. Not production — the single-process mutex is a substitute for
|
|
9
|
+
* Postgres' `FOR UPDATE SKIP LOCKED`; acceptable non-parity is listed in
|
|
10
|
+
* `docs/specs/JOB-4.md` (fsync, query perf, multi-process claim).
|
|
11
|
+
*
|
|
12
|
+
* The `MemoryJobStore` is shared with `MemoryJobRunService` /
|
|
13
|
+
* `MemoryJobStepService` — all three services mutate the same Maps under
|
|
14
|
+
* the orchestrator's mutex.
|
|
15
|
+
*/
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
|
18
|
+
import { ModuleRef } from '@nestjs/core';
|
|
19
|
+
import type {
|
|
20
|
+
JobDefinitionRow,
|
|
21
|
+
JobRunRow,
|
|
22
|
+
} from './job-orchestration.schema';
|
|
23
|
+
import type {
|
|
24
|
+
CancelOptions,
|
|
25
|
+
IJobOrchestrator,
|
|
26
|
+
JobPoolDef,
|
|
27
|
+
JobRun,
|
|
28
|
+
JobUpsertEntry,
|
|
29
|
+
StartOptions,
|
|
30
|
+
} from './job-orchestrator.protocol';
|
|
31
|
+
import type {
|
|
32
|
+
JobContext,
|
|
33
|
+
JobHandlerBase,
|
|
34
|
+
JobHandlerMeta,
|
|
35
|
+
RetryPolicy,
|
|
36
|
+
SpawnChildOptions,
|
|
37
|
+
StepOptions,
|
|
38
|
+
} from './job-handler.base';
|
|
39
|
+
import { ParentClosePolicy } from './job-handler.base';
|
|
40
|
+
import {
|
|
41
|
+
JobCollisionError,
|
|
42
|
+
JobNotReplayableError,
|
|
43
|
+
JobTemplateFieldMissingError,
|
|
44
|
+
JobTypeNotFoundError,
|
|
45
|
+
MissingTenantIdError,
|
|
46
|
+
} from './jobs-errors';
|
|
47
|
+
import { MemoryJobStore } from './memory-job-store';
|
|
48
|
+
import { MemoryJobStepService } from './job-step-service.memory-backend';
|
|
49
|
+
import { JOBS_MULTI_TENANT } from './jobs-domain.tokens';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Sentinel `run_at` for runs that lost the `queue` collision — they stay
|
|
53
|
+
* unclaimable until the incumbent transitions terminal and the orchestrator
|
|
54
|
+
* advances their `run_at` back to `now()`. Mirrors the Drizzle backend's
|
|
55
|
+
* `claim-time gate` behaviour without requiring a separate claim query.
|
|
56
|
+
*/
|
|
57
|
+
const QUEUED_RUN_AT = new Date(8_640_000_000_000_000); // "distant future"
|
|
58
|
+
const TERMINAL_STATUSES: JobRunRow['status'][] = [
|
|
59
|
+
'completed',
|
|
60
|
+
'failed',
|
|
61
|
+
'timed_out',
|
|
62
|
+
'canceled',
|
|
63
|
+
];
|
|
64
|
+
const DEDUPE_EXCLUDED_STATUSES: JobRunRow['status'][] = ['canceled', 'failed'];
|
|
65
|
+
const IN_FLIGHT_STATUSES: JobRunRow['status'][] = ['pending', 'running'];
|
|
66
|
+
|
|
67
|
+
function isTerminal(status: JobRunRow['status']): boolean {
|
|
68
|
+
return TERMINAL_STATUSES.includes(status);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Mirror of `evaluateKeyTemplate` in the Drizzle backend. Kept private here
|
|
73
|
+
* rather than exported so the memory backend has no dependency on the
|
|
74
|
+
* Drizzle module.
|
|
75
|
+
*/
|
|
76
|
+
function evaluateKeyTemplate(
|
|
77
|
+
template: string,
|
|
78
|
+
input: Record<string, unknown>,
|
|
79
|
+
): string {
|
|
80
|
+
return template.replace(
|
|
81
|
+
/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g,
|
|
82
|
+
(_m, field: string) => {
|
|
83
|
+
const value = input[field];
|
|
84
|
+
if (value === undefined || value === null) {
|
|
85
|
+
throw new JobTemplateFieldMissingError(template, field);
|
|
86
|
+
}
|
|
87
|
+
return String(value);
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Single-promise-chain mutex. Every mutating op on the store goes through
|
|
94
|
+
* `run(...)` so two concurrent `start` calls observe the same sequential
|
|
95
|
+
* consistency Postgres gives us via `FOR UPDATE SKIP LOCKED`. Error
|
|
96
|
+
* swallowing on the chain pointer prevents one failed call from poisoning
|
|
97
|
+
* the queue for subsequent callers.
|
|
98
|
+
*
|
|
99
|
+
* Kept private to this file on purpose — the spec explicitly forbids
|
|
100
|
+
* exporting this; it exists only for the memory backend's internal
|
|
101
|
+
* serialisation.
|
|
102
|
+
*/
|
|
103
|
+
class PromiseMutex {
|
|
104
|
+
private queue: Promise<void> = Promise.resolve();
|
|
105
|
+
|
|
106
|
+
async run<T>(fn: () => Promise<T>): Promise<T> {
|
|
107
|
+
const next = this.queue.then(() => fn());
|
|
108
|
+
// Swallow errors on the chain pointer so a throwing `fn` doesn't
|
|
109
|
+
// permanently reject every future caller.
|
|
110
|
+
this.queue = next.then(
|
|
111
|
+
() => undefined,
|
|
112
|
+
() => undefined,
|
|
113
|
+
);
|
|
114
|
+
return next;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Handler registry entry — class + frozen metadata. */
|
|
119
|
+
interface HandlerRegistration {
|
|
120
|
+
type: string;
|
|
121
|
+
meta: JobHandlerMeta<unknown>;
|
|
122
|
+
handlerClass: new (...args: unknown[]) => JobHandlerBase<unknown>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@Injectable()
|
|
126
|
+
export class MemoryJobOrchestrator implements IJobOrchestrator {
|
|
127
|
+
private readonly logger = new Logger(MemoryJobOrchestrator.name);
|
|
128
|
+
private readonly mutex = new PromiseMutex();
|
|
129
|
+
private readonly handlerRegistry = new Map<string, HandlerRegistration>();
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* `runId → dependent runId[]` — when a run with `concurrencyKey = K`
|
|
133
|
+
* blocks on an incumbent, its id is added here under the incumbent's id.
|
|
134
|
+
* On incumbent terminal transition we advance every dependent's `runAt`
|
|
135
|
+
* back to `now()` so it becomes claimable.
|
|
136
|
+
*/
|
|
137
|
+
private readonly queueBlockers = new Map<string, string[]>();
|
|
138
|
+
|
|
139
|
+
constructor(
|
|
140
|
+
private readonly store: MemoryJobStore,
|
|
141
|
+
private readonly stepService: MemoryJobStepService,
|
|
142
|
+
@Inject(JOBS_MULTI_TENANT) private readonly multiTenant: boolean,
|
|
143
|
+
@Optional() private readonly moduleRef?: ModuleRef,
|
|
144
|
+
) {}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* JOB-8 — mirror of the Drizzle backend's `resolveTenantId`. Returns the
|
|
148
|
+
* value to stamp on `tenant_id` / compare against in memory predicates.
|
|
149
|
+
* Off → always `null`. On + `undefined` → throw. On + `null`/string → pass.
|
|
150
|
+
*/
|
|
151
|
+
private resolveTenantId(
|
|
152
|
+
method: string,
|
|
153
|
+
tenantId: string | null | undefined,
|
|
154
|
+
): string | null {
|
|
155
|
+
if (!this.multiTenant) return null;
|
|
156
|
+
if (tenantId === undefined) throw new MissingTenantIdError(method);
|
|
157
|
+
return tenantId;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ==========================================================================
|
|
161
|
+
// registerHandler — replaces Drizzle's `job` table upsert
|
|
162
|
+
// ==========================================================================
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Populate the in-memory job definition row plus handler class lookup.
|
|
166
|
+
* Called by `JobWorkerModule.onModuleInit` in memory mode, or directly by
|
|
167
|
+
* unit tests that want to seed the registry without NestJS.
|
|
168
|
+
*/
|
|
169
|
+
registerHandler<TInput>(
|
|
170
|
+
type: string,
|
|
171
|
+
meta: JobHandlerMeta<TInput>,
|
|
172
|
+
handlerClass: new (...args: unknown[]) => JobHandlerBase<TInput>,
|
|
173
|
+
): void {
|
|
174
|
+
const concurrencyKeyTemplate =
|
|
175
|
+
(meta.concurrency as { key?: string } | undefined)?.key ?? null;
|
|
176
|
+
const dedupeKeyTemplate =
|
|
177
|
+
(meta.dedupe as { key?: string } | undefined)?.key ?? null;
|
|
178
|
+
const dedupeWindowMs = meta.dedupe?.windowMs ?? null;
|
|
179
|
+
const now = new Date();
|
|
180
|
+
|
|
181
|
+
const def: JobDefinitionRow = {
|
|
182
|
+
type,
|
|
183
|
+
version: 1,
|
|
184
|
+
pool: meta.pool ?? 'batch',
|
|
185
|
+
scopeEntityType: meta.scope?.entity ?? null,
|
|
186
|
+
retryPolicy: meta.retry ?? {
|
|
187
|
+
attempts: 1,
|
|
188
|
+
backoff: 'fixed',
|
|
189
|
+
baseMs: 0,
|
|
190
|
+
},
|
|
191
|
+
timeoutMs: meta.timeoutMs ?? null,
|
|
192
|
+
concurrencyKeyTemplate:
|
|
193
|
+
typeof concurrencyKeyTemplate === 'string' ? concurrencyKeyTemplate : null,
|
|
194
|
+
collisionMode:
|
|
195
|
+
(meta.concurrency?.collisionMode as JobDefinitionRow['collisionMode']) ??
|
|
196
|
+
'queue',
|
|
197
|
+
dedupeKeyTemplate:
|
|
198
|
+
typeof dedupeKeyTemplate === 'string' ? dedupeKeyTemplate : null,
|
|
199
|
+
dedupeWindowMs,
|
|
200
|
+
priorityDefault: 0,
|
|
201
|
+
replayFrom: meta.replayFrom ?? 'last_checkpoint',
|
|
202
|
+
createdAt: now,
|
|
203
|
+
updatedAt: now,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
this.store.jobs.set(type, def);
|
|
207
|
+
this.handlerRegistry.set(type, {
|
|
208
|
+
type,
|
|
209
|
+
meta: meta as JobHandlerMeta<unknown>,
|
|
210
|
+
handlerClass: handlerClass as unknown as new (
|
|
211
|
+
...args: unknown[]
|
|
212
|
+
) => JobHandlerBase<unknown>,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Test helper — look up a registered handler without exposing the map. */
|
|
217
|
+
getHandlerRegistration(type: string): HandlerRegistration | undefined {
|
|
218
|
+
return this.handlerRegistry.get(type);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Boot-time upsert per `IJobOrchestrator.upsertJobRows`. Memory backend
|
|
223
|
+
* just funnels each entry through `registerHandler`. The validator is
|
|
224
|
+
* skipped entirely in memory mode (Q4 resolution 2026-04-19), so the
|
|
225
|
+
* orphaned list is always empty — there are no DB rows to compare against.
|
|
226
|
+
*/
|
|
227
|
+
async upsertJobRows(
|
|
228
|
+
entries: JobUpsertEntry[],
|
|
229
|
+
poolConfig: ReadonlyMap<string, JobPoolDef>,
|
|
230
|
+
): Promise<{ orphaned: string[] }> {
|
|
231
|
+
void poolConfig; // pool validation is the module's responsibility
|
|
232
|
+
for (const entry of entries) {
|
|
233
|
+
this.registerHandler(
|
|
234
|
+
entry.type,
|
|
235
|
+
entry.meta as JobHandlerMeta<unknown>,
|
|
236
|
+
entry.handlerClass as new (...args: unknown[]) => JobHandlerBase<unknown>,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return { orphaned: [] };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ==========================================================================
|
|
243
|
+
// start
|
|
244
|
+
// ==========================================================================
|
|
245
|
+
|
|
246
|
+
async start(
|
|
247
|
+
type: string,
|
|
248
|
+
input: unknown,
|
|
249
|
+
opts: StartOptions = {},
|
|
250
|
+
// BRIDGE-7: signature parity with Drizzle backend. The memory backend
|
|
251
|
+
// has no real transactions (its "atomic" boundary is a process-wide
|
|
252
|
+
// mutex acquired by the body below), so the parameter is intentionally
|
|
253
|
+
// ignored. Accepting it lets EventFlowService unit tests exercise the
|
|
254
|
+
// same code path without two stub orchestrators.
|
|
255
|
+
_tx?: unknown,
|
|
256
|
+
): Promise<JobRun> {
|
|
257
|
+
// JOB-8 — resolve tenant gate outside the mutex so the error throws
|
|
258
|
+
// synchronously-ish from the caller's stack rather than via the mutex's
|
|
259
|
+
// deferred chain (matches Drizzle backend's pre-transaction guard).
|
|
260
|
+
const tenantId = this.resolveTenantId('start', opts.tenantId);
|
|
261
|
+
|
|
262
|
+
return this.mutex.run(async () => {
|
|
263
|
+
const payload = (input ?? {}) as Record<string, unknown>;
|
|
264
|
+
const definition = this.store.jobs.get(type);
|
|
265
|
+
if (!definition) throw new JobTypeNotFoundError(type);
|
|
266
|
+
|
|
267
|
+
// 1. Dedupe — return existing non-excluded run within the window.
|
|
268
|
+
if (definition.dedupeKeyTemplate && definition.dedupeWindowMs) {
|
|
269
|
+
const dedupeKey = evaluateKeyTemplate(
|
|
270
|
+
definition.dedupeKeyTemplate,
|
|
271
|
+
payload,
|
|
272
|
+
);
|
|
273
|
+
const windowStart = Date.now() - definition.dedupeWindowMs;
|
|
274
|
+
const existing = this.findDedupeCandidate(type, dedupeKey, windowStart);
|
|
275
|
+
if (existing) return existing;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 2. Concurrency collision check.
|
|
279
|
+
let concurrencyKey: string | null = null;
|
|
280
|
+
let queueBlockedBy: string | null = null;
|
|
281
|
+
if (definition.concurrencyKeyTemplate) {
|
|
282
|
+
concurrencyKey = evaluateKeyTemplate(
|
|
283
|
+
definition.concurrencyKeyTemplate,
|
|
284
|
+
payload,
|
|
285
|
+
);
|
|
286
|
+
const incumbent = this.findInFlightByConcurrencyKey(concurrencyKey);
|
|
287
|
+
if (incumbent) {
|
|
288
|
+
switch (definition.collisionMode) {
|
|
289
|
+
case 'reject':
|
|
290
|
+
throw new JobCollisionError(type, concurrencyKey, incumbent);
|
|
291
|
+
case 'replace':
|
|
292
|
+
// Cancel incumbent (cascading children). Must happen inside
|
|
293
|
+
// the mutex — call the internal helper, not public `cancel()`
|
|
294
|
+
// (public `cancel` would re-enter the mutex and deadlock).
|
|
295
|
+
// Internal replace path sidesteps the tenant gate — it uses
|
|
296
|
+
// the incumbent's own tenant (same concurrency key implies
|
|
297
|
+
// same tenant in practice, but the gate is bypassed via
|
|
298
|
+
// `incumbent.tenantId` to avoid accidental cross-tenant
|
|
299
|
+
// MissingTenantIdError bubbling from the user's `start` call).
|
|
300
|
+
this.cancelLocked(
|
|
301
|
+
incumbent.id,
|
|
302
|
+
{ cascade: true, reason: 'replaced' },
|
|
303
|
+
incumbent.tenantId,
|
|
304
|
+
);
|
|
305
|
+
break;
|
|
306
|
+
case 'queue':
|
|
307
|
+
queueBlockedBy = incumbent.id;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 3. Resolve lineage.
|
|
314
|
+
const newId = randomUUID();
|
|
315
|
+
let rootRunId: string = newId;
|
|
316
|
+
if (opts.parentRunId) {
|
|
317
|
+
const parent = this.store.runs.get(opts.parentRunId);
|
|
318
|
+
if (!parent) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`parentRunId ${opts.parentRunId} does not reference an existing job_run`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
rootRunId = parent.rootRunId;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 4. Compute dedupe key for the persisted row (separate from dedupe
|
|
327
|
+
// short-circuit above — we store it even when no prior run matched
|
|
328
|
+
// so future dedupe checks see it).
|
|
329
|
+
const dedupeKey = definition.dedupeKeyTemplate
|
|
330
|
+
? evaluateKeyTemplate(definition.dedupeKeyTemplate, payload)
|
|
331
|
+
: null;
|
|
332
|
+
|
|
333
|
+
const now = new Date();
|
|
334
|
+
const runAt = queueBlockedBy
|
|
335
|
+
? QUEUED_RUN_AT
|
|
336
|
+
: (opts.runAt ?? now);
|
|
337
|
+
|
|
338
|
+
const row: JobRunRow = {
|
|
339
|
+
id: newId,
|
|
340
|
+
jobType: type,
|
|
341
|
+
jobVersion: definition.version,
|
|
342
|
+
parentRunId: opts.parentRunId ?? null,
|
|
343
|
+
rootRunId,
|
|
344
|
+
parentClosePolicy: opts.parentClosePolicy ?? 'terminate',
|
|
345
|
+
scopeEntityType: opts.scope?.entityType ?? null,
|
|
346
|
+
scopeEntityId: opts.scope?.entityId ?? null,
|
|
347
|
+
tenantId,
|
|
348
|
+
tags: opts.tags ?? {},
|
|
349
|
+
pool: opts.pool ?? definition.pool,
|
|
350
|
+
priority: opts.priority ?? definition.priorityDefault,
|
|
351
|
+
concurrencyKey,
|
|
352
|
+
dedupeKey,
|
|
353
|
+
status: 'pending',
|
|
354
|
+
input: payload,
|
|
355
|
+
output: null,
|
|
356
|
+
error: null,
|
|
357
|
+
triggerSource: opts.triggerSource ?? 'manual',
|
|
358
|
+
triggerRef: opts.triggerRef ?? null,
|
|
359
|
+
runAt,
|
|
360
|
+
startedAt: null,
|
|
361
|
+
finishedAt: null,
|
|
362
|
+
claimedAt: null,
|
|
363
|
+
attempts: 0,
|
|
364
|
+
waitKind: null,
|
|
365
|
+
resumeToken: null,
|
|
366
|
+
waitDeadline: null,
|
|
367
|
+
createdAt: now,
|
|
368
|
+
updatedAt: now,
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
this.store.runs.set(newId, row);
|
|
372
|
+
if (queueBlockedBy) {
|
|
373
|
+
const list = this.queueBlockers.get(queueBlockedBy) ?? [];
|
|
374
|
+
list.push(newId);
|
|
375
|
+
this.queueBlockers.set(queueBlockedBy, list);
|
|
376
|
+
}
|
|
377
|
+
return row;
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ==========================================================================
|
|
382
|
+
// cancel
|
|
383
|
+
// ==========================================================================
|
|
384
|
+
|
|
385
|
+
async cancel(runId: string, opts: CancelOptions = {}): Promise<void> {
|
|
386
|
+
// JOB-8 — strict tenant gate outside the mutex (matches Drizzle path).
|
|
387
|
+
const tenantId = this.resolveTenantId('cancel', opts.tenantId);
|
|
388
|
+
await this.mutex.run(async () => {
|
|
389
|
+
this.cancelLocked(runId, opts, tenantId);
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Internal cancel that assumes the caller already holds the mutex.
|
|
395
|
+
* Synchronous because all store ops are in-memory. Idempotent.
|
|
396
|
+
*
|
|
397
|
+
* `tenantForGate` is the already-validated tenant id (or `null`). When
|
|
398
|
+
* non-null it gates the initial cancellation to that tenant's run; the
|
|
399
|
+
* cascade step then sweeps descendants on the same `rootRunId` without
|
|
400
|
+
* re-checking — children of a tenant-gated parent always share the
|
|
401
|
+
* tenant (enforced at `start` time).
|
|
402
|
+
*/
|
|
403
|
+
private cancelLocked(
|
|
404
|
+
runId: string,
|
|
405
|
+
opts: CancelOptions,
|
|
406
|
+
tenantForGate: string | null,
|
|
407
|
+
): void {
|
|
408
|
+
const run = this.store.runs.get(runId);
|
|
409
|
+
if (!run) return;
|
|
410
|
+
// JOB-8 — cross-tenant cancel is silent no-op.
|
|
411
|
+
if (this.multiTenant && run.tenantId !== tenantForGate) return;
|
|
412
|
+
if (isTerminal(run.status)) return;
|
|
413
|
+
|
|
414
|
+
const now = new Date();
|
|
415
|
+
|
|
416
|
+
// Collect descendants up front so Cancel-policy parents can wait on
|
|
417
|
+
// children (their `finished_at` is set after children transition).
|
|
418
|
+
const descendants =
|
|
419
|
+
opts.cascade === false
|
|
420
|
+
? []
|
|
421
|
+
: Array.from(this.store.runs.values()).filter(
|
|
422
|
+
(r) =>
|
|
423
|
+
r.rootRunId === run.rootRunId &&
|
|
424
|
+
r.id !== runId &&
|
|
425
|
+
!isTerminal(r.status),
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
// Group by policy stored on the child.
|
|
429
|
+
const terminateChildren = descendants.filter(
|
|
430
|
+
(d) => d.parentClosePolicy === ParentClosePolicy.Terminate,
|
|
431
|
+
);
|
|
432
|
+
const cancelChildren = descendants.filter(
|
|
433
|
+
(d) => d.parentClosePolicy === ParentClosePolicy.Cancel,
|
|
434
|
+
);
|
|
435
|
+
// 'abandon' → do nothing.
|
|
436
|
+
|
|
437
|
+
// Terminate policy: cancel children, then parent.
|
|
438
|
+
for (const child of terminateChildren) {
|
|
439
|
+
this.transitionToCanceled(child.id, now);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Cancel policy: cancel children first, then parent (so parent's
|
|
443
|
+
// finished_at is set only after children transitioned).
|
|
444
|
+
for (const child of cancelChildren) {
|
|
445
|
+
this.transitionToCanceled(child.id, now);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
this.transitionToCanceled(runId, now);
|
|
449
|
+
|
|
450
|
+
void opts.reason; // reserved for future audit logging
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private transitionToCanceled(runId: string, at: Date): void {
|
|
454
|
+
const run = this.store.runs.get(runId);
|
|
455
|
+
if (!run) return;
|
|
456
|
+
if (isTerminal(run.status)) return;
|
|
457
|
+
const next: JobRunRow = {
|
|
458
|
+
...run,
|
|
459
|
+
status: 'canceled',
|
|
460
|
+
finishedAt: at,
|
|
461
|
+
updatedAt: at,
|
|
462
|
+
};
|
|
463
|
+
this.store.runs.set(runId, next);
|
|
464
|
+
this.unblockQueuedDependents(runId);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* When `runId` transitions to a terminal state, advance every dependent
|
|
469
|
+
* `queue`-blocked run's `run_at` back to `now()` so `claimNext` picks
|
|
470
|
+
* them up.
|
|
471
|
+
*/
|
|
472
|
+
private unblockQueuedDependents(runId: string): void {
|
|
473
|
+
const dependents = this.queueBlockers.get(runId);
|
|
474
|
+
if (!dependents || dependents.length === 0) return;
|
|
475
|
+
const now = new Date();
|
|
476
|
+
for (const dep of dependents) {
|
|
477
|
+
const depRun = this.store.runs.get(dep);
|
|
478
|
+
if (!depRun) continue;
|
|
479
|
+
if (depRun.status !== 'pending') continue;
|
|
480
|
+
this.store.runs.set(dep, { ...depRun, runAt: now, updatedAt: now });
|
|
481
|
+
}
|
|
482
|
+
this.queueBlockers.delete(runId);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ==========================================================================
|
|
486
|
+
// claimNext — consumed by JobWorker in memory mode (tests exercise directly)
|
|
487
|
+
// ==========================================================================
|
|
488
|
+
|
|
489
|
+
async claimNext(pool: string): Promise<JobRunRow | null> {
|
|
490
|
+
return this.mutex.run(async () => {
|
|
491
|
+
const now = Date.now();
|
|
492
|
+
const candidates = Array.from(this.store.runs.values()).filter(
|
|
493
|
+
(r) =>
|
|
494
|
+
r.status === 'pending' &&
|
|
495
|
+
r.pool === pool &&
|
|
496
|
+
r.runAt.getTime() <= now,
|
|
497
|
+
);
|
|
498
|
+
if (candidates.length === 0) return null;
|
|
499
|
+
|
|
500
|
+
// ORDER BY priority DESC, run_at ASC (Drizzle parity).
|
|
501
|
+
candidates.sort((a, b) => {
|
|
502
|
+
if (a.priority !== b.priority) return b.priority - a.priority;
|
|
503
|
+
return a.runAt.getTime() - b.runAt.getTime();
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const winner = candidates[0]!;
|
|
507
|
+
const claimedAt = new Date();
|
|
508
|
+
const next: JobRunRow = {
|
|
509
|
+
...winner,
|
|
510
|
+
status: 'running',
|
|
511
|
+
claimedAt,
|
|
512
|
+
startedAt: claimedAt,
|
|
513
|
+
updatedAt: claimedAt,
|
|
514
|
+
};
|
|
515
|
+
this.store.runs.set(winner.id, next);
|
|
516
|
+
return next;
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ==========================================================================
|
|
521
|
+
// replay
|
|
522
|
+
// ==========================================================================
|
|
523
|
+
|
|
524
|
+
async replay(runId: string): Promise<JobRun> {
|
|
525
|
+
return this.mutex.run(async () => {
|
|
526
|
+
const run = this.store.runs.get(runId);
|
|
527
|
+
if (!run) throw new Error(`replay: run ${runId} not found`);
|
|
528
|
+
if (!isTerminal(run.status)) {
|
|
529
|
+
throw new JobNotReplayableError(runId, run.status);
|
|
530
|
+
}
|
|
531
|
+
const def = this.store.jobs.get(run.jobType);
|
|
532
|
+
if (!def) throw new JobTypeNotFoundError(run.jobType);
|
|
533
|
+
|
|
534
|
+
const mode = def.replayFrom;
|
|
535
|
+
if (mode === 'scratch') {
|
|
536
|
+
this.stepService.clearStepsForRun(runId);
|
|
537
|
+
} else {
|
|
538
|
+
// `last_step` and `last_checkpoint` collapse to the same semantic
|
|
539
|
+
// in Phase 1 — delete non-completed rows, preserve memoized ones.
|
|
540
|
+
// Matches the Drizzle backend exactly (see JOB-3 notes).
|
|
541
|
+
this.stepService.clearIncompleteSteps(runId);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const now = new Date();
|
|
545
|
+
const next: JobRunRow = {
|
|
546
|
+
...run,
|
|
547
|
+
status: 'pending',
|
|
548
|
+
attempts: 0,
|
|
549
|
+
runAt: now,
|
|
550
|
+
startedAt: null,
|
|
551
|
+
finishedAt: null,
|
|
552
|
+
claimedAt: null,
|
|
553
|
+
error: null,
|
|
554
|
+
output: null,
|
|
555
|
+
updatedAt: now,
|
|
556
|
+
};
|
|
557
|
+
this.store.runs.set(runId, next);
|
|
558
|
+
return next;
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ==========================================================================
|
|
563
|
+
// tick — used by unit tests + memory-mode JobWorker
|
|
564
|
+
// ==========================================================================
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Execute a single claimed run to completion, retry, or failure. Not on
|
|
568
|
+
* `IJobOrchestrator` — it's the memory equivalent of the Drizzle
|
|
569
|
+
* `JobWorker.processRun` code path. The unit tests drive it directly so
|
|
570
|
+
* they can assert memoization across ticks without spinning up a worker.
|
|
571
|
+
*/
|
|
572
|
+
async tick(runId: string): Promise<void> {
|
|
573
|
+
// We load state outside the mutex because handler execution cannot
|
|
574
|
+
// hold the serialisation lock — `fn()` inside `ctx.step` can call back
|
|
575
|
+
// into `start` / `spawnChild` which would deadlock. Mutation points
|
|
576
|
+
// (recordStep, status transition) go through the services or the
|
|
577
|
+
// orchestrator entry points and re-enter the mutex there.
|
|
578
|
+
const run = this.store.runs.get(runId);
|
|
579
|
+
if (!run) throw new Error(`tick: run ${runId} not found`);
|
|
580
|
+
if (run.status !== 'running') {
|
|
581
|
+
throw new Error(
|
|
582
|
+
`tick: run ${runId} must be 'running' (got '${run.status}')`,
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const registration = this.handlerRegistry.get(run.jobType);
|
|
587
|
+
if (!registration) {
|
|
588
|
+
await this.markFailed(run, new Error(
|
|
589
|
+
`No handler registered for jobType='${run.jobType}'`,
|
|
590
|
+
), (run.attempts ?? 0) + 1);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const meta = registration.meta;
|
|
594
|
+
const HandlerClass = registration.handlerClass;
|
|
595
|
+
// Match the Drizzle backend: resolve the handler through Nest's
|
|
596
|
+
// ModuleRef so `@Inject` constructor params work. ModuleRef is
|
|
597
|
+
// @Optional() — zero-dep test stubs that construct this orchestrator
|
|
598
|
+
// manually still hit the legacy `new HandlerClass()` path.
|
|
599
|
+
const handler = this.moduleRef
|
|
600
|
+
? ((await this.moduleRef.create(
|
|
601
|
+
HandlerClass as unknown as new (...args: unknown[]) => unknown,
|
|
602
|
+
)) as JobHandlerBase<unknown>)
|
|
603
|
+
: new HandlerClass();
|
|
604
|
+
|
|
605
|
+
const ctx: JobContext<unknown> = {
|
|
606
|
+
input: run.input,
|
|
607
|
+
run: run as JobRun,
|
|
608
|
+
step: this.makeStepFn(run),
|
|
609
|
+
spawnChild: this.makeSpawnFn(run),
|
|
610
|
+
logger: new Logger(`JobRun:${run.id}`),
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
const attemptsBefore = run.attempts ?? 0;
|
|
614
|
+
try {
|
|
615
|
+
const output = (await handler.run(ctx)) as Record<string, unknown> | undefined;
|
|
616
|
+
await this.markCompleted(run, output ?? {}, attemptsBefore + 1);
|
|
617
|
+
} catch (err) {
|
|
618
|
+
const policy = meta.retry;
|
|
619
|
+
const decision = classifyError(err, policy, attemptsBefore);
|
|
620
|
+
const nextAttempts = attemptsBefore + 1;
|
|
621
|
+
if (decision === 'retry' && policy) {
|
|
622
|
+
const delay = computeBackoff(policy, nextAttempts);
|
|
623
|
+
await this.rescheduleForRetry(run, err, nextAttempts, delay);
|
|
624
|
+
} else {
|
|
625
|
+
await this.markFailed(run, err, nextAttempts);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
private makeStepFn(run: JobRunRow) {
|
|
631
|
+
return async <TOutput>(
|
|
632
|
+
stepId: string,
|
|
633
|
+
fn: () => Promise<TOutput>,
|
|
634
|
+
_opts?: StepOptions,
|
|
635
|
+
): Promise<TOutput> => {
|
|
636
|
+
void _opts;
|
|
637
|
+
const existing = await this.stepService.findStep(run.id, stepId);
|
|
638
|
+
if (existing?.status === 'completed') {
|
|
639
|
+
return existing.output as TOutput;
|
|
640
|
+
}
|
|
641
|
+
const seq = this.nextStepSeq(run.id);
|
|
642
|
+
const startedAt = new Date();
|
|
643
|
+
const nextAttempts = (existing?.attempts ?? 0) + 1;
|
|
644
|
+
await this.stepService.recordStep({
|
|
645
|
+
jobRunId: run.id,
|
|
646
|
+
stepId,
|
|
647
|
+
kind: 'task',
|
|
648
|
+
seq,
|
|
649
|
+
status: 'running',
|
|
650
|
+
startedAt,
|
|
651
|
+
attempts: nextAttempts,
|
|
652
|
+
});
|
|
653
|
+
try {
|
|
654
|
+
const output = await fn();
|
|
655
|
+
await this.stepService.recordStep({
|
|
656
|
+
jobRunId: run.id,
|
|
657
|
+
stepId,
|
|
658
|
+
kind: 'task',
|
|
659
|
+
seq,
|
|
660
|
+
status: 'completed',
|
|
661
|
+
output: output as Record<string, unknown> | undefined,
|
|
662
|
+
finishedAt: new Date(),
|
|
663
|
+
attempts: nextAttempts,
|
|
664
|
+
});
|
|
665
|
+
return output;
|
|
666
|
+
} catch (err) {
|
|
667
|
+
await this.stepService.recordStep({
|
|
668
|
+
jobRunId: run.id,
|
|
669
|
+
stepId,
|
|
670
|
+
kind: 'task',
|
|
671
|
+
seq,
|
|
672
|
+
status: 'failed',
|
|
673
|
+
error: serialiseError(err, nextAttempts, false),
|
|
674
|
+
finishedAt: new Date(),
|
|
675
|
+
attempts: nextAttempts,
|
|
676
|
+
});
|
|
677
|
+
throw err;
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
private makeSpawnFn(run: JobRunRow) {
|
|
683
|
+
return async (
|
|
684
|
+
type: string,
|
|
685
|
+
input: unknown,
|
|
686
|
+
opts?: SpawnChildOptions,
|
|
687
|
+
): Promise<JobRun> => {
|
|
688
|
+
return this.start(type, input, {
|
|
689
|
+
parentRunId: run.id,
|
|
690
|
+
parentClosePolicy: opts?.closePolicy,
|
|
691
|
+
runAt: opts?.runAt,
|
|
692
|
+
priority: opts?.priority,
|
|
693
|
+
tags: opts?.tags,
|
|
694
|
+
triggerSource: 'parent',
|
|
695
|
+
triggerRef: run.id,
|
|
696
|
+
});
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
private nextStepSeq(runId: string): number {
|
|
701
|
+
const rows = this.store.steps.get(runId);
|
|
702
|
+
if (!rows || rows.length === 0) return 1;
|
|
703
|
+
let max = 0;
|
|
704
|
+
for (const r of rows) if (r.seq > max) max = r.seq;
|
|
705
|
+
return max + 1;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private async markCompleted(
|
|
709
|
+
run: JobRunRow,
|
|
710
|
+
output: Record<string, unknown>,
|
|
711
|
+
attempts: number,
|
|
712
|
+
): Promise<void> {
|
|
713
|
+
await this.mutex.run(async () => {
|
|
714
|
+
const current = this.store.runs.get(run.id);
|
|
715
|
+
if (!current || isTerminal(current.status)) return;
|
|
716
|
+
const now = new Date();
|
|
717
|
+
this.store.runs.set(run.id, {
|
|
718
|
+
...current,
|
|
719
|
+
status: 'completed',
|
|
720
|
+
output,
|
|
721
|
+
finishedAt: now,
|
|
722
|
+
updatedAt: now,
|
|
723
|
+
attempts,
|
|
724
|
+
});
|
|
725
|
+
this.unblockQueuedDependents(run.id);
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
private async markFailed(
|
|
730
|
+
run: JobRunRow,
|
|
731
|
+
err: unknown,
|
|
732
|
+
attempts: number,
|
|
733
|
+
): Promise<void> {
|
|
734
|
+
await this.mutex.run(async () => {
|
|
735
|
+
const current = this.store.runs.get(run.id);
|
|
736
|
+
if (!current || isTerminal(current.status)) return;
|
|
737
|
+
const now = new Date();
|
|
738
|
+
this.store.runs.set(run.id, {
|
|
739
|
+
...current,
|
|
740
|
+
status: 'failed',
|
|
741
|
+
finishedAt: now,
|
|
742
|
+
updatedAt: now,
|
|
743
|
+
attempts,
|
|
744
|
+
error: serialiseError(err, attempts, false),
|
|
745
|
+
});
|
|
746
|
+
this.unblockQueuedDependents(run.id);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
// parent_close_policy = 'terminate' cascade mirrors the Drizzle worker
|
|
750
|
+
// (cancel runs outside its own terminal transition). We pass the run's
|
|
751
|
+
// own `tenantId` so the cancel passes the multi-tenant gate — this is
|
|
752
|
+
// system-internal cascade, not a user-initiated call.
|
|
753
|
+
if (run.parentClosePolicy === 'terminate') {
|
|
754
|
+
try {
|
|
755
|
+
await this.cancel(run.id, {
|
|
756
|
+
cascade: true,
|
|
757
|
+
reason: 'parent-failed',
|
|
758
|
+
tenantId: run.tenantId,
|
|
759
|
+
});
|
|
760
|
+
} catch (cascadeErr) {
|
|
761
|
+
this.logger.warn(
|
|
762
|
+
`cascade on failed run ${run.id}: ${(cascadeErr as Error).message}`,
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
private async rescheduleForRetry(
|
|
769
|
+
run: JobRunRow,
|
|
770
|
+
err: unknown,
|
|
771
|
+
attempts: number,
|
|
772
|
+
delayMs: number,
|
|
773
|
+
): Promise<void> {
|
|
774
|
+
await this.mutex.run(async () => {
|
|
775
|
+
const current = this.store.runs.get(run.id);
|
|
776
|
+
if (!current || isTerminal(current.status)) return;
|
|
777
|
+
const now = new Date();
|
|
778
|
+
this.store.runs.set(run.id, {
|
|
779
|
+
...current,
|
|
780
|
+
status: 'pending',
|
|
781
|
+
attempts,
|
|
782
|
+
runAt: new Date(Date.now() + delayMs),
|
|
783
|
+
startedAt: null,
|
|
784
|
+
claimedAt: null,
|
|
785
|
+
updatedAt: now,
|
|
786
|
+
error: serialiseError(err, attempts, true),
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// ==========================================================================
|
|
792
|
+
// Internal queries — used by start / cancel
|
|
793
|
+
// ==========================================================================
|
|
794
|
+
|
|
795
|
+
private findDedupeCandidate(
|
|
796
|
+
jobType: string,
|
|
797
|
+
dedupeKey: string,
|
|
798
|
+
windowStartMs: number,
|
|
799
|
+
): JobRunRow | null {
|
|
800
|
+
let best: JobRunRow | null = null;
|
|
801
|
+
for (const r of this.store.runs.values()) {
|
|
802
|
+
if (r.jobType !== jobType) continue;
|
|
803
|
+
if (r.dedupeKey !== dedupeKey) continue;
|
|
804
|
+
if (DEDUPE_EXCLUDED_STATUSES.includes(r.status)) continue;
|
|
805
|
+
if (r.createdAt.getTime() <= windowStartMs) continue;
|
|
806
|
+
if (!best || r.createdAt.getTime() > best.createdAt.getTime()) {
|
|
807
|
+
best = r;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return best;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
private findInFlightByConcurrencyKey(key: string): JobRunRow | null {
|
|
814
|
+
for (const r of this.store.runs.values()) {
|
|
815
|
+
if (r.concurrencyKey !== key) continue;
|
|
816
|
+
if (!IN_FLIGHT_STATUSES.includes(r.status)) continue;
|
|
817
|
+
return r;
|
|
818
|
+
}
|
|
819
|
+
return null;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// ─── Pure helpers (mirrored from JobWorker so memory mode is standalone) ────
|
|
824
|
+
|
|
825
|
+
function classifyError(
|
|
826
|
+
err: unknown,
|
|
827
|
+
policy: RetryPolicy | undefined,
|
|
828
|
+
currentAttempts: number,
|
|
829
|
+
): 'retry' | 'fail' {
|
|
830
|
+
if (!policy) return 'fail';
|
|
831
|
+
const errObj = err as { name?: string; code?: string } | undefined;
|
|
832
|
+
const name = errObj?.name;
|
|
833
|
+
const code = errObj?.code;
|
|
834
|
+
const nonRetryable = policy.nonRetryableErrors ?? [];
|
|
835
|
+
if (nonRetryable.some((n) => n === name || n === code)) return 'fail';
|
|
836
|
+
if (currentAttempts + 1 >= policy.attempts) return 'fail';
|
|
837
|
+
return 'retry';
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function computeBackoff(policy: RetryPolicy, attempts: number): number {
|
|
841
|
+
const base = Math.max(policy.baseMs, 0);
|
|
842
|
+
if (policy.backoff === 'fixed') return base;
|
|
843
|
+
const exponent = Math.max(attempts - 1, 0);
|
|
844
|
+
if (exponent >= 53) return Number.MAX_SAFE_INTEGER;
|
|
845
|
+
const raw = base * Math.pow(2, exponent);
|
|
846
|
+
if (!Number.isFinite(raw) || raw >= Number.MAX_SAFE_INTEGER) {
|
|
847
|
+
return Number.MAX_SAFE_INTEGER;
|
|
848
|
+
}
|
|
849
|
+
return raw;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function serialiseError(err: unknown, attempt: number, retryable: boolean) {
|
|
853
|
+
const e = err as { message?: string; stack?: string } | undefined;
|
|
854
|
+
return {
|
|
855
|
+
message: (e?.message ?? String(err)) as string,
|
|
856
|
+
stack: e?.stack,
|
|
857
|
+
retryable,
|
|
858
|
+
attempt,
|
|
859
|
+
};
|
|
860
|
+
}
|