nmtjs 0.15.0-beta.21 → 0.15.0-beta.22

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.
@@ -3,7 +3,6 @@ import type { TProcedureContract, TRouterContract } from '@nmtjs/contract'
3
3
  import type { Dependencies, DependencyContext, Metadata } from '@nmtjs/core'
4
4
  import type { NullableType, OptionalType } from '@nmtjs/type'
5
5
  import type { NeverType } from '@nmtjs/type/never'
6
- import type { JobState } from 'bullmq'
7
6
  import { CoreInjectables } from '@nmtjs/core'
8
7
  import { t } from '@nmtjs/type'
9
8
 
@@ -12,6 +11,7 @@ import type { AnyMiddleware } from '../application/api/middlewares.ts'
12
11
  import type { AnyProcedure } from '../application/api/procedure.ts'
13
12
  import type { AnyRouter, Router } from '../application/api/router.ts'
14
13
  import type { AnyJob } from './job.ts'
14
+ import type { JobStatus } from './types.ts'
15
15
  import { createProcedure } from '../application/api/procedure.ts'
16
16
  import { createRouter } from '../application/api/router.ts'
17
17
  import { jobManager } from '../injectables.ts'
@@ -142,21 +142,35 @@ export type CreateJobsRouterOptions<Jobs extends Record<string, AnyJob>> = {
142
142
  // Router Contract Types
143
143
  // ============================================================================
144
144
 
145
+ /** Type-level schema for JobProgressCheckpoint - typed per job */
146
+ type JobProgressCheckpointSchemaType<T extends AnyJob> = t.ObjectType<{
147
+ stepIndex: t.NumberType
148
+ stepLabel: OptionalType<t.StringType>
149
+ result: t.RecordType<t.StringType, t.AnyType>
150
+ stepResults: t.ArrayType<
151
+ t.ObjectType<{
152
+ data: NullableType<t.RecordType<t.StringType, t.AnyType>>
153
+ duration: t.NumberType
154
+ }>
155
+ >
156
+ progress: T['progress']
157
+ }>
158
+
145
159
  /** Type-level representation of createJobItemSchema output */
146
160
  type JobItemSchemaType<T extends AnyJob> = t.ObjectType<{
147
161
  id: t.StringType
148
- queueName: t.StringType
149
- priority: OptionalType<t.NumberType>
150
- progress: t.AnyType
151
162
  name: t.StringType
163
+ queue: t.StringType
152
164
  data: T['input']
153
- returnvalue: OptionalType<NullableType<T['output']>>
154
- attemptsMade: t.NumberType
155
- processedOn: OptionalType<t.NumberType>
156
- finishedOn: OptionalType<t.NumberType>
157
- failedReason: OptionalType<t.StringType>
158
- stacktrace: OptionalType<t.ArrayType<t.StringType>>
165
+ output: OptionalType<NullableType<T['output']>>
159
166
  status: t.StringType
167
+ priority: OptionalType<t.NumberType>
168
+ progress: OptionalType<JobProgressCheckpointSchemaType<T>>
169
+ attempts: t.NumberType
170
+ startedAt: OptionalType<t.NumberType>
171
+ completedAt: OptionalType<t.NumberType>
172
+ error: OptionalType<t.StringType>
173
+ stacktrace: OptionalType<t.ArrayType<t.StringType>>
160
174
  }>
161
175
 
162
176
  /** Type-level representation of createListOutputSchema output */
@@ -216,7 +230,7 @@ export type JobsRouter<Jobs extends Record<string, AnyJob>> = Router<
216
230
  const listInputSchema = t.object({
217
231
  page: t.number().optional(),
218
232
  limit: t.number().optional(),
219
- state: t.array(t.string()).optional(),
233
+ status: t.array(t.string()).optional(),
220
234
  })
221
235
 
222
236
  /** Input schema for get operation */
@@ -242,22 +256,41 @@ const infoOutputSchema = t.object({
242
256
  ),
243
257
  })
244
258
 
