nmtjs 0.15.0-beta.19 → 0.15.0-beta.20

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.
@@ -12,11 +12,17 @@ import { jobAbortSignal } from '../injectables.ts'
12
12
 
13
13
  export type JobRunnerOptions = { logging?: LoggingOptions }
14
14
 
15
+ export interface StepResultEntry {
16
+ data: Record<string, unknown> | null
17
+ duration: number
18
+ }
19
+
15
20
  export interface JobRunnerRunOptions {
16
21
  signal: AbortSignal
17
22
  result: Record<string, unknown>
18
- stepResults: any[]
23
+ stepResults: StepResultEntry[]
19
24
  currentStepIndex: number
25
+ progress: Record<string, unknown>
20
26
  }
21
27
 
22
28
  export interface JobRunnerRunBeforeStepParams<
@@ -25,15 +31,15 @@ export interface JobRunnerRunBeforeStepParams<
25
31
  job: AnyJob
26
32
  step: AnyJobStep
27
33
  stepIndex: number
28
- result: any
29
- stepResults: any[]
34
+ result: Record<string, unknown>
35
+ stepResults: StepResultEntry[]
30
36
  options: Options
31
37
  }
32
38
 
33
39
  export interface JobRunnerRunAfterStepParams<
34
40
  Options extends JobRunnerRunOptions,
35
41
  > extends JobRunnerRunBeforeStepParams<Options> {
36
- stepResult: any
42
+ stepResult: StepResultEntry
37
43
  }
38
44
 
39
45
  export class JobRunner<
@@ -58,117 +64,132 @@ export class JobRunner<
58
64
  async runJob<T extends AnyJob>(
59
65
  job: T,
60
66
  data: any,
61
- _options?: Partial<RunOptions>,
67
+ options: Partial<RunOptions> = {},
62
68
  ): Promise<T['_']['output']> {
63
69
  const {
64
- signal: _signal,
65
- result: _result = {},
66
- stepResults: _stepResults = [] as RunOptions['stepResults'],
70
+ signal: runSignal,
71
+ result: runResult = {},
72
+ stepResults: runStepResults = [] as RunOptions['stepResults'],
73
+ progress: runProgress = {},
67
74
  currentStepIndex = 0,
68
- } = _options ?? {}
75
+ ...rest
76
+ } = options
69
77
 
70
78
  const { input, output, steps } = job
71
79
 
72
- const result: Record<string, unknown> = { ..._result }
80
+ const result: Record<string, unknown> = { ...runResult }
73
81
  const decodedInput = input.decode(data)
74
82
 
75
- const signal = anyAbortSignal(
76
- _signal,
77
- this.runtime.lifecycleHooks.createSignal(LifecycleHook.BeforeDispose)
78
- .signal,
83
+ // Initialize progress: decode from checkpoint or start fresh
84
+ const progress: Record<string, unknown> = job.progress
85
+ ? job.progress.decode(runProgress)
86
+ : { ...runProgress }
87
+
88
+ using stopListener = this.runtime.lifecycleHooks.once(
89
+ LifecycleHook.BeforeDispose,
79
90
  )
91
+ const signal = anyAbortSignal(runSignal, stopListener.signal)
80
92
  await using container = this.container.fork(Scope.Global)
81
93
  await container.provide(jobAbortSignal, signal)
82
94
 
83
95
  const jobDependencyContext = await container.createContext(job.dependencies)
84
96
  const jobData = job.options.data
85
- ? await job.options.data(jobDependencyContext, decodedInput)
97
+ ? await job.options.data(jobDependencyContext, decodedInput, progress)
86
98
  : undefined
87
99
 
88
- const stepResults = Array.from({ length: steps.length }) as unknown[]
100
+ const stepResults: StepResultEntry[] = Array.from({ length: steps.length })
101
+
102
+ // Restore previous step results and reconstruct accumulated result
103
+ for (let stepIndex = 0; stepIndex < runStepResults.length; stepIndex++) {
104
+ const entry = runStepResults[stepIndex]
105
+ stepResults[stepIndex] = entry
106
+ if (entry?.data) Object.assign(result, entry.data)
107
+ }
89
108
 
90
- //@ts-expect-error
91
- const options: RunOptions = {
109
+ // @ts-expect-error
110
+ const runOptions: RunOptions = {
92
111
  signal,
93
112
  result,
94
113
  stepResults,
95
114
  currentStepIndex: currentStepIndex,
115
+ progress,
116
+ ...rest,
96
117
  } satisfies JobRunnerRunOptions
97
118
 
98
- for (let stepIndex = 0; stepIndex < _stepResults.length; stepIndex++) {
99
- stepResults[stepIndex] = _stepResults[stepIndex]
100
- }
101
-
102
119
  for (
103
120
  let stepIndex = currentStepIndex;
104
121
  stepIndex < steps.length;
105
122
  stepIndex++
106
123
  ) {
107
- if (signal.aborted) {
108
- const reason = (signal as unknown as { reason?: unknown }).reason
109
- if (reason instanceof UnrecoverableError) throw reason
110
- throw new UnrecoverableError('Job cancelled')
111
- }
112
-
113
124
  const step = steps[stepIndex]
114
- const resultSnapshot = Object.freeze(Object.assign({}, result))
125
+ const resultSnapshot = Object.freeze(
126
+ Object.assign({}, decodedInput, result),
127
+ )
115
128
 
116
- const condition = job.conditions.get(stepIndex)
117
- if (condition) {
118
- const shouldRun = await condition({
119
- context: jobDependencyContext as any,
120
- data: jobData,
121
- input: decodedInput as any,
122
- result: resultSnapshot as any,
123
- })
124
- if (!shouldRun) {
125
- stepResults[stepIndex] = null
126
- continue
129
+ try {
130
+ if (signal.aborted) {
131
+ const { reason } = signal
132
+ if (reason instanceof UnrecoverableError) throw reason
133
+ throw new UnrecoverableError('Job cancelled')
127
134
  }
128
- }
129
135
 
130
- const stepContext = await container.createContext({
131
- ...job.dependencies,
132
- ...step.dependencies,
133
- })
136
+ const condition = job.conditions.get(stepIndex)
137
+ if (condition) {
138
+ const shouldRun = await condition({
139
+ context: jobDependencyContext,
140
+ data: jobData,
141
+ input: decodedInput,
142
+ result: resultSnapshot,
143
+ progress,
144
+ })
145
+ if (!shouldRun) {
146
+ stepResults[stepIndex] = { data: null, duration: 0 }
147
+ continue
148
+ }
149
+ }
134
150
 
135
- const stepInput = step.input.decode(resultSnapshot as any)
151
+ const stepStartTime = Date.now()
136
152
 
137
- await this.beforeStep({
138
- job,
139
- step,
140
- stepIndex,
141
- result,
142
- options,
143
- stepResults,
144
- })
153
+ await this.beforeStep({
154
+ job,
155
+ step,
156
+ stepIndex,
157
+ result,
158
+ options: runOptions,
159
+ stepResults,
160
+ })
161
+
162
+ const stepContext = await container.createContext(step.dependencies)
163
+ const stepInput = step.input.decode(resultSnapshot)
145
164
 
146
- try {
147
165
  await job.beforeEachHandler?.({
148
- context: jobDependencyContext as any,
166
+ context: jobDependencyContext,
149
167
  data: jobData,
150
- input: decodedInput as any,
151
- result: resultSnapshot as any,
168
+ input: decodedInput,
169
+ result: resultSnapshot,
170
+ progress,
152
171
  step,
153
172
  stepIndex,
154
173
  })
155
174
 
156
175
  const handlerReturn = await step.handler(
157
- stepContext as any,
158
- stepInput as any,
176
+ stepContext,
177
+ stepInput,
159
178
  jobData,
160
179
  )
161
180
 
162
- const produced = step.output.encode((handlerReturn ?? {}) as any)
181
+ const produced = step.output.encode(handlerReturn ?? {})
182
+ const duration = Date.now() - stepStartTime
163
183
 
164
- stepResults[stepIndex] = produced
184
+ stepResults[stepIndex] = { data: produced, duration }
165
185
  Object.assign(result, produced)
166
186
 
167
187
  await job.afterEachHandler?.({
168
- context: jobDependencyContext as any,
188
+ context: jobDependencyContext,
169
189
  data: jobData,
170
- input: decodedInput as any,
171
- result: Object.freeze(Object.assign({}, result)) as any,
190
+ input: decodedInput,
191
+ result,
192
+ progress,
172
193
  step,
173
194
  stepIndex,
174
195
  })
@@ -178,16 +199,22 @@ export class JobRunner<
178
199
  step,
179
200
  stepIndex,
180
201
  result,
181
- stepResult: produced,
202
+ stepResult: stepResults[stepIndex],
182
203
  stepResults,
183
- options,
204
+ options: runOptions,
184
205
  })
185
206
  } catch (error) {
207
+ const wrapped = new Error(`Error during step [${stepIndex}]`, {
208
+ cause: error,
209
+ })
210
+ this.logger.error(wrapped)
211
+
186
212
  const allowRetry = await job.onErrorHandler?.({
187
- context: jobDependencyContext as any,
213
+ context: jobDependencyContext,
188
214
  data: jobData,
189
- input: decodedInput as any,
190
- result: resultSnapshot as any,
215
+ input: decodedInput,
216
+ result: result,
217
+ progress,
191
218
  step,
192
219
  stepIndex,
193
220
  error,
@@ -197,22 +224,19 @@ export class JobRunner<
197
224
  throw new UnrecoverableError('Job failed (unrecoverable)')
198
225
  }
199
226
 
200
- const wrapped = new Error(`Error during step [${stepIndex}]`, {
201
- cause: error,
202
- })
203
- this.logger.error(wrapped)
204
227
  throw wrapped
205
228
  }
206
229
  }
207
230
 
208
231
  const finalPayload = await job.returnHandler!({
209
- context: jobDependencyContext as any,
232
+ context: jobDependencyContext,
210
233
  data: jobData,
211
- input: decodedInput as any,
212
- result: Object.freeze(Object.assign({}, result)) as any,
234
+ input: decodedInput,
235
+ result,
236
+ progress,
213
237
  })
214
238
 
215
- return output.encode(finalPayload as any)
239
+ return output.encode(finalPayload)
216
240
  }
217
241
 
218
242
  protected async beforeStep(
@@ -260,21 +284,36 @@ export class ApplicationWorkerJobRunner extends JobRunner<
260
284
  JobRunnerRunOptions & { queueJob: Job }
261
285
  >,
262
286
  ): Promise<void> {
287
+ await super.afterStep(params)
263
288
  const {
289
+ job,
264
290
  step,
265
291
  result,
292
+ stepResult,
266
293
  stepResults,
267
294
  stepIndex,
268
- options: { queueJob },
295
+ options: { queueJob, progress },
269
296
  } = params
270
297
  const nextStepIndex = stepIndex + 1
298
+ const totalSteps = job.steps.length
299
+ const percentage = Math.round((nextStepIndex / totalSteps) * 100)
300
+
301
+ // Encode progress before persisting if schema is defined
302
+ const encodedProgress = job.progress
303
+ ? job.progress.encode(progress)
304
+ : progress
305
+
271
306
  await Promise.all([
272
- queueJob.log('Completed step ' + (step.label || nextStepIndex)),
307
+ queueJob.log(
308
+ `Step ${step.label || nextStepIndex} completed in ${(stepResult.duration / 1000).toFixed(3)}s`,
309
+ ),
273
310
  queueJob.updateProgress({
274
311
  stepIndex: nextStepIndex,
275
312
  stepLabel: step.label,
276
313
  result,
277
314
  stepResults,
315
+ progress: encodedProgress,
316
+ percentage,
278
317
  }),
279
318
  ])
280
319
  }
@@ -4,19 +4,17 @@ import type { Queue } from 'bullmq'
4
4
  import { createBullBoard } from '@bull-board/api'
5
5
  import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'
6
6
  import { H3Adapter } from '@bull-board/h3'
7
- import { createApp, createRouter, toNodeListener } from 'h3'
7
+ import { createApp, toNodeListener } from 'h3'
8
8
 
9
9
  export function createJobsUI(queues: Queue[]) {
10
10
  const app = createApp()
11
- const router = createRouter()
12
11
  const serverAdapter = new H3Adapter()
13
- serverAdapter.setBasePath('/')
14
12
  createBullBoard({
15
- queues: queues.map((q) => new BullMQAdapter(q)),
13
+ queues: queues.map((q) => new BullMQAdapter(q, { readOnlyMode: true })),
16
14
  serverAdapter,
17
15
  })
16
+ const router = serverAdapter.registerHandlers()
18
17
  app.use(router)
19
- app.use(serverAdapter.registerHandlers())
20
18
  return createServer(toNodeListener(app))
21
19
  }
22
20
 
@@ -184,6 +184,7 @@ export class ApplicationServerJobs {
184
184
  case 'queue_job_not_found':
185
185
  throw new UnrecoverableError(result.type)
186
186
  case 'error':
187
+ console.error(result.error)
187
188
  throw result.error
188
189
  default:
189
190
  throw new UnrecoverableError('Unknown job task result')
@@ -3,6 +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
7
  import type { ServerConfig } from '../server/config.ts'
7
8
  import type { ServerPortMessage, ThreadPortMessage } from '../types.ts'
8
9
  import { LifecycleHook, WorkerType } from '../enums.ts'
@@ -93,20 +94,22 @@ export class JobWorkerRuntime extends BaseWorkerRuntime {
93
94
  }
94
95
 
95
96
  // Load checkpoint from BullMQ progress for resume support
96
- const progress = bullJob.progress as
97
+ const checkpoint = bullJob.progress as
97
98
  | {
98
99
  stepIndex: number
99
100
  result: Record<string, unknown>
100
- stepResults: unknown[]
101
+ stepResults: StepResultEntry[]
102
+ progress: Record<string, unknown>
101
103
  }
102
104
  | undefined
103
105
 
104
106
  const result = await this.jobRunner.runJob(job, task.data, {
105
107
  signal: cancellationSignal,
106
108
  queueJob: bullJob,
107
- result: progress?.result,
108
- stepResults: progress?.stepResults,
109
- currentStepIndex: progress?.stepIndex ?? 0,
109
+ result: checkpoint?.result,
110
+ stepResults: checkpoint?.stepResults,
111
+ currentStepIndex: checkpoint?.stepIndex ?? 0,
112
+ progress: checkpoint?.progress,
110
113
  })
111
114
  this.runtimeOptions.port.postMessage({
112
115
  type: 'task',
@@ -141,7 +144,9 @@ export class JobWorkerRuntime extends BaseWorkerRuntime {
141
144
  if (this.config.jobs) {
142
145
  for (const job of this.config.jobs.jobs.values()) {
143
146
  yield job
144
- yield* job.steps
147
+ // we explicitly DO NOT WANT to yield steps here, so per-job container
148
+ // resolves them independently each time — creates more isolation
149
+ // yield* job.steps
145
150
  }
146
151
  }
147
152
  }