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
package/src/client.ts
ADDED
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
import pino, { type Logger } from 'pino'
|
|
2
|
+
import { zocker } from 'zocker'
|
|
3
|
+
import * as z from 'zod'
|
|
4
|
+
|
|
5
|
+
import type { Action, ConcurrencyHandlerContext } from './action.js'
|
|
6
|
+
import { ActionManager } from './action-manager.js'
|
|
7
|
+
import type {
|
|
8
|
+
Adapter,
|
|
9
|
+
GetActionsResult,
|
|
10
|
+
GetJobStepsOptions,
|
|
11
|
+
GetJobStepsResult,
|
|
12
|
+
GetJobsOptions,
|
|
13
|
+
GetJobsResult,
|
|
14
|
+
Job,
|
|
15
|
+
JobStep,
|
|
16
|
+
} from './adapters/adapter.js'
|
|
17
|
+
import type { JobStatusResult, JobStepStatusResult } from './adapters/schemas.js'
|
|
18
|
+
import { JOB_STATUS_CANCELLED, JOB_STATUS_COMPLETED, JOB_STATUS_FAILED, type JobStatus } from './constants.js'
|
|
19
|
+
|
|
20
|
+
const BaseOptionsSchema = z.object({
|
|
21
|
+
/**
|
|
22
|
+
* Unique identifier for this Duron instance.
|
|
23
|
+
* Used for multi-process coordination and job ownership.
|
|
24
|
+
* Defaults to a random UUID if not provided.
|
|
25
|
+
*/
|
|
26
|
+
id: z.string().optional(),
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Synchronization pattern for fetching jobs.
|
|
30
|
+
* - `'pull'`: Periodically poll the database for new jobs
|
|
31
|
+
* - `'push'`: Listen for database notifications when jobs are available
|
|
32
|
+
* - `'hybrid'`: Use both pull and push patterns (recommended)
|
|
33
|
+
* - `false`: Disable automatic job fetching (manual fetching only)
|
|
34
|
+
*
|
|
35
|
+
* @default 'hybrid'
|
|
36
|
+
*/
|
|
37
|
+
syncPattern: z.union([z.literal('pull'), z.literal('push'), z.literal('hybrid'), z.literal(false)]).default('hybrid'),
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Interval in milliseconds between pull operations when using pull or hybrid sync pattern.
|
|
41
|
+
*
|
|
42
|
+
* @default 5000
|
|
43
|
+
*/
|
|
44
|
+
pullInterval: z.number().default(5_000),
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Maximum number of jobs to fetch in a single batch.
|
|
48
|
+
*
|
|
49
|
+
* @default 10
|
|
50
|
+
*/
|
|
51
|
+
batchSize: z.number().default(10),
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Maximum number of jobs that can run concurrently per action.
|
|
55
|
+
* This controls the concurrency limit for the action's fastq queue.
|
|
56
|
+
*
|
|
57
|
+
* @default 100
|
|
58
|
+
*/
|
|
59
|
+
actionConcurrencyLimit: z.number().default(100),
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Maximum number of jobs that can run concurrently per group key.
|
|
63
|
+
* Jobs with the same group key will respect this limit.
|
|
64
|
+
* This can be overridden using action -> groups -> concurrency.
|
|
65
|
+
*
|
|
66
|
+
* @default 10
|
|
67
|
+
*/
|
|
68
|
+
groupConcurrencyLimit: z.number().default(10),
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether to run database migrations on startup.
|
|
72
|
+
* When enabled, Duron will automatically apply pending migrations when the adapter starts.
|
|
73
|
+
*
|
|
74
|
+
* @default true
|
|
75
|
+
*/
|
|
76
|
+
migrateOnStart: z.boolean().default(true),
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether to recover stuck jobs on startup.
|
|
80
|
+
* Stuck jobs are jobs that were marked as active but the process that owned them
|
|
81
|
+
* is no longer running.
|
|
82
|
+
*
|
|
83
|
+
* @default true
|
|
84
|
+
*/
|
|
85
|
+
recoverJobsOnStart: z.boolean().default(true),
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Enable multi-process mode for job recovery.
|
|
89
|
+
* When enabled, Duron will ping other processes to check if they're alive
|
|
90
|
+
* before recovering their jobs.
|
|
91
|
+
*
|
|
92
|
+
* @default false
|
|
93
|
+
*/
|
|
94
|
+
multiProcessMode: z.boolean().default(false),
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Timeout in milliseconds to wait for process ping responses in multi-process mode.
|
|
98
|
+
* Processes that don't respond within this timeout will have their jobs recovered.
|
|
99
|
+
*
|
|
100
|
+
* @default 300000 (5 minutes)
|
|
101
|
+
*/
|
|
102
|
+
processTimeout: z.number().default(5 * 60 * 1000),
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Options for configuring a Duron instance.
|
|
107
|
+
*
|
|
108
|
+
* @template TActions - Record of action definitions keyed by action name
|
|
109
|
+
* @template TVariables - Type of variables available to actions
|
|
110
|
+
*/
|
|
111
|
+
export interface ClientOptions<
|
|
112
|
+
TActions extends Record<string, Action<any, any, TVariables>>,
|
|
113
|
+
TVariables = Record<string, unknown>,
|
|
114
|
+
> extends z.input<typeof BaseOptionsSchema> {
|
|
115
|
+
/**
|
|
116
|
+
* The database adapter to use for storing jobs and steps.
|
|
117
|
+
* Required.
|
|
118
|
+
*/
|
|
119
|
+
database: Adapter
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A record of action definitions, where each key is the action name.
|
|
123
|
+
* Required.
|
|
124
|
+
*/
|
|
125
|
+
actions?: TActions
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Logger instance or log level for logging events and errors.
|
|
129
|
+
* Can be a pino Logger instance or a log level string ('fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent').
|
|
130
|
+
* If not provided, defaults to 'error' level.
|
|
131
|
+
*/
|
|
132
|
+
logger?: Logger | 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent'
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Variables available to all actions via the context.
|
|
136
|
+
* These can be accessed in action handlers using `ctx.var`.
|
|
137
|
+
*/
|
|
138
|
+
variables?: TVariables
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface FetchOptions {
|
|
142
|
+
batchSize?: number
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Client is the main entry point for Duron.
|
|
147
|
+
* Manages job execution, action handling, and database operations.
|
|
148
|
+
*
|
|
149
|
+
* @template TActions - Record of action definitions keyed by action name
|
|
150
|
+
* @template TVariables - Type of variables available to actions
|
|
151
|
+
*/
|
|
152
|
+
export class Client<
|
|
153
|
+
TActions extends Record<string, Action<any, any, TVariables>>,
|
|
154
|
+
TVariables = Record<string, unknown>,
|
|
155
|
+
> {
|
|
156
|
+
#options: z.infer<typeof BaseOptionsSchema>
|
|
157
|
+
#id: string
|
|
158
|
+
#actions: TActions | null
|
|
159
|
+
#database: Adapter
|
|
160
|
+
#variables: Record<string, unknown>
|
|
161
|
+
#logger: Logger
|
|
162
|
+
#started: boolean = false
|
|
163
|
+
#stopped: boolean = false
|
|
164
|
+
#starting: Promise<boolean> | null = null
|
|
165
|
+
#stopping: Promise<boolean> | null = null
|
|
166
|
+
#pullInterval: NodeJS.Timeout | null = null
|
|
167
|
+
#actionManagers = new Map<string, ActionManager<Action<any, any, any>>>()
|
|
168
|
+
#mockInputSchemas = new Map<string, any>()
|
|
169
|
+
#pendingJobWaits = new Map<
|
|
170
|
+
string,
|
|
171
|
+
Set<{
|
|
172
|
+
resolve: (job: Job | null) => void
|
|
173
|
+
timeoutId?: NodeJS.Timeout
|
|
174
|
+
signal?: AbortSignal
|
|
175
|
+
abortHandler?: () => void
|
|
176
|
+
}>
|
|
177
|
+
>()
|
|
178
|
+
#jobStatusListenerSetup = false
|
|
179
|
+
|
|
180
|
+
// ============================================================================
|
|
181
|
+
// Constructor
|
|
182
|
+
// ============================================================================
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Create a new Duron Client instance.
|
|
186
|
+
*
|
|
187
|
+
* @param options - Configuration options for the client
|
|
188
|
+
*/
|
|
189
|
+
constructor(options: ClientOptions<TActions, TVariables>) {
|
|
190
|
+
this.#options = BaseOptionsSchema.parse(options)
|
|
191
|
+
this.#id = options.id ?? globalThis.crypto.randomUUID()
|
|
192
|
+
this.#database = options.database
|
|
193
|
+
this.#actions = options.actions ?? null
|
|
194
|
+
this.#variables = options?.variables ?? {}
|
|
195
|
+
this.#logger = this.#normalizeLogger(options?.logger)
|
|
196
|
+
this.#database.setId(this.#id)
|
|
197
|
+
this.#database.setLogger(this.#logger)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#normalizeLogger(logger?: Logger | 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent'): Logger {
|
|
201
|
+
let pinoInstance: Logger | null = null
|
|
202
|
+
if (!logger) {
|
|
203
|
+
pinoInstance = pino({ level: 'error' })
|
|
204
|
+
} else if (typeof logger === 'string') {
|
|
205
|
+
pinoInstance = pino({ level: logger })
|
|
206
|
+
} else {
|
|
207
|
+
pinoInstance = logger
|
|
208
|
+
}
|
|
209
|
+
return pinoInstance.child({ duron: this.#id })
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ============================================================================
|
|
213
|
+
// Public API Methods
|
|
214
|
+
// ============================================================================
|
|
215
|
+
|
|
216
|
+
get logger() {
|
|
217
|
+
return this.#logger
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Get the current configuration of this Duron instance.
|
|
222
|
+
*
|
|
223
|
+
* @returns Configuration object including options, actions, and variables
|
|
224
|
+
*/
|
|
225
|
+
getConfig() {
|
|
226
|
+
return {
|
|
227
|
+
...this.#options,
|
|
228
|
+
actions: this.#actions,
|
|
229
|
+
variables: this.#variables,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Run an action by creating a new job.
|
|
235
|
+
*
|
|
236
|
+
* @param actionName - Name of the action to run
|
|
237
|
+
* @param input - Input data for the action (validated against action's input schema if provided)
|
|
238
|
+
* @returns Promise resolving to the created job ID
|
|
239
|
+
* @throws Error if action is not found or job creation fails
|
|
240
|
+
*/
|
|
241
|
+
async runAction<TActionName extends keyof TActions>(
|
|
242
|
+
actionName: TActionName,
|
|
243
|
+
input?: NonNullable<TActions[TActionName]['input']> extends z.ZodObject
|
|
244
|
+
? z.input<NonNullable<TActions[TActionName]['input']>>
|
|
245
|
+
: never,
|
|
246
|
+
): Promise<string> {
|
|
247
|
+
await this.start()
|
|
248
|
+
|
|
249
|
+
const action = this.#actions?.[actionName]
|
|
250
|
+
if (!action) {
|
|
251
|
+
throw new Error(`Action ${String(actionName)} not found`)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Validate input if schema is provided
|
|
255
|
+
let validatedInput: any = input ?? {}
|
|
256
|
+
if (action.input) {
|
|
257
|
+
validatedInput = action.input.parse(validatedInput, {
|
|
258
|
+
error: () => 'Error parsing action input',
|
|
259
|
+
reportInput: true,
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Determine groupKey and concurrency limit using concurrency handler or defaults
|
|
264
|
+
const concurrencyCtx: ConcurrencyHandlerContext<typeof action.input, TVariables> = {
|
|
265
|
+
input: validatedInput,
|
|
266
|
+
var: this.#variables as TVariables,
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let groupKey = '@default'
|
|
270
|
+
if (action.groups?.groupKey) {
|
|
271
|
+
groupKey = await action.groups.groupKey(concurrencyCtx)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let concurrencyLimit = this.#options.groupConcurrencyLimit
|
|
275
|
+
if (action.groups?.concurrency) {
|
|
276
|
+
concurrencyLimit = await action.groups.concurrency(concurrencyCtx)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Create job in database
|
|
280
|
+
const jobId = await this.#database.createJob({
|
|
281
|
+
queue: action.name,
|
|
282
|
+
groupKey,
|
|
283
|
+
input: validatedInput,
|
|
284
|
+
timeoutMs: action.expire,
|
|
285
|
+
checksum: action.checksum,
|
|
286
|
+
concurrencyLimit,
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
if (!jobId) {
|
|
290
|
+
throw new Error(`Failed to create job for action ${String(actionName)}`)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
this.#logger.debug({ jobId, actionName: String(actionName), groupKey }, '[Duron] Action sent/created')
|
|
294
|
+
|
|
295
|
+
return jobId
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Fetch and process jobs from the database.
|
|
300
|
+
* Concurrency limits are determined from the latest job created for each groupKey.
|
|
301
|
+
*
|
|
302
|
+
* @param options - Fetch options including batch size
|
|
303
|
+
* @returns Promise resolving to the array of fetched jobs
|
|
304
|
+
*/
|
|
305
|
+
async fetch(options: FetchOptions) {
|
|
306
|
+
await this.start()
|
|
307
|
+
|
|
308
|
+
if (!this.#actions) {
|
|
309
|
+
return []
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Fetch jobs from each action's queue
|
|
313
|
+
// Concurrency limits are determined from the latest job created for each groupKey
|
|
314
|
+
const jobs = await this.#database.fetch({
|
|
315
|
+
batch: options.batchSize ?? this.#options.batchSize,
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
// Process fetched jobs
|
|
319
|
+
for (const job of jobs) {
|
|
320
|
+
this.#executeJob(job)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return jobs
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Cancel a job by its ID.
|
|
328
|
+
* If the job is currently being processed, it will be cancelled immediately.
|
|
329
|
+
* Otherwise, it will be cancelled in the database.
|
|
330
|
+
*
|
|
331
|
+
* @param jobId - The ID of the job to cancel
|
|
332
|
+
* @returns Promise resolving to `true` if cancelled, `false` otherwise
|
|
333
|
+
*/
|
|
334
|
+
async cancelJob(jobId: string) {
|
|
335
|
+
await this.start()
|
|
336
|
+
|
|
337
|
+
let cancelled = false
|
|
338
|
+
for (const manager of this.#actionManagers.values()) {
|
|
339
|
+
cancelled = manager.cancelJob(jobId)
|
|
340
|
+
if (cancelled) {
|
|
341
|
+
break
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!cancelled) {
|
|
346
|
+
// If the job is not being processed, cancel it in the database
|
|
347
|
+
await this.#database.cancelJob({ jobId })
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return cancelled
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Retry a failed job by creating a copy of it with status 'created' and cleared output/error.
|
|
355
|
+
*
|
|
356
|
+
* @param jobId - The ID of the job to retry
|
|
357
|
+
* @returns Promise resolving to the new job ID, or `null` if retry failed
|
|
358
|
+
*/
|
|
359
|
+
async retryJob(jobId: string): Promise<string | null> {
|
|
360
|
+
await this.start()
|
|
361
|
+
return this.#database.retryJob({ jobId })
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Delete a job by its ID.
|
|
366
|
+
* Active jobs cannot be deleted.
|
|
367
|
+
*
|
|
368
|
+
* @param jobId - The ID of the job to delete
|
|
369
|
+
* @returns Promise resolving to `true` if deleted, `false` otherwise
|
|
370
|
+
*/
|
|
371
|
+
async deleteJob(jobId: string): Promise<boolean> {
|
|
372
|
+
await this.start()
|
|
373
|
+
return this.#database.deleteJob({ jobId })
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Delete multiple jobs using the same filters as getJobs.
|
|
378
|
+
* Active jobs cannot be deleted and will be excluded from deletion.
|
|
379
|
+
*
|
|
380
|
+
* @param options - Query options including filters (same as getJobs)
|
|
381
|
+
* @returns Promise resolving to the number of jobs deleted
|
|
382
|
+
*/
|
|
383
|
+
async deleteJobs(options?: GetJobsOptions): Promise<number> {
|
|
384
|
+
await this.start()
|
|
385
|
+
return this.#database.deleteJobs(options)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ============================================================================
|
|
389
|
+
// Query Methods
|
|
390
|
+
// ============================================================================
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Get a job by its ID. Does not include step information.
|
|
394
|
+
*
|
|
395
|
+
* @param jobId - The ID of the job to retrieve
|
|
396
|
+
* @returns Promise resolving to the job, or `null` if not found
|
|
397
|
+
*/
|
|
398
|
+
async getJobById(jobId: string): Promise<Job | null> {
|
|
399
|
+
await this.start()
|
|
400
|
+
return this.#database.getJobById(jobId)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Get steps for a job with pagination and fuzzy search.
|
|
405
|
+
* Steps are always ordered by created_at ASC.
|
|
406
|
+
* Steps do not include output data.
|
|
407
|
+
*
|
|
408
|
+
* @param options - Query options including jobId, pagination, and search
|
|
409
|
+
* @returns Promise resolving to steps result with pagination info
|
|
410
|
+
*/
|
|
411
|
+
async getJobSteps(options: GetJobStepsOptions): Promise<GetJobStepsResult> {
|
|
412
|
+
await this.start()
|
|
413
|
+
return this.#database.getJobSteps(options)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Get jobs with pagination, filtering, and sorting.
|
|
418
|
+
* Does not include step information or job output.
|
|
419
|
+
*
|
|
420
|
+
* @param options - Query options including pagination, filters, and sort
|
|
421
|
+
* @returns Promise resolving to jobs result with pagination info
|
|
422
|
+
*/
|
|
423
|
+
async getJobs(options?: GetJobsOptions): Promise<GetJobsResult> {
|
|
424
|
+
await this.start()
|
|
425
|
+
return this.#database.getJobs(options)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Get a step by its ID with all information.
|
|
430
|
+
*
|
|
431
|
+
* @param stepId - The ID of the step to retrieve
|
|
432
|
+
* @returns Promise resolving to the step, or `null` if not found
|
|
433
|
+
*/
|
|
434
|
+
async getJobStepById(stepId: string): Promise<JobStep | null> {
|
|
435
|
+
await this.start()
|
|
436
|
+
return this.#database.getJobStepById(stepId)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Get job status and updatedAt timestamp.
|
|
441
|
+
*
|
|
442
|
+
* @param jobId - The ID of the job
|
|
443
|
+
* @returns Promise resolving to job status result, or `null` if not found
|
|
444
|
+
*/
|
|
445
|
+
async getJobStatus(jobId: string): Promise<JobStatusResult | null> {
|
|
446
|
+
await this.start()
|
|
447
|
+
return this.#database.getJobStatus(jobId)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Get job step status and updatedAt timestamp.
|
|
452
|
+
*
|
|
453
|
+
* @param stepId - The ID of the step
|
|
454
|
+
* @returns Promise resolving to step status result, or `null` if not found
|
|
455
|
+
*/
|
|
456
|
+
async getJobStepStatus(stepId: string): Promise<JobStepStatusResult | null> {
|
|
457
|
+
await this.start()
|
|
458
|
+
return this.#database.getJobStepStatus(stepId)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Wait for a job to change status by subscribing to job-status-changed events.
|
|
463
|
+
* When the job status changes, the job is fetched and returned.
|
|
464
|
+
*
|
|
465
|
+
* @param jobId - The ID of the job to wait for
|
|
466
|
+
* @param options - Optional configuration including timeout
|
|
467
|
+
* @returns Promise resolving to the job when its status changes, or `null` if timeout
|
|
468
|
+
*/
|
|
469
|
+
async waitForJob(
|
|
470
|
+
jobId: string,
|
|
471
|
+
options?: {
|
|
472
|
+
/**
|
|
473
|
+
* Timeout in milliseconds. If the job status doesn't change within this time, the promise resolves to `null`.
|
|
474
|
+
* Defaults to no timeout (waits indefinitely).
|
|
475
|
+
*/
|
|
476
|
+
timeout?: number
|
|
477
|
+
/**
|
|
478
|
+
* AbortSignal to cancel waiting. If aborted, the promise resolves to `null`.
|
|
479
|
+
*/
|
|
480
|
+
signal?: AbortSignal
|
|
481
|
+
},
|
|
482
|
+
): Promise<Job | null> {
|
|
483
|
+
await this.start()
|
|
484
|
+
|
|
485
|
+
// First, check if the job already exists and is in a terminal state
|
|
486
|
+
const existingJobStatus = await this.getJobStatus(jobId)
|
|
487
|
+
if (existingJobStatus) {
|
|
488
|
+
const terminalStatuses: JobStatus[] = [JOB_STATUS_COMPLETED, JOB_STATUS_FAILED, JOB_STATUS_CANCELLED]
|
|
489
|
+
if (terminalStatuses.includes(existingJobStatus.status)) {
|
|
490
|
+
const job = await this.getJobById(jobId)
|
|
491
|
+
if (!job) {
|
|
492
|
+
return null
|
|
493
|
+
}
|
|
494
|
+
return job
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Set up the shared event listener if not already set up
|
|
499
|
+
this.#setupJobStatusListener()
|
|
500
|
+
|
|
501
|
+
return new Promise<Job | null>((resolve) => {
|
|
502
|
+
// Check if already aborted before setting up wait
|
|
503
|
+
if (options?.signal?.aborted) {
|
|
504
|
+
resolve(null)
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
let timeoutId: NodeJS.Timeout | undefined
|
|
509
|
+
let abortHandler: (() => void) | undefined
|
|
510
|
+
|
|
511
|
+
// Set up timeout if provided
|
|
512
|
+
if (options?.timeout) {
|
|
513
|
+
timeoutId = setTimeout(() => {
|
|
514
|
+
this.#removeJobWait(jobId, resolve)
|
|
515
|
+
resolve(null)
|
|
516
|
+
}, options.timeout)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Set up abort signal if provided
|
|
520
|
+
if (options?.signal) {
|
|
521
|
+
abortHandler = () => {
|
|
522
|
+
this.#removeJobWait(jobId, resolve)
|
|
523
|
+
resolve(null)
|
|
524
|
+
}
|
|
525
|
+
options.signal.addEventListener('abort', abortHandler)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Add this wait request to the pending waits
|
|
529
|
+
if (!this.#pendingJobWaits.has(jobId)) {
|
|
530
|
+
this.#pendingJobWaits.set(jobId, new Set())
|
|
531
|
+
}
|
|
532
|
+
this.#pendingJobWaits.get(jobId)!.add({
|
|
533
|
+
resolve,
|
|
534
|
+
timeoutId,
|
|
535
|
+
signal: options?.signal,
|
|
536
|
+
abortHandler,
|
|
537
|
+
})
|
|
538
|
+
})
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Get action statistics including counts and last job created date.
|
|
543
|
+
*
|
|
544
|
+
* @returns Promise resolving to action statistics
|
|
545
|
+
*/
|
|
546
|
+
async getActions(): Promise<GetActionsResult> {
|
|
547
|
+
await this.start()
|
|
548
|
+
return this.#database.getActions()
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Get action metadata including input schemas and mock data.
|
|
553
|
+
* This is useful for generating UI forms or mock data.
|
|
554
|
+
*
|
|
555
|
+
* @returns Promise resolving to action metadata
|
|
556
|
+
*/
|
|
557
|
+
async getActionsMetadata(): Promise<Array<{ name: string; mockInput: any }>> {
|
|
558
|
+
await this.start()
|
|
559
|
+
|
|
560
|
+
if (!this.#actions) {
|
|
561
|
+
return []
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return Object.values(this.#actions).map((action) => {
|
|
565
|
+
let mockInput = {}
|
|
566
|
+
if (action.input) {
|
|
567
|
+
if (!this.#mockInputSchemas.has(action.name)) {
|
|
568
|
+
this.#mockInputSchemas.set(
|
|
569
|
+
action.name,
|
|
570
|
+
zocker(action.input as z.ZodObject)
|
|
571
|
+
.override(z.ZodString, 'string')
|
|
572
|
+
.generate(),
|
|
573
|
+
)
|
|
574
|
+
}
|
|
575
|
+
mockInput = this.#mockInputSchemas.get(action.name)
|
|
576
|
+
}
|
|
577
|
+
return {
|
|
578
|
+
name: action.name,
|
|
579
|
+
mockInput,
|
|
580
|
+
}
|
|
581
|
+
})
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ============================================================================
|
|
585
|
+
// Lifecycle Methods
|
|
586
|
+
// ============================================================================
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Start the Duron instance.
|
|
590
|
+
* Initializes the database, recovers stuck jobs, and sets up sync patterns.
|
|
591
|
+
*
|
|
592
|
+
* @returns Promise resolving to `true` if started successfully, `false` otherwise
|
|
593
|
+
*/
|
|
594
|
+
async start() {
|
|
595
|
+
if (this.#stopping || this.#stopped) {
|
|
596
|
+
return false
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (this.#started) {
|
|
600
|
+
return true
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (this.#starting) {
|
|
604
|
+
return this.#starting
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
this.#starting = (async () => {
|
|
608
|
+
const dbStarted = await this.#database.start()
|
|
609
|
+
if (!dbStarted) {
|
|
610
|
+
return false
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (this.#actions) {
|
|
614
|
+
if (this.#options.recoverJobsOnStart) {
|
|
615
|
+
await this.#database.recoverJobs({
|
|
616
|
+
checksums: Object.values(this.#actions).map((action) => action.checksum),
|
|
617
|
+
multiProcessMode: this.#options.multiProcessMode,
|
|
618
|
+
processTimeout: this.#options.processTimeout,
|
|
619
|
+
})
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Setup sync pattern
|
|
623
|
+
if (this.#options.syncPattern === 'pull' || this.#options.syncPattern === 'hybrid') {
|
|
624
|
+
this.#startPullLoop()
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (this.#options.syncPattern === 'push' || this.#options.syncPattern === 'hybrid') {
|
|
628
|
+
this.#setupPushListener()
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
this.#started = true
|
|
633
|
+
this.#starting = null
|
|
634
|
+
return true
|
|
635
|
+
})()
|
|
636
|
+
|
|
637
|
+
return this.#starting
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Stop the Duron instance.
|
|
642
|
+
* Stops the pull loop, aborts all running jobs, waits for queues to drain, and stops the database.
|
|
643
|
+
*
|
|
644
|
+
* @returns Promise resolving to `true` if stopped successfully, `false` otherwise
|
|
645
|
+
*/
|
|
646
|
+
async stop() {
|
|
647
|
+
if (this.#stopped) {
|
|
648
|
+
return true
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (this.#stopping) {
|
|
652
|
+
return this.#stopping
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
this.#stopping = (async () => {
|
|
656
|
+
// Stop pull loop
|
|
657
|
+
if (this.#pullInterval) {
|
|
658
|
+
clearTimeout(this.#pullInterval)
|
|
659
|
+
this.#pullInterval = null
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Clean up all pending job waits
|
|
663
|
+
for (const waits of this.#pendingJobWaits.values()) {
|
|
664
|
+
for (const wait of waits) {
|
|
665
|
+
if (wait.timeoutId) {
|
|
666
|
+
clearTimeout(wait.timeoutId)
|
|
667
|
+
}
|
|
668
|
+
if (wait.signal && wait.abortHandler) {
|
|
669
|
+
wait.signal.removeEventListener('abort', wait.abortHandler)
|
|
670
|
+
}
|
|
671
|
+
wait.resolve(null)
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
this.#pendingJobWaits.clear()
|
|
675
|
+
|
|
676
|
+
// Wait for action managers to drain
|
|
677
|
+
await Promise.all(
|
|
678
|
+
Array.from(this.#actionManagers.values()).map(async (manager) => {
|
|
679
|
+
await manager.stop()
|
|
680
|
+
}),
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
const dbStopped = await this.#database.stop()
|
|
684
|
+
if (!dbStopped) {
|
|
685
|
+
return false
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
this.#stopped = true
|
|
689
|
+
this.#stopping = null
|
|
690
|
+
return true
|
|
691
|
+
})()
|
|
692
|
+
|
|
693
|
+
return this.#stopping
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ============================================================================
|
|
697
|
+
// Private Methods
|
|
698
|
+
// ============================================================================
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Set up the shared event listener for job-status-changed events.
|
|
702
|
+
* This listener is shared across all waitForJob calls to avoid multiple listeners.
|
|
703
|
+
*/
|
|
704
|
+
#setupJobStatusListener() {
|
|
705
|
+
if (this.#jobStatusListenerSetup) {
|
|
706
|
+
return
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
this.#jobStatusListenerSetup = true
|
|
710
|
+
|
|
711
|
+
this.#database.on(
|
|
712
|
+
'job-status-changed',
|
|
713
|
+
async (event: { jobId: string; status: JobStatus | 'retried'; ownerId: string }) => {
|
|
714
|
+
const pendingWaits = this.#pendingJobWaits.get(event.jobId)
|
|
715
|
+
if (!pendingWaits || pendingWaits.size === 0) {
|
|
716
|
+
return
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Fetch the job once for all pending waits
|
|
720
|
+
const job = await this.getJobById(event.jobId)
|
|
721
|
+
|
|
722
|
+
// Resolve all pending waits for this job
|
|
723
|
+
const waitsToResolve = Array.from(pendingWaits)
|
|
724
|
+
this.#pendingJobWaits.delete(event.jobId)
|
|
725
|
+
|
|
726
|
+
for (const wait of waitsToResolve) {
|
|
727
|
+
// Clean up timeout and abort signal
|
|
728
|
+
if (wait.timeoutId) {
|
|
729
|
+
clearTimeout(wait.timeoutId)
|
|
730
|
+
}
|
|
731
|
+
if (wait.signal && wait.abortHandler) {
|
|
732
|
+
wait.signal.removeEventListener('abort', wait.abortHandler)
|
|
733
|
+
}
|
|
734
|
+
wait.resolve(job)
|
|
735
|
+
}
|
|
736
|
+
},
|
|
737
|
+
)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Remove a specific wait request from the pending waits.
|
|
742
|
+
*
|
|
743
|
+
* @param jobId - The job ID
|
|
744
|
+
* @param resolve - The resolve function to remove
|
|
745
|
+
*/
|
|
746
|
+
#removeJobWait(jobId: string, resolve: (job: Job | null) => void) {
|
|
747
|
+
const pendingWaits = this.#pendingJobWaits.get(jobId)
|
|
748
|
+
if (!pendingWaits) {
|
|
749
|
+
return
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Find and remove the specific wait request
|
|
753
|
+
for (const wait of pendingWaits) {
|
|
754
|
+
if (wait.resolve === resolve) {
|
|
755
|
+
if (wait.timeoutId) {
|
|
756
|
+
clearTimeout(wait.timeoutId)
|
|
757
|
+
}
|
|
758
|
+
if (wait.signal && wait.abortHandler) {
|
|
759
|
+
wait.signal.removeEventListener('abort', wait.abortHandler)
|
|
760
|
+
}
|
|
761
|
+
pendingWaits.delete(wait)
|
|
762
|
+
break
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Clean up empty sets
|
|
767
|
+
if (pendingWaits.size === 0) {
|
|
768
|
+
this.#pendingJobWaits.delete(jobId)
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Execute a job by finding its action and queuing it with the appropriate ActionManager.
|
|
774
|
+
*
|
|
775
|
+
* @param job - The job to execute
|
|
776
|
+
*/
|
|
777
|
+
#executeJob(job: Job) {
|
|
778
|
+
if (!this.#actions) {
|
|
779
|
+
return
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const action = Object.values(this.#actions).find((a) => a.name === job.actionName)
|
|
783
|
+
if (!action) {
|
|
784
|
+
const error = { name: 'ActionNotFoundError', message: `Action "${job.actionName}" not found for job ${job.id}` }
|
|
785
|
+
this.#logger.warn({ jobId: job.id, actionName: job.actionName }, `[Duron] Action not found for job ${job.id}`)
|
|
786
|
+
this.#database.failJob({ jobId: job.id, error }).catch((dbError) => {
|
|
787
|
+
this.#logger.error({ error: dbError, jobId: job.id }, `[Duron] Error failing job ${job.id}`)
|
|
788
|
+
})
|
|
789
|
+
return
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Get or create ActionManager for this action
|
|
793
|
+
let actionManager = this.#actionManagers.get(action.name)
|
|
794
|
+
if (!actionManager) {
|
|
795
|
+
actionManager = new ActionManager({
|
|
796
|
+
action,
|
|
797
|
+
database: this.#database,
|
|
798
|
+
variables: this.#variables,
|
|
799
|
+
logger: this.#logger,
|
|
800
|
+
concurrencyLimit: this.#options.actionConcurrencyLimit,
|
|
801
|
+
})
|
|
802
|
+
this.#actionManagers.set(action.name, actionManager)
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Queue job execution
|
|
806
|
+
actionManager.push(job).catch((err) => {
|
|
807
|
+
// Only log unexpected errors (not cancellation/timeout which are handled elsewhere)
|
|
808
|
+
this.#logger.error(
|
|
809
|
+
{ err, jobId: job.id, actionName: action.name },
|
|
810
|
+
`[Duron] Error executing job ${job.id} for action ${action.name}`,
|
|
811
|
+
)
|
|
812
|
+
})
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Start the pull loop for periodically fetching jobs.
|
|
817
|
+
* Only starts if not already running.
|
|
818
|
+
*/
|
|
819
|
+
#startPullLoop() {
|
|
820
|
+
if (this.#pullInterval) {
|
|
821
|
+
return
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const pull = async () => {
|
|
825
|
+
if (this.#stopped) {
|
|
826
|
+
return
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
try {
|
|
830
|
+
await this.fetch({
|
|
831
|
+
batchSize: this.#options.batchSize,
|
|
832
|
+
})
|
|
833
|
+
} catch (error) {
|
|
834
|
+
this.#logger.error({ error }, '[Duron] [PullLoop] Error in pull loop')
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (!this.#stopped) {
|
|
838
|
+
this.#pullInterval = setTimeout(pull, this.#options.pullInterval)
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Start immediately
|
|
843
|
+
pull()
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Setup the push listener for database notifications.
|
|
848
|
+
* Listens for 'job-available' events and fetches jobs when notified.
|
|
849
|
+
*/
|
|
850
|
+
#setupPushListener() {
|
|
851
|
+
this.#database.on('job-available', async () => {
|
|
852
|
+
this.fetch({
|
|
853
|
+
batchSize: 1,
|
|
854
|
+
}).catch((error) => {
|
|
855
|
+
this.#logger.error({ error }, '[Duron] [PushListener] Error fetching job')
|
|
856
|
+
})
|
|
857
|
+
})
|
|
858
|
+
}
|
|
859
|
+
}
|