259
+ /** Schema for step result entry */
260
+ const stepResultEntrySchema = t.object({
261
+ data: t.record(t.string(), t.any()).nullable(),
262
+ duration: t.number(),
263
+ })
264
+
265
+ /** Creates JobProgressCheckpoint schema for a specific job */
266
+ function createJobProgressCheckpointSchema<T extends AnyJob>(
267
+ job: T,
268
+ ): JobProgressCheckpointSchemaType<T> {
269
+ return t.object({
270
+ stepIndex: t.number(),
271
+ stepLabel: t.string().optional(),
272
+ result: t.record(t.string(), t.any()),
273
+ stepResults: t.array(stepResultEntrySchema),
274
+ progress: job.progress,
275
+ })
276
+ }
277
+
245
278
  /** JobItem schema for list/get responses - typed per job */
246
279
  function createJobItemSchema<T extends AnyJob>(job: T): JobItemSchemaType<T> {
247
280
  return t.object({
248
281
  id: t.string(),
249
- queueName: t.string(),
250
- priority: t.number().optional(),
251
- progress: t.any(),
252
282
  name: t.string(),
283
+ queue: t.string(),
253
284
  data: job.input,
254
- returnvalue: job.output.nullish(),
255
- attemptsMade: t.number(),
256
- processedOn: t.number().optional(),
257
- finishedOn: t.number().optional(),
258
- failedReason: t.string().optional(),
259
- stacktrace: t.array(t.string()).optional(),
285
+ output: job.output.nullish(),
260
286
  status: t.string(),
287
+ priority: t.number().optional(),
288
+ progress: createJobProgressCheckpointSchema(job).optional(),
289
+ attempts: t.number(),
290
+ startedAt: t.number().optional(),
291
+ completedAt: t.number().optional(),
292
+ error: t.string().optional(),
293
+ stacktrace: t.array(t.string()).optional(),
261
294
  })
262
295
  }
263
296
 
@@ -424,14 +457,14 @@ function createListProcedure(
424
457
  jobName: job.options.name,
425
458
  page: input.page,
426
459
  limit: input.limit,
427
- state: input.state,
460
+ status: input.status,
428
461
  },
429
462
  'Listing jobs',
430
463
  )
431
464
  const result = await ctx.jobManager.list(job, {
432
465
  page: input.page,
433
466
  limit: input.limit,
434
- state: input.state as JobState[],
467
+ status: input.status as JobStatus[],
435
468
  })
436
469
  ctx.logger.debug(
437
470
  { jobName: job.options.name, total: result.total, pages: result.pages },
@@ -7,15 +7,21 @@ import { UnrecoverableError } from 'bullmq'
7
7
  import type { LifecycleHooks } from '../core/hooks.ts'
8
8
  import type { AnyJob } from './job.ts'
9
9
  import type { AnyJobStep } from './step.ts'
10
+ import type {
11
+ JobExecutionContext,
12
+ JobProgressCheckpoint,
13
+ StepResultEntry,
14
+ } from './types.ts'
10
15
  import { LifecycleHook } from '../enums.ts'
11
- import { jobAbortSignal } from '../injectables.ts'
16
+ import {
17
+ currentJobInfo,
18
+ jobAbortSignal,
19
+ saveJobProgress,
20
+ } from '../injectables.ts'
12
21
 
13
22
  export type JobRunnerOptions = { logging?: LoggingOptions }
14
23
 
15
- export interface StepResultEntry {
16
- data: Record<string, unknown> | null
17
- duration: number
18
- }
24
+ export type { StepResultEntry } from './types.ts'
19
25
 
20
26
  export interface JobRunnerRunOptions {
21
27
  signal: AbortSignal
@@ -42,6 +48,18 @@ export interface JobRunnerRunAfterStepParams<
42
48
  stepResult: StepResultEntry
43
49
  }
44
50
 
51
+ /** Context for saveProgress function - contains mutable state references */
52
+ export interface SaveProgressContext<
53
+ Options extends JobRunnerRunOptions = JobRunnerRunOptions,
54
+ > {
55
+ job: AnyJob
56
+ progress: Record<string, unknown>
57
+ result: Record<string, unknown>
58
+ stepResults: StepResultEntry[] | null
59
+ currentStepIndex: number
60
+ options: Options | null
61
+ }
62
+
45
63
  export class JobRunner<
46
64
  RunOptions extends JobRunnerRunOptions = JobRunnerRunOptions,
47
65
  > {
@@ -61,6 +79,28 @@ export class JobRunner<
61
79
  return this.runtime.container
62
80
  }
63
81
 
82
+ /**
83
+ * Creates a function that saves the current job progress state.
84
+ * Override in subclasses to implement actual persistence.
85
+ */
86
+ protected createSaveProgressFn(
87
+ _context: SaveProgressContext<RunOptions>,
88
+ ): () => Promise<void> {
89
+ // No-op in base runner - subclasses implement actual persistence
90
+ return async () => {}
91
+ }
92
+
93
+ /**
94
+ * Creates the current job info object.
95
+ * Override in subclasses to include additional info (e.g., BullMQ job ID).
96
+ */
97
+ protected createJobInfo(
98
+ job: AnyJob,
99
+ _options: Partial<RunOptions>,
100
+ ): JobExecutionContext {
101
+ return { name: job.options.name }
102
+ }
103
+
64
104
  async runJob<T extends AnyJob>(
65
105
  job: T,
66
106
  data: any,
@@ -91,6 +131,23 @@ export class JobRunner<
91
131
  const signal = anyAbortSignal(runSignal, stopListener.signal)
92
132
  await using container = this.container.fork(Scope.Global)
93
133
  await container.provide(jobAbortSignal, signal)
134
+ await container.provide(currentJobInfo, this.createJobInfo(job, options))
135
+
136
+ // Create mutable state context for saveProgress
137
+ const progressContext = {
138
+ job,
139
+ progress,
140
+ result,
141
+ stepResults: null as StepResultEntry[] | null, // Will be set below
142
+ currentStepIndex,
143
+ options: null as RunOptions | null, // Will be set below
144
+ }
145
+
146
+ // Provide saveProgress injectable
147
+ await container.provide(
148
+ saveJobProgress,
149
+ this.createSaveProgressFn(progressContext),
150
+ )
94
151
 
95
152
  const jobDependencyContext = await container.createContext(job.dependencies)
96
153
  const jobData = job.options.data
@@ -116,6 +173,10 @@ export class JobRunner<
116
173
  ...rest,
117
174
  } satisfies JobRunnerRunOptions
118
175
 
176
+ // Update mutable context references
177
+ progressContext.stepResults = stepResults
178
+ progressContext.options = runOptions
179
+
119
180
  for (
120
181
  let stepIndex = currentStepIndex;
121
182
  stepIndex < steps.length;
@@ -279,6 +340,51 @@ export class ApplicationWorkerJobRunner extends JobRunner<
279
340
  super(runtime)
280
341
  }
281
342
 
343
+ protected createJobInfo(
344
+ job: AnyJob,
345
+ options: Partial<JobRunnerRunOptions & { queueJob: Job }>,
346
+ ): JobExecutionContext {
347
+ const { queueJob } = options
348
+ return {
349
+ name: job.options.name,
350
+ id: queueJob?.id,
351
+ queue: queueJob?.queueName,
352
+ attempts: queueJob?.attemptsMade,
353
+ stepIndex: options.currentStepIndex,
354
+ }
355
+ }
356
+
357
+ protected createSaveProgressFn(
358
+ context: SaveProgressContext<JobRunnerRunOptions & { queueJob: Job }>,
359
+ ): () => Promise<void> {
360
+ return async () => {
361
+ const { job, progress, result, stepResults, options } = context
362
+ if (!options || !stepResults) return
363
+
364
+ const { queueJob } = options
365
+
366
+ // Find current step index based on completed steps
367
+ let currentStepIndex = 0
368
+ for (let i = 0; i < stepResults.length; i++) {
369
+ if (stepResults[i]) currentStepIndex = i + 1
370
+ }
371
+
372
+ // Encode progress before persisting if schema is defined
373
+ const encodedProgress = job.progress
374
+ ? job.progress.encode(progress)
375
+ : progress
376
+
377
+ const checkpoint: JobProgressCheckpoint = {
378
+ stepIndex: currentStepIndex,
379
+ result,
380
+ stepResults,
381
+ progress: encodedProgress,
382
+ }
383
+
384
+ await queueJob.updateProgress(checkpoint)
385
+ }
386
+ }
387
+
282
388
  protected async afterStep(
283
389
  params: JobRunnerRunAfterStepParams<
284
390
  JobRunnerRunOptions & { queueJob: Job }
@@ -295,26 +401,25 @@ export class ApplicationWorkerJobRunner extends JobRunner<
295
401
  options: { queueJob, progress },
296
402
  } = params
297
403
  const nextStepIndex = stepIndex + 1
298
- const totalSteps = job.steps.length
299
- const percentage = Math.round((nextStepIndex / totalSteps) * 100)
300
404
 
301
405
  // Encode progress before persisting if schema is defined
302
406
  const encodedProgress = job.progress
303
407
  ? job.progress.encode(progress)
304
408
  : progress
305
409
 
410
+ const checkpoint: JobProgressCheckpoint = {
411
+ stepIndex: nextStepIndex,
412
+ stepLabel: step.label,
413
+ result,
414
+ stepResults,
415
+ progress: encodedProgress,
416
+ }
417
+
306
418
  await Promise.all([
307
419
  queueJob.log(
308
420
  `Step ${step.label || nextStepIndex} completed in ${(stepResult.duration / 1000).toFixed(3)}s`,
309
421
  ),
310
- queueJob.updateProgress({
311
- stepIndex: nextStepIndex,
312
- stepLabel: step.label,
313
- result,
314
- stepResults,
315
- progress: encodedProgress,
316
- percentage,
317
- }),
422
+ queueJob.updateProgress(checkpoint),
318
423
  ])
319
424
  }
320
425
  }
@@ -0,0 +1,110 @@
1
+ // ============================================================================
2
+ // Job Definition Types (metadata about job structure)
3
+ // ============================================================================
4
+
5
+ /** Metadata about a job step in a job definition */
6
+ export interface JobStepInfo {
7
+ /** Optional human-readable label for the step */
8
+ label?: string
9
+ /** Whether this step has a condition that may skip it */
10
+ conditional: boolean
11
+ }
12
+
13
+ /** Metadata about a job definition (not a running job instance) */
14
+ export interface JobDefinitionInfo {
15
+ /** Job name from definition */
16
+ name: string
17
+ /** Information about each step in the job */
18
+ steps: JobStepInfo[]
19
+ }
20
+
21
+ // ============================================================================
22
+ // Job Execution Types (runtime state of a running job)
23
+ // ============================================================================
24
+
25
+ /** Result of a single step execution */
26
+ export interface StepResultEntry {
27
+ /** Output data produced by the step, or null if skipped */
28
+ data: Record<string, unknown> | null
29
+ /** Duration in milliseconds */
30
+ duration: number
31
+ }
32
+
33
+ /** Checkpoint data persisted for job resume support */
34
+ export interface JobProgressCheckpoint {
35
+ /** Index of the next step to execute (0 = not started, length = completed) */
36
+ stepIndex: number
37
+ /** Label of the last completed step */
38
+ stepLabel?: string
39
+ /** Accumulated result from all completed steps */
40
+ result: Record<string, unknown>
41
+ /** Results of each individual step */
42
+ stepResults: StepResultEntry[]
43
+ /** User-defined progress state */
44
+ progress: Record<string, unknown>
45
+ }
46
+
47
+ /** Information about the currently executing job, available via injectable */
48
+ export interface JobExecutionContext {
49
+ /** Job definition name */
50
+ name: string
51
+ /** Queue job ID */
52
+ id?: string
53
+ /** Queue name */
54
+ queue?: string
55
+ /** Number of attempts made so far */
56
+ attempts?: number
57
+ /** Current step index being executed */
58
+ stepIndex?: number
59
+ }
60
+
61
+ // ============================================================================
62
+ // Job Item Types (job instances from queue)
63
+ // ============================================================================
64
+
65
+ /** Vendor-agnostic job status */
66
+ export type JobStatus =
67
+ | 'pending' // Queued, waiting to be picked up
68
+ | 'active' // Currently executing
69
+ | 'completed' // Successfully finished
70
+ | 'failed' // Failed (may retry)
71
+ | 'delayed' // Scheduled for future
72
+ | 'cancelled' // Manually cancelled
73
+ | 'unknown' // State cannot be determined
74
+
75
+ /** A job instance retrieved from the queue */
76
+ export interface JobItem<TInput = unknown, TOutput = unknown> {
77
+ /** Unique job instance ID */
78
+ id: string
79
+ /** Job definition name */
80
+ name: string
81
+ /** Queue name this job belongs to */
82
+ queue: string
83
+ /** Input data passed to the job */
84
+ data: TInput
85
+ /** Output produced by the job (if completed) */
86
+ output?: TOutput | null
87
+ /** Current job status */
88
+ status: JobStatus
89
+ /** Job priority (lower = higher priority) */
90
+ priority?: number
91
+ /** Job progress checkpoint (includes step state and user progress) */
92
+ progress?: JobProgressCheckpoint
93
+ /** Number of execution attempts */
94
+ attempts: number
95
+ /** Timestamp when job started processing (ms) */
96
+ startedAt?: number
97
+ /** Timestamp when job completed (ms) */
98
+ completedAt?: number
99
+ /** Error message if job failed */
100
+ error?: string
101
+ /** Stack trace if job failed */
102
+ stacktrace?: string[]
103
+ }
104
+
105
+ // ============================================================================
106
+ // Injectable Types
107
+ // ============================================================================
108
+
109
+ /** Function to manually trigger saving job progress state */
110
+ export type SaveJobProgress = () => Promise<void>
@@ -203,6 +203,8 @@ export class ApplicationServerJobs {
203
203
 
204
204
  async stop() {
205
205
  const { logger } = this.params
206
+ // TODO: make configurable
207
+ const closeTimeout = 10_000 // 10 seconds timeout for graceful close
206
208
 
207
209
  if (this.ui) {
208
210
  await new Promise<void>((resolve) => {
@@ -212,29 +214,43 @@ export class ApplicationServerJobs {
212
214
  })
213
215
  }
214
216
 
217
+ // Stop accepting new jobs first.
215
218
  await Promise.all(
216
- this.uiQueues.map(async (queue) => {
219
+ [...this.queueWorkers].map(async (worker) => {
217
220
  try {
218
- await queue.close()
221
+ // Try graceful close with timeout
222
+ const closePromise = worker.close()
223
+ const timeoutPromise = new Promise<'timeout'>((resolve) =>
224
+ setTimeout(() => resolve('timeout'), closeTimeout),
225
+ )
226
+
227
+ const result = await Promise.race([closePromise, timeoutPromise])
228
+
229
+ if (result === 'timeout') {
230
+ logger.warn(
231
+ { worker: worker.name },
232
+ 'Worker close timed out, forcing close',
233
+ )
234
+ await worker.close(true)
235
+ }
219
236
  } catch (error) {
220
- logger.warn({ error }, 'Failed to close Jobs UI queue')
237
+ logger.warn({ error }, 'Failed to close BullMQ worker')
221
238
  }
222
239
  }),
223
240
  )
224
- this.uiQueues = []
225
- this.ui = undefined
241
+ this.queueWorkers.clear()
226
242
 
227
- // Stop accepting new jobs first.
228
243
  await Promise.all(
229
- [...this.queueWorkers].map(async (worker) => {
244
+ this.uiQueues.map(async (queue) => {
230
245
  try {
231
- await worker.close()
246
+ await queue.close()
232
247
  } catch (error) {
233
- logger.warn({ error }, 'Failed to close BullMQ worker')
248
+ logger.warn({ error }, 'Failed to close Jobs UI queue')
234
249
  }
235
250
  }),
236
251
  )
237
- this.queueWorkers.clear()
252
+ this.uiQueues = []
253
+ this.ui = undefined
238
254
 
239
255
  await Promise.all(
240
256
  Array.from(this.pools.values()).map(async (pool) => {
@@ -3,7 +3,7 @@ import type { MessagePort } from 'node:worker_threads'
3
3
  import { UnrecoverableError } from 'bullmq'
4
4
 
5
5
  import type { JobWorkerPool } from '../enums.ts'
6
- import type { StepResultEntry } from '../jobs/runner.ts'
6
+ import type { JobProgressCheckpoint } from '../jobs/types.ts'
7
7
  import type { ServerConfig } from '../server/config.ts'
8
8
  import type { ServerPortMessage, ThreadPortMessage } from '../types.ts'
9
9
  import { LifecycleHook, WorkerType } from '../enums.ts'
@@ -95,12 +95,7 @@ export class JobWorkerRuntime extends BaseWorkerRuntime {
95
95
 
96
96
  // Load checkpoint from BullMQ progress for resume support
97
97
  const checkpoint = bullJob.progress as
98
- | {
99
- stepIndex: number
100
- result: Record<string, unknown>
101
- stepResults: StepResultEntry[]
102
- progress: Record<string, unknown>
103
- }
98
+ | JobProgressCheckpoint
104
99
  | undefined
105
100
 
106
101
  const result = await this.jobRunner.runJob(job, task.data, {