@pattern-stack/codegen 0.8.0 → 0.9.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/CHANGELOG.md +70 -0
- package/dist/runtime/subsystems/auth/controllers/auth.controller.d.ts +1 -0
- package/dist/runtime/subsystems/auth/index.d.ts +2 -0
- package/dist/runtime/subsystems/auth/index.js +55 -0
- package/dist/runtime/subsystems/auth/index.js.map +1 -1
- package/dist/runtime/subsystems/auth/middleware/requester-context.d.ts +81 -0
- package/dist/runtime/subsystems/auth/middleware/requester-context.js +60 -0
- package/dist/runtime/subsystems/auth/middleware/requester-context.js.map +1 -0
- package/dist/runtime/subsystems/auth/protocols/user-context.d.ts +18 -0
- package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js.map +1 -1
- package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js.map +1 -1
- package/dist/runtime/subsystems/bridge/bridge.module.d.ts +3 -0
- package/dist/runtime/subsystems/bridge/bridge.module.js +930 -275
- package/dist/runtime/subsystems/bridge/bridge.module.js.map +1 -1
- package/dist/runtime/subsystems/bridge/event-flow.service.js.map +1 -1
- package/dist/runtime/subsystems/bridge/index.d.ts +3 -0
- package/dist/runtime/subsystems/bridge/index.js +837 -182
- package/dist/runtime/subsystems/bridge/index.js.map +1 -1
- package/dist/runtime/subsystems/events/event-bus.drizzle-backend.d.ts +3 -1
- package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +92 -1
- package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js.map +1 -1
- package/dist/runtime/subsystems/events/event-bus.memory-backend.d.ts +3 -1
- package/dist/runtime/subsystems/events/event-bus.memory-backend.js +99 -0
- package/dist/runtime/subsystems/events/event-bus.memory-backend.js.map +1 -1
- package/dist/runtime/subsystems/events/event-bus.redis-backend.js.map +1 -1
- package/dist/runtime/subsystems/events/event-keyset-cursor.d.ts +32 -0
- package/dist/runtime/subsystems/events/event-keyset-cursor.js +38 -0
- package/dist/runtime/subsystems/events/event-keyset-cursor.js.map +1 -0
- package/dist/runtime/subsystems/events/event-read.protocol.d.ts +94 -0
- package/dist/runtime/subsystems/events/event-read.protocol.js +9 -0
- package/dist/runtime/subsystems/events/event-read.protocol.js.map +1 -0
- package/dist/runtime/subsystems/events/events.module.js +177 -3
- package/dist/runtime/subsystems/events/events.module.js.map +1 -1
- package/dist/runtime/subsystems/events/events.tokens.d.ts +16 -1
- package/dist/runtime/subsystems/events/events.tokens.js +2 -0
- package/dist/runtime/subsystems/events/events.tokens.js.map +1 -1
- package/dist/runtime/subsystems/events/generated/bus.js.map +1 -1
- package/dist/runtime/subsystems/events/generated/index.js.map +1 -1
- package/dist/runtime/subsystems/events/index.d.ts +2 -1
- package/dist/runtime/subsystems/events/index.js +178 -3
- package/dist/runtime/subsystems/events/index.js.map +1 -1
- package/dist/runtime/subsystems/index.d.ts +2 -0
- package/dist/runtime/subsystems/index.js +1198 -264
- package/dist/runtime/subsystems/index.js.map +1 -1
- package/dist/runtime/subsystems/jobs/bullmq.config.d.ts +98 -0
- package/dist/runtime/subsystems/jobs/bullmq.config.js +143 -0
- package/dist/runtime/subsystems/jobs/bullmq.config.js.map +1 -0
- package/dist/runtime/subsystems/jobs/index.d.ts +6 -2
- package/dist/runtime/subsystems/jobs/index.js +861 -201
- package/dist/runtime/subsystems/jobs/index.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.d.ts +107 -0
- package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +922 -0
- package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js.map +1 -0
- package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.d.ts +52 -0
- package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js +57 -0
- package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js.map +1 -0
- package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.d.ts +2 -1
- package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +81 -1
- package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.d.ts +2 -1
- package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +81 -0
- package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-run-service.protocol.d.ts +74 -1
- package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.d.ts +48 -0
- package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +374 -0
- package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js.map +1 -0
- package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +42 -4
- package/dist/runtime/subsystems/jobs/job-worker.module.js +832 -178
- package/dist/runtime/subsystems/jobs/job-worker.module.js.map +1 -1
- package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +10 -1
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js +519 -20
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js.map +1 -1
- package/dist/runtime/subsystems/jobs/pool-config.loader.d.ts +9 -1
- package/dist/runtime/subsystems/jobs/pool-config.loader.js +4 -0
- package/dist/runtime/subsystems/jobs/pool-config.loader.js.map +1 -1
- package/dist/runtime/subsystems/observability/index.d.ts +4 -3
- package/dist/runtime/subsystems/observability/index.js +109 -2
- package/dist/runtime/subsystems/observability/index.js.map +1 -1
- package/dist/runtime/subsystems/observability/observability.module.js +109 -2
- package/dist/runtime/subsystems/observability/observability.module.js.map +1 -1
- package/dist/runtime/subsystems/observability/observability.protocol.d.ts +63 -2
- package/dist/runtime/subsystems/observability/observability.service.d.ts +21 -3
- package/dist/runtime/subsystems/observability/observability.service.js +109 -2
- package/dist/runtime/subsystems/observability/observability.service.js.map +1 -1
- package/dist/runtime/subsystems/observability/reporters/bridge-metrics.reporter.d.ts +1 -0
- package/dist/runtime/subsystems/observability/reporters/index.d.ts +1 -0
- package/dist/src/cli/index.js +43 -7
- package/dist/src/cli/index.js.map +1 -1
- package/package.json +1 -1
- package/runtime/subsystems/auth/index.ts +8 -0
- package/runtime/subsystems/auth/middleware/requester-context.ts +141 -0
- package/runtime/subsystems/auth/protocols/user-context.ts +17 -0
- package/runtime/subsystems/bridge/bridge.module.ts +5 -0
- package/runtime/subsystems/events/event-bus.drizzle-backend.ts +109 -3
- package/runtime/subsystems/events/event-bus.memory-backend.ts +103 -1
- package/runtime/subsystems/events/event-keyset-cursor.ts +59 -0
- package/runtime/subsystems/events/event-read.protocol.ts +97 -0
- package/runtime/subsystems/events/events.module.ts +18 -2
- package/runtime/subsystems/events/events.tokens.ts +16 -0
- package/runtime/subsystems/events/index.ts +7 -0
- package/runtime/subsystems/jobs/bullmq.config.ts +125 -0
- package/runtime/subsystems/jobs/index.ts +22 -0
- package/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.ts +381 -0
- package/runtime/subsystems/jobs/job-run-keyset-cursor.ts +88 -0
- package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +59 -1
- package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +53 -0
- package/runtime/subsystems/jobs/job-run-service.protocol.ts +77 -0
- package/runtime/subsystems/jobs/job-worker.bullmq-backend.ts +311 -0
- package/runtime/subsystems/jobs/job-worker.module.ts +124 -10
- package/runtime/subsystems/jobs/jobs-domain.module.ts +40 -21
- package/runtime/subsystems/jobs/pool-config.loader.ts +11 -0
- package/runtime/subsystems/observability/index.ts +8 -0
- package/runtime/subsystems/observability/observability.protocol.ts +76 -0
- package/runtime/subsystems/observability/observability.service.ts +148 -1
- package/templates/entity/new/clean-lite-ps/prompt-extension.js +14 -12
- package/templates/relationship/new/prompt.js +8 -5
- package/templates/subsystem/jobs/worker.ejs.t +30 -7
- package/templates/subsystem/sync/sync-audit.schema.ejs.t +12 -16
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* `idx_job_run_scope`, whereas orchestrator mutates individual runs by id.
|
|
8
8
|
*/
|
|
9
9
|
import { Inject, Injectable } from '@nestjs/common';
|
|
10
|
-
import { and, asc, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
|
|
10
|
+
import { and, asc, desc, eq, gte, inArray, isNull, lt, or, sql } from 'drizzle-orm';
|
|
11
11
|
import type { DrizzleClient } from '../../types/drizzle';
|
|
12
12
|
import { DRIZZLE } from '../../constants/tokens';
|
|
13
13
|
import { jobRuns, type JobRunRow } from './job-orchestration.schema';
|
|
@@ -19,7 +19,16 @@ import type {
|
|
|
19
19
|
RescheduleForScopeOptions,
|
|
20
20
|
PoolStatusCount,
|
|
21
21
|
JobRunFailure,
|
|
22
|
+
ListJobRunsQuery,
|
|
23
|
+
JobRunPage,
|
|
24
|
+
JobRunSummary,
|
|
22
25
|
} from './job-run-service.protocol';
|
|
26
|
+
import {
|
|
27
|
+
clampLimit,
|
|
28
|
+
decodeKeysetCursor,
|
|
29
|
+
encodeKeysetCursor,
|
|
30
|
+
toJobRunSummary,
|
|
31
|
+
} from './job-run-keyset-cursor';
|
|
23
32
|
import type { IJobOrchestrator } from './job-orchestrator.protocol';
|
|
24
33
|
import { JOB_ORCHESTRATOR, JOBS_MULTI_TENANT } from './jobs-domain.tokens';
|
|
25
34
|
import { MissingTenantIdError } from './jobs-errors';
|
|
@@ -208,6 +217,55 @@ export class DrizzleJobRunService implements IJobRunService {
|
|
|
208
217
|
}));
|
|
209
218
|
}
|
|
210
219
|
|
|
220
|
+
async listJobRuns(query: ListJobRunsQuery = {}): Promise<JobRunPage> {
|
|
221
|
+
const limit = clampLimit(query.limit);
|
|
222
|
+
const conditions = [];
|
|
223
|
+
|
|
224
|
+
const tenantCond = this.tenantCondition('listJobRuns', query.tenantId);
|
|
225
|
+
if (tenantCond) conditions.push(tenantCond);
|
|
226
|
+
if (query.poolId) conditions.push(eq(jobRuns.pool, query.poolId));
|
|
227
|
+
if (query.rootRunId) conditions.push(eq(jobRuns.rootRunId, query.rootRunId));
|
|
228
|
+
if (query.status) conditions.push(eq(jobRuns.status, query.status));
|
|
229
|
+
if (query.since) conditions.push(gte(jobRuns.createdAt, query.since));
|
|
230
|
+
|
|
231
|
+
// Keyset seek: WHERE (created_at, id) < (cursorCreatedAt, cursorId),
|
|
232
|
+
// expanded into a SARGable OR so the same `created_at` index is used.
|
|
233
|
+
if (query.cursor) {
|
|
234
|
+
const keyset = decodeKeysetCursor(query.cursor);
|
|
235
|
+
if (keyset) {
|
|
236
|
+
conditions.push(
|
|
237
|
+
or(
|
|
238
|
+
lt(jobRuns.createdAt, keyset.createdAt),
|
|
239
|
+
and(
|
|
240
|
+
eq(jobRuns.createdAt, keyset.createdAt),
|
|
241
|
+
lt(jobRuns.id, keyset.id),
|
|
242
|
+
),
|
|
243
|
+
)!,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Fetch one extra row to determine whether a next page exists without a
|
|
249
|
+
// separate COUNT.
|
|
250
|
+
const rows = await this.db
|
|
251
|
+
.select()
|
|
252
|
+
.from(jobRuns)
|
|
253
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
254
|
+
.orderBy(desc(jobRuns.createdAt), desc(jobRuns.id))
|
|
255
|
+
.limit(limit + 1);
|
|
256
|
+
|
|
257
|
+
const hasMore = rows.length > limit;
|
|
258
|
+
const page = hasMore ? rows.slice(0, limit) : rows;
|
|
259
|
+
const items = page.map(toJobRunSummary);
|
|
260
|
+
const last = page[page.length - 1];
|
|
261
|
+
const nextCursor =
|
|
262
|
+
hasMore && last
|
|
263
|
+
? encodeKeysetCursor({ createdAt: last.createdAt, id: last.id })
|
|
264
|
+
: null;
|
|
265
|
+
|
|
266
|
+
return { items, nextCursor };
|
|
267
|
+
}
|
|
268
|
+
|
|
211
269
|
/**
|
|
212
270
|
* Internal helper used by cascade paths (not on the public protocol).
|
|
213
271
|
* Exposed as a public method on the concrete class so infrastructure
|
|
@@ -16,7 +16,15 @@ import type {
|
|
|
16
16
|
RescheduleForScopeOptions,
|
|
17
17
|
PoolStatusCount,
|
|
18
18
|
JobRunFailure,
|
|
19
|
+
ListJobRunsQuery,
|
|
20
|
+
JobRunPage,
|
|
19
21
|
} from './job-run-service.protocol';
|
|
22
|
+
import {
|
|
23
|
+
clampLimit,
|
|
24
|
+
decodeKeysetCursor,
|
|
25
|
+
encodeKeysetCursor,
|
|
26
|
+
toJobRunSummary,
|
|
27
|
+
} from './job-run-keyset-cursor';
|
|
20
28
|
import type { IJobOrchestrator } from './job-orchestrator.protocol';
|
|
21
29
|
import { JOB_ORCHESTRATOR, JOBS_MULTI_TENANT } from './jobs-domain.tokens';
|
|
22
30
|
import { MissingTenantIdError } from './jobs-errors';
|
|
@@ -177,6 +185,51 @@ export class MemoryJobRunService implements IJobRunService {
|
|
|
177
185
|
}));
|
|
178
186
|
}
|
|
179
187
|
|
|
188
|
+
async listJobRuns(query: ListJobRunsQuery = {}): Promise<JobRunPage> {
|
|
189
|
+
const limit = clampLimit(query.limit);
|
|
190
|
+
const tenantCheck = this.tenantPredicate('listJobRuns', query.tenantId);
|
|
191
|
+
const keyset = query.cursor ? decodeKeysetCursor(query.cursor) : null;
|
|
192
|
+
|
|
193
|
+
const matched: JobRunRow[] = [];
|
|
194
|
+
for (const r of this.store.runs.values()) {
|
|
195
|
+
if (tenantCheck && !tenantCheck(r)) continue;
|
|
196
|
+
if (query.poolId && r.pool !== query.poolId) continue;
|
|
197
|
+
if (query.rootRunId && r.rootRunId !== query.rootRunId) continue;
|
|
198
|
+
if (query.status && r.status !== query.status) continue;
|
|
199
|
+
if (query.since && r.createdAt.getTime() < query.since.getTime()) continue;
|
|
200
|
+
matched.push(r);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Order created_at DESC, id DESC to match the Drizzle backend's keyset.
|
|
204
|
+
matched.sort((a, b) => {
|
|
205
|
+
const dt = b.createdAt.getTime() - a.createdAt.getTime();
|
|
206
|
+
if (dt !== 0) return dt;
|
|
207
|
+
return a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Keyset seek: drop everything at/after the cursor's (created_at, id).
|
|
211
|
+
const seeked = keyset
|
|
212
|
+
? matched.filter((r) => {
|
|
213
|
+
const ct = r.createdAt.getTime();
|
|
214
|
+
const kt = keyset.createdAt.getTime();
|
|
215
|
+
if (ct < kt) return true;
|
|
216
|
+
if (ct > kt) return false;
|
|
217
|
+
return r.id < keyset.id;
|
|
218
|
+
})
|
|
219
|
+
: matched;
|
|
220
|
+
|
|
221
|
+
const hasMore = seeked.length > limit;
|
|
222
|
+
const page = hasMore ? seeked.slice(0, limit) : seeked;
|
|
223
|
+
const items = page.map(toJobRunSummary);
|
|
224
|
+
const last = page[page.length - 1];
|
|
225
|
+
const nextCursor =
|
|
226
|
+
hasMore && last
|
|
227
|
+
? encodeKeysetCursor({ createdAt: last.createdAt, id: last.id })
|
|
228
|
+
: null;
|
|
229
|
+
|
|
230
|
+
return { items, nextCursor };
|
|
231
|
+
}
|
|
232
|
+
|
|
180
233
|
/**
|
|
181
234
|
* Direct lookup. Not on the protocol — concrete-class convenience for
|
|
182
235
|
* tests. Matches `DrizzleJobRunService.findByRootRunId` in spirit; both
|
|
@@ -54,6 +54,75 @@ export interface PoolStatusCount {
|
|
|
54
54
|
count: number;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Filter + keyset-pagination input for `IJobRunService.listJobRuns`
|
|
59
|
+
* (OBS-LIST-1). The combiner's `listJobRuns` forwards this verbatim.
|
|
60
|
+
*
|
|
61
|
+
* Pagination is keyset (a.k.a. seek) on `created_at` descending: pass the
|
|
62
|
+
* previous page's `nextCursor` as `cursor` to fetch the following page.
|
|
63
|
+
* Keyset (not offset) so deep pages stay O(log n) and don't drift as new
|
|
64
|
+
* rows arrive at the head.
|
|
65
|
+
*/
|
|
66
|
+
export interface ListJobRunsQuery {
|
|
67
|
+
/** Filter to a single `pool`. */
|
|
68
|
+
poolId?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Filter to a single run tree by `root_run_id`. Used by the correlation
|
|
71
|
+
* timeline to gather every run sharing a root.
|
|
72
|
+
*/
|
|
73
|
+
rootRunId?: string;
|
|
74
|
+
/** Filter to a single status. Accepts any `JobRun['status']`. */
|
|
75
|
+
status?: JobRun['status'];
|
|
76
|
+
/** Lower bound on `created_at` (inclusive). */
|
|
77
|
+
since?: Date;
|
|
78
|
+
/**
|
|
79
|
+
* Opaque keyset cursor returned as `nextCursor` from a previous page.
|
|
80
|
+
* Encodes the `(createdAt, id)` of the last row seen.
|
|
81
|
+
*/
|
|
82
|
+
cursor?: string;
|
|
83
|
+
/** Page size. Backend clamps to a sane default + max. */
|
|
84
|
+
limit?: number;
|
|
85
|
+
/**
|
|
86
|
+
* Multi-tenancy gate, same semantics as `countByPoolAndStatus`:
|
|
87
|
+
* - `multiTenant` off → ignored.
|
|
88
|
+
* - on + string → filters `tenant_id = :tenantId`.
|
|
89
|
+
* - on + null → filters `tenant_id IS NULL`.
|
|
90
|
+
* - on + undefined → throws `MissingTenantIdError`.
|
|
91
|
+
*/
|
|
92
|
+
tenantId?: string | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Summary row for the `job_run` list (OBS-LIST-1). A narrow projection over
|
|
97
|
+
* `JobRun` carrying the columns a runs viewer renders. `rootRunId` is
|
|
98
|
+
* included so the correlation timeline can stitch runs to events.
|
|
99
|
+
*/
|
|
100
|
+
export interface JobRunSummary {
|
|
101
|
+
runId: string;
|
|
102
|
+
rootRunId: string;
|
|
103
|
+
jobType: string;
|
|
104
|
+
pool: string;
|
|
105
|
+
status: JobRun['status'];
|
|
106
|
+
scopeEntityType: string | null;
|
|
107
|
+
scopeEntityId: string | null;
|
|
108
|
+
tenantId: string | null;
|
|
109
|
+
attempts: number;
|
|
110
|
+
errorMessage: string | null;
|
|
111
|
+
runAt: Date;
|
|
112
|
+
startedAt: Date | null;
|
|
113
|
+
finishedAt: Date | null;
|
|
114
|
+
createdAt: Date;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One page of `listJobRuns` results. `nextCursor` is `null` when there are
|
|
119
|
+
* no more rows; otherwise pass it back as `query.cursor` for the next page.
|
|
120
|
+
*/
|
|
121
|
+
export interface JobRunPage {
|
|
122
|
+
items: JobRunSummary[];
|
|
123
|
+
nextCursor: string | null;
|
|
124
|
+
}
|
|
125
|
+
|
|
57
126
|
/**
|
|
58
127
|
* Summary row for the "recent failed runs" observability widget (OBS-2). A
|
|
59
128
|
* narrow projection over `JobRun` — just the fields a dashboard needs.
|
|
@@ -122,4 +191,12 @@ export interface IJobRunService {
|
|
|
122
191
|
limit: number,
|
|
123
192
|
tenantId?: string | null,
|
|
124
193
|
): Promise<JobRunFailure[]>;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Paginated, filterable list of `job_run` rows for the observability runs
|
|
197
|
+
* viewer (OBS-LIST-1). Newest first (`created_at` desc, `id` desc as the
|
|
198
|
+
* keyset tie-break). Returns a `JobRunPage` with an opaque `nextCursor`
|
|
199
|
+
* for keyset pagination. Tenant gate follows `countByPoolAndStatus`.
|
|
200
|
+
*/
|
|
201
|
+
listJobRuns(query?: ListJobRunsQuery): Promise<JobRunPage>;
|
|
125
202
|
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BullMQJobWorker — BullMQ-backed claim/dispatch worker (BULLMQ-1).
|
|
3
|
+
*
|
|
4
|
+
* Replaces the Drizzle `JobWorker` polling loop with one BullMQ `Worker` per
|
|
5
|
+
* active pool. BullMQ owns claim (its native atomic BRPOPLPUSH), concurrency
|
|
6
|
+
* (`{ concurrency }`), and retry/backoff (job opts set by the orchestrator) —
|
|
7
|
+
* so this class is thinner than the Drizzle poller: no claim query, no stale
|
|
8
|
+
* sweeper, no backoff math.
|
|
9
|
+
*
|
|
10
|
+
* The processor still drives the domain through Postgres `job_run` (the
|
|
11
|
+
* source of truth) and runs the user handler through the existing
|
|
12
|
+
* `JobHandlerBase` contract (`ctx.input` / `ctx.step` / `ctx.spawnChild`),
|
|
13
|
+
* identical to the Drizzle path — only the claim mechanism differs.
|
|
14
|
+
*
|
|
15
|
+
* BullMQ job (runId) → load job_run → mark running → resolve handler via
|
|
16
|
+
* ModuleRef → run(ctx) → mark completed / let BullMQ retry on throw.
|
|
17
|
+
*
|
|
18
|
+
* On a thrown handler error we rethrow so BullMQ applies the job's `attempts`/
|
|
19
|
+
* `backoff` policy; the final failure (attempts exhausted) is mirrored to
|
|
20
|
+
* `job_run.status='failed'` in the `failed` event handler.
|
|
21
|
+
*/
|
|
22
|
+
import { Logger } from '@nestjs/common';
|
|
23
|
+
import type { ModuleRef } from '@nestjs/core';
|
|
24
|
+
// `bullmq` is an OPTIONAL peer dependency — TYPE imports ONLY here. `Worker`,
|
|
25
|
+
// `Job`, `ConnectionOptions` are erased at compile time and never resolve
|
|
26
|
+
// `'bullmq'` at runtime. The `Worker` VALUE constructor is loaded lazily via
|
|
27
|
+
// `await import('bullmq')` in `onModuleInit` (mirrors
|
|
28
|
+
// `event-bus.redis-backend.ts:createRedisClient`). See BULLMQ-1 §Lazy import.
|
|
29
|
+
import type { Worker, Job, ConnectionOptions } from 'bullmq';
|
|
30
|
+
import { eq } from 'drizzle-orm';
|
|
31
|
+
import type { DrizzleClient } from '../../types/drizzle';
|
|
32
|
+
import { jobRuns, type JobRunRow } from './job-orchestration.schema';
|
|
33
|
+
import type { IJobOrchestrator, JobRun } from './job-orchestrator.protocol';
|
|
34
|
+
import type { IJobStepService } from './job-step-service.protocol';
|
|
35
|
+
import {
|
|
36
|
+
JOB_HANDLER_REGISTRY,
|
|
37
|
+
type JobContext,
|
|
38
|
+
type JobHandlerBase,
|
|
39
|
+
type SpawnChildOptions,
|
|
40
|
+
type StepOptions,
|
|
41
|
+
} from './job-handler.base';
|
|
42
|
+
|
|
43
|
+
interface BullJobPayload {
|
|
44
|
+
runId: string;
|
|
45
|
+
type: string;
|
|
46
|
+
input: unknown;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function serialiseError(err: unknown, attempt: number, retryable: boolean) {
|
|
50
|
+
const e = err as { message?: string; stack?: string } | undefined;
|
|
51
|
+
return {
|
|
52
|
+
message: (e?.message ?? String(err)) as string,
|
|
53
|
+
stack: e?.stack,
|
|
54
|
+
retryable,
|
|
55
|
+
attempt,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Options for a single per-pool BullMQ worker.
|
|
61
|
+
*/
|
|
62
|
+
export interface BullMQJobWorkerOptions {
|
|
63
|
+
/** Logical pool name (matches `job_run.pool`). */
|
|
64
|
+
pool: string;
|
|
65
|
+
/** Fully-resolved BullMQ queue name to consume. */
|
|
66
|
+
queueName: string;
|
|
67
|
+
/** Max concurrent in-flight processors. */
|
|
68
|
+
concurrency: number;
|
|
69
|
+
/** ioredis-compatible connection. */
|
|
70
|
+
connection: ConnectionOptions;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class BullMQJobWorker {
|
|
74
|
+
private readonly logger = new Logger(BullMQJobWorker.name);
|
|
75
|
+
private worker: Worker | null = null;
|
|
76
|
+
|
|
77
|
+
constructor(
|
|
78
|
+
private readonly db: DrizzleClient,
|
|
79
|
+
private readonly orchestrator: IJobOrchestrator,
|
|
80
|
+
private readonly stepService: IJobStepService,
|
|
81
|
+
private readonly options: BullMQJobWorkerOptions,
|
|
82
|
+
private readonly moduleRef: ModuleRef,
|
|
83
|
+
) {}
|
|
84
|
+
|
|
85
|
+
async onModuleInit(): Promise<void> {
|
|
86
|
+
let WorkerCtor: typeof import('bullmq').Worker;
|
|
87
|
+
try {
|
|
88
|
+
const mod = await import('bullmq');
|
|
89
|
+
WorkerCtor = mod.Worker;
|
|
90
|
+
} catch {
|
|
91
|
+
throw new Error(
|
|
92
|
+
'BullMQ backend requires the "bullmq" package. Install it with: npm install bullmq',
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
this.worker = new WorkerCtor(
|
|
96
|
+
this.options.queueName,
|
|
97
|
+
(job) => this.process(job as Job<BullJobPayload>),
|
|
98
|
+
{
|
|
99
|
+
connection: this.options.connection,
|
|
100
|
+
concurrency: this.options.concurrency,
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
this.worker.on('failed', (job, err) => {
|
|
104
|
+
// BullMQ fires `failed` after EACH attempt; only mirror to job_run when
|
|
105
|
+
// attempts are exhausted (BullMQ will not retry further).
|
|
106
|
+
if (!job) return;
|
|
107
|
+
const attemptsMade = job.attemptsMade;
|
|
108
|
+
const maxAttempts = job.opts.attempts ?? 1;
|
|
109
|
+
if (attemptsMade >= maxAttempts) {
|
|
110
|
+
void this.markFailed(job.data.runId, err, attemptsMade);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
this.logger.log(
|
|
114
|
+
`BullMQ worker started: pool='${this.options.pool}' queue='${this.options.queueName}' concurrency=${this.options.concurrency}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async onModuleDestroy(): Promise<void> {
|
|
119
|
+
if (this.worker) {
|
|
120
|
+
await this.worker.close();
|
|
121
|
+
this.worker = null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Process one BullMQ job. Returns the handler output (stored by BullMQ as
|
|
127
|
+
* the job return value AND written to `job_run.output`). Throws on handler
|
|
128
|
+
* failure so BullMQ applies the retry policy.
|
|
129
|
+
*/
|
|
130
|
+
private async process(job: Job<BullJobPayload>): Promise<unknown> {
|
|
131
|
+
const { runId } = job.data;
|
|
132
|
+
const [row] = await this.db
|
|
133
|
+
.select()
|
|
134
|
+
.from(jobRuns)
|
|
135
|
+
.where(eq(jobRuns.id, runId))
|
|
136
|
+
.limit(1);
|
|
137
|
+
if (!row) {
|
|
138
|
+
// Domain row vanished (canceled + removed). Treat as a no-op success so
|
|
139
|
+
// BullMQ doesn't retry a job whose authoritative state is gone.
|
|
140
|
+
this.logger.warn(`process: job_run ${runId} not found; skipping`);
|
|
141
|
+
return {};
|
|
142
|
+
}
|
|
143
|
+
const run = row as JobRunRow;
|
|
144
|
+
|
|
145
|
+
// Canceled in Postgres after enqueue but before claim — honour the domain
|
|
146
|
+
// decision and skip without running the handler.
|
|
147
|
+
if (run.status === 'canceled') {
|
|
148
|
+
return {};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const registryEntry = JOB_HANDLER_REGISTRY.get(run.jobType);
|
|
152
|
+
if (!registryEntry) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`No handler registered for jobType='${run.jobType}' (run ${run.id})`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Mark running (mirrors the Drizzle worker's claim transition).
|
|
159
|
+
await this.db
|
|
160
|
+
.update(jobRuns)
|
|
161
|
+
.set({
|
|
162
|
+
status: 'running',
|
|
163
|
+
claimedAt: new Date(),
|
|
164
|
+
startedAt: new Date(),
|
|
165
|
+
attempts: job.attemptsMade + 1,
|
|
166
|
+
updatedAt: new Date(),
|
|
167
|
+
})
|
|
168
|
+
.where(eq(jobRuns.id, run.id));
|
|
169
|
+
|
|
170
|
+
const HandlerClass = registryEntry.handlerClass;
|
|
171
|
+
const handler = this.moduleRef.get(
|
|
172
|
+
HandlerClass as unknown as new (...args: unknown[]) => unknown,
|
|
173
|
+
{ strict: false },
|
|
174
|
+
) as JobHandlerBase<unknown>;
|
|
175
|
+
|
|
176
|
+
const ctx: JobContext<unknown> = {
|
|
177
|
+
input: run.input,
|
|
178
|
+
run: run as JobRun,
|
|
179
|
+
step: this.makeStepFn(run),
|
|
180
|
+
spawnChild: this.makeSpawnFn(run),
|
|
181
|
+
logger: new Logger(`JobRun:${run.id}`),
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const output = (await handler.run(ctx)) as
|
|
185
|
+
| Record<string, unknown>
|
|
186
|
+
| undefined;
|
|
187
|
+
|
|
188
|
+
await this.db
|
|
189
|
+
.update(jobRuns)
|
|
190
|
+
.set({
|
|
191
|
+
status: 'completed',
|
|
192
|
+
output: (output ?? {}) as Record<string, unknown>,
|
|
193
|
+
finishedAt: new Date(),
|
|
194
|
+
updatedAt: new Date(),
|
|
195
|
+
})
|
|
196
|
+
.where(eq(jobRuns.id, run.id));
|
|
197
|
+
|
|
198
|
+
return output ?? {};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private async markFailed(
|
|
202
|
+
runId: string,
|
|
203
|
+
err: unknown,
|
|
204
|
+
finalAttempts: number,
|
|
205
|
+
): Promise<void> {
|
|
206
|
+
const [row] = await this.db
|
|
207
|
+
.select()
|
|
208
|
+
.from(jobRuns)
|
|
209
|
+
.where(eq(jobRuns.id, runId))
|
|
210
|
+
.limit(1);
|
|
211
|
+
if (!row) return;
|
|
212
|
+
const run = row as JobRunRow;
|
|
213
|
+
await this.db
|
|
214
|
+
.update(jobRuns)
|
|
215
|
+
.set({
|
|
216
|
+
status: 'failed',
|
|
217
|
+
attempts: finalAttempts,
|
|
218
|
+
finishedAt: new Date(),
|
|
219
|
+
error: serialiseError(err, finalAttempts, false),
|
|
220
|
+
updatedAt: new Date(),
|
|
221
|
+
})
|
|
222
|
+
.where(eq(jobRuns.id, runId));
|
|
223
|
+
|
|
224
|
+
// Parent-close-policy cascade — identical semantics to the Drizzle worker.
|
|
225
|
+
if (run.parentClosePolicy === 'terminate') {
|
|
226
|
+
try {
|
|
227
|
+
await this.orchestrator.cancel(run.id, {
|
|
228
|
+
cascade: true,
|
|
229
|
+
reason: 'parent-failed',
|
|
230
|
+
tenantId: run.tenantId,
|
|
231
|
+
});
|
|
232
|
+
} catch (cascadeErr) {
|
|
233
|
+
this.logger.warn(
|
|
234
|
+
`cascade on failed run ${run.id}: ${(cascadeErr as Error).message}`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── ctx.step / ctx.spawnChild (mirror JobWorker) ──────────────────────────
|
|
241
|
+
|
|
242
|
+
private makeStepFn(run: JobRunRow) {
|
|
243
|
+
return async <TOutput>(
|
|
244
|
+
stepId: string,
|
|
245
|
+
fn: () => Promise<TOutput>,
|
|
246
|
+
_opts?: StepOptions,
|
|
247
|
+
): Promise<TOutput> => {
|
|
248
|
+
void _opts;
|
|
249
|
+
const existing = await this.stepService.findStep(run.id, stepId);
|
|
250
|
+
if (existing?.status === 'completed') {
|
|
251
|
+
return existing.output as TOutput;
|
|
252
|
+
}
|
|
253
|
+
const nextAttempts = (existing?.attempts ?? 0) + 1;
|
|
254
|
+
const seq = nextAttempts; // BullMQ path: seq is per-step attempt index
|
|
255
|
+
await this.stepService.recordStep({
|
|
256
|
+
jobRunId: run.id,
|
|
257
|
+
stepId,
|
|
258
|
+
kind: 'task',
|
|
259
|
+
seq,
|
|
260
|
+
status: 'running',
|
|
261
|
+
startedAt: new Date(),
|
|
262
|
+
attempts: nextAttempts,
|
|
263
|
+
});
|
|
264
|
+
try {
|
|
265
|
+
const output = await fn();
|
|
266
|
+
await this.stepService.recordStep({
|
|
267
|
+
jobRunId: run.id,
|
|
268
|
+
stepId,
|
|
269
|
+
kind: 'task',
|
|
270
|
+
seq,
|
|
271
|
+
status: 'completed',
|
|
272
|
+
output: output as Record<string, unknown> | undefined,
|
|
273
|
+
finishedAt: new Date(),
|
|
274
|
+
attempts: nextAttempts,
|
|
275
|
+
});
|
|
276
|
+
return output;
|
|
277
|
+
} catch (err) {
|
|
278
|
+
await this.stepService.recordStep({
|
|
279
|
+
jobRunId: run.id,
|
|
280
|
+
stepId,
|
|
281
|
+
kind: 'task',
|
|
282
|
+
seq,
|
|
283
|
+
status: 'failed',
|
|
284
|
+
error: serialiseError(err, nextAttempts, false),
|
|
285
|
+
finishedAt: new Date(),
|
|
286
|
+
attempts: nextAttempts,
|
|
287
|
+
});
|
|
288
|
+
throw err;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private makeSpawnFn(run: JobRunRow) {
|
|
294
|
+
return async (
|
|
295
|
+
type: string,
|
|
296
|
+
input: unknown,
|
|
297
|
+
opts?: SpawnChildOptions,
|
|
298
|
+
): Promise<JobRun> => {
|
|
299
|
+
return this.orchestrator.start(type, input, {
|
|
300
|
+
parentRunId: run.id,
|
|
301
|
+
parentClosePolicy: opts?.closePolicy,
|
|
302
|
+
runAt: opts?.runAt,
|
|
303
|
+
priority: opts?.priority,
|
|
304
|
+
tags: opts?.tags,
|
|
305
|
+
triggerSource: 'parent',
|
|
306
|
+
triggerRef: run.id,
|
|
307
|
+
tenantId: run.tenantId,
|
|
308
|
+
});
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|