duron 0.1.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/LICENSE +7 -0
- package/README.md +140 -0
- package/dist/action-job.d.ts +24 -0
- package/dist/action-job.d.ts.map +1 -0
- package/dist/action-job.js +108 -0
- package/dist/action-manager.d.ts +21 -0
- package/dist/action-manager.d.ts.map +1 -0
- package/dist/action-manager.js +78 -0
- package/dist/action.d.ts +129 -0
- package/dist/action.d.ts.map +1 -0
- package/dist/action.js +87 -0
- package/dist/adapters/adapter.d.ts +92 -0
- package/dist/adapters/adapter.d.ts.map +1 -0
- package/dist/adapters/adapter.js +424 -0
- package/dist/adapters/postgres/drizzle.config.d.ts +3 -0
- package/dist/adapters/postgres/drizzle.config.d.ts.map +1 -0
- package/dist/adapters/postgres/drizzle.config.js +10 -0
- package/dist/adapters/postgres/pglite.d.ts +13 -0
- package/dist/adapters/postgres/pglite.d.ts.map +1 -0
- package/dist/adapters/postgres/pglite.js +36 -0
- package/dist/adapters/postgres/postgres.d.ts +51 -0
- package/dist/adapters/postgres/postgres.d.ts.map +1 -0
- package/dist/adapters/postgres/postgres.js +867 -0
- package/dist/adapters/postgres/schema.d.ts +581 -0
- package/dist/adapters/postgres/schema.d.ts.map +1 -0
- package/dist/adapters/postgres/schema.default.d.ts +577 -0
- package/dist/adapters/postgres/schema.default.d.ts.map +1 -0
- package/dist/adapters/postgres/schema.default.js +3 -0
- package/dist/adapters/postgres/schema.js +87 -0
- package/dist/adapters/schemas.d.ts +516 -0
- package/dist/adapters/schemas.d.ts.map +1 -0
- package/dist/adapters/schemas.js +184 -0
- package/dist/client.d.ts +85 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +416 -0
- package/dist/constants.d.ts +14 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +22 -0
- package/dist/errors.d.ts +43 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +75 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/server.d.ts +1193 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +516 -0
- package/dist/step-manager.d.ts +46 -0
- package/dist/step-manager.d.ts.map +1 -0
- package/dist/step-manager.js +216 -0
- package/dist/utils/checksum.d.ts +2 -0
- package/dist/utils/checksum.d.ts.map +1 -0
- package/dist/utils/checksum.js +6 -0
- package/dist/utils/p-retry.d.ts +19 -0
- package/dist/utils/p-retry.d.ts.map +1 -0
- package/dist/utils/p-retry.js +130 -0
- package/dist/utils/wait-for-abort.d.ts +5 -0
- package/dist/utils/wait-for-abort.d.ts.map +1 -0
- package/dist/utils/wait-for-abort.js +32 -0
- package/migrations/postgres/0000_lethal_speed_demon.sql +64 -0
- package/migrations/postgres/meta/0000_snapshot.json +606 -0
- package/migrations/postgres/meta/_journal.json +13 -0
- package/package.json +88 -0
- package/src/action-job.ts +201 -0
- package/src/action-manager.ts +166 -0
- package/src/action.ts +247 -0
- package/src/adapters/adapter.ts +969 -0
- package/src/adapters/postgres/drizzle.config.ts +11 -0
- package/src/adapters/postgres/pglite.ts +86 -0
- package/src/adapters/postgres/postgres.ts +1346 -0
- package/src/adapters/postgres/schema.default.ts +5 -0
- package/src/adapters/postgres/schema.ts +119 -0
- package/src/adapters/schemas.ts +320 -0
- package/src/client.ts +859 -0
- package/src/constants.ts +37 -0
- package/src/errors.ts +205 -0
- package/src/index.ts +14 -0
- package/src/server.ts +718 -0
- package/src/step-manager.ts +471 -0
- package/src/utils/checksum.ts +7 -0
- package/src/utils/p-retry.ts +213 -0
- package/src/utils/wait-for-abort.ts +40 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import fastq from 'fastq'
|
|
2
|
+
import type { Logger } from 'pino'
|
|
3
|
+
import type { z } from 'zod'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type Action,
|
|
7
|
+
type ActionHandlerContext,
|
|
8
|
+
type StepHandlerContext,
|
|
9
|
+
type StepOptions,
|
|
10
|
+
StepOptionsSchema,
|
|
11
|
+
} from './action.js'
|
|
12
|
+
import type { Adapter, CreateOrRecoverJobStepResult } from './adapters/adapter.js'
|
|
13
|
+
import { STEP_STATUS_CANCELLED, STEP_STATUS_COMPLETED, STEP_STATUS_FAILED, type StepStatus } from './constants.js'
|
|
14
|
+
import {
|
|
15
|
+
ActionCancelError,
|
|
16
|
+
isCancelError,
|
|
17
|
+
isNonRetriableError,
|
|
18
|
+
NonRetriableError,
|
|
19
|
+
StepAlreadyExecutedError,
|
|
20
|
+
StepTimeoutError,
|
|
21
|
+
serializeError,
|
|
22
|
+
} from './errors.js'
|
|
23
|
+
import pRetry from './utils/p-retry.js'
|
|
24
|
+
import waitForAbort from './utils/wait-for-abort.js'
|
|
25
|
+
|
|
26
|
+
export interface TaskStep {
|
|
27
|
+
name: string
|
|
28
|
+
cb: (ctx: StepHandlerContext) => Promise<any>
|
|
29
|
+
options: StepOptions
|
|
30
|
+
abortSignal: AbortSignal
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* StepStore manages step records in the database.
|
|
35
|
+
* Provides methods to create, update, and delay steps.
|
|
36
|
+
*/
|
|
37
|
+
export class StepStore {
|
|
38
|
+
#adapter: Adapter
|
|
39
|
+
|
|
40
|
+
// ============================================================================
|
|
41
|
+
// Constructor
|
|
42
|
+
// ============================================================================
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create a new StepStore instance.
|
|
46
|
+
*
|
|
47
|
+
* @param adapter - The database adapter to use for step operations
|
|
48
|
+
*/
|
|
49
|
+
constructor(adapter: Adapter) {
|
|
50
|
+
this.#adapter = adapter
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// Public API Methods
|
|
55
|
+
// ============================================================================
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get or create a step record in the database.
|
|
59
|
+
*
|
|
60
|
+
* @param jobId - The ID of the job this step belongs to
|
|
61
|
+
* @param name - The name of the step
|
|
62
|
+
* @param timeoutMs - Timeout in milliseconds for the step
|
|
63
|
+
* @param retriesLimit - Maximum number of retries for the step
|
|
64
|
+
* @returns Promise resolving to the created step ID
|
|
65
|
+
* @throws Error if step creation fails
|
|
66
|
+
*/
|
|
67
|
+
async getOrCreate(jobId: string, name: string, timeoutMs: number, retriesLimit: number) {
|
|
68
|
+
try {
|
|
69
|
+
return await this.#adapter.createOrRecoverJobStep({
|
|
70
|
+
jobId,
|
|
71
|
+
name,
|
|
72
|
+
timeoutMs,
|
|
73
|
+
retriesLimit,
|
|
74
|
+
})
|
|
75
|
+
} catch (error) {
|
|
76
|
+
throw new NonRetriableError(`Failed to get or create step "${name}" for job "${jobId}"`, { cause: error })
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Update the status of a step in the database.
|
|
82
|
+
*
|
|
83
|
+
* @param stepId - The ID of the step to update
|
|
84
|
+
* @param status - The new status (completed, failed, or cancelled)
|
|
85
|
+
* @param output - Optional output data for completed steps
|
|
86
|
+
* @param error - Optional error data for failed steps
|
|
87
|
+
* @returns Promise resolving to `true` if update succeeded, `false` otherwise
|
|
88
|
+
*/
|
|
89
|
+
async updateStatus(stepId: string, status: StepStatus, output?: any, error?: any): Promise<boolean> {
|
|
90
|
+
if (status === STEP_STATUS_COMPLETED) {
|
|
91
|
+
return this.#adapter.completeJobStep({ stepId, output })
|
|
92
|
+
} else if (status === STEP_STATUS_FAILED) {
|
|
93
|
+
return this.#adapter.failJobStep({ stepId, error })
|
|
94
|
+
} else if (status === STEP_STATUS_CANCELLED) {
|
|
95
|
+
return this.#adapter.cancelJobStep({ stepId })
|
|
96
|
+
}
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Delay a step execution.
|
|
102
|
+
* Used when a step fails and needs to be retried after a delay.
|
|
103
|
+
*
|
|
104
|
+
* @param stepId - The ID of the step to delay
|
|
105
|
+
* @param delayMs - The delay in milliseconds before retrying
|
|
106
|
+
* @param error - The error that caused the delay
|
|
107
|
+
* @returns Promise resolving to `true` if delayed successfully, `false` otherwise
|
|
108
|
+
*/
|
|
109
|
+
async delay(stepId: string, delayMs: number, error: any): Promise<boolean> {
|
|
110
|
+
return this.#adapter.delayJobStep({ stepId, delayMs, error })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface StepManagerOptions {
|
|
115
|
+
jobId: string
|
|
116
|
+
actionName: string
|
|
117
|
+
adapter: Adapter
|
|
118
|
+
logger: Logger
|
|
119
|
+
concurrencyLimit: number
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* StepManager manages steps for a single ActionJob.
|
|
124
|
+
* Each ActionJob has its own StepManager instance.
|
|
125
|
+
*/
|
|
126
|
+
export class StepManager {
|
|
127
|
+
#jobId: string
|
|
128
|
+
#actionName: string
|
|
129
|
+
#stepStore: StepStore
|
|
130
|
+
#queue: fastq.queueAsPromised<TaskStep, any>
|
|
131
|
+
#logger: Logger
|
|
132
|
+
// each step name should be executed only once per action job
|
|
133
|
+
#historySteps = new Set<string>()
|
|
134
|
+
|
|
135
|
+
// ============================================================================
|
|
136
|
+
// Constructor
|
|
137
|
+
// ============================================================================
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Create a new StepManager instance.
|
|
141
|
+
*
|
|
142
|
+
* @param options - Configuration options for the step manager
|
|
143
|
+
*/
|
|
144
|
+
constructor(options: StepManagerOptions) {
|
|
145
|
+
this.#jobId = options.jobId
|
|
146
|
+
this.#actionName = options.actionName
|
|
147
|
+
this.#logger = options.logger
|
|
148
|
+
this.#stepStore = new StepStore(options.adapter)
|
|
149
|
+
this.#queue = fastq.promise(async (task: TaskStep) => {
|
|
150
|
+
if (this.#historySteps.has(task.name)) {
|
|
151
|
+
throw new StepAlreadyExecutedError(task.name, this.#jobId, this.#actionName)
|
|
152
|
+
}
|
|
153
|
+
this.#historySteps.add(task.name)
|
|
154
|
+
return this.#executeStep(task.name, task.cb, task.options, task.abortSignal)
|
|
155
|
+
}, options.concurrencyLimit)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ============================================================================
|
|
159
|
+
// Public API Methods
|
|
160
|
+
// ============================================================================
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Create an ActionContext for the action handler.
|
|
164
|
+
* The context provides access to input, variables, logger, and the step function.
|
|
165
|
+
*
|
|
166
|
+
* @param job - The job data including ID, input, and optional group key
|
|
167
|
+
* @param action - The action definition
|
|
168
|
+
* @param variables - Variables available to the action
|
|
169
|
+
* @param abortSignal - Abort signal for cancelling the action
|
|
170
|
+
* @param logger - Pino child logger for this job
|
|
171
|
+
* @returns ActionHandlerContext instance
|
|
172
|
+
*/
|
|
173
|
+
createActionContext<TInput extends z.ZodObject, TOutput extends z.ZodObject, TVariables = Record<string, unknown>>(
|
|
174
|
+
job: { id: string; input: z.infer<TInput>; groupKey?: string },
|
|
175
|
+
action: Action<TInput, TOutput, TVariables>,
|
|
176
|
+
variables: TVariables,
|
|
177
|
+
abortSignal: AbortSignal,
|
|
178
|
+
logger: Logger,
|
|
179
|
+
): ActionHandlerContext<TInput, TVariables> {
|
|
180
|
+
return new ActionContext(this, job, action, variables, abortSignal, logger)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Queue a step task for execution.
|
|
185
|
+
*
|
|
186
|
+
* @param task - The step task to queue
|
|
187
|
+
* @returns Promise resolving to the step result
|
|
188
|
+
*/
|
|
189
|
+
async push(task: TaskStep): Promise<any> {
|
|
190
|
+
return this.#queue.push(task)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Clean up step queues by waiting for them to drain.
|
|
195
|
+
* Should be called when the job completes or is cancelled.
|
|
196
|
+
*/
|
|
197
|
+
async drain(): Promise<void> {
|
|
198
|
+
await this.#queue.drain()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Execute a step with retry logic and timeout handling.
|
|
203
|
+
* Creates a step record, queues the execution, and handles errors appropriately.
|
|
204
|
+
*
|
|
205
|
+
* @param name - The name of the step
|
|
206
|
+
* @param cb - The step handler function
|
|
207
|
+
* @param options - Step options including concurrency, retry, and expire settings
|
|
208
|
+
* @param abortSignal - Abort signal for cancelling the step
|
|
209
|
+
* @returns Promise resolving to the step result
|
|
210
|
+
* @throws StepTimeoutError if the step times out
|
|
211
|
+
* @throws StepCancelError if the step is cancelled
|
|
212
|
+
* @throws Error if the step fails
|
|
213
|
+
*/
|
|
214
|
+
async #executeStep<TResult>(
|
|
215
|
+
name: string,
|
|
216
|
+
cb: (ctx: StepHandlerContext) => Promise<TResult>,
|
|
217
|
+
options: StepOptions,
|
|
218
|
+
abortSignal: AbortSignal,
|
|
219
|
+
): Promise<TResult> {
|
|
220
|
+
const expire = options.expire
|
|
221
|
+
const retryOptions = options.retry
|
|
222
|
+
let step: CreateOrRecoverJobStepResult | null = null
|
|
223
|
+
|
|
224
|
+
const executeStep = async (): Promise<TResult> => {
|
|
225
|
+
if (!step) {
|
|
226
|
+
if (abortSignal.aborted) {
|
|
227
|
+
throw new ActionCancelError(this.#actionName, this.#jobId, { cause: 'step cancelled before create step' })
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Create step record
|
|
231
|
+
const newStep = await this.#stepStore.getOrCreate(this.#jobId, name, expire, retryOptions.limit)
|
|
232
|
+
if (!newStep) {
|
|
233
|
+
throw new NonRetriableError(
|
|
234
|
+
`Failed to create step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`,
|
|
235
|
+
{ cause: 'step not created' },
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
step = newStep
|
|
240
|
+
|
|
241
|
+
if (abortSignal.aborted) {
|
|
242
|
+
throw new ActionCancelError(this.#actionName, this.#jobId, { cause: 'step cancelled after create step' })
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (step.status === STEP_STATUS_COMPLETED) {
|
|
246
|
+
// this is how we recover a completed step
|
|
247
|
+
this.#logger.debug(
|
|
248
|
+
{ jobId: this.#jobId, actionName: this.#actionName, stepName: name, stepId: step.id },
|
|
249
|
+
'[StepManager] Step recovered (already completed)',
|
|
250
|
+
)
|
|
251
|
+
return step.output as TResult
|
|
252
|
+
} else if (step.status === STEP_STATUS_FAILED) {
|
|
253
|
+
throw new NonRetriableError(
|
|
254
|
+
`Cannot recover a failed step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`,
|
|
255
|
+
{
|
|
256
|
+
cause: step.error,
|
|
257
|
+
},
|
|
258
|
+
)
|
|
259
|
+
} else if (step.status === STEP_STATUS_CANCELLED) {
|
|
260
|
+
throw new NonRetriableError(
|
|
261
|
+
`Cannot recover a cancelled step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`,
|
|
262
|
+
{ cause: step.error },
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Log step start
|
|
267
|
+
this.#logger.debug(
|
|
268
|
+
{ jobId: this.#jobId, actionName: this.#actionName, stepName: name, stepId: step.id },
|
|
269
|
+
'[StepManager] Step started executing',
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const stepAbortController = new AbortController()
|
|
274
|
+
const timeoutId = setTimeout(() => {
|
|
275
|
+
const timeoutError = new StepTimeoutError(name, this.#jobId, expire)
|
|
276
|
+
stepAbortController.abort(timeoutError)
|
|
277
|
+
}, expire)
|
|
278
|
+
|
|
279
|
+
timeoutId?.unref?.()
|
|
280
|
+
|
|
281
|
+
// Combine abort signals
|
|
282
|
+
const signal = AbortSignal.any([abortSignal, stepAbortController.signal])
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
// Race between abort signal and callback execution
|
|
286
|
+
const abortPromise = waitForAbort(signal)
|
|
287
|
+
const callbackPromise = cb({ signal })
|
|
288
|
+
|
|
289
|
+
let result: any = null
|
|
290
|
+
|
|
291
|
+
await Promise.race([
|
|
292
|
+
abortPromise.promise,
|
|
293
|
+
callbackPromise
|
|
294
|
+
.then((res) => {
|
|
295
|
+
if (res !== undefined && res !== null) {
|
|
296
|
+
result = res
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
.finally(() => {
|
|
300
|
+
abortPromise.release()
|
|
301
|
+
}),
|
|
302
|
+
])
|
|
303
|
+
|
|
304
|
+
// Update step as completed
|
|
305
|
+
const completed = await this.#stepStore.updateStatus(step.id, 'completed', result)
|
|
306
|
+
if (!completed) {
|
|
307
|
+
throw new Error(`Failed to complete step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Log step completion
|
|
311
|
+
this.#logger.debug(
|
|
312
|
+
{ jobId: this.#jobId, actionName: this.#actionName, stepName: name, stepId: step.id },
|
|
313
|
+
'[StepManager] Step finished executing',
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
return result as TResult
|
|
317
|
+
} finally {
|
|
318
|
+
clearTimeout(timeoutId)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Apply retry logic - skip retries for NonRetriableError
|
|
323
|
+
return pRetry(executeStep, {
|
|
324
|
+
retries: retryOptions.limit,
|
|
325
|
+
factor: retryOptions.factor,
|
|
326
|
+
randomize: false,
|
|
327
|
+
signal: abortSignal,
|
|
328
|
+
minTimeout: retryOptions.minTimeout,
|
|
329
|
+
maxTimeout: retryOptions.maxTimeout,
|
|
330
|
+
onFailedAttempt: async (ctx) => {
|
|
331
|
+
const error = ctx.error as any
|
|
332
|
+
// Don't retry if error is non-retriable
|
|
333
|
+
if (
|
|
334
|
+
isNonRetriableError(error) ||
|
|
335
|
+
(error.cause && isNonRetriableError(error.cause)) ||
|
|
336
|
+
(error instanceof Error && error.name === 'AbortError' && isNonRetriableError(error.cause))
|
|
337
|
+
) {
|
|
338
|
+
throw error
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (ctx.retriesLeft > 0 && step) {
|
|
342
|
+
const delayed = await this.#stepStore.delay(step.id, ctx.finalDelay, serializeError(error))
|
|
343
|
+
if (!delayed) {
|
|
344
|
+
throw new Error(`Failed to delay step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
}).catch(async (error) => {
|
|
349
|
+
if (step) {
|
|
350
|
+
if (isCancelError(error)) {
|
|
351
|
+
await this.#stepStore.updateStatus(step.id, 'cancelled')
|
|
352
|
+
} else {
|
|
353
|
+
await this.#stepStore.updateStatus(step.id, STEP_STATUS_FAILED, null, serializeError(error))
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
throw error
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ============================================================================
|
|
362
|
+
// ActionContext Class
|
|
363
|
+
// ============================================================================
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* ActionContext provides the context for action handlers.
|
|
367
|
+
* It implements ActionHandlerContext and provides access to input, variables, logger, and the step function.
|
|
368
|
+
*/
|
|
369
|
+
class ActionContext<TInput extends z.ZodObject, TOutput extends z.ZodObject, TVariables = Record<string, unknown>>
|
|
370
|
+
implements ActionHandlerContext<TInput, TVariables>
|
|
371
|
+
{
|
|
372
|
+
#stepManager: StepManager
|
|
373
|
+
#variables: TVariables
|
|
374
|
+
#abortSignal: AbortSignal
|
|
375
|
+
#logger: Logger
|
|
376
|
+
#input: z.infer<TInput>
|
|
377
|
+
#jobId: string
|
|
378
|
+
#groupKey: string = '@default'
|
|
379
|
+
#action: Action<TInput, TOutput, TVariables>
|
|
380
|
+
|
|
381
|
+
// ============================================================================
|
|
382
|
+
// Constructor
|
|
383
|
+
// ============================================================================
|
|
384
|
+
|
|
385
|
+
constructor(
|
|
386
|
+
stepManager: StepManager,
|
|
387
|
+
job: { id: string; input: z.infer<TInput>; groupKey?: string },
|
|
388
|
+
action: Action<TInput, TOutput, TVariables>,
|
|
389
|
+
variables: TVariables,
|
|
390
|
+
abortSignal: AbortSignal,
|
|
391
|
+
logger: Logger,
|
|
392
|
+
) {
|
|
393
|
+
this.#stepManager = stepManager
|
|
394
|
+
this.#variables = variables
|
|
395
|
+
this.#abortSignal = abortSignal
|
|
396
|
+
this.#logger = logger
|
|
397
|
+
this.#action = action
|
|
398
|
+
this.#jobId = job.id
|
|
399
|
+
this.#groupKey = job.groupKey ?? '@default'
|
|
400
|
+
if (action.input) {
|
|
401
|
+
this.#input = action.input.parse(job.input, {
|
|
402
|
+
error: () => 'Error parsing action input',
|
|
403
|
+
reportInput: true,
|
|
404
|
+
})
|
|
405
|
+
}
|
|
406
|
+
this.#input = job.input ?? {}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ============================================================================
|
|
410
|
+
// Public API Methods
|
|
411
|
+
// ============================================================================
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Get the input data for this action.
|
|
415
|
+
*/
|
|
416
|
+
get input(): z.infer<TInput> {
|
|
417
|
+
return this.#input
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Get the job ID for this action context.
|
|
422
|
+
*
|
|
423
|
+
* @returns The job ID
|
|
424
|
+
*/
|
|
425
|
+
get jobId(): string {
|
|
426
|
+
return this.#jobId
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Get the group key for this action context.
|
|
431
|
+
*
|
|
432
|
+
* @returns The group key
|
|
433
|
+
*/
|
|
434
|
+
get groupKey(): string {
|
|
435
|
+
return this.#groupKey
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Get the variables available to this action.
|
|
440
|
+
*/
|
|
441
|
+
get var(): TVariables {
|
|
442
|
+
return this.#variables
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Get the logger for this action job.
|
|
447
|
+
*/
|
|
448
|
+
get logger(): Logger {
|
|
449
|
+
return this.#logger
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Execute a step within the action.
|
|
454
|
+
*
|
|
455
|
+
* @param name - The name of the step
|
|
456
|
+
* @param cb - The step handler function
|
|
457
|
+
* @param options - Optional step options (will be merged with defaults)
|
|
458
|
+
* @returns Promise resolving to the step result
|
|
459
|
+
*/
|
|
460
|
+
async step<TResult>(
|
|
461
|
+
name: string,
|
|
462
|
+
cb: (ctx: StepHandlerContext) => Promise<TResult>,
|
|
463
|
+
options: z.input<typeof StepOptionsSchema> = {},
|
|
464
|
+
): Promise<TResult> {
|
|
465
|
+
const parsedOptions = StepOptionsSchema.parse({
|
|
466
|
+
...this.#action.steps,
|
|
467
|
+
...options,
|
|
468
|
+
})
|
|
469
|
+
return this.#stepManager.push({ name, cb, options: parsedOptions, abortSignal: this.#abortSignal })
|
|
470
|
+
}
|
|
471
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @author github.com/sindresorhus
|
|
3
|
+
* @license MIT
|
|
4
|
+
* @description A refactor version of p-retry remove unnecessary code and add the necessary context to the retry function.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
interface RetryOptions {
|
|
8
|
+
retries: number
|
|
9
|
+
factor: number
|
|
10
|
+
minTimeout: number
|
|
11
|
+
maxTimeout: number
|
|
12
|
+
maxRetryTime?: number
|
|
13
|
+
randomize: boolean
|
|
14
|
+
signal: AbortSignal
|
|
15
|
+
onFailedAttempt: (context: {
|
|
16
|
+
error: Error
|
|
17
|
+
attemptNumber: number
|
|
18
|
+
retriesLeft: number
|
|
19
|
+
retriesConsumed: number
|
|
20
|
+
finalDelay: number
|
|
21
|
+
}) => Promise<void>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function validateRetries(retries: number) {
|
|
25
|
+
if (typeof retries === 'number') {
|
|
26
|
+
if (retries < 0) {
|
|
27
|
+
throw new TypeError('Expected `retries` to be a non-negative number.')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (Number.isNaN(retries)) {
|
|
31
|
+
throw new TypeError('Expected `retries` to be a valid number or Infinity, got NaN.')
|
|
32
|
+
}
|
|
33
|
+
} else if (retries !== undefined) {
|
|
34
|
+
throw new TypeError('Expected `retries` to be a number or Infinity.')
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function validateNumberOption(name: string, value: number, { min = 0, allowInfinity = false } = {}) {
|
|
39
|
+
if (value === undefined) {
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (typeof value !== 'number' || Number.isNaN(value)) {
|
|
44
|
+
throw new TypeError(`Expected \`${name}\` to be a number${allowInfinity ? ' or Infinity' : ''}.`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!allowInfinity && !Number.isFinite(value)) {
|
|
48
|
+
throw new TypeError(`Expected \`${name}\` to be a finite number.`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (value < min) {
|
|
52
|
+
throw new TypeError(`Expected \`${name}\` to be \u2265 ${min}.`)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function calculateDelay(retriesConsumed: number, options: RetryOptions) {
|
|
57
|
+
const attempt = Math.max(1, retriesConsumed + 1)
|
|
58
|
+
const random = options.randomize ? Math.random() + 1 : 1
|
|
59
|
+
|
|
60
|
+
let timeout = Math.round(random * options.minTimeout * options.factor ** (attempt - 1))
|
|
61
|
+
timeout = Math.min(timeout, options.maxTimeout)
|
|
62
|
+
|
|
63
|
+
return timeout
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function calculateRemainingTime(start: number, max: number) {
|
|
67
|
+
if (!Number.isFinite(max)) {
|
|
68
|
+
return max
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return max - (performance.now() - start)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function onAttemptFailure({
|
|
75
|
+
error,
|
|
76
|
+
attemptNumber,
|
|
77
|
+
retriesConsumed,
|
|
78
|
+
startTime,
|
|
79
|
+
options,
|
|
80
|
+
}: {
|
|
81
|
+
error: Error
|
|
82
|
+
attemptNumber: number
|
|
83
|
+
retriesConsumed: number
|
|
84
|
+
startTime: number
|
|
85
|
+
options: RetryOptions
|
|
86
|
+
}) {
|
|
87
|
+
const normalizedError =
|
|
88
|
+
error instanceof Error ? error : new TypeError(`Non-error was thrown: "${error}". You should only throw errors.`)
|
|
89
|
+
|
|
90
|
+
const retriesLeft = Number.isFinite(options.retries)
|
|
91
|
+
? Math.max(0, options.retries - retriesConsumed)
|
|
92
|
+
: options.retries
|
|
93
|
+
|
|
94
|
+
const maxRetryTime = options.maxRetryTime ?? Number.POSITIVE_INFINITY
|
|
95
|
+
|
|
96
|
+
const delayTime = calculateDelay(retriesConsumed, options)
|
|
97
|
+
const remainingTime = calculateRemainingTime(startTime, maxRetryTime)
|
|
98
|
+
const finalDelay = Math.min(delayTime, remainingTime)
|
|
99
|
+
|
|
100
|
+
if (remainingTime <= 0) {
|
|
101
|
+
throw normalizedError
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const context = Object.freeze({
|
|
105
|
+
error: normalizedError,
|
|
106
|
+
attemptNumber,
|
|
107
|
+
retriesLeft,
|
|
108
|
+
retriesConsumed,
|
|
109
|
+
finalDelay,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
await options.onFailedAttempt(context)
|
|
113
|
+
|
|
114
|
+
if (remainingTime <= 0 || retriesLeft <= 0) {
|
|
115
|
+
throw normalizedError
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (normalizedError instanceof TypeError) {
|
|
119
|
+
options.signal?.throwIfAborted()
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (finalDelay > 0) {
|
|
124
|
+
await new Promise((resolve, reject) => {
|
|
125
|
+
const onAbort = () => {
|
|
126
|
+
clearTimeout(timeoutToken)
|
|
127
|
+
options.signal?.removeEventListener('abort', onAbort)
|
|
128
|
+
reject(options.signal.reason)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const timeoutToken = setTimeout(() => {
|
|
132
|
+
options.signal?.removeEventListener('abort', onAbort)
|
|
133
|
+
resolve(undefined)
|
|
134
|
+
}, finalDelay)
|
|
135
|
+
|
|
136
|
+
timeoutToken.unref?.()
|
|
137
|
+
|
|
138
|
+
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
options.signal?.throwIfAborted()
|
|
143
|
+
|
|
144
|
+
return true
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export default async function pRetry<TResult>(
|
|
148
|
+
input: (attemptNumber: number) => Promise<TResult>,
|
|
149
|
+
options: RetryOptions,
|
|
150
|
+
): Promise<TResult> {
|
|
151
|
+
options = { ...options }
|
|
152
|
+
|
|
153
|
+
validateRetries(options.retries)
|
|
154
|
+
|
|
155
|
+
if (Object.hasOwn(options, 'forever')) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
'The `forever` option is no longer supported. For many use-cases, you can set `retries: Infinity` instead.',
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
options.retries ??= 10
|
|
162
|
+
options.factor ??= 2
|
|
163
|
+
options.minTimeout ??= 1000
|
|
164
|
+
options.maxTimeout ??= Number.POSITIVE_INFINITY
|
|
165
|
+
options.maxRetryTime ??= Number.POSITIVE_INFINITY
|
|
166
|
+
options.randomize ??= false
|
|
167
|
+
|
|
168
|
+
// Validate numeric options and normalize edge cases
|
|
169
|
+
validateNumberOption('factor', options.factor as number, { min: 0, allowInfinity: false })
|
|
170
|
+
validateNumberOption('minTimeout', options.minTimeout as number, { min: 0, allowInfinity: false })
|
|
171
|
+
validateNumberOption('maxTimeout', options.maxTimeout as number, { min: 0, allowInfinity: true })
|
|
172
|
+
validateNumberOption('maxRetryTime', options.maxRetryTime as number, { min: 0, allowInfinity: true })
|
|
173
|
+
|
|
174
|
+
// Treat non-positive factor as 1 to avoid zero backoff or negative behavior
|
|
175
|
+
if (!(options.factor > 0)) {
|
|
176
|
+
options.factor = 1
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
options.signal?.throwIfAborted()
|
|
180
|
+
|
|
181
|
+
let attemptNumber = 0
|
|
182
|
+
let retriesConsumed = 0
|
|
183
|
+
const startTime = performance.now()
|
|
184
|
+
|
|
185
|
+
while (Number.isFinite(options.retries) ? retriesConsumed <= options.retries : true) {
|
|
186
|
+
attemptNumber++
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
options.signal?.throwIfAborted()
|
|
190
|
+
|
|
191
|
+
const result = await input(attemptNumber)
|
|
192
|
+
|
|
193
|
+
options.signal?.throwIfAborted()
|
|
194
|
+
|
|
195
|
+
return result
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (
|
|
198
|
+
await onAttemptFailure({
|
|
199
|
+
error: error as Error,
|
|
200
|
+
attemptNumber,
|
|
201
|
+
retriesConsumed,
|
|
202
|
+
startTime,
|
|
203
|
+
options,
|
|
204
|
+
})
|
|
205
|
+
) {
|
|
206
|
+
retriesConsumed++
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Should not reach here, but in case it does, throw an error
|
|
212
|
+
throw new Error('Retry attempts exhausted without throwing an error.')
|
|
213
|
+
}
|