@nest-batch/core 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +5 -3
  2. package/dist/src/adapters/in-process.adapter.d.ts +16 -13
  3. package/dist/src/adapters/in-process.adapter.d.ts.map +1 -1
  4. package/dist/src/adapters/in-process.adapter.js +2 -0
  5. package/dist/src/adapters/in-process.adapter.js.map +1 -1
  6. package/dist/src/compiler/definition-compiler.d.ts.map +1 -1
  7. package/dist/src/compiler/definition-compiler.js +3 -0
  8. package/dist/src/compiler/definition-compiler.js.map +1 -1
  9. package/dist/src/core/ir/listener-definition.d.ts +2 -0
  10. package/dist/src/core/ir/listener-definition.d.ts.map +1 -1
  11. package/dist/src/core/item/interfaces.d.ts +14 -4
  12. package/dist/src/core/item/interfaces.d.ts.map +1 -1
  13. package/dist/src/decorators/listener.decorators.d.ts +4 -3
  14. package/dist/src/decorators/listener.decorators.d.ts.map +1 -1
  15. package/dist/src/decorators/listener.decorators.js +6 -3
  16. package/dist/src/decorators/listener.decorators.js.map +1 -1
  17. package/dist/src/execution/chunk-step-executor.d.ts +7 -1
  18. package/dist/src/execution/chunk-step-executor.d.ts.map +1 -1
  19. package/dist/src/execution/chunk-step-executor.js +104 -13
  20. package/dist/src/execution/chunk-step-executor.js.map +1 -1
  21. package/dist/src/execution/in-process-schedule.d.ts +25 -0
  22. package/dist/src/execution/in-process-schedule.d.ts.map +1 -0
  23. package/dist/src/execution/in-process-schedule.js +129 -0
  24. package/dist/src/execution/in-process-schedule.js.map +1 -0
  25. package/dist/src/execution/index.d.ts +1 -0
  26. package/dist/src/execution/index.d.ts.map +1 -1
  27. package/dist/src/execution/index.js +1 -0
  28. package/dist/src/execution/index.js.map +1 -1
  29. package/dist/src/execution/job-executor.d.ts.map +1 -1
  30. package/dist/src/execution/job-executor.js +14 -8
  31. package/dist/src/execution/job-executor.js.map +1 -1
  32. package/dist/src/execution/listener-invoker.d.ts +25 -9
  33. package/dist/src/execution/listener-invoker.d.ts.map +1 -1
  34. package/dist/src/execution/listener-invoker.js +70 -14
  35. package/dist/src/execution/listener-invoker.js.map +1 -1
  36. package/dist/src/execution/tasklet-step-executor.d.ts +4 -1
  37. package/dist/src/execution/tasklet-step-executor.d.ts.map +1 -1
  38. package/dist/src/execution/tasklet-step-executor.js +20 -16
  39. package/dist/src/execution/tasklet-step-executor.js.map +1 -1
  40. package/dist/src/explorer/batch-explorer.d.ts +2 -1
  41. package/dist/src/explorer/batch-explorer.d.ts.map +1 -1
  42. package/dist/src/explorer/batch-explorer.js +3 -0
  43. package/dist/src/explorer/batch-explorer.js.map +1 -1
  44. package/dist/src/index.d.ts +1 -0
  45. package/dist/src/index.d.ts.map +1 -1
  46. package/dist/src/index.js +1 -0
  47. package/dist/src/index.js.map +1 -1
  48. package/dist/src/module/batch-schedule-registry.d.ts +13 -14
  49. package/dist/src/module/batch-schedule-registry.d.ts.map +1 -1
  50. package/dist/src/module/batch-schedule-registry.js +0 -0
  51. package/dist/src/module/batch-schedule-registry.js.map +1 -1
  52. package/dist/src/module/nest-batch.module.d.ts +4 -3
  53. package/dist/src/module/nest-batch.module.d.ts.map +1 -1
  54. package/dist/src/module/nest-batch.module.js +3 -2
  55. package/dist/src/module/nest-batch.module.js.map +1 -1
  56. package/dist/src/module/tokens.d.ts +5 -6
  57. package/dist/src/module/tokens.d.ts.map +1 -1
  58. package/dist/src/module/tokens.js.map +1 -1
  59. package/dist/src/partition-helpers.d.ts +3 -3
  60. package/dist/src/partition-helpers.d.ts.map +1 -1
  61. package/dist/src/partition-helpers.js +3 -3
  62. package/dist/src/partition-helpers.js.map +1 -1
  63. package/dist/src/scheduling/batch-scheduled.d.ts +9 -11
  64. package/dist/src/scheduling/batch-scheduled.d.ts.map +1 -1
  65. package/dist/src/scheduling/batch-scheduled.js +12 -20
  66. package/dist/src/scheduling/batch-scheduled.js.map +1 -1
  67. package/package.json +4 -1
  68. package/src/adapters/in-process.adapter.ts +18 -13
  69. package/src/compiler/definition-compiler.ts +12 -5
  70. package/src/core/ir/listener-definition.ts +2 -0
  71. package/src/core/item/interfaces.ts +15 -4
  72. package/src/decorators/listener.decorators.ts +11 -13
  73. package/src/execution/chunk-step-executor.ts +212 -18
  74. package/src/execution/in-process-schedule.ts +143 -0
  75. package/src/execution/index.ts +1 -0
  76. package/src/execution/job-executor.ts +30 -21
  77. package/src/execution/listener-invoker.ts +105 -27
  78. package/src/execution/tasklet-step-executor.ts +40 -16
  79. package/src/explorer/batch-explorer.ts +10 -4
  80. package/src/index.ts +1 -0
  81. package/src/module/batch-schedule-registry.ts +0 -0
  82. package/src/module/nest-batch.module.ts +21 -42
  83. package/src/module/tokens.ts +8 -15
  84. package/src/partition-helpers.ts +13 -17
  85. package/src/scheduling/batch-scheduled.ts +22 -32
@@ -0,0 +1,143 @@
1
+ import { Injectable, Logger, OnApplicationBootstrap, OnApplicationShutdown } from '@nestjs/common';
2
+ import { CronJob } from 'cron';
3
+
4
+ import { type BatchScheduleEntry, BatchScheduleRegistry } from '../module/batch-schedule-registry';
5
+ import { JobLauncher } from './job-launcher';
6
+
7
+ interface ScheduleState {
8
+ readonly entry: BatchScheduleEntry;
9
+ readonly job: CronJob;
10
+ runningCount: number;
11
+ queued: boolean;
12
+ queuedAt: Date | null;
13
+ }
14
+
15
+ function scheduleKey(entry: BatchScheduleEntry): string {
16
+ return `${entry.jobId}::${entry.scheduleName}`;
17
+ }
18
+
19
+ /**
20
+ * In-process scheduler for `@BatchScheduled` jobs.
21
+ *
22
+ * This is deliberately part of the in-process transport, not the
23
+ * `@BatchScheduled` decorator. The decorator remains metadata-only;
24
+ * this provider consumes the `BatchScheduleRegistry` and turns matching
25
+ * cron ticks into `JobLauncher.launch(...)` calls inside the same server
26
+ * process.
27
+ */
28
+ @Injectable()
29
+ export class InProcessSchedule implements OnApplicationBootstrap, OnApplicationShutdown {
30
+ private readonly logger = new Logger(InProcessSchedule.name);
31
+ private readonly states = new Map<string, ScheduleState>();
32
+ private stopped = true;
33
+
34
+ constructor(
35
+ private readonly scheduleRegistry: BatchScheduleRegistry,
36
+ private readonly launcher: JobLauncher,
37
+ ) {}
38
+
39
+ onApplicationBootstrap(): void {
40
+ this.stopped = false;
41
+ for (const entry of this.scheduleRegistry.getAll()) {
42
+ if (entry.inert) {
43
+ this.logger.log(`Skipping inert schedule: ${entry.jobId}::${entry.scheduleName}`);
44
+ continue;
45
+ }
46
+
47
+ try {
48
+ let state!: ScheduleState;
49
+ const job = CronJob.from({
50
+ cronTime: entry.cron,
51
+ timeZone: entry.timezone,
52
+ start: false,
53
+ unrefTimeout: true,
54
+ name: scheduleKey(entry),
55
+ errorHandler: (err) => {
56
+ this.logger.warn(
57
+ `In-process schedule ${scheduleKey(entry)} callback failed: ${
58
+ err instanceof Error ? err.message : String(err)
59
+ }`,
60
+ );
61
+ },
62
+ onTick: () => {
63
+ if (this.stopped) return;
64
+ this.dispatch(state, state.job.lastDate() ?? new Date());
65
+ },
66
+ });
67
+ state = {
68
+ entry,
69
+ job,
70
+ runningCount: 0,
71
+ queued: false,
72
+ queuedAt: null,
73
+ };
74
+ this.states.set(scheduleKey(entry), state);
75
+ job.start();
76
+ } catch (err) {
77
+ this.logger.warn(
78
+ `Failed to install in-process schedule ${entry.jobId}::${entry.scheduleName}: ${
79
+ err instanceof Error ? err.message : String(err)
80
+ }`,
81
+ );
82
+ }
83
+ }
84
+
85
+ if (this.states.size === 0) return;
86
+
87
+ this.logger.log(`InProcessSchedule started: schedules=${this.states.size}`);
88
+ }
89
+
90
+ onApplicationShutdown(): void {
91
+ this.stopped = true;
92
+ for (const state of this.states.values()) {
93
+ void state.job.stop();
94
+ }
95
+ this.states.clear();
96
+ }
97
+
98
+ private dispatch(state: ScheduleState, scheduledAt: Date): void {
99
+ if (this.stopped) return;
100
+ const overlap = state.entry.overlap ?? 'skip';
101
+ if (state.runningCount > 0) {
102
+ if (overlap === 'skip') return;
103
+ if (overlap === 'queue') {
104
+ state.queued = true;
105
+ state.queuedAt ??= scheduledAt;
106
+ return;
107
+ }
108
+ }
109
+
110
+ state.runningCount += 1;
111
+ void this.launch(state, scheduledAt).finally(() => {
112
+ state.runningCount -= 1;
113
+ if (this.stopped) return;
114
+ if (state.runningCount === 0 && state.queued) {
115
+ state.queued = false;
116
+ const queuedAt = state.queuedAt ?? new Date();
117
+ state.queuedAt = null;
118
+ this.dispatch(state, queuedAt);
119
+ }
120
+ });
121
+ }
122
+
123
+ private async launch(state: ScheduleState, scheduledAt: Date): Promise<void> {
124
+ const { entry } = state;
125
+ try {
126
+ const execution = await this.launcher.launch(entry.jobId, {
127
+ scheduled: true,
128
+ scheduleName: entry.scheduleName,
129
+ scheduledAt: scheduledAt.toISOString(),
130
+ });
131
+ this.logger.log(
132
+ `Fired schedule ${entry.jobId}::${entry.scheduleName} -> ` +
133
+ `execution=${execution.id} status=${execution.status}`,
134
+ );
135
+ } catch (err) {
136
+ this.logger.warn(
137
+ `Failed to fire schedule ${entry.jobId}::${entry.scheduleName}: ${
138
+ err instanceof Error ? err.message : String(err)
139
+ }`,
140
+ );
141
+ }
142
+ }
143
+ }
@@ -11,3 +11,4 @@ export * from './job-key';
11
11
  export * from './execution-strategy';
12
12
  export * from './ref-resolver';
13
13
  export * from './in-process-execution-strategy';
14
+ export * from './in-process-schedule';
@@ -7,11 +7,7 @@ import { StepStatus, JobStatus, FlowExecutionStatus } from '../core/status';
7
7
  import { JobNotRestartableError } from '../core/errors';
8
8
  import { TaskletStepExecutor, type StepExecutionResult } from './tasklet-step-executor';
9
9
  import { ChunkStepExecutor, type ChunkExecutionResult } from './chunk-step-executor';
10
- import {
11
- ListenerInvoker,
12
- type ResolverMap,
13
- type ListenerResolver,
14
- } from './listener-invoker';
10
+ import { ListenerInvoker, type ResolverMap, type ListenerResolver } from './listener-invoker';
15
11
  import { FlowEvaluator } from '../flow/flow-evaluator';
16
12
  import {
17
13
  BATCH_EVENT,
@@ -137,6 +133,7 @@ export class JobExecutor {
137
133
  await this.listenerInvoker.invokeBefore(jobResolvers, 'job', {
138
134
  jobExecutionId: execution.id,
139
135
  stepExecutionId: '<job>',
136
+ jobParameters: execution.params,
140
137
  });
141
138
 
142
139
  // Cache the step order once. `Object.keys` returns insertion order
@@ -196,6 +193,9 @@ export class JobExecutor {
196
193
  if (step.kind === 'tasklet') {
197
194
  result = await this.taskletExecutor.execute(step, {
198
195
  jobExecutionId: execution.id,
196
+ stepExecutionId: stepExecution.id,
197
+ stepName: step.id,
198
+ jobParameters: execution.params,
199
199
  jobRepository: this.repository,
200
200
  transactionManager: this.transactionManager,
201
201
  listenerInvoker: this.listenerInvoker,
@@ -217,9 +217,12 @@ export class JobExecutor {
217
217
  result = await this.chunkExecutor.execute(step, {
218
218
  jobExecutionId: execution.id,
219
219
  stepExecutionId: stepExecution.id,
220
+ stepName: step.id,
221
+ jobParameters: execution.params,
220
222
  jobRepository: this.repository,
221
223
  transactionManager: this.transactionManager,
222
224
  listenerInvoker: this.listenerInvoker,
225
+ listenerResolvers: jobResolvers,
223
226
  jobExecutionId2: execution.id,
224
227
  resolvers: new Map(),
225
228
  ...(resumeFromChunkIndex !== undefined ? { resumeFromChunkIndex } : {}),
@@ -281,18 +284,14 @@ export class JobExecutor {
281
284
  : result.status === StepStatus.FAILED
282
285
  ? FlowExecutionStatus.FAILED
283
286
  : FlowExecutionStatus.UNKNOWN;
284
- const deciderExitStatus = await this.resolveDeciderExitStatus(
285
- jobDef,
286
- currentStepId,
287
- {
288
- jobExecution: execution,
289
- stepId: step.id,
290
- stepExecutionId: stepExecution.id,
291
- stepStatus: result.status,
292
- exitCode: result.exitCode,
293
- exitMessage: result.exitMessage,
294
- },
295
- );
287
+ const deciderExitStatus = await this.resolveDeciderExitStatus(jobDef, currentStepId, {
288
+ jobExecution: execution,
289
+ stepId: step.id,
290
+ stepExecutionId: stepExecution.id,
291
+ stepStatus: result.status,
292
+ exitCode: result.exitCode,
293
+ exitMessage: result.exitMessage,
294
+ });
296
295
  const flowExitStatus = deciderExitStatus ?? (result.exitCode || flowStatus);
297
296
 
298
297
  let evaluatorResult = await this.flowEvaluator.evaluate(
@@ -373,8 +372,12 @@ export class JobExecutor {
373
372
  await this.listenerInvoker.invokeAfter(
374
373
  jobResolvers,
375
374
  'job',
376
- { jobExecutionId: execution.id, stepExecutionId: '<job>' },
377
- [{ status: finalStatus }],
375
+ {
376
+ jobExecutionId: execution.id,
377
+ stepExecutionId: '<job>',
378
+ jobParameters: execution.params,
379
+ },
380
+ { status: finalStatus },
378
381
  );
379
382
 
380
383
  await this.emit({
@@ -423,7 +426,10 @@ export class JobExecutor {
423
426
  const name = this.resolveListenerName(def.ref, lambdaCounter);
424
427
  if (def.ref.kind === RefKind.BuilderLambda) lambdaCounter += 1;
425
428
 
426
- const key = `${def.phase}:${def.kind}:${name}`;
429
+ const key =
430
+ def.kind === 'skip' && def.skipKind !== undefined
431
+ ? `on-skip:${def.skipKind}:${name}`
432
+ : `${def.phase}:${def.kind}:${name}`;
427
433
  resolvers.set(key, {
428
434
  fn,
429
435
  ...(def.nonCritical !== undefined ? { nonCritical: def.nonCritical } : {}),
@@ -497,7 +503,10 @@ export class JobExecutor {
497
503
  } else if (key.startsWith('after:step:')) {
498
504
  legacy.set(`after-step:${key.slice('after:step:'.length)}`, entry.fn as ListenerResolver);
499
505
  } else if (key.startsWith('on-error:step:')) {
500
- legacy.set(`on-step-error:${key.slice('on-error:step:'.length)}`, entry.fn as ListenerResolver);
506
+ legacy.set(
507
+ `on-step-error:${key.slice('on-error:step:'.length)}`,
508
+ entry.fn as ListenerResolver,
509
+ );
501
510
  }
502
511
  }
503
512
  return legacy;
@@ -26,7 +26,8 @@
26
26
  * Registration order is preserved (Map iteration is insertion-ordered in JS).
27
27
  */
28
28
  import { Injectable, Logger } from '@nestjs/common';
29
- import type { ExecutionContext } from '../core/repository';
29
+ import type { ExecutionContext, JobParameters } from '../core/repository';
30
+ import type { SkipSubKind } from '../core/ir/listener-definition';
30
31
 
31
32
  // ---------------------------------------------------------------------------
32
33
  // Public types
@@ -122,8 +123,6 @@ export type OnErrorKind = 'job' | 'step' | 'chunk';
122
123
  * Sub-kinds for the `on-skip` phase. The resolver key looks like
123
124
  * `on-skip:${SkipSubKind}:${name}` — for example `on-skip:read:MySkipListener`.
124
125
  */
125
- export type SkipSubKind = 'read' | 'process' | 'write';
126
-
127
126
  // ---------------------------------------------------------------------------
128
127
  // Implementation
129
128
  // ---------------------------------------------------------------------------
@@ -140,11 +139,10 @@ export class ListenerInvoker {
140
139
  * Invoke every `before:<kind>:<name>` resolver, in registration order.
141
140
  *
142
141
  * Listener signature depends on `<kind>`:
143
- * - `job` / `chunk` — `fn(ctx)`
144
- * - `step` — `fn(ctx, result)` (the optional `args` is the result)
145
- * - `item-read` / `item-process` / `item-write` `fn(item, ctx)` (the
146
- * optional `args` is the item, placed in the first position by
147
- * convention)
142
+ * - `job` / `step` / `chunk` — `fn(ctx, result?)`
143
+ * - `item-read` / `item-process` / `item-write` — `fn(item, ctx)` for the
144
+ * legacy generic path. Prefer the explicit item helpers below for exact
145
+ * read/process/write signatures.
148
146
  */
149
147
  async invokeBefore(
150
148
  resolvers: ResolverMap,
@@ -183,30 +181,115 @@ export class ListenerInvoker {
183
181
  await this.invokeMatching(resolvers, `${LISTENER_PHASE.OnError}:${kind}:`, [ctx, err]);
184
182
  }
185
183
 
186
- /** Invoke every `on-skip:read:<name>` resolver. Listener signature: `fn(err, item)`. */
187
- async invokeOnSkipRead(
184
+ /** Invoke every `before:item-read:<name>` resolver. Listener signature: `fn(ctx)`. */
185
+ async invokeBeforeRead(resolvers: ResolverMap, ctx: ListenerContext): Promise<void> {
186
+ await this.invokeMatching(resolvers, `${LISTENER_PHASE.Before}:item-read:`, [ctx]);
187
+ }
188
+
189
+ /** Invoke every `after:item-read:<name>` resolver. Listener signature: `fn(item, ctx)`. */
190
+ async invokeAfterRead(
191
+ resolvers: ResolverMap,
192
+ item: unknown,
193
+ ctx: ListenerContext,
194
+ ): Promise<void> {
195
+ await this.invokeMatching(resolvers, `${LISTENER_PHASE.After}:item-read:`, [item, ctx]);
196
+ }
197
+
198
+ /** Invoke every `on-error:item-read:<name>` resolver. Listener signature: `fn(err, ctx)`. */
199
+ async invokeOnReadError(
188
200
  resolvers: ResolverMap,
189
201
  err: unknown,
202
+ ctx: ListenerContext,
203
+ ): Promise<void> {
204
+ await this.invokeMatching(resolvers, `${LISTENER_PHASE.OnError}:item-read:`, [err, ctx]);
205
+ }
206
+
207
+ /** Invoke every `before:item-process:<name>` resolver. Listener signature: `fn(item, ctx)`. */
208
+ async invokeBeforeProcess(
209
+ resolvers: ResolverMap,
190
210
  item: unknown,
211
+ ctx: ListenerContext,
191
212
  ): Promise<void> {
192
- await this.invokeMatching(resolvers, `${LISTENER_PHASE.OnSkip}:read:`, [err, item]);
213
+ await this.invokeMatching(resolvers, `${LISTENER_PHASE.Before}:item-process:`, [item, ctx]);
193
214
  }
194
215
 
195
- /** Invoke every `on-skip:process:<name>` resolver. Listener signature: `fn(item, err)`. */
196
- async invokeOnSkipProcess(
216
+ /** Invoke every `after:item-process:<name>` resolver. Listener signature: `fn(item, result, ctx)`. */
217
+ async invokeAfterProcess(
218
+ resolvers: ResolverMap,
219
+ item: unknown,
220
+ result: unknown,
221
+ ctx: ListenerContext,
222
+ ): Promise<void> {
223
+ await this.invokeMatching(resolvers, `${LISTENER_PHASE.After}:item-process:`, [
224
+ item,
225
+ result,
226
+ ctx,
227
+ ]);
228
+ }
229
+
230
+ /** Invoke every `on-error:item-process:<name>` resolver. Listener signature: `fn(item, err, ctx)`. */
231
+ async invokeOnProcessError(
197
232
  resolvers: ResolverMap,
198
233
  item: unknown,
199
234
  err: unknown,
235
+ ctx: ListenerContext,
200
236
  ): Promise<void> {
201
- await this.invokeMatching(resolvers, `${LISTENER_PHASE.OnSkip}:process:`, [item, err]);
237
+ await this.invokeMatching(resolvers, `${LISTENER_PHASE.OnError}:item-process:`, [
238
+ item,
239
+ err,
240
+ ctx,
241
+ ]);
202
242
  }
203
243
 
204
- /** Invoke every `on-skip:write:<name>` resolver. Listener signature: `fn(items, err)`. */
205
- async invokeOnSkipWrite(
244
+ /** Invoke every `before:item-write:<name>` resolver. Listener signature: `fn(items, ctx)`. */
245
+ async invokeBeforeWrite(
246
+ resolvers: ResolverMap,
247
+ items: unknown[],
248
+ ctx: ListenerContext,
249
+ ): Promise<void> {
250
+ await this.invokeMatching(resolvers, `${LISTENER_PHASE.Before}:item-write:`, [items, ctx]);
251
+ }
252
+
253
+ /** Invoke every `after:item-write:<name>` resolver. Listener signature: `fn(items, result, ctx)`. */
254
+ async invokeAfterWrite(
255
+ resolvers: ResolverMap,
256
+ items: unknown[],
257
+ result: unknown,
258
+ ctx: ListenerContext,
259
+ ): Promise<void> {
260
+ await this.invokeMatching(resolvers, `${LISTENER_PHASE.After}:item-write:`, [
261
+ items,
262
+ result,
263
+ ctx,
264
+ ]);
265
+ }
266
+
267
+ /** Invoke every `on-error:item-write:<name>` resolver. Listener signature: `fn(items, err, ctx)`. */
268
+ async invokeOnWriteError(
206
269
  resolvers: ResolverMap,
207
270
  items: unknown[],
208
271
  err: unknown,
272
+ ctx: ListenerContext,
209
273
  ): Promise<void> {
274
+ await this.invokeMatching(resolvers, `${LISTENER_PHASE.OnError}:item-write:`, [
275
+ items,
276
+ err,
277
+ ctx,
278
+ ]);
279
+ }
280
+
281
+ /** Invoke every `on-skip:read:<name>` resolver. Listener signature: `fn(err, item)`. */
282
+ async invokeOnSkipRead(resolvers: ResolverMap, err: unknown, item: unknown): Promise<void> {
283
+ await this.invokeMatching(resolvers, `${LISTENER_PHASE.OnSkip}:read:`, [err, item]);
284
+ }
285
+
286
+ /** Invoke every `on-skip:process:<name>` resolver. Listener signature: `fn(item, err)`. */
287
+ async invokeOnSkipProcess(resolvers: ResolverMap, item: unknown, err: unknown): Promise<void> {
288
+ await this.invokeMatching(resolvers, `${LISTENER_PHASE.OnSkip}:process:`, [item, err]);
289
+ }
290
+
291
+ /** Invoke every `on-skip:write:<name>` resolver. Listener signature: `fn(items, err)`. */
292
+ async invokeOnSkipWrite(resolvers: ResolverMap, items: unknown[], err: unknown): Promise<void> {
210
293
  await this.invokeMatching(resolvers, `${LISTENER_PHASE.OnSkip}:write:`, [items, err]);
211
294
  }
212
295
 
@@ -309,27 +392,21 @@ export class ListenerInvoker {
309
392
  * Compute the positional argument list to forward to a before/after
310
393
  * listener, based on the listener's kind.
311
394
  *
312
- * - `job` / `chunk` → `[ctx]`
313
- * - `step` → `[ctx, args]` (args is the result)
395
+ * - `job` / `step` / `chunk` → `[ctx]` or `[ctx, args]`
314
396
  * - `item-read` /
315
397
  * `item-process` /
316
398
  * `item-write` → `[args, ctx]` (args is the item, leading position)
317
399
  */
318
- private buildCallArgs(
319
- kind: LifecyclePhaseKind,
320
- ctx: ListenerContext,
321
- args: unknown,
322
- ): unknown[] {
400
+ private buildCallArgs(kind: LifecyclePhaseKind, ctx: ListenerContext, args: unknown): unknown[] {
323
401
  switch (kind) {
324
402
  case 'item-read':
325
403
  case 'item-process':
326
404
  case 'item-write':
327
- return [args, ctx];
328
- case 'step':
329
- return [ctx, args];
405
+ return args === undefined ? [ctx] : [args, ctx];
330
406
  case 'job':
407
+ case 'step':
331
408
  case 'chunk':
332
- return [ctx];
409
+ return args === undefined ? [ctx] : [ctx, args];
333
410
  default: {
334
411
  // exhaustive guard
335
412
  const _exhaustive: never = kind;
@@ -380,6 +457,7 @@ export interface ListenerContext {
380
457
  jobExecutionId: string;
381
458
  stepExecutionId?: string;
382
459
  stepName?: string;
460
+ jobParameters?: JobParameters;
383
461
  /** Arbitrary, executor-supplied metadata (transaction context, etc.). */
384
462
  [extra: string]: unknown;
385
463
  }
@@ -7,6 +7,7 @@ import type {
7
7
  ExecutionContext,
8
8
  StepExecution,
9
9
  ExecutionScope,
10
+ JobParameters,
10
11
  } from '../core/repository';
11
12
  import type { TransactionManager } from '../core/transaction';
12
13
  import { StepStatus, JobStatus } from '../core/status';
@@ -19,6 +20,9 @@ import { resolveProviderToken, type ProviderResolvers } from './ref-resolver';
19
20
  */
20
21
  export interface TaskletExecutionContext {
21
22
  jobExecutionId: string;
23
+ stepExecutionId?: string;
24
+ stepName?: string;
25
+ jobParameters?: JobParameters;
22
26
  jobRepository: JobRepository;
23
27
  transactionManager: TransactionManager;
24
28
  listenerInvoker: ListenerInvoker;
@@ -76,26 +80,26 @@ export class TaskletStepExecutor {
76
80
  ): Promise<StepExecutionResult> {
77
81
  // Build the TaskletContext the tasklet will see.
78
82
  //
79
- // `stepExecutionId` is a placeholder here — the JobExecutor knows the real
80
- // ID (it created the StepExecution) and will patch this object before the
81
- // tasklet uses it. The placeholder keeps the contract explicit.
82
- //
83
- // `getExecutionContext` / `saveExecutionContext` are also stubbed here;
84
- // they become real once the JobExecutor wires the stepExecutionId in.
85
- // (For Wave 3 tests we do not exercise these methods.)
83
+ const stepExecutionId = context.stepExecutionId ?? '<pending>';
84
+ const stepName = context.stepName ?? step.id;
85
+ const jobParameters = context.jobParameters ?? {};
86
86
  const taskletCtx: TaskletContext = {
87
87
  jobExecutionId: context.jobExecutionId,
88
- stepExecutionId: '<pending>',
89
- getExecutionContext: async () => ({ data: null, version: 0 }),
90
- saveExecutionContext: async (_ctx: ExecutionContext) => {
91
- // wired by JobExecutor (Task 20) once stepExecutionId is known
88
+ stepExecutionId,
89
+ jobParameters,
90
+ getExecutionContext: async () =>
91
+ context.jobRepository.getExecutionContext({ stepExecutionId }),
92
+ saveExecutionContext: async (ctx: ExecutionContext) => {
93
+ await context.jobRepository.saveExecutionContext({ stepExecutionId }, ctx);
92
94
  },
93
95
  };
94
96
 
95
97
  // 1. before-step listeners
96
98
  await context.listenerInvoker.invokeBeforeStep(context.listenerResolvers, {
97
99
  jobExecutionId: context.jobExecutionId,
98
- stepExecutionId: taskletCtx.stepExecutionId,
100
+ stepExecutionId,
101
+ stepName,
102
+ jobParameters,
99
103
  });
100
104
 
101
105
  let result: StepExecutionResult;
@@ -117,7 +121,12 @@ export class TaskletStepExecutor {
117
121
  // 3. on-step-error listeners (best-effort: rethrow their failures too)
118
122
  await context.listenerInvoker.invokeOnErrorStep(
119
123
  context.listenerResolvers,
120
- { jobExecutionId: context.jobExecutionId, stepExecutionId: taskletCtx.stepExecutionId },
124
+ {
125
+ jobExecutionId: context.jobExecutionId,
126
+ stepExecutionId,
127
+ stepName,
128
+ jobParameters,
129
+ },
121
130
  err,
122
131
  );
123
132
  result = {
@@ -136,7 +145,12 @@ export class TaskletStepExecutor {
136
145
  // different branch — transition evaluation runs AFTER this call.
137
146
  await context.listenerInvoker.invokeAfterStep(
138
147
  context.listenerResolvers,
139
- { jobExecutionId: context.jobExecutionId, stepExecutionId: taskletCtx.stepExecutionId },
148
+ {
149
+ jobExecutionId: context.jobExecutionId,
150
+ stepExecutionId,
151
+ stepName,
152
+ jobParameters,
153
+ },
140
154
  result,
141
155
  );
142
156
 
@@ -156,7 +170,13 @@ export class TaskletStepExecutor {
156
170
  * `builder-lambda` ref before reaching this executor.
157
171
  */
158
172
  private resolveTasklet(
159
- taskletRef: { kind: string; token?: string; fn?: ListenerResolver; classToken?: string; methodName?: string },
173
+ taskletRef: {
174
+ kind: string;
175
+ token?: string;
176
+ fn?: ListenerResolver;
177
+ classToken?: string;
178
+ methodName?: string;
179
+ },
160
180
  context: TaskletExecutionContext,
161
181
  ): Tasklet {
162
182
  if (taskletRef.kind === RefKind.BuilderLambda && taskletRef.fn) {
@@ -164,7 +184,11 @@ export class TaskletStepExecutor {
164
184
  if (typeof result === 'function') {
165
185
  return { execute: result as Tasklet['execute'] };
166
186
  }
167
- if (result !== null && typeof result === 'object' && typeof (result as Tasklet).execute === 'function') {
187
+ if (
188
+ result !== null &&
189
+ typeof result === 'object' &&
190
+ typeof (result as Tasklet).execute === 'function'
191
+ ) {
168
192
  return result as Tasklet;
169
193
  }
170
194
  return { execute: taskletRef.fn as Tasklet['execute'] };
@@ -10,7 +10,7 @@ import {
10
10
  BATCH_TRANSITION_METADATA,
11
11
  } from '../decorators/constants';
12
12
  import type { JobableOptions, StepableOptions } from '../decorators';
13
- import type { ListenerKind, ListenerPhase } from '../core/ir/listener-definition';
13
+ import type { ListenerKind, ListenerPhase, SkipSubKind } from '../core/ir/listener-definition';
14
14
 
15
15
  /**
16
16
  * Raw shape of a discovered batch job, as it appears immediately after the
@@ -50,6 +50,7 @@ export interface DiscoveredListener {
50
50
  methodName: string;
51
51
  kind: ListenerKind;
52
52
  phase: ListenerPhase;
53
+ skipKind?: SkipSubKind;
53
54
  nonCritical?: boolean;
54
55
  }
55
56
 
@@ -164,8 +165,7 @@ export class BatchExplorer implements OnModuleInit {
164
165
  | StepableOptions
165
166
  | undefined;
166
167
  if (!opts) continue;
167
- const isTasklet =
168
- Reflect.getMetadata(BATCH_TASKLET_METADATA, prototype, name) === true;
168
+ const isTasklet = Reflect.getMetadata(BATCH_TASKLET_METADATA, prototype, name) === true;
169
169
  result.push({ methodName: name, options: opts, isTasklet });
170
170
  }
171
171
  return result;
@@ -186,13 +186,19 @@ export class BatchExplorer implements OnModuleInit {
186
186
  const result: DiscoveredListener[] = [];
187
187
  for (const name of this.allMethodNames(prototype)) {
188
188
  const opts = Reflect.getMetadata(BATCH_LISTENER_METADATA, prototype, name) as
189
- | { kind: ListenerKind; phase: ListenerPhase; nonCritical?: boolean }
189
+ | {
190
+ kind: ListenerKind;
191
+ phase: ListenerPhase;
192
+ skipKind?: SkipSubKind;
193
+ nonCritical?: boolean;
194
+ }
190
195
  | undefined;
191
196
  if (!opts) continue;
192
197
  result.push({
193
198
  methodName: name,
194
199
  kind: opts.kind,
195
200
  phase: opts.phase,
201
+ ...(opts.skipKind !== undefined ? { skipKind: opts.skipKind } : {}),
196
202
  nonCritical: opts.nonCritical,
197
203
  });
198
204
  }
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export * from './transaction';
14
14
  export * from './repository';
15
15
  export * as BatchDecorators from './decorators';
16
16
  export * from './module';
17
+ export * from './scheduling/batch-scheduled';
17
18
  export * from './builder';
18
19
  export * from './explorer';
19
20
  export * from './listeners';