duron 0.3.0-beta.10 → 0.3.0-beta.12
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/dist/action-job.d.ts +33 -2
- package/dist/action-job.d.ts.map +1 -1
- package/dist/action-job.js +87 -22
- package/dist/action-manager.d.ts +44 -2
- package/dist/action-manager.d.ts.map +1 -1
- package/dist/action-manager.js +64 -3
- package/dist/action.d.ts +146 -3
- package/dist/action.d.ts.map +1 -1
- package/dist/action.js +131 -0
- package/dist/adapters/adapter.d.ts +365 -8
- package/dist/adapters/adapter.d.ts.map +1 -1
- package/dist/adapters/adapter.js +221 -15
- package/dist/adapters/postgres/base.d.ts +174 -5
- package/dist/adapters/postgres/base.d.ts.map +1 -1
- package/dist/adapters/postgres/base.js +349 -66
- package/dist/adapters/postgres/pglite.d.ts +37 -0
- package/dist/adapters/postgres/pglite.d.ts.map +1 -1
- package/dist/adapters/postgres/pglite.js +38 -0
- package/dist/adapters/postgres/postgres.d.ts +35 -0
- package/dist/adapters/postgres/postgres.d.ts.map +1 -1
- package/dist/adapters/postgres/postgres.js +42 -0
- package/dist/adapters/postgres/schema.d.ts +118 -35
- package/dist/adapters/postgres/schema.d.ts.map +1 -1
- package/dist/adapters/postgres/schema.default.d.ts +119 -36
- package/dist/adapters/postgres/schema.default.d.ts.map +1 -1
- package/dist/adapters/postgres/schema.default.js +2 -2
- package/dist/adapters/postgres/schema.js +55 -22
- package/dist/adapters/schemas.d.ts +107 -80
- package/dist/adapters/schemas.d.ts.map +1 -1
- package/dist/adapters/schemas.js +131 -26
- package/dist/client.d.ts +315 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +391 -21
- package/dist/constants.js +6 -0
- package/dist/errors.d.ts +119 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +111 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/server.d.ts +91 -37
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +81 -25
- package/dist/step-manager.d.ts +111 -4
- package/dist/step-manager.d.ts.map +1 -1
- package/dist/step-manager.js +340 -69
- package/dist/telemetry/adapter.d.ts +322 -0
- package/dist/telemetry/adapter.d.ts.map +1 -1
- package/dist/telemetry/adapter.js +145 -0
- package/dist/telemetry/index.d.ts +1 -4
- package/dist/telemetry/index.d.ts.map +1 -1
- package/dist/telemetry/index.js +2 -4
- package/dist/telemetry/local-span-exporter.d.ts +56 -0
- package/dist/telemetry/local-span-exporter.d.ts.map +1 -0
- package/dist/telemetry/local-span-exporter.js +118 -0
- package/dist/telemetry/local.d.ts +48 -0
- package/dist/telemetry/local.d.ts.map +1 -1
- package/dist/telemetry/local.js +102 -0
- package/dist/telemetry/noop.d.ts +10 -0
- package/dist/telemetry/noop.d.ts.map +1 -1
- package/dist/telemetry/noop.js +43 -0
- package/dist/telemetry/opentelemetry.d.ts +23 -0
- package/dist/telemetry/opentelemetry.d.ts.map +1 -1
- package/dist/telemetry/opentelemetry.js +39 -0
- package/dist/utils/p-retry.d.ts +5 -0
- package/dist/utils/p-retry.d.ts.map +1 -1
- package/dist/utils/p-retry.js +8 -0
- package/dist/utils/wait-for-abort.d.ts +1 -0
- package/dist/utils/wait-for-abort.d.ts.map +1 -1
- package/dist/utils/wait-for-abort.js +1 -0
- package/migrations/postgres/{20260119153838_flimsy_thor_girl → 20260120154151_mean_magdalene}/migration.sql +27 -19
- package/migrations/postgres/{20260119153838_flimsy_thor_girl → 20260120154151_mean_magdalene}/snapshot.json +172 -65
- package/package.json +7 -2
- package/src/action-job.ts +32 -28
- package/src/action-manager.ts +5 -5
- package/src/action.ts +7 -7
- package/src/adapters/adapter.ts +54 -54
- package/src/adapters/postgres/base.ts +140 -77
- package/src/adapters/postgres/schema.default.ts +2 -2
- package/src/adapters/postgres/schema.ts +47 -23
- package/src/adapters/schemas.ts +83 -36
- package/src/client.ts +195 -42
- package/src/index.ts +1 -0
- package/src/server.ts +37 -37
- package/src/step-manager.ts +170 -86
- package/src/telemetry/index.ts +2 -20
- package/src/telemetry/local-span-exporter.ts +148 -0
- package/src/telemetry/adapter.ts +0 -642
- package/src/telemetry/local.ts +0 -429
- package/src/telemetry/noop.ts +0 -141
- package/src/telemetry/opentelemetry.ts +0 -453
package/dist/client.js
CHANGED
|
@@ -1,27 +1,105 @@
|
|
|
1
|
+
import { trace } from '@opentelemetry/api';
|
|
2
|
+
import { resourceFromAttributes } from '@opentelemetry/resources';
|
|
3
|
+
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
|
|
4
|
+
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
|
|
5
|
+
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
|
|
1
6
|
import pino, {} from 'pino';
|
|
2
7
|
import { zocker } from 'zocker';
|
|
3
8
|
import * as z from 'zod';
|
|
4
9
|
import { ActionManager } from './action-manager.js';
|
|
5
10
|
import { JOB_STATUS_CANCELLED, JOB_STATUS_COMPLETED, JOB_STATUS_FAILED } from './constants.js';
|
|
6
|
-
import {
|
|
11
|
+
import { LocalSpanExporter } from './telemetry/local-span-exporter.js';
|
|
7
12
|
const BaseOptionsSchema = z.object({
|
|
13
|
+
/**
|
|
14
|
+
* Unique identifier for this Duron instance.
|
|
15
|
+
* Used for multi-process coordination and job ownership.
|
|
16
|
+
* Defaults to a random UUID if not provided.
|
|
17
|
+
*/
|
|
8
18
|
id: z.string().optional(),
|
|
19
|
+
/**
|
|
20
|
+
* Synchronization pattern for fetching jobs.
|
|
21
|
+
* - `'pull'`: Periodically poll the database for new jobs
|
|
22
|
+
* - `'push'`: Listen for database notifications when jobs are available
|
|
23
|
+
* - `'hybrid'`: Use both pull and push patterns (recommended)
|
|
24
|
+
* - `false`: Disable automatic job fetching (manual fetching only)
|
|
25
|
+
*
|
|
26
|
+
* @default 'hybrid'
|
|
27
|
+
*/
|
|
9
28
|
syncPattern: z.union([z.literal('pull'), z.literal('push'), z.literal('hybrid'), z.literal(false)]).default('hybrid'),
|
|
29
|
+
/**
|
|
30
|
+
* Interval in milliseconds between pull operations when using pull or hybrid sync pattern.
|
|
31
|
+
*
|
|
32
|
+
* @default 5000
|
|
33
|
+
*/
|
|
10
34
|
pullInterval: z.number().default(5_000),
|
|
35
|
+
/**
|
|
36
|
+
* Maximum number of jobs to fetch in a single batch.
|
|
37
|
+
*
|
|
38
|
+
* @default 10
|
|
39
|
+
*/
|
|
11
40
|
batchSize: z.number().default(10),
|
|
41
|
+
/**
|
|
42
|
+
* Maximum number of jobs that can run concurrently per action.
|
|
43
|
+
* This controls the concurrency limit for the action's fastq queue.
|
|
44
|
+
*
|
|
45
|
+
* @default 100
|
|
46
|
+
*/
|
|
12
47
|
actionConcurrencyLimit: z.number().default(100),
|
|
48
|
+
/**
|
|
49
|
+
* Maximum number of jobs that can run concurrently per group key.
|
|
50
|
+
* Jobs with the same group key will respect this limit.
|
|
51
|
+
* This can be overridden using action -> groups -> concurrency.
|
|
52
|
+
*
|
|
53
|
+
* @default 10
|
|
54
|
+
*/
|
|
13
55
|
groupConcurrencyLimit: z.number().default(10),
|
|
56
|
+
/**
|
|
57
|
+
* Whether to run database migrations on startup.
|
|
58
|
+
* When enabled, Duron will automatically apply pending migrations when the adapter starts.
|
|
59
|
+
*
|
|
60
|
+
* @default true
|
|
61
|
+
*/
|
|
14
62
|
migrateOnStart: z.boolean().default(true),
|
|
63
|
+
/**
|
|
64
|
+
* Whether to recover stuck jobs on startup.
|
|
65
|
+
* Stuck jobs are jobs that were marked as active but the process that owned them
|
|
66
|
+
* is no longer running.
|
|
67
|
+
*
|
|
68
|
+
* @default true
|
|
69
|
+
*/
|
|
15
70
|
recoverJobsOnStart: z.boolean().default(true),
|
|
71
|
+
/**
|
|
72
|
+
* Enable multi-process mode for job recovery.
|
|
73
|
+
* When enabled, Duron will ping other processes to check if they're alive
|
|
74
|
+
* before recovering their jobs.
|
|
75
|
+
*
|
|
76
|
+
* @default false
|
|
77
|
+
*/
|
|
16
78
|
multiProcessMode: z.boolean().default(false),
|
|
17
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Timeout in milliseconds to wait for process ping responses in multi-process mode.
|
|
81
|
+
* Processes that don't respond within this timeout will have their jobs recovered.
|
|
82
|
+
*
|
|
83
|
+
* @default 5000 (5 seconds)
|
|
84
|
+
*/
|
|
85
|
+
processTimeout: z.number().default(5 * 1000), // 5 seconds
|
|
18
86
|
});
|
|
87
|
+
/**
|
|
88
|
+
* Client is the main entry point for Duron.
|
|
89
|
+
* Manages job execution, action handling, and database operations.
|
|
90
|
+
*
|
|
91
|
+
* @template TActions - Record of action definitions keyed by action name
|
|
92
|
+
* @template TVariables - Type of variables available to actions
|
|
93
|
+
*/
|
|
19
94
|
export class Client {
|
|
20
95
|
#options;
|
|
21
96
|
#id;
|
|
22
97
|
#actions;
|
|
23
98
|
#database;
|
|
24
|
-
#
|
|
99
|
+
#tracerProvider = null;
|
|
100
|
+
#tracer;
|
|
101
|
+
#telemetryOptions = null;
|
|
102
|
+
#localSpansEnabled = false;
|
|
25
103
|
#variables;
|
|
26
104
|
#logger;
|
|
27
105
|
#started = false;
|
|
@@ -33,18 +111,68 @@ export class Client {
|
|
|
33
111
|
#mockInputSchemas = new Map();
|
|
34
112
|
#pendingJobWaits = new Map();
|
|
35
113
|
#jobStatusListenerSetup = false;
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// Constructor
|
|
116
|
+
// ============================================================================
|
|
117
|
+
/**
|
|
118
|
+
* Create a new Duron Client instance.
|
|
119
|
+
*
|
|
120
|
+
* @param options - Configuration options for the client
|
|
121
|
+
*/
|
|
36
122
|
constructor(options) {
|
|
37
123
|
this.#options = BaseOptionsSchema.parse(options);
|
|
38
124
|
this.#id = options.id ?? globalThis.crypto.randomUUID();
|
|
39
125
|
this.#database = options.database;
|
|
40
|
-
this.#
|
|
126
|
+
this.#telemetryOptions = options.telemetry ?? null;
|
|
41
127
|
this.#actions = options.actions ?? null;
|
|
42
128
|
this.#variables = options?.variables ?? {};
|
|
43
129
|
this.#logger = this.#normalizeLogger(options?.logger);
|
|
44
130
|
this.#database.setId(this.#id);
|
|
45
131
|
this.#database.setLogger(this.#logger);
|
|
46
|
-
|
|
47
|
-
|
|
132
|
+
// Initialize OpenTelemetry TracerProvider if telemetry options are provided
|
|
133
|
+
// When no options are provided, the tracer will be a no-op (from OpenTelemetry API)
|
|
134
|
+
if (this.#telemetryOptions) {
|
|
135
|
+
this.#initTelemetry(this.#telemetryOptions);
|
|
136
|
+
}
|
|
137
|
+
// Get tracer from our provider if configured, otherwise use global no-op tracer
|
|
138
|
+
// This keeps telemetry scoped to this client instance rather than globally registered
|
|
139
|
+
this.#tracer = this.#tracerProvider?.getTracer('duron') ?? trace.getTracer('duron');
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Initialize OpenTelemetry TracerProvider with configured processors.
|
|
143
|
+
*/
|
|
144
|
+
#initTelemetry(options) {
|
|
145
|
+
const serviceName = options.serviceName ?? 'duron';
|
|
146
|
+
const processors = [];
|
|
147
|
+
// Add local span exporter if enabled
|
|
148
|
+
if (options.local) {
|
|
149
|
+
const localOptions = typeof options.local === 'boolean' ? {} : options.local;
|
|
150
|
+
const flushDelayMs = localOptions.flushDelayMs ?? 5000;
|
|
151
|
+
const localExporter = new LocalSpanExporter({ adapter: this.#database });
|
|
152
|
+
processors.push(new BatchSpanProcessor(localExporter, {
|
|
153
|
+
scheduledDelayMillis: flushDelayMs,
|
|
154
|
+
}));
|
|
155
|
+
this.#localSpansEnabled = true;
|
|
156
|
+
}
|
|
157
|
+
// Add custom span processors
|
|
158
|
+
if (options.spanProcessors) {
|
|
159
|
+
processors.push(...options.spanProcessors);
|
|
160
|
+
}
|
|
161
|
+
// Add custom trace exporter wrapped in BatchSpanProcessor
|
|
162
|
+
if (options.traceExporter) {
|
|
163
|
+
processors.push(new BatchSpanProcessor(options.traceExporter));
|
|
164
|
+
}
|
|
165
|
+
// Only create TracerProvider if we have processors
|
|
166
|
+
if (processors.length > 0) {
|
|
167
|
+
this.#tracerProvider = new NodeTracerProvider({
|
|
168
|
+
resource: resourceFromAttributes({
|
|
169
|
+
[ATTR_SERVICE_NAME]: serviceName,
|
|
170
|
+
}),
|
|
171
|
+
spanProcessors: processors,
|
|
172
|
+
});
|
|
173
|
+
// Note: We do NOT call .register() here to avoid global state pollution
|
|
174
|
+
// The tracer is obtained directly from this provider instance
|
|
175
|
+
}
|
|
48
176
|
}
|
|
49
177
|
#normalizeLogger(logger) {
|
|
50
178
|
let pinoInstance = null;
|
|
@@ -59,18 +187,46 @@ export class Client {
|
|
|
59
187
|
}
|
|
60
188
|
return pinoInstance.child({ duron: this.#id });
|
|
61
189
|
}
|
|
190
|
+
// ============================================================================
|
|
191
|
+
// Public API Methods
|
|
192
|
+
// ============================================================================
|
|
62
193
|
get logger() {
|
|
63
194
|
return this.#logger;
|
|
64
195
|
}
|
|
65
|
-
|
|
66
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Get the OpenTelemetry tracer for creating custom spans.
|
|
198
|
+
* Always returns a tracer - it's a no-op tracer when no SDK is configured.
|
|
199
|
+
*/
|
|
200
|
+
get tracer() {
|
|
201
|
+
return this.#tracer;
|
|
67
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* Get the database adapter instance.
|
|
205
|
+
*/
|
|
68
206
|
get database() {
|
|
69
207
|
return this.#database;
|
|
70
208
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Check if local span storage is enabled.
|
|
211
|
+
* Returns true if telemetry.local is enabled.
|
|
212
|
+
*/
|
|
213
|
+
get spansEnabled() {
|
|
214
|
+
return this.#localSpansEnabled;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Force flush any pending telemetry data.
|
|
218
|
+
* Useful in tests or when you need to ensure spans are exported before querying.
|
|
219
|
+
*/
|
|
220
|
+
async flushTelemetry() {
|
|
221
|
+
if (this.#tracerProvider) {
|
|
222
|
+
await this.#tracerProvider.forceFlush();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Get the current configuration of this Duron instance.
|
|
227
|
+
*
|
|
228
|
+
* @returns Configuration object including options, actions, and variables
|
|
229
|
+
*/
|
|
74
230
|
getConfig() {
|
|
75
231
|
return {
|
|
76
232
|
...this.#options,
|
|
@@ -78,12 +234,21 @@ export class Client {
|
|
|
78
234
|
variables: this.#variables,
|
|
79
235
|
};
|
|
80
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* Run an action by creating a new job.
|
|
239
|
+
*
|
|
240
|
+
* @param actionName - Name of the action to run
|
|
241
|
+
* @param input - Input data for the action (validated against action's input schema if provided)
|
|
242
|
+
* @returns Promise resolving to the created job ID
|
|
243
|
+
* @throws Error if action is not found or job creation fails
|
|
244
|
+
*/
|
|
81
245
|
async runAction(actionName, input) {
|
|
82
246
|
await this.start();
|
|
83
247
|
const action = this.#actions?.[actionName];
|
|
84
248
|
if (!action) {
|
|
85
249
|
throw new Error(`Action ${String(actionName)} not found`);
|
|
86
250
|
}
|
|
251
|
+
// Validate input if schema is provided
|
|
87
252
|
let validatedInput = input ?? {};
|
|
88
253
|
if (action.input) {
|
|
89
254
|
validatedInput = action.input.parse(validatedInput, {
|
|
@@ -91,6 +256,7 @@ export class Client {
|
|
|
91
256
|
reportInput: true,
|
|
92
257
|
});
|
|
93
258
|
}
|
|
259
|
+
// Determine groupKey and concurrency limit using concurrency handler or defaults
|
|
94
260
|
const concurrencyCtx = {
|
|
95
261
|
input: validatedInput,
|
|
96
262
|
var: this.#variables,
|
|
@@ -103,6 +269,7 @@ export class Client {
|
|
|
103
269
|
if (action.groups?.concurrency) {
|
|
104
270
|
concurrencyLimit = await action.groups.concurrency(concurrencyCtx);
|
|
105
271
|
}
|
|
272
|
+
// Create job in database
|
|
106
273
|
const jobId = await this.#database.createJob({
|
|
107
274
|
queue: action.name,
|
|
108
275
|
groupKey,
|
|
@@ -117,11 +284,24 @@ export class Client {
|
|
|
117
284
|
this.#logger.debug({ jobId, actionName: String(actionName), groupKey }, '[Duron] Action sent/created');
|
|
118
285
|
return jobId;
|
|
119
286
|
}
|
|
287
|
+
/**
|
|
288
|
+
* Run an action and wait for its completion.
|
|
289
|
+
* This is a convenience method that combines `runAction` and `waitForJob`.
|
|
290
|
+
*
|
|
291
|
+
* @param actionName - Name of the action to run
|
|
292
|
+
* @param input - Input data for the action (validated against action's input schema if provided)
|
|
293
|
+
* @param options - Options including abort signal and timeout
|
|
294
|
+
* @returns Promise resolving to the job result with typed input and output
|
|
295
|
+
* @throws Error if action is not found, job creation fails, job is cancelled, or operation is aborted
|
|
296
|
+
*/
|
|
120
297
|
async runActionAndWait(actionName, input, options) {
|
|
298
|
+
// Check if already aborted before starting
|
|
121
299
|
if (options?.signal?.aborted) {
|
|
122
300
|
throw new Error('Operation was aborted');
|
|
123
301
|
}
|
|
302
|
+
// Create the job
|
|
124
303
|
const jobId = await this.runAction(actionName, input);
|
|
304
|
+
// Set up abort handler to cancel the job if signal is aborted
|
|
125
305
|
let abortHandler;
|
|
126
306
|
if (options?.signal) {
|
|
127
307
|
abortHandler = () => {
|
|
@@ -131,6 +311,7 @@ export class Client {
|
|
|
131
311
|
};
|
|
132
312
|
options.signal.addEventListener('abort', abortHandler, { once: true });
|
|
133
313
|
}
|
|
314
|
+
// Set up timeout handler to cancel the job if timeout is reached
|
|
134
315
|
let timeoutId;
|
|
135
316
|
let timeoutAbortController;
|
|
136
317
|
if (options?.timeout) {
|
|
@@ -143,6 +324,7 @@ export class Client {
|
|
|
143
324
|
}, options.timeout);
|
|
144
325
|
}
|
|
145
326
|
try {
|
|
327
|
+
// Combine signals if both are provided
|
|
146
328
|
let waitSignal;
|
|
147
329
|
if (options?.signal && timeoutAbortController) {
|
|
148
330
|
waitSignal = AbortSignal.any([options.signal, timeoutAbortController.signal]);
|
|
@@ -153,13 +335,16 @@ export class Client {
|
|
|
153
335
|
else if (timeoutAbortController) {
|
|
154
336
|
waitSignal = timeoutAbortController.signal;
|
|
155
337
|
}
|
|
338
|
+
// Wait for the job to complete
|
|
156
339
|
const job = await this.waitForJob(jobId, { signal: waitSignal });
|
|
340
|
+
// Clean up
|
|
157
341
|
if (timeoutId) {
|
|
158
342
|
clearTimeout(timeoutId);
|
|
159
343
|
}
|
|
160
344
|
if (options?.signal && abortHandler) {
|
|
161
345
|
options.signal.removeEventListener('abort', abortHandler);
|
|
162
346
|
}
|
|
347
|
+
// Handle null result (aborted or timed out)
|
|
163
348
|
if (!job) {
|
|
164
349
|
if (options?.signal?.aborted) {
|
|
165
350
|
throw new Error('Operation was aborted');
|
|
@@ -169,6 +354,7 @@ export class Client {
|
|
|
169
354
|
}
|
|
170
355
|
throw new Error('Job not found');
|
|
171
356
|
}
|
|
357
|
+
// Handle cancelled job
|
|
172
358
|
if (job.status === JOB_STATUS_CANCELLED) {
|
|
173
359
|
if (options?.signal?.aborted) {
|
|
174
360
|
throw new Error('Operation was aborted');
|
|
@@ -178,6 +364,7 @@ export class Client {
|
|
|
178
364
|
}
|
|
179
365
|
throw new Error('Job was cancelled');
|
|
180
366
|
}
|
|
367
|
+
// Handle failed job
|
|
181
368
|
if (job.status === JOB_STATUS_FAILED) {
|
|
182
369
|
const errorMessage = job.error?.message ?? 'Job failed';
|
|
183
370
|
const error = new Error(errorMessage);
|
|
@@ -186,9 +373,11 @@ export class Client {
|
|
|
186
373
|
}
|
|
187
374
|
throw error;
|
|
188
375
|
}
|
|
376
|
+
// Return the job result with typed input/output
|
|
189
377
|
return job;
|
|
190
378
|
}
|
|
191
379
|
catch (err) {
|
|
380
|
+
// Clean up on error
|
|
192
381
|
if (timeoutId) {
|
|
193
382
|
clearTimeout(timeoutId);
|
|
194
383
|
}
|
|
@@ -198,19 +387,37 @@ export class Client {
|
|
|
198
387
|
throw err;
|
|
199
388
|
}
|
|
200
389
|
}
|
|
201
|
-
|
|
390
|
+
/**
|
|
391
|
+
* Fetch and process jobs from the database.
|
|
392
|
+
* Concurrency limits are determined from the latest job created for each groupKey.
|
|
393
|
+
*
|
|
394
|
+
* @param [options.batchSize] - Maximum number of jobs to fetch in this batch (defaults to `batchSize` from client options)
|
|
395
|
+
* @returns Promise resolving to the array of fetched jobs
|
|
396
|
+
*/
|
|
397
|
+
async fetch(options = {}) {
|
|
202
398
|
await this.start();
|
|
203
399
|
if (!this.#actions) {
|
|
204
400
|
return [];
|
|
205
401
|
}
|
|
402
|
+
// Fetch jobs from each action's queue
|
|
403
|
+
// Concurrency limits are determined from the latest job created for each groupKey
|
|
206
404
|
const jobs = await this.#database.fetch({
|
|
207
405
|
batch: options.batchSize ?? this.#options.batchSize,
|
|
208
406
|
});
|
|
407
|
+
// Process fetched jobs
|
|
209
408
|
for (const job of jobs) {
|
|
210
409
|
this.#executeJob(job);
|
|
211
410
|
}
|
|
212
411
|
return jobs;
|
|
213
412
|
}
|
|
413
|
+
/**
|
|
414
|
+
* Cancel a job by its ID.
|
|
415
|
+
* If the job is currently being processed, it will be cancelled immediately.
|
|
416
|
+
* Otherwise, it will be cancelled in the database.
|
|
417
|
+
*
|
|
418
|
+
* @param jobId - The ID of the job to cancel
|
|
419
|
+
* @returns Promise resolving to `true` if cancelled, `false` otherwise
|
|
420
|
+
*/
|
|
214
421
|
async cancelJob(jobId) {
|
|
215
422
|
await this.start();
|
|
216
423
|
let cancelled = false;
|
|
@@ -221,52 +428,134 @@ export class Client {
|
|
|
221
428
|
}
|
|
222
429
|
}
|
|
223
430
|
if (!cancelled) {
|
|
431
|
+
// If the job is not being processed, cancel it in the database
|
|
224
432
|
await this.#database.cancelJob({ jobId });
|
|
225
433
|
}
|
|
226
434
|
return cancelled;
|
|
227
435
|
}
|
|
436
|
+
/**
|
|
437
|
+
* Retry a failed job by creating a copy of it with status 'created' and cleared output/error.
|
|
438
|
+
*
|
|
439
|
+
* @param jobId - The ID of the job to retry
|
|
440
|
+
* @returns Promise resolving to the new job ID, or `null` if retry failed
|
|
441
|
+
*/
|
|
228
442
|
async retryJob(jobId) {
|
|
229
443
|
await this.start();
|
|
230
444
|
return this.#database.retryJob({ jobId });
|
|
231
445
|
}
|
|
446
|
+
/**
|
|
447
|
+
* Time travel a job to restart from a specific step.
|
|
448
|
+
* The job must be in completed, failed, or cancelled status.
|
|
449
|
+
* Resets the job and ancestor steps to active status, deletes subsequent steps,
|
|
450
|
+
* and preserves completed parallel siblings.
|
|
451
|
+
*
|
|
452
|
+
* @param jobId - The ID of the job to time travel
|
|
453
|
+
* @param stepId - The ID of the step to restart from
|
|
454
|
+
* @returns Promise resolving to `true` if time travel succeeded, `false` otherwise
|
|
455
|
+
*/
|
|
232
456
|
async timeTravelJob(jobId, stepId) {
|
|
233
457
|
await this.start();
|
|
234
458
|
return this.#database.timeTravelJob({ jobId, stepId });
|
|
235
459
|
}
|
|
460
|
+
/**
|
|
461
|
+
* Delete a job by its ID.
|
|
462
|
+
* Active jobs cannot be deleted.
|
|
463
|
+
*
|
|
464
|
+
* @param jobId - The ID of the job to delete
|
|
465
|
+
* @returns Promise resolving to `true` if deleted, `false` otherwise
|
|
466
|
+
*/
|
|
236
467
|
async deleteJob(jobId) {
|
|
237
468
|
await this.start();
|
|
238
469
|
return this.#database.deleteJob({ jobId });
|
|
239
470
|
}
|
|
471
|
+
/**
|
|
472
|
+
* Delete multiple jobs using the same filters as getJobs.
|
|
473
|
+
* Active jobs cannot be deleted and will be excluded from deletion.
|
|
474
|
+
*
|
|
475
|
+
* @param options - Query options including filters (same as getJobs)
|
|
476
|
+
* @returns Promise resolving to the number of jobs deleted
|
|
477
|
+
*/
|
|
240
478
|
async deleteJobs(options) {
|
|
241
479
|
await this.start();
|
|
242
480
|
return this.#database.deleteJobs(options);
|
|
243
481
|
}
|
|
482
|
+
// ============================================================================
|
|
483
|
+
// Query Methods
|
|
484
|
+
// ============================================================================
|
|
485
|
+
/**
|
|
486
|
+
* Get a job by its ID. Does not include step information.
|
|
487
|
+
*
|
|
488
|
+
* @param jobId - The ID of the job to retrieve
|
|
489
|
+
* @returns Promise resolving to the job, or `null` if not found
|
|
490
|
+
*/
|
|
244
491
|
async getJobById(jobId) {
|
|
245
492
|
await this.start();
|
|
246
493
|
return this.#database.getJobById(jobId);
|
|
247
494
|
}
|
|
495
|
+
/**
|
|
496
|
+
* Get steps for a job with pagination and fuzzy search.
|
|
497
|
+
* Steps are always ordered by created_at ASC.
|
|
498
|
+
* Steps do not include output data.
|
|
499
|
+
*
|
|
500
|
+
* @param options - Query options including jobId, pagination, and search
|
|
501
|
+
* @returns Promise resolving to steps result with pagination info
|
|
502
|
+
*/
|
|
248
503
|
async getJobSteps(options) {
|
|
249
504
|
await this.start();
|
|
250
505
|
return this.#database.getJobSteps(options);
|
|
251
506
|
}
|
|
507
|
+
/**
|
|
508
|
+
* Get jobs with pagination, filtering, and sorting.
|
|
509
|
+
* Does not include step information or job output.
|
|
510
|
+
*
|
|
511
|
+
* @param options - Query options including pagination, filters, and sort
|
|
512
|
+
* @returns Promise resolving to jobs result with pagination info
|
|
513
|
+
*/
|
|
252
514
|
async getJobs(options) {
|
|
253
515
|
await this.start();
|
|
254
516
|
return this.#database.getJobs(options);
|
|
255
517
|
}
|
|
518
|
+
/**
|
|
519
|
+
* Get a step by its ID with all information.
|
|
520
|
+
*
|
|
521
|
+
* @param stepId - The ID of the step to retrieve
|
|
522
|
+
* @returns Promise resolving to the step, or `null` if not found
|
|
523
|
+
*/
|
|
256
524
|
async getJobStepById(stepId) {
|
|
257
525
|
await this.start();
|
|
258
526
|
return this.#database.getJobStepById(stepId);
|
|
259
527
|
}
|
|
528
|
+
/**
|
|
529
|
+
* Get job status and updatedAt timestamp.
|
|
530
|
+
*
|
|
531
|
+
* @param jobId - The ID of the job
|
|
532
|
+
* @returns Promise resolving to job status result, or `null` if not found
|
|
533
|
+
*/
|
|
260
534
|
async getJobStatus(jobId) {
|
|
261
535
|
await this.start();
|
|
262
536
|
return this.#database.getJobStatus(jobId);
|
|
263
537
|
}
|
|
538
|
+
/**
|
|
539
|
+
* Get job step status and updatedAt timestamp.
|
|
540
|
+
*
|
|
541
|
+
* @param stepId - The ID of the step
|
|
542
|
+
* @returns Promise resolving to step status result, or `null` if not found
|
|
543
|
+
*/
|
|
264
544
|
async getJobStepStatus(stepId) {
|
|
265
545
|
await this.start();
|
|
266
546
|
return this.#database.getJobStepStatus(stepId);
|
|
267
547
|
}
|
|
548
|
+
/**
|
|
549
|
+
* Wait for a job to change status by subscribing to job-status-changed events.
|
|
550
|
+
* When the job status changes, the job result is returned.
|
|
551
|
+
*
|
|
552
|
+
* @param jobId - The ID of the job to wait for
|
|
553
|
+
* @param options - Optional configuration including timeout
|
|
554
|
+
* @returns Promise resolving to the job result when its status changes, or `null` if timeout
|
|
555
|
+
*/
|
|
268
556
|
async waitForJob(jobId, options) {
|
|
269
557
|
await this.start();
|
|
558
|
+
// First, check if the job already exists and is in a terminal state
|
|
270
559
|
const existingJobStatus = await this.getJobStatus(jobId);
|
|
271
560
|
if (existingJobStatus) {
|
|
272
561
|
const terminalStatuses = [JOB_STATUS_COMPLETED, JOB_STATUS_FAILED, JOB_STATUS_CANCELLED];
|
|
@@ -276,7 +565,7 @@ export class Client {
|
|
|
276
565
|
return null;
|
|
277
566
|
}
|
|
278
567
|
return {
|
|
279
|
-
|
|
568
|
+
id: job.id,
|
|
280
569
|
actionName: job.actionName,
|
|
281
570
|
status: job.status,
|
|
282
571
|
groupKey: job.groupKey,
|
|
@@ -286,20 +575,24 @@ export class Client {
|
|
|
286
575
|
};
|
|
287
576
|
}
|
|
288
577
|
}
|
|
578
|
+
// Set up the shared event listener if not already set up
|
|
289
579
|
this.#setupJobStatusListener();
|
|
290
580
|
return new Promise((resolve) => {
|
|
581
|
+
// Check if already aborted before setting up wait
|
|
291
582
|
if (options?.signal?.aborted) {
|
|
292
583
|
resolve(null);
|
|
293
584
|
return;
|
|
294
585
|
}
|
|
295
586
|
let timeoutId;
|
|
296
587
|
let abortHandler;
|
|
588
|
+
// Set up timeout if provided
|
|
297
589
|
if (options?.timeout) {
|
|
298
590
|
timeoutId = setTimeout(() => {
|
|
299
591
|
this.#removeJobWait(jobId, resolve);
|
|
300
592
|
resolve(null);
|
|
301
593
|
}, options.timeout);
|
|
302
594
|
}
|
|
595
|
+
// Set up abort signal if provided
|
|
303
596
|
if (options?.signal) {
|
|
304
597
|
abortHandler = () => {
|
|
305
598
|
this.#removeJobWait(jobId, resolve);
|
|
@@ -307,6 +600,7 @@ export class Client {
|
|
|
307
600
|
};
|
|
308
601
|
options.signal.addEventListener('abort', abortHandler);
|
|
309
602
|
}
|
|
603
|
+
// Add this wait request to the pending waits
|
|
310
604
|
if (!this.#pendingJobWaits.has(jobId)) {
|
|
311
605
|
this.#pendingJobWaits.set(jobId, new Set());
|
|
312
606
|
}
|
|
@@ -318,17 +612,36 @@ export class Client {
|
|
|
318
612
|
});
|
|
319
613
|
});
|
|
320
614
|
}
|
|
615
|
+
/**
|
|
616
|
+
* Get action statistics including counts and last job created date.
|
|
617
|
+
*
|
|
618
|
+
* @returns Promise resolving to action statistics
|
|
619
|
+
*/
|
|
321
620
|
async getActions() {
|
|
322
621
|
await this.start();
|
|
323
622
|
return this.#database.getActions();
|
|
324
623
|
}
|
|
325
|
-
|
|
624
|
+
/**
|
|
625
|
+
* Get spans for a job or step.
|
|
626
|
+
* Only available when telemetry.local is enabled.
|
|
627
|
+
*
|
|
628
|
+
* @param options - Query options including jobId/stepId, filters, and sort
|
|
629
|
+
* @returns Promise resolving to spans result
|
|
630
|
+
* @throws Error if local telemetry is not enabled
|
|
631
|
+
*/
|
|
632
|
+
async getSpans(options) {
|
|
326
633
|
await this.start();
|
|
327
|
-
if (!this.
|
|
328
|
-
throw new Error('
|
|
634
|
+
if (!this.spansEnabled) {
|
|
635
|
+
throw new Error('Spans are only available when telemetry.local is enabled');
|
|
329
636
|
}
|
|
330
|
-
return this.#database.
|
|
637
|
+
return this.#database.getSpans(options);
|
|
331
638
|
}
|
|
639
|
+
/**
|
|
640
|
+
* Get action metadata including input schemas and mock data.
|
|
641
|
+
* This is useful for generating UI forms or mock data.
|
|
642
|
+
*
|
|
643
|
+
* @returns Promise resolving to action metadata
|
|
644
|
+
*/
|
|
332
645
|
async getActionsMetadata() {
|
|
333
646
|
await this.start();
|
|
334
647
|
if (!this.#actions) {
|
|
@@ -350,6 +663,15 @@ export class Client {
|
|
|
350
663
|
};
|
|
351
664
|
});
|
|
352
665
|
}
|
|
666
|
+
// ============================================================================
|
|
667
|
+
// Lifecycle Methods
|
|
668
|
+
// ============================================================================
|
|
669
|
+
/**
|
|
670
|
+
* Start the Duron instance.
|
|
671
|
+
* Initializes the database, recovers stuck jobs, and sets up sync patterns.
|
|
672
|
+
*
|
|
673
|
+
* @returns Promise resolving to `true` if started successfully, `false` otherwise
|
|
674
|
+
*/
|
|
353
675
|
async start() {
|
|
354
676
|
if (this.#stopping || this.#stopped) {
|
|
355
677
|
return false;
|
|
@@ -365,7 +687,6 @@ export class Client {
|
|
|
365
687
|
if (!dbStarted) {
|
|
366
688
|
return false;
|
|
367
689
|
}
|
|
368
|
-
await this.#telemetry.start();
|
|
369
690
|
if (this.#actions) {
|
|
370
691
|
if (this.#options.recoverJobsOnStart) {
|
|
371
692
|
await this.#database.recoverJobs({
|
|
@@ -374,6 +695,7 @@ export class Client {
|
|
|
374
695
|
processTimeout: this.#options.processTimeout,
|
|
375
696
|
});
|
|
376
697
|
}
|
|
698
|
+
// Setup sync pattern
|
|
377
699
|
if (this.#options.syncPattern === 'pull' || this.#options.syncPattern === 'hybrid') {
|
|
378
700
|
this.#startPullLoop();
|
|
379
701
|
}
|
|
@@ -387,6 +709,12 @@ export class Client {
|
|
|
387
709
|
})();
|
|
388
710
|
return this.#starting;
|
|
389
711
|
}
|
|
712
|
+
/**
|
|
713
|
+
* Stop the Duron instance.
|
|
714
|
+
* Stops the pull loop, aborts all running jobs, waits for queues to drain, and stops the database.
|
|
715
|
+
*
|
|
716
|
+
* @returns Promise resolving to `true` if stopped successfully, `false` otherwise
|
|
717
|
+
*/
|
|
390
718
|
async stop() {
|
|
391
719
|
if (this.#stopped) {
|
|
392
720
|
return true;
|
|
@@ -395,10 +723,12 @@ export class Client {
|
|
|
395
723
|
return this.#stopping;
|
|
396
724
|
}
|
|
397
725
|
this.#stopping = (async () => {
|
|
726
|
+
// Stop pull loop
|
|
398
727
|
if (this.#pullInterval) {
|
|
399
728
|
clearTimeout(this.#pullInterval);
|
|
400
729
|
this.#pullInterval = null;
|
|
401
730
|
}
|
|
731
|
+
// Clean up all pending job waits
|
|
402
732
|
for (const waits of this.#pendingJobWaits.values()) {
|
|
403
733
|
for (const wait of waits) {
|
|
404
734
|
if (wait.timeoutId) {
|
|
@@ -411,10 +741,14 @@ export class Client {
|
|
|
411
741
|
}
|
|
412
742
|
}
|
|
413
743
|
this.#pendingJobWaits.clear();
|
|
744
|
+
// Wait for action managers to drain
|
|
414
745
|
await Promise.all(Array.from(this.#actionManagers.values()).map(async (manager) => {
|
|
415
746
|
await manager.stop();
|
|
416
747
|
}));
|
|
417
|
-
|
|
748
|
+
// Shutdown TracerProvider if configured
|
|
749
|
+
if (this.#tracerProvider) {
|
|
750
|
+
await this.#tracerProvider.shutdown();
|
|
751
|
+
}
|
|
418
752
|
const dbStopped = await this.#database.stop();
|
|
419
753
|
if (!dbStopped) {
|
|
420
754
|
return false;
|
|
@@ -425,6 +759,13 @@ export class Client {
|
|
|
425
759
|
})();
|
|
426
760
|
return this.#stopping;
|
|
427
761
|
}
|
|
762
|
+
// ============================================================================
|
|
763
|
+
// Private Methods
|
|
764
|
+
// ============================================================================
|
|
765
|
+
/**
|
|
766
|
+
* Set up the shared event listener for job-status-changed events.
|
|
767
|
+
* This listener is shared across all waitForJob calls to avoid multiple listeners.
|
|
768
|
+
*/
|
|
428
769
|
#setupJobStatusListener() {
|
|
429
770
|
if (this.#jobStatusListenerSetup) {
|
|
430
771
|
return;
|
|
@@ -435,10 +776,12 @@ export class Client {
|
|
|
435
776
|
if (!pendingWaits || pendingWaits.size === 0) {
|
|
436
777
|
return;
|
|
437
778
|
}
|
|
779
|
+
// Fetch the job once for all pending waits
|
|
438
780
|
const job = await this.getJobById(event.jobId);
|
|
781
|
+
// Transform to JobResult
|
|
439
782
|
const result = job
|
|
440
783
|
? {
|
|
441
|
-
|
|
784
|
+
id: job.id,
|
|
442
785
|
actionName: job.actionName,
|
|
443
786
|
status: job.status,
|
|
444
787
|
groupKey: job.groupKey,
|
|
@@ -447,9 +790,11 @@ export class Client {
|
|
|
447
790
|
error: job.error,
|
|
448
791
|
}
|
|
449
792
|
: null;
|
|
793
|
+
// Resolve all pending waits for this job
|
|
450
794
|
const waitsToResolve = Array.from(pendingWaits);
|
|
451
795
|
this.#pendingJobWaits.delete(event.jobId);
|
|
452
796
|
for (const wait of waitsToResolve) {
|
|
797
|
+
// Clean up timeout and abort signal
|
|
453
798
|
if (wait.timeoutId) {
|
|
454
799
|
clearTimeout(wait.timeoutId);
|
|
455
800
|
}
|
|
@@ -460,11 +805,18 @@ export class Client {
|
|
|
460
805
|
}
|
|
461
806
|
});
|
|
462
807
|
}
|
|
808
|
+
/**
|
|
809
|
+
* Remove a specific wait request from the pending waits.
|
|
810
|
+
*
|
|
811
|
+
* @param jobId - The job ID
|
|
812
|
+
* @param resolve - The resolve function to remove
|
|
813
|
+
*/
|
|
463
814
|
#removeJobWait(jobId, resolve) {
|
|
464
815
|
const pendingWaits = this.#pendingJobWaits.get(jobId);
|
|
465
816
|
if (!pendingWaits) {
|
|
466
817
|
return;
|
|
467
818
|
}
|
|
819
|
+
// Find and remove the specific wait request
|
|
468
820
|
for (const wait of pendingWaits) {
|
|
469
821
|
if (wait.resolve === resolve) {
|
|
470
822
|
if (wait.timeoutId) {
|
|
@@ -477,10 +829,16 @@ export class Client {
|
|
|
477
829
|
break;
|
|
478
830
|
}
|
|
479
831
|
}
|
|
832
|
+
// Clean up empty sets
|
|
480
833
|
if (pendingWaits.size === 0) {
|
|
481
834
|
this.#pendingJobWaits.delete(jobId);
|
|
482
835
|
}
|
|
483
836
|
}
|
|
837
|
+
/**
|
|
838
|
+
* Execute a job by finding its action and queuing it with the appropriate ActionManager.
|
|
839
|
+
*
|
|
840
|
+
* @param job - The job to execute
|
|
841
|
+
*/
|
|
484
842
|
#executeJob(job) {
|
|
485
843
|
if (!this.#actions) {
|
|
486
844
|
return;
|
|
@@ -494,22 +852,29 @@ export class Client {
|
|
|
494
852
|
});
|
|
495
853
|
return;
|
|
496
854
|
}
|
|
855
|
+
// Get or create ActionManager for this action
|
|
497
856
|
let actionManager = this.#actionManagers.get(action.name);
|
|
498
857
|
if (!actionManager) {
|
|
499
858
|
actionManager = new ActionManager({
|
|
500
859
|
action,
|
|
501
860
|
database: this.#database,
|
|
502
|
-
|
|
861
|
+
tracer: this.#tracer,
|
|
503
862
|
variables: this.#variables,
|
|
504
863
|
logger: this.#logger,
|
|
505
864
|
concurrencyLimit: this.#options.actionConcurrencyLimit,
|
|
506
865
|
});
|
|
507
866
|
this.#actionManagers.set(action.name, actionManager);
|
|
508
867
|
}
|
|
868
|
+
// Queue job execution
|
|
509
869
|
actionManager.push(job).catch((err) => {
|
|
870
|
+
// Only log unexpected errors (not cancellation/timeout which are handled elsewhere)
|
|
510
871
|
this.#logger.error({ err, jobId: job.id, actionName: action.name }, `[Duron] Error executing job ${job.id} for action ${action.name}`);
|
|
511
872
|
});
|
|
512
873
|
}
|
|
874
|
+
/**
|
|
875
|
+
* Start the pull loop for periodically fetching jobs.
|
|
876
|
+
* Only starts if not already running.
|
|
877
|
+
*/
|
|
513
878
|
#startPullLoop() {
|
|
514
879
|
if (this.#pullInterval) {
|
|
515
880
|
return;
|
|
@@ -530,8 +895,13 @@ export class Client {
|
|
|
530
895
|
this.#pullInterval = setTimeout(pull, this.#options.pullInterval);
|
|
531
896
|
}
|
|
532
897
|
};
|
|
898
|
+
// Start immediately
|
|
533
899
|
pull();
|
|
534
900
|
}
|
|
901
|
+
/**
|
|
902
|
+
* Setup the push listener for database notifications.
|
|
903
|
+
* Listens for 'job-available' events and fetches jobs when notified.
|
|
904
|
+
*/
|
|
535
905
|
#setupPushListener() {
|
|
536
906
|
this.#database.on('job-available', async () => {
|
|
537
907
|
this.fetch({
|