@structure-ai/jobs 0.0.12 → 0.0.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.
package/README.md CHANGED
@@ -52,7 +52,7 @@ Payloads are `Schema<P, string>` (usually `Schema.parseJson(...)`) — stored as
52
52
 
53
53
  ## Worker lifecycle and roles
54
54
 
55
- `workerLayer({ role })` follows the platform's `SERVICE_ROLE` convention: `api` processes only schedule (the layer logs and does nothing), `worker`/`all` fork the dispatch loop. The loop registers a `Shutdown` finalizer: on shutdown it stops claiming, waits for in-flight handlers (bounded by the coordinator's finalizer timeout), then exits — no in-flight job is killed. `jobsSettings` (`@structure-ai/config`) maps `SERVICE_ROLE`, `JOBS_POLL_INTERVAL`, `JOBS_BATCH_SIZE`, `JOBS_LEASE`, `JOBS_TABLE_PREFIX`.
55
+ `workerLayer({ role })` follows the platform's `SERVICE_ROLE` convention: `api` processes only schedule (the layer logs and does nothing), `worker`/`all` fork the dispatch loop. The loop runs at most `concurrency` handlers at once (default `batchSize`; a semaphore enforces it) and claims only as many rows as it has free slots, waking when a slot frees rather than polling, so a backlog never turns into an unbounded fan-out. Every claim mints a `lease_owner` token, and every completion, retry, dead-letter and heartbeat is fenced on it (`WHERE id = ? AND status = 'running' AND lease_owner = ?`): a worker whose lease another worker reclaimed affects zero rows and logs `job lease lost; write skipped` instead of destroying the row the other worker is running (the fence is the token, not the expiry, so a late completion nobody reclaimed still lands). It registers a `Shutdown` finalizer: on shutdown it stops claiming and waits for in-flight handlers, up to `drainTimeout` when set (keep it below the coordinator's finalizer timeout) or until the coordinator cuts the finalizer. A drain that gives up either way is loud and reversible: it logs `jobs worker abandoned drain` at error level with the job ids and returns every in-flight job to the queue (status `queued`, lease released, claimable at once), and the abandoned handlers' late writes are fenced out; no handler is interrupted. `jobsSettings` (`@structure-ai/config`) maps `SERVICE_ROLE`, `JOBS_POLL_INTERVAL`, `JOBS_BATCH_SIZE`, `JOBS_CONCURRENCY`, `JOBS_LEASE`, `JOBS_DRAIN_TIMEOUT`, `JOBS_TABLE_PREFIX`.
56
56
 
57
57
  ## Observability
58
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structure-ai/jobs",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "description": "Delayed and recurring jobs on PostgreSQL: named schema-typed handlers, SKIP LOCKED dispatch with heartbeat leases, cron scheduling with timezones, at-least-once delivery, dead letters, graceful drain, per-job metrics.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,9 +24,9 @@
24
24
  "test": "bun test"
25
25
  },
26
26
  "dependencies": {
27
- "@structure-ai/config": "0.0.12",
28
- "@structure-ai/observability": "0.0.12",
29
- "@structure-ai/runtime": "0.0.12",
27
+ "@structure-ai/config": "0.0.14",
28
+ "@structure-ai/observability": "0.0.14",
29
+ "@structure-ai/runtime": "0.0.14",
30
30
  "@effect/sql": "^0.52.1",
31
31
  "@effect/sql-pg": "^0.53.0",
32
32
  "effect": "^3.22.1"
package/src/scheduler.ts CHANGED
@@ -1,14 +1,17 @@
1
1
  import * as SqlClient from "@effect/sql/SqlClient";
2
+ import type * as Statement from "@effect/sql/Statement";
2
3
  import { Correlation, Metrics } from "@structure-ai/observability";
3
4
  import { Shutdown } from "@structure-ai/runtime";
4
5
  import {
5
6
  Context,
6
7
  Data,
8
+ Deferred,
7
9
  Duration,
8
10
  Effect,
9
11
  Fiber,
10
12
  Layer,
11
13
  Metric,
14
+ Option,
12
15
  Schema as S,
13
16
  Schedule,
14
17
  } from "effect";
@@ -123,10 +126,25 @@ export interface RecurOptions {
123
126
  export interface WorkerOptions {
124
127
  /** Idle poll interval. Default 1s. */
125
128
  readonly pollInterval?: Duration.DurationInput;
126
- /** Claims per poll. Default 10. */
129
+ /** Rows claimed per poll, never more than the free concurrency. Default 10. */
127
130
  readonly batchSize?: number;
131
+ /**
132
+ * Ceiling on handlers executing at once in this worker, enforced by a
133
+ * semaphore; the claim loop never takes more rows than it has free slots
134
+ * and waits for a slot instead of polling when full. Default `batchSize`.
135
+ */
136
+ readonly concurrency?: number;
128
137
  /** Lease held while a handler runs; expiry makes the row reclaimable. Default 60s. */
129
138
  readonly lease?: Duration.DurationInput;
139
+ /**
140
+ * How long the shutdown drain waits for in-flight handlers before giving
141
+ * up. Keep it below the coordinator's finalizer timeout so the worker,
142
+ * not the coordinator, decides. On overrun (or when the coordinator cuts
143
+ * the drain first) every job still in flight is returned to the queue:
144
+ * lease released, claimable at once, logged by id at error level; its
145
+ * late completion is fenced out. Default: wait until the coordinator cuts.
146
+ */
147
+ readonly drainTimeout?: Duration.DurationInput;
130
148
  /**
131
149
  * Role-aware boot: `api` runs no worker (scheduling only), `worker` and
132
150
  * `all` do. Default `all`.
@@ -144,6 +162,8 @@ interface QueueRow {
144
162
  readonly cron_timezone: string | null;
145
163
  readonly correlation_id: string | null;
146
164
  readonly run_at: Date | string;
165
+ /** The token minted by the claim that produced this row; the fence for every write to it. */
166
+ readonly lease_owner: string;
147
167
  }
148
168
 
149
169
  export interface SchedulerService {
@@ -168,7 +188,8 @@ export interface SchedulerService {
168
188
  readonly registeredJobs: () => ReadonlyArray<string>;
169
189
  /**
170
190
  * Runs the worker loop until the Shutdown coordinator triggers, then
171
- * drains: no in-flight job is interrupted. Claims with
191
+ * drains: no in-flight job is interrupted, and a drain that outlives its
192
+ * budget returns the in-flight jobs to the queue. Claims with
172
193
  * `FOR UPDATE SKIP LOCKED`, heartbeats its lease, retries transient
173
194
  * failures with jittered backoff, and dead-letters permanent failures and
174
195
  * exhausted attempts.
@@ -232,16 +253,58 @@ export const makeScheduler = (
232
253
 
233
254
  const maxAttemptsFor = (jobName: string): number => handlers.get(jobName)?.maxAttempts ?? 5;
234
255
 
235
- const deadLetter = (row: QueueRow, reason: string): Effect.Effect<void, JobQueueError> =>
256
+ /**
257
+ * Runs a terminal write scoped to the row AND the lease this worker
258
+ * holds on it. A worker whose lease was reclaimed by another affects
259
+ * zero rows and logs that it lost the lease instead of destroying the
260
+ * row the other worker is running. The fence is the owner token, not
261
+ * the lease expiry: a late completion whose lease expired but was not
262
+ * reclaimed still lands, which is a job done once rather than twice.
263
+ */
264
+ const fenced = (
265
+ row: QueueRow,
266
+ operation: string,
267
+ statement: Statement.Fragment,
268
+ ): Effect.Effect<boolean, unknown> =>
236
269
  Effect.gen(function* () {
237
- yield* sql`
238
- INSERT INTO ${sql(tables.deadLetters)}
239
- (id, job_name, payload, attempts, last_error, correlation_id, dead_at)
240
- VALUES
241
- (${crypto.randomUUID()}, ${row.job_name}, ${row.payload}, ${row.attempt},
242
- ${reason.slice(0, 2_048)}, ${row.correlation_id}, ${now().toISOString()})
270
+ const affected = yield* sql<{ readonly id: string }>`
271
+ ${statement}
272
+ WHERE id = ${row.id} AND status = 'running' AND lease_owner = ${row.lease_owner}
273
+ RETURNING id
243
274
  `;
244
- yield* sql`DELETE FROM ${sql(tables.queue)} WHERE id = ${row.id}`;
275
+ if (affected.length > 0) return true;
276
+ yield* Effect.logWarning("job lease lost; write skipped").pipe(
277
+ Effect.annotateLogs({
278
+ jobId: row.id,
279
+ jobName: row.job_name,
280
+ jobAttempt: row.attempt,
281
+ jobOperation: operation,
282
+ }),
283
+ );
284
+ return false;
285
+ });
286
+
287
+ const deadLetter = (row: QueueRow, reason: string): Effect.Effect<void, JobQueueError> =>
288
+ Effect.gen(function* () {
289
+ // The fence comes first, in one transaction with the dead-letter
290
+ // insert: a worker that lost its lease must not file a dead letter
291
+ // for a job another worker is running, and a job whose queue row is
292
+ // gone must always have its dead letter.
293
+ const owned = yield* sql.withTransaction(
294
+ Effect.gen(function* () {
295
+ const owned = yield* fenced(row, "dead-letter", sql`DELETE FROM ${sql(tables.queue)}`);
296
+ if (!owned) return false;
297
+ yield* sql`
298
+ INSERT INTO ${sql(tables.deadLetters)}
299
+ (id, job_name, payload, attempts, last_error, correlation_id, dead_at)
300
+ VALUES
301
+ (${crypto.randomUUID()}, ${row.job_name}, ${row.payload}, ${row.attempt},
302
+ ${reason.slice(0, 2_048)}, ${row.correlation_id}, ${now().toISOString()})
303
+ `;
304
+ return true;
305
+ }),
306
+ );
307
+ if (!owned) return;
245
308
  yield* Metric.increment(deadLettered);
246
309
  yield* Effect.logError("job dead-lettered").pipe(
247
310
  Effect.annotateLogs({
@@ -256,7 +319,7 @@ export const makeScheduler = (
256
319
  const completeSuccess = (row: QueueRow): Effect.Effect<void, JobQueueError> =>
257
320
  Effect.gen(function* () {
258
321
  if (row.cron_expr === null) {
259
- yield* sql`DELETE FROM ${sql(tables.queue)} WHERE id = ${row.id}`;
322
+ yield* fenced(row, "complete-success", sql`DELETE FROM ${sql(tables.queue)}`);
260
323
  return;
261
324
  }
262
325
  const fields = yield* parseCron(row.cron_expr);
@@ -269,27 +332,34 @@ export const makeScheduler = (
269
332
  timezone,
270
333
  );
271
334
  if (next === undefined) {
272
- yield* sql`DELETE FROM ${sql(tables.queue)} WHERE id = ${row.id}`;
335
+ yield* fenced(row, "complete-success", sql`DELETE FROM ${sql(tables.queue)}`);
273
336
  return;
274
337
  }
275
- yield* sql`
276
- UPDATE ${sql(tables.queue)}
277
- SET status = 'queued', run_at = ${next.toISOString()}, attempt = 0,
278
- lease_expires_at = NULL, updated_at = ${now().toISOString()}
279
- WHERE id = ${row.id}
280
- `;
338
+ yield* fenced(
339
+ row,
340
+ "complete-success",
341
+ sql`
342
+ UPDATE ${sql(tables.queue)}
343
+ SET status = 'queued', run_at = ${next.toISOString()}, attempt = 0,
344
+ lease_expires_at = NULL, lease_owner = NULL, updated_at = ${now().toISOString()}
345
+ `,
346
+ );
281
347
  }).pipe(Effect.mapError((cause) => queueError("complete-success", cause)));
282
348
 
283
349
  const rescheduleRetry = (row: QueueRow, reason: string): Effect.Effect<void, JobQueueError> =>
284
350
  Effect.gen(function* () {
285
351
  const backoff = backoffMillis(row.attempt, random);
286
- yield* sql`
287
- UPDATE ${sql(tables.queue)}
288
- SET status = 'queued', run_at = ${new Date(now().getTime() + backoff).toISOString()},
289
- lease_expires_at = NULL, last_error = ${reason.slice(0, 2_048)},
290
- updated_at = ${now().toISOString()}
291
- WHERE id = ${row.id}
292
- `;
352
+ const owned = yield* fenced(
353
+ row,
354
+ "reschedule-retry",
355
+ sql`
356
+ UPDATE ${sql(tables.queue)}
357
+ SET status = 'queued', run_at = ${new Date(now().getTime() + backoff).toISOString()},
358
+ lease_expires_at = NULL, lease_owner = NULL, last_error = ${reason.slice(0, 2_048)},
359
+ updated_at = ${now().toISOString()}
360
+ `,
361
+ );
362
+ if (!owned) return;
293
363
  yield* Metric.increment(retried);
294
364
  yield* Effect.logWarning("job attempt failed, retrying with backoff").pipe(
295
365
  Effect.annotateLogs({
@@ -308,6 +378,7 @@ export const makeScheduler = (
308
378
  ): Effect.Effect<ReadonlyArray<QueueRow>, JobQueueError> => {
309
379
  const timestamp = now();
310
380
  const leaseUntil = new Date(timestamp.getTime() + leaseMillis);
381
+ const owner = crypto.randomUUID();
311
382
  return sql<QueueRow>`
312
383
  WITH picked AS (
313
384
  SELECT id FROM ${sql(tables.queue)}
@@ -320,12 +391,13 @@ export const makeScheduler = (
320
391
  UPDATE ${sql(tables.queue)} queue
321
392
  SET status = 'running', attempt = queue.attempt + 1,
322
393
  lease_expires_at = ${leaseUntil.toISOString()},
394
+ lease_owner = ${owner},
323
395
  updated_at = ${timestamp.toISOString()}
324
396
  FROM picked
325
397
  WHERE queue.id = picked.id
326
398
  RETURNING queue.id, queue.job_name, queue.payload, queue.attempt,
327
399
  queue.max_attempts, queue.cron_expr, queue.cron_timezone,
328
- queue.correlation_id, queue.run_at
400
+ queue.correlation_id, queue.run_at, queue.lease_owner
329
401
  `.pipe(
330
402
  Effect.mapError((cause) => queueError("claim", cause)),
331
403
  Effect.tap((rows) =>
@@ -349,10 +421,12 @@ export const makeScheduler = (
349
421
 
350
422
  const heartbeat: Effect.Effect<void> = Effect.gen(function* () {
351
423
  const until = new Date(now().getTime() + leaseMillis);
424
+ // Fenced like every other write: an evicted worker must not keep
425
+ // extending the lease another worker now owns.
352
426
  yield* sql`
353
427
  UPDATE ${sql(tables.queue)}
354
428
  SET lease_expires_at = ${until.toISOString()}
355
- WHERE id = ${row.id} AND status = 'running'
429
+ WHERE id = ${row.id} AND status = 'running' AND lease_owner = ${row.lease_owner}
356
430
  `.pipe(Effect.asVoid);
357
431
  }).pipe(
358
432
  Effect.asVoid,
@@ -427,15 +501,61 @@ export const makeScheduler = (
427
501
  workerOptions.pollInterval === undefined
428
502
  ? 1_000
429
503
  : Duration.toMillis(Duration.decode(workerOptions.pollInterval));
430
- const batchSize = workerOptions.batchSize ?? 10;
504
+ const batchSize = Math.max(1, workerOptions.batchSize ?? 10);
505
+ const concurrency = Math.max(1, workerOptions.concurrency ?? batchSize);
431
506
  const leaseMillis =
432
507
  workerOptions.lease === undefined
433
508
  ? 60_000
434
509
  : Duration.toMillis(Duration.decode(workerOptions.lease));
435
510
 
436
- const inflight = new Set<Fiber.RuntimeFiber<void, unknown>>();
511
+ const drainTimeout =
512
+ workerOptions.drainTimeout === undefined
513
+ ? undefined
514
+ : Duration.decode(workerOptions.drainTimeout);
515
+
516
+ const inflight = new Map<Fiber.RuntimeFiber<void, unknown>, QueueRow>();
517
+ // The bound itself: a handler runs only while holding one permit, so
518
+ // even a miscounted claim can never exceed `concurrency` executions.
519
+ const permits = yield* Effect.makeSemaphore(concurrency);
520
+ // Signalled whenever an in-flight fiber ends, so a full worker wakes
521
+ // as soon as a slot frees instead of sleeping a whole poll interval.
522
+ let slotFreed = yield* Deferred.make<void>();
437
523
  let stopRequested = false;
438
524
 
525
+ const awaitInflight: Effect.Effect<void> = Effect.whileLoop({
526
+ while: () => inflight.size > 0,
527
+ body: () => Effect.sleep("10 millis"),
528
+ step: () => undefined,
529
+ });
530
+
531
+ // The drain gave up (its own budget, or the coordinator's cut): return
532
+ // every in-flight job to the queue so another worker picks it up at
533
+ // once instead of after a full lease, and say which ones, loudly.
534
+ // The handlers keep running until the process ends; their writes are
535
+ // fenced out by the released lease.
536
+ const abandonDrain: Effect.Effect<void> = Effect.gen(function* () {
537
+ const rows = [...inflight.values()];
538
+ if (rows.length === 0) return;
539
+ yield* Effect.logError("jobs worker abandoned drain").pipe(
540
+ Effect.annotateLogs({
541
+ jobIds: rows.map((row) => row.id).join(","),
542
+ jobCount: rows.length,
543
+ }),
544
+ );
545
+ const timestamp = now().toISOString();
546
+ yield* Effect.forEach(
547
+ rows,
548
+ (row) =>
549
+ sql`
550
+ UPDATE ${sql(tables.queue)}
551
+ SET status = 'queued', run_at = ${timestamp}, lease_expires_at = NULL,
552
+ lease_owner = NULL, updated_at = ${timestamp}
553
+ WHERE id = ${row.id} AND status = 'running' AND lease_owner = ${row.lease_owner}
554
+ `.pipe(Effect.asVoid),
555
+ { discard: true },
556
+ );
557
+ }).pipe(Effect.catchAllCause(() => Effect.void));
558
+
439
559
  yield* shutdown.onShutdown(
440
560
  "jobs-worker",
441
561
  Effect.sync(() => {
@@ -443,12 +563,16 @@ export const makeScheduler = (
443
563
  }).pipe(
444
564
  Effect.zipRight(Effect.logInfo("jobs worker draining")),
445
565
  Effect.zipRight(
446
- Effect.whileLoop({
447
- while: () => inflight.size > 0,
448
- body: () => Effect.sleep("10 millis"),
449
- step: () => undefined,
450
- }),
566
+ drainTimeout === undefined
567
+ ? awaitInflight
568
+ : awaitInflight.pipe(
569
+ Effect.timeoutOption(drainTimeout),
570
+ Effect.flatMap((completed) =>
571
+ Option.isNone(completed) ? abandonDrain : Effect.void,
572
+ ),
573
+ ),
451
574
  ),
575
+ Effect.onInterrupt(() => abandonDrain),
452
576
  ),
453
577
  );
454
578
 
@@ -459,26 +583,36 @@ export const makeScheduler = (
459
583
  const shuttingDown = yield* shutdown.isShuttingDown;
460
584
  if (shuttingDown) stopRequested = true;
461
585
  if (stopRequested) return;
462
- const rows = yield* Effect.orDie(claim(batchSize, leaseMillis));
586
+ // Claim at most as many rows as there are free slots: a claimed
587
+ // row that could not start would sit on a ticking lease.
588
+ const waiter = slotFreed;
589
+ const capacity = Math.min(batchSize, concurrency - inflight.size);
590
+ if (capacity <= 0) {
591
+ yield* Deferred.await(waiter).pipe(Effect.timeout(pollMillis), Effect.ignore);
592
+ return;
593
+ }
594
+ const rows = yield* Effect.orDie(claim(capacity, leaseMillis));
463
595
  if (rows.length === 0) {
464
596
  yield* Effect.sleep(pollMillis);
465
597
  return;
466
598
  }
467
599
  for (const row of rows) {
468
- const fiber = yield* Effect.fork(execute(row, leaseMillis));
469
- inflight.add(fiber);
470
- void fiber.addObserver(() => inflight.delete(fiber));
600
+ const fiber = yield* Effect.fork(permits.withPermits(1)(execute(row, leaseMillis)));
601
+ inflight.set(fiber, row);
602
+ void fiber.addObserver(() => {
603
+ inflight.delete(fiber);
604
+ Deferred.unsafeDone(slotFreed, Effect.void);
605
+ });
606
+ }
607
+ if (inflight.size >= concurrency) {
608
+ slotFreed = yield* Deferred.make<void>();
471
609
  }
472
610
  }),
473
611
  step: () => undefined,
474
612
  });
475
613
  yield* loop;
476
614
  // Graceful drain: wait for in-flight handlers to finish.
477
- yield* Effect.whileLoop({
478
- while: () => inflight.size > 0,
479
- body: () => Effect.sleep("10 millis"),
480
- step: () => undefined,
481
- });
615
+ yield* awaitInflight;
482
616
  yield* Effect.logInfo("jobs worker drained").pipe(
483
617
  Effect.annotateLogs({ drained: inflight.size }),
484
618
  );
package/src/schema.ts CHANGED
@@ -23,7 +23,8 @@ export const tableNames = (options: AdapterOptions = {}): TableNames => {
23
23
  * Creates the jobs schema in one transaction, `@structure-ai/migrations`
24
24
  * style (idempotent DDL a designated migrator can run at boot). The queue
25
25
  * index covers the dispatch predicate (`status = queued AND run_at <= now`
26
- * plus lease-expiry reclaims).
26
+ * plus lease-expiry reclaims). `lease_owner` is the per-claim token every
27
+ * completion, retry, dead-letter and heartbeat is fenced on.
27
28
  */
28
29
  export const migrate = (
29
30
  options: AdapterOptions = {},
@@ -43,6 +44,7 @@ export const migrate = (
43
44
  attempt INTEGER NOT NULL DEFAULT 0,
44
45
  max_attempts INTEGER NOT NULL DEFAULT 5,
45
46
  lease_expires_at TIMESTAMPTZ,
47
+ lease_owner TEXT,
46
48
  last_error TEXT,
47
49
  correlation_id TEXT,
48
50
  causation_id TEXT,
@@ -50,6 +52,10 @@ export const migrate = (
50
52
  updated_at TIMESTAMPTZ NOT NULL
51
53
  )
52
54
  `;
55
+ // Instances created before the lease fence existed gain the owner column.
56
+ yield* sql`
57
+ ALTER TABLE ${sql(tables.queue)} ADD COLUMN IF NOT EXISTS lease_owner TEXT
58
+ `;
53
59
  yield* sql`
54
60
  CREATE INDEX IF NOT EXISTS ${sql(`${tables.queue}_dispatch_idx`)}
55
61
  ON ${sql(tables.queue)} (status, run_at)
package/src/settings.ts CHANGED
@@ -16,13 +16,24 @@ export const jobsSettings = Settings.struct({
16
16
  default: Duration.seconds(1),
17
17
  }),
18
18
  batchSize: Settings.int("JOBS_BATCH_SIZE", {
19
- description: "jobs claimed per poll",
19
+ description: "jobs claimed per poll (never more than the free concurrency)",
20
20
  default: 10,
21
21
  }),
22
+ concurrency: Settings.optional(
23
+ Settings.int("JOBS_CONCURRENCY", {
24
+ description: "ceiling on jobs executing at once per worker (default: JOBS_BATCH_SIZE)",
25
+ }),
26
+ ),
22
27
  lease: Settings.duration("JOBS_LEASE", {
23
28
  description: "dispatch lease duration before a running job becomes reclaimable",
24
29
  default: Duration.seconds(60),
25
30
  }),
31
+ drainTimeout: Settings.optional(
32
+ Settings.duration("JOBS_DRAIN_TIMEOUT", {
33
+ description:
34
+ "how long the shutdown drain waits for in-flight jobs before returning them to the queue (keep below the coordinator's finalizer timeout; default: until the coordinator cuts)",
35
+ }),
36
+ ),
26
37
  tablePrefix: Settings.string("JOBS_TABLE_PREFIX", {
27
38
  description: "jobs table name prefix",
28
39
  default: "jobs_",