duron 0.3.0-beta.12 → 0.3.0-beta.14

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.
@@ -141,7 +141,15 @@ export class PostgresBaseAdapter<Database extends DrizzleDatabase, Connection> e
141
141
  *
142
142
  * @returns Promise resolving to the job ID, or `null` if creation failed
143
143
  */
144
- protected async _createJob({ queue, groupKey, input, timeoutMs, checksum, concurrencyLimit }: CreateJobOptions) {
144
+ protected async _createJob({
145
+ queue,
146
+ groupKey,
147
+ input,
148
+ timeoutMs,
149
+ checksum,
150
+ concurrencyLimit,
151
+ concurrencyStepLimit,
152
+ }: CreateJobOptions) {
145
153
  const [result] = await this.db
146
154
  .insert(this.tables.jobsTable)
147
155
  .values({
@@ -152,6 +160,7 @@ export class PostgresBaseAdapter<Database extends DrizzleDatabase, Connection> e
152
160
  status: JOB_STATUS_CREATED,
153
161
  timeout_ms: timeoutMs,
154
162
  concurrency_limit: concurrencyLimit,
163
+ concurrency_step_limit: concurrencyStepLimit,
155
164
  })
156
165
  .returning({ id: this.tables.jobsTable.id })
157
166
 
@@ -667,7 +676,8 @@ export class PostgresBaseAdapter<Database extends DrizzleDatabase, Connection> e
667
676
  j.finished_at as "finishedAt",
668
677
  j.created_at as "createdAt",
669
678
  j.updated_at as "updatedAt",
670
- j.concurrency_limit as "concurrencyLimit"
679
+ j.concurrency_limit as "concurrencyLimit",
680
+ j.concurrency_step_limit as "concurrencyStepLimit"
671
681
  `),
672
682
  )
673
683
 
@@ -1038,6 +1048,7 @@ export class PostgresBaseAdapter<Database extends DrizzleDatabase, Connection> e
1038
1048
  createdAt: jobsTable.created_at,
1039
1049
  updatedAt: jobsTable.updated_at,
1040
1050
  concurrencyLimit: jobsTable.concurrency_limit,
1051
+ concurrencyStepLimit: jobsTable.concurrency_step_limit,
1041
1052
  clientId: jobsTable.client_id,
1042
1053
  durationMs,
1043
1054
  })
@@ -1241,6 +1252,7 @@ export class PostgresBaseAdapter<Database extends DrizzleDatabase, Connection> e
1241
1252
  createdAt: jobsTable.created_at,
1242
1253
  updatedAt: jobsTable.updated_at,
1243
1254
  concurrencyLimit: jobsTable.concurrency_limit,
1255
+ concurrencyStepLimit: jobsTable.concurrency_step_limit,
1244
1256
  clientId: jobsTable.client_id,
1245
1257
  durationMs,
1246
1258
  })
@@ -1418,21 +1430,29 @@ export class PostgresBaseAdapter<Database extends DrizzleDatabase, Connection> e
1418
1430
 
1419
1431
  /**
1420
1432
  * Internal method to get spans for a job or step.
1433
+ * For step queries, uses a recursive CTE to find all descendant spans.
1421
1434
  */
1422
1435
  protected async _getSpans(options: GetSpansOptions): Promise<GetSpansResult> {
1423
1436
  const spansTable = this.tables.spansTable
1424
1437
  const filters = options.filters ?? {}
1425
1438
 
1426
- // Build WHERE clause
1427
- const where = this._buildSpansWhereClause(options.jobId, options.stepId, filters)
1428
-
1429
1439
  // Build sort
1430
1440
  const sortInput = options.sort ?? { field: 'startTimeUnixNano', order: 'asc' }
1431
- const sortFieldMap: Record<SpanSort['field'], any> = {
1432
- name: spansTable.name,
1433
- startTimeUnixNano: spansTable.start_time_unix_nano,
1434
- endTimeUnixNano: spansTable.end_time_unix_nano,
1441
+ const sortFieldMap: Record<SpanSort['field'], string> = {
1442
+ name: 'name',
1443
+ startTimeUnixNano: 'start_time_unix_nano',
1444
+ endTimeUnixNano: 'end_time_unix_nano',
1435
1445
  }
1446
+ const sortField = sortFieldMap[sortInput.field]
1447
+ const sortOrder = sortInput.order === 'asc' ? 'ASC' : 'DESC'
1448
+
1449
+ // For step queries, use a recursive CTE to get descendant spans
1450
+ if (options.stepId) {
1451
+ return this._getStepSpansRecursive(options.stepId, sortField, sortOrder, filters)
1452
+ }
1453
+
1454
+ // Build WHERE clause for job queries
1455
+ const where = this._buildSpansWhereClause(options.jobId, undefined, filters)
1436
1456
 
1437
1457
  // Get total count
1438
1458
  const total = await this.db.$count(spansTable, where)
@@ -1443,8 +1463,11 @@ export class PostgresBaseAdapter<Database extends DrizzleDatabase, Connection> e
1443
1463
  }
1444
1464
  }
1445
1465
 
1446
- const sortField = sortFieldMap[sortInput.field]
1447
- const orderByClause = sortInput.order === 'asc' ? asc(sortField) : desc(sortField)
1466
+ const sortFieldColumn = sortFieldMap[sortInput.field]
1467
+ const orderByClause =
1468
+ sortInput.order === 'asc'
1469
+ ? asc(spansTable[sortFieldColumn as keyof typeof spansTable] as any)
1470
+ : desc(spansTable[sortFieldColumn as keyof typeof spansTable] as any)
1448
1471
 
1449
1472
  const rows = await this.db
1450
1473
  .select({
@@ -1483,6 +1506,89 @@ export class PostgresBaseAdapter<Database extends DrizzleDatabase, Connection> e
1483
1506
  }
1484
1507
  }
1485
1508
 
1509
+ /**
1510
+ * Get spans for a step using a recursive CTE to traverse the span hierarchy.
1511
+ * This returns the step's span and all its descendant spans (children, grandchildren, etc.)
1512
+ */
1513
+ protected async _getStepSpansRecursive(
1514
+ stepId: string,
1515
+ sortField: string,
1516
+ sortOrder: string,
1517
+ _filters?: GetSpansOptions['filters'],
1518
+ ): Promise<GetSpansResult> {
1519
+ const schemaName = this.schema
1520
+
1521
+ // Use a recursive CTE to find all descendant spans
1522
+ // 1. Base case: find the span with step_id = stepId
1523
+ // 2. Recursive case: find all spans where parent_span_id = span_id of a span we've already found
1524
+ const query = sql`
1525
+ WITH RECURSIVE span_tree AS (
1526
+ -- Base case: the span(s) for the step
1527
+ SELECT * FROM ${sql.identifier(schemaName)}.spans WHERE step_id = ${stepId}::uuid
1528
+ UNION ALL
1529
+ -- Recursive case: children of spans we've found
1530
+ SELECT s.* FROM ${sql.identifier(schemaName)}.spans s
1531
+ INNER JOIN span_tree st ON s.parent_span_id = st.span_id
1532
+ )
1533
+ SELECT
1534
+ id,
1535
+ trace_id as "traceId",
1536
+ span_id as "spanId",
1537
+ parent_span_id as "parentSpanId",
1538
+ job_id as "jobId",
1539
+ step_id as "stepId",
1540
+ name,
1541
+ kind,
1542
+ start_time_unix_nano as "startTimeUnixNano",
1543
+ end_time_unix_nano as "endTimeUnixNano",
1544
+ status_code as "statusCode",
1545
+ status_message as "statusMessage",
1546
+ attributes,
1547
+ events
1548
+ FROM span_tree
1549
+ ORDER BY ${sql.identifier(sortField)} ${sql.raw(sortOrder)}
1550
+ `
1551
+
1552
+ // Raw SQL returns numeric types as strings, so we type them as such
1553
+ const rows = (await this.db.execute(query)) as unknown as Array<{
1554
+ id: string | number
1555
+ traceId: string
1556
+ spanId: string
1557
+ parentSpanId: string | null
1558
+ jobId: string | null
1559
+ stepId: string | null
1560
+ name: string
1561
+ kind: string | number
1562
+ startTimeUnixNano: string | bigint | null
1563
+ endTimeUnixNano: string | bigint | null
1564
+ statusCode: string | number
1565
+ statusMessage: string | null
1566
+ attributes: Record<string, any>
1567
+ events: Array<{ name: string; timeUnixNano: string; attributes?: Record<string, any> }>
1568
+ }>
1569
+
1570
+ // Convert types: raw SQL returns numeric types as strings
1571
+ const spans = rows.map((row) => ({
1572
+ ...row,
1573
+ // Convert id to number (bigserial comes as string from raw SQL)
1574
+ id: typeof row.id === 'string' ? Number.parseInt(row.id, 10) : row.id,
1575
+ // Convert kind and statusCode to proper types
1576
+ kind: (typeof row.kind === 'string' ? Number.parseInt(row.kind, 10) : row.kind) as 0 | 1 | 2 | 3 | 4,
1577
+ statusCode: (typeof row.statusCode === 'string' ? Number.parseInt(row.statusCode, 10) : row.statusCode) as
1578
+ | 0
1579
+ | 1
1580
+ | 2,
1581
+ // Convert BigInt to string for JSON serialization
1582
+ startTimeUnixNano: row.startTimeUnixNano?.toString() ?? null,
1583
+ endTimeUnixNano: row.endTimeUnixNano?.toString() ?? null,
1584
+ }))
1585
+
1586
+ return {
1587
+ spans,
1588
+ total: spans.length,
1589
+ }
1590
+ }
1591
+
1486
1592
  /**
1487
1593
  * Internal method to delete all spans for a job.
1488
1594
  */
@@ -1496,12 +1602,15 @@ export class PostgresBaseAdapter<Database extends DrizzleDatabase, Connection> e
1496
1602
  }
1497
1603
 
1498
1604
  /**
1499
- * Build WHERE clause for spans queries.
1500
- * When querying by jobId or stepId, we find all spans that share the same trace_id
1501
- * as spans with that job/step. This includes spans from external libraries that
1605
+ * Build WHERE clause for spans queries (used for job queries only).
1606
+ * When querying by jobId, we find all spans that share the same trace_id
1607
+ * as spans with that job. This includes spans from external libraries that
1502
1608
  * don't have the duron.job.id attribute but are part of the same trace.
1609
+ *
1610
+ * Note: Step queries are handled separately by _getStepSpansRecursive using
1611
+ * a recursive CTE to traverse the span hierarchy.
1503
1612
  */
1504
- protected _buildSpansWhereClause(jobId?: string, stepId?: string, filters?: GetSpansOptions['filters']) {
1613
+ protected _buildSpansWhereClause(jobId?: string, _stepId?: string, filters?: GetSpansOptions['filters']) {
1505
1614
  const spansTable = this.tables.spansTable
1506
1615
 
1507
1616
  // Build condition for finding spans by trace_id (includes external spans)
@@ -1514,12 +1623,6 @@ export class PostgresBaseAdapter<Database extends DrizzleDatabase, Connection> e
1514
1623
  spansTable.trace_id,
1515
1624
  this.db.select({ traceId: spansTable.trace_id }).from(spansTable).where(eq(spansTable.job_id, jobId)),
1516
1625
  )
1517
- } else if (stepId) {
1518
- // Find all spans that share a trace_id with any span that has this step_id
1519
- traceCondition = inArray(
1520
- spansTable.trace_id,
1521
- this.db.select({ traceId: spansTable.trace_id }).from(spansTable).where(eq(spansTable.step_id, stepId)),
1522
- )
1523
1626
  }
1524
1627
 
1525
1628
  return and(
@@ -37,6 +37,7 @@ export default function createSchema(schemaName: string) {
37
37
  finished_at: timestamp('finished_at', { withTimezone: true }),
38
38
  client_id: text('client_id'),
39
39
  concurrency_limit: integer('concurrency_limit').notNull().default(10),
40
+ concurrency_step_limit: integer('concurrency_step_limit').notNull().default(100),
40
41
  created_at: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
41
42
  updated_at: timestamp('updated_at', { withTimezone: true })
42
43
  .notNull()
@@ -59,6 +60,7 @@ export default function createSchema(schemaName: string) {
59
60
  index('idx_jobs_client_id').on(table.client_id),
60
61
  index('idx_jobs_checksum').on(table.checksum),
61
62
  index('idx_jobs_concurrency_limit').on(table.concurrency_limit),
63
+ index('idx_jobs_concurrency_step_limit').on(table.concurrency_step_limit),
62
64
  // Composite indexes
63
65
  index('idx_jobs_action_status').on(table.action_name, table.status),
64
66
  index('idx_jobs_action_group').on(table.action_name, table.group_key),
@@ -45,6 +45,7 @@ export const JobSchema = z.object({
45
45
  createdAt: DateSchema,
46
46
  updatedAt: DateSchema,
47
47
  concurrencyLimit: z.coerce.number(),
48
+ concurrencyStepLimit: z.coerce.number(),
48
49
  clientId: z.string().nullable().optional(),
49
50
  /** Duration in milliseconds (finishedAt - startedAt). Null if job hasn't finished. */
50
51
  durationMs: z.coerce.number().nullable().default(null),
@@ -146,6 +147,8 @@ export const CreateJobOptionsSchema = z.object({
146
147
  timeoutMs: z.number(),
147
148
  /** The concurrency limit for this job's group */
148
149
  concurrencyLimit: z.number(),
150
+ /** The concurrency limit for steps within this job */
151
+ concurrencyStepLimit: z.number(),
149
152
  })
150
153
 
151
154
  export const RecoverJobsOptionsSchema = z.object({
package/src/client.ts CHANGED
@@ -130,93 +130,145 @@ export interface TelemetryOptions {
130
130
  serviceName?: string
131
131
  }
132
132
 
133
- const BaseOptionsSchema = z.object({
133
+ /**
134
+ * Base configuration options for a Duron client instance.
135
+ * These options control job fetching, concurrency, and recovery behavior.
136
+ */
137
+ export interface BaseOptionsInput {
134
138
  /**
135
139
  * Unique identifier for this Duron instance.
136
140
  * Used for multi-process coordination and job ownership.
137
- * Defaults to a random UUID if not provided.
141
+ * If not provided, a random UUID will be generated.
142
+ *
143
+ * @example 'worker-1', 'api-server', 'background-processor'
138
144
  */
139
- id: z.string().optional(),
145
+ id?: string
140
146
 
141
147
  /**
142
- * Synchronization pattern for fetching jobs.
143
- * - `'pull'`: Periodically poll the database for new jobs
144
- * - `'push'`: Listen for database notifications when jobs are available
145
- * - `'hybrid'`: Use both pull and push patterns (recommended)
146
- * - `false`: Disable automatic job fetching (manual fetching only)
148
+ * Synchronization pattern for fetching jobs from the database.
149
+ *
150
+ * - `'pull'`: Periodically poll the database for new jobs at `pullInterval`
151
+ * - `'push'`: Listen for database notifications when jobs are available (real-time)
152
+ * - `'hybrid'`: Use both pull and push patterns (recommended for reliability)
153
+ * - `false`: Disable automatic job fetching (use `fetch()` manually)
147
154
  *
148
155
  * @default 'hybrid'
156
+ *
157
+ * @example
158
+ * ```typescript
159
+ * // Real-time job processing with fallback polling
160
+ * syncPattern: 'hybrid'
161
+ *
162
+ * // Disable auto-fetching for API-only servers
163
+ * syncPattern: false
164
+ * ```
149
165
  */
150
- syncPattern: z.union([z.literal('pull'), z.literal('push'), z.literal('hybrid'), z.literal(false)]).default('hybrid'),
166
+ syncPattern?: 'pull' | 'push' | 'hybrid' | false
151
167
 
152
168
  /**
153
- * Interval in milliseconds between pull operations when using pull or hybrid sync pattern.
169
+ * Interval in milliseconds between pull operations when using `'pull'` or `'hybrid'` sync pattern.
170
+ * Lower values mean faster job pickup but more database queries.
154
171
  *
155
172
  * @default 5000
156
173
  */
157
- pullInterval: z.number().default(5_000),
174
+ pullInterval?: number
158
175
 
159
176
  /**
160
- * Maximum number of jobs to fetch in a single batch.
177
+ * Maximum number of jobs to fetch in a single batch from the database.
178
+ * Higher values reduce database round-trips but may increase memory usage.
161
179
  *
162
180
  * @default 10
163
181
  */
164
- batchSize: z.number().default(10),
182
+ batchSize?: number
165
183
 
166
184
  /**
167
185
  * Maximum number of jobs that can run concurrently per action.
168
- * This controls the concurrency limit for the action's fastq queue.
186
+ * This controls the concurrency limit for each action's internal queue.
187
+ * Use this to prevent any single action from consuming all resources.
169
188
  *
170
189
  * @default 100
171
190
  */
172
- actionConcurrencyLimit: z.number().default(100),
191
+ actionConcurrencyLimit?: number
173
192
 
174
193
  /**
175
194
  * Maximum number of jobs that can run concurrently per group key.
176
195
  * Jobs with the same group key will respect this limit.
177
- * This can be overridden using action -> groups -> concurrency.
196
+ * This is the default value; it can be overridden per-job using `action.groups.concurrency`.
178
197
  *
179
198
  * @default 10
199
+ *
200
+ * @example
201
+ * ```typescript
202
+ * // Limit concurrent jobs per user to 2
203
+ * groupConcurrencyLimit: 2
204
+ * ```
180
205
  */
181
- groupConcurrencyLimit: z.number().default(10),
206
+ groupConcurrencyLimit?: number
182
207
 
183
208
  /**
184
209
  * Whether to run database migrations on startup.
185
210
  * When enabled, Duron will automatically apply pending migrations when the adapter starts.
211
+ * Disable this if you manage migrations separately or use a read-only database connection.
186
212
  *
187
213
  * @default true
188
214
  */
189
- migrateOnStart: z.boolean().default(true),
215
+ migrateOnStart?: boolean
190
216
 
191
217
  /**
192
218
  * Whether to recover stuck jobs on startup.
193
219
  * Stuck jobs are jobs that were marked as active but the process that owned them
194
- * is no longer running.
220
+ * is no longer running (e.g., after a crash or restart).
221
+ * These jobs will be reset to 'created' status so they can be picked up again.
195
222
  *
196
223
  * @default true
197
224
  */
198
- recoverJobsOnStart: z.boolean().default(true),
225
+ recoverJobsOnStart?: boolean
199
226
 
200
227
  /**
201
228
  * Enable multi-process mode for job recovery.
202
229
  * When enabled, Duron will ping other processes to check if they're alive
203
- * before recovering their jobs.
230
+ * before recovering their jobs. This prevents recovering jobs from processes
231
+ * that are still running but slow to respond.
232
+ *
233
+ * Only enable this if you're running multiple Duron instances sharing the same database.
204
234
  *
205
235
  * @default false
206
236
  */
207
- multiProcessMode: z.boolean().default(false),
237
+ multiProcessMode?: boolean
208
238
 
209
239
  /**
210
240
  * Timeout in milliseconds to wait for process ping responses in multi-process mode.
211
241
  * Processes that don't respond within this timeout will have their jobs recovered.
242
+ * Increase this value if your processes may be temporarily unresponsive under load.
212
243
  *
213
- * @default 5000 (5 seconds)
244
+ * @default 5000
214
245
  */
215
- processTimeout: z.number().default(5 * 1000), // 5 seconds
246
+ processTimeout?: number
247
+ }
248
+
249
+ const BaseOptionsSchema = z.object({
250
+ id: z.string().optional(),
251
+ syncPattern: z.union([z.literal('pull'), z.literal('push'), z.literal('hybrid'), z.literal(false)]).default('hybrid'),
252
+ pullInterval: z.number().default(5_000),
253
+ batchSize: z.number().default(10),
254
+ actionConcurrencyLimit: z.number().default(100),
255
+ groupConcurrencyLimit: z.number().default(10),
256
+ migrateOnStart: z.boolean().default(true),
257
+ recoverJobsOnStart: z.boolean().default(true),
258
+ multiProcessMode: z.boolean().default(false),
259
+ processTimeout: z.number().default(5 * 1000),
216
260
  })
217
261
 
262
+ // Compile-time check: ensure BaseOptionsInput is assignable to the Zod schema's input type
263
+ type _EnsureBaseOptionsCompatible = BaseOptionsInput extends z.input<typeof BaseOptionsSchema>
264
+ ? true
265
+ : 'ERROR: BaseOptionsInput does not match Zod schema input type'
266
+
267
+ declare const _baseOptionsCheck: _EnsureBaseOptionsCompatible
268
+ const _checkOptions: _EnsureBaseOptionsCompatible = true
269
+
218
270
  /**
219
- * Options for configuring a Duron instance.
271
+ * Options for configuring a Duron client instance.
220
272
  *
221
273
  * @template TActions - Record of action definitions keyed by action name
222
274
  * @template TVariables - Type of variables available to actions
@@ -224,7 +276,7 @@ const BaseOptionsSchema = z.object({
224
276
  export interface ClientOptions<
225
277
  TActions extends Record<string, Action<any, any, TVariables>>,
226
278
  TVariables = Record<string, unknown>,
227
- > extends z.input<typeof BaseOptionsSchema> {
279
+ > extends BaseOptionsInput {
228
280
  /**
229
281
  * The database adapter to use for storing jobs and steps.
230
282
  * Required.
@@ -510,6 +562,7 @@ export class Client<
510
562
  timeoutMs: action.expire,
511
563
  checksum: action.checksum,
512
564
  concurrencyLimit,
565
+ concurrencyStepLimit: action.steps.concurrency,
513
566
  })
514
567
 
515
568
  if (!jobId) {