@structure-ai/jobs 0.1.1 → 0.2.1

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
@@ -60,8 +60,12 @@ Per-job metrics under bounded, handler-derived names: `job_<name>_calls_total` /
60
60
 
61
61
  ## Errors
62
62
 
63
- `UnknownJob`, `InvalidJobPayload` (permanent), `JobQueueError` (transient), `InvalidCronExpression` (permanent, lists every problem). All classified per the framework taxonomy.
63
+ `UnknownJob`, `InvalidJobPayload` (permanent), `JobQueueError` (transient), `InvalidCronExpression` (permanent, lists every problem). All classified per the framework taxonomy. `migrate` exposes `SqlError`. `runWorker` exposes `WorkerError`: queue errors, plus handler failures retained in compound causes.
64
64
 
65
65
  ## Schema
66
66
 
67
67
  Two tables created by the idempotent `migrate` (own prefix, `@structure-ai/migrations`-compatible DDL): `jobs_queue` (status `queued|running`, `run_at`, `cron_expr`, `cron_timezone`, `attempt`, `max_attempts`, `lease_expires_at`, `last_error`, correlation fields) with dispatch and lease indexes, and `jobs_dead_letters`.
68
+
69
+ Only a single expected handler failure participates in job retry/dead-letter policy. A handler defect or cancellation, including one combined with an expected failure, stops the worker with its cause intact. Claim, completion and heartbeat failures also stop the worker. A failed heartbeat interrupts its handler because the lease can no longer be maintained. Rows remain reclaimable after lease expiry.
70
+
71
+ `workerLayer` logs unexpected worker termination and registers a `jobs-worker` readiness check when a `Readiness` service is available. A stopped worker reports not ready. Failed lease release during shutdown is logged; rows remain reclaimable after expiry.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structure-ai/jobs",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
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,15 +24,16 @@
24
24
  "test": "bun test"
25
25
  },
26
26
  "dependencies": {
27
- "@structure-ai/config": "0.1.1",
28
- "@structure-ai/observability": "0.1.1",
29
- "@structure-ai/runtime": "0.1.1",
27
+ "@structure-ai/config": "0.2.1",
28
+ "@structure-ai/observability": "0.2.1",
29
+ "@structure-ai/runtime": "0.2.1",
30
30
  "@effect/sql": "^0.52.1",
31
31
  "@effect/sql-pg": "^0.53.0",
32
32
  "effect": "^3.22.1"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/bun": "^1.3.14",
36
- "typescript": "^5.9.2"
36
+ "typescript": "^5.9.2",
37
+ "@effect/experimental": "^0.61.0"
37
38
  }
38
39
  }
package/src/index.ts CHANGED
@@ -34,6 +34,7 @@ export {
34
34
  type SchedulerService,
35
35
  schedulerLayer,
36
36
  UnknownJob,
37
+ type WorkerError,
37
38
  type WorkerOptions,
38
39
  } from "./scheduler.js";
39
40
  export { type AdapterOptions, migrate, type TableNames, tableNames } from "./schema.js";
package/src/layer.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type * as SqlClient from "@effect/sql/SqlClient";
2
2
  import type { SqlError } from "@effect/sql/SqlError";
3
3
  import { PgClient } from "@effect/sql-pg";
4
- import type { Shutdown } from "@structure-ai/runtime";
5
- import { Effect, Layer, Redacted } from "effect";
4
+ import { Readiness, type Shutdown } from "@structure-ai/runtime";
5
+ import { Cause, Effect, Layer, Option, Redacted, Ref } from "effect";
6
6
  import {
7
7
  Scheduler,
8
8
  type SchedulerOptions,
@@ -64,6 +64,20 @@ export const workerLayer = (
64
64
  );
65
65
  return;
66
66
  }
67
- yield* Effect.forkScoped(scheduler.runWorker(options));
67
+ const readiness = yield* Effect.serviceOption(Readiness);
68
+ const running = yield* Ref.make(true);
69
+ if (Option.isSome(readiness)) {
70
+ yield* readiness.value.register({ name: "jobs-worker", run: Ref.get(running) });
71
+ }
72
+ yield* Effect.forkScoped(
73
+ scheduler.runWorker(options).pipe(
74
+ Effect.tapErrorCause((cause) =>
75
+ Cause.isInterruptedOnly(cause)
76
+ ? Effect.void
77
+ : Effect.logError("jobs worker stopped after failure", cause),
78
+ ),
79
+ Effect.ensuring(Ref.set(running, false)),
80
+ ),
81
+ );
68
82
  }),
69
83
  );
package/src/scheduler.ts CHANGED
@@ -3,12 +3,14 @@ import type * as Statement from "@effect/sql/Statement";
3
3
  import { Correlation, Metrics } from "@structure-ai/observability";
4
4
  import { Shutdown } from "@structure-ai/runtime";
5
5
  import {
6
+ Cause,
6
7
  Context,
7
8
  Data,
8
9
  Deferred,
9
10
  Duration,
10
11
  Effect,
11
- Fiber,
12
+ Exit,
13
+ type Fiber,
12
14
  Layer,
13
15
  Metric,
14
16
  Option,
@@ -58,6 +60,9 @@ export interface JobFailure {
58
60
  readonly classification: "transient" | "permanent";
59
61
  }
60
62
 
63
+ /** Worker failures include handler failures only when part of an unhandled compound cause. */
64
+ export type WorkerError = JobQueueError | JobFailure;
65
+
61
66
  // --- definitions ------------------------------------------------------------------
62
67
 
63
68
  /** Minimal reference for scheduling: a name plus its payload codec schema. */
@@ -194,7 +199,7 @@ export interface SchedulerService {
194
199
  * failures with jittered backoff, and dead-letters permanent failures and
195
200
  * exhausted attempts.
196
201
  */
197
- readonly runWorker: (options?: WorkerOptions) => Effect.Effect<void, never, Shutdown>;
202
+ readonly runWorker: (options?: WorkerOptions) => Effect.Effect<void, WorkerError, Shutdown>;
198
203
  }
199
204
 
200
205
  export class Scheduler extends Context.Tag("@structure-ai/jobs/Scheduler")<
@@ -406,7 +411,7 @@ export const makeScheduler = (
406
411
  );
407
412
  };
408
413
 
409
- const execute = (row: QueueRow, leaseMillis: number): Effect.Effect<void> => {
414
+ const execute = (row: QueueRow, leaseMillis: number): Effect.Effect<void, WorkerError> => {
410
415
  const handler: StoredJobHandler | undefined = handlers.get(row.job_name);
411
416
  const context: JobContext = {
412
417
  jobId: row.id,
@@ -419,7 +424,7 @@ export const makeScheduler = (
419
424
  causationId: row.id,
420
425
  });
421
426
 
422
- const heartbeat: Effect.Effect<void> = Effect.gen(function* () {
427
+ const heartbeat: Effect.Effect<void, JobQueueError> = Effect.gen(function* () {
423
428
  const until = new Date(now().getTime() + leaseMillis);
424
429
  // Fenced like every other write: an evicted worker must not keep
425
430
  // extending the lease another worker now owns.
@@ -432,35 +437,34 @@ export const makeScheduler = (
432
437
  Effect.asVoid,
433
438
  Effect.repeat(Schedule.spaced(`${Math.max(1, Math.floor(leaseMillis / 3))} millis`)),
434
439
  Effect.asVoid,
435
- Effect.catchAllCause(() => Effect.void),
440
+ Effect.mapError((cause) => queueError("heartbeat", cause)),
436
441
  );
437
442
 
438
- const runOutcome: Effect.Effect<void> = Effect.gen(function* () {
443
+ const runOutcome: Effect.Effect<void, WorkerError> = Effect.gen(function* () {
439
444
  if (handler === undefined) {
440
445
  yield* Effect.logError("job dispatched with no registered handler").pipe(
441
446
  Effect.annotateLogs({ jobId: row.id, jobName: row.job_name }),
442
447
  );
443
- yield* deadLetter(row, "unknown-job").pipe(Effect.orDie);
448
+ yield* deadLetter(row, "unknown-job");
444
449
  return;
445
450
  }
446
451
  const decoded = yield* S.decodeUnknown(handler.payloadSchema)(row.payload).pipe(
447
452
  Effect.either,
448
453
  );
449
454
  if (decoded._tag === "Left") {
450
- yield* deadLetter(row, `invalid-payload: ${String(decoded.left).slice(0, 128)}`).pipe(
451
- Effect.orDie,
452
- );
455
+ yield* deadLetter(row, `invalid-payload: ${String(decoded.left).slice(0, 128)}`);
453
456
  return;
454
457
  }
455
458
  const failure = yield* handler
456
459
  .handle(decoded.right, context)
457
- .pipe(Metrics.track(`job_${row.job_name}`, boundaryFor(row.job_name)), Effect.either);
458
- if (failure._tag === "Right") {
460
+ .pipe(Metrics.track(`job_${row.job_name}`, boundaryFor(row.job_name)), Effect.exit);
461
+ if (Exit.isSuccess(failure)) {
459
462
  yield* Metric.increment(succeeded);
460
- yield* completeSuccess(row).pipe(Effect.orDie);
463
+ yield* completeSuccess(row);
461
464
  return;
462
465
  }
463
- const error = failure.left;
466
+ if (!Cause.isFailType(failure.cause)) return yield* Effect.failCause(failure.cause);
467
+ const error = failure.cause.error;
464
468
  yield* Effect.logWarning("job attempt failed").pipe(
465
469
  Effect.annotateLogs({
466
470
  jobId: row.id,
@@ -472,29 +476,20 @@ export const makeScheduler = (
472
476
  );
473
477
  const exhausted = row.attempt >= (row.max_attempts || maxAttemptsFor(row.job_name));
474
478
  if (error.classification === "permanent" || exhausted) {
475
- yield* deadLetter(row, error.reason).pipe(Effect.orDie);
479
+ yield* deadLetter(row, error.reason);
476
480
  return;
477
481
  }
478
- yield* rescheduleRetry(row, error.reason).pipe(Effect.orDie);
482
+ yield* rescheduleRetry(row, error.reason);
479
483
  });
480
484
 
481
- return Effect.gen(function* () {
482
- const heartbeatFiber = yield* Effect.fork(
483
- heartbeat.pipe(Effect.catchAllCause(() => Effect.void)),
484
- );
485
- yield* runOutcome.pipe(
486
- Effect.ensuring(
487
- Fiber.interrupt(heartbeatFiber).pipe(
488
- Effect.catchAllCause(() => Effect.void),
489
- Effect.asVoid,
490
- ),
491
- ),
492
- correlation,
493
- );
494
- });
485
+ // A failed heartbeat invalidates this execution's lease. Stop the
486
+ // handler and propagate the failure to the worker's supervisor.
487
+ return runOutcome.pipe(Effect.raceFirst(heartbeat), correlation);
495
488
  };
496
489
 
497
- const runWorker = (workerOptions: WorkerOptions = {}): Effect.Effect<void, never, Shutdown> =>
490
+ const runWorker = (
491
+ workerOptions: WorkerOptions = {},
492
+ ): Effect.Effect<void, WorkerError, Shutdown> =>
498
493
  Effect.gen(function* () {
499
494
  const shutdown = yield* Shutdown;
500
495
  const pollMillis =
@@ -513,7 +508,8 @@ export const makeScheduler = (
513
508
  ? undefined
514
509
  : Duration.decode(workerOptions.drainTimeout);
515
510
 
516
- const inflight = new Map<Fiber.RuntimeFiber<void, unknown>, QueueRow>();
511
+ const inflight = new Map<Fiber.RuntimeFiber<void, WorkerError>, QueueRow>();
512
+ const workerFailed = yield* Deferred.make<never, WorkerError>();
517
513
  // The bound itself: a handler runs only while holding one permit, so
518
514
  // even a miscounted claim can never exceed `concurrency` executions.
519
515
  const permits = yield* Effect.makeSemaphore(concurrency);
@@ -554,7 +550,13 @@ export const makeScheduler = (
554
550
  `.pipe(Effect.asVoid),
555
551
  { discard: true },
556
552
  );
557
- }).pipe(Effect.catchAllCause(() => Effect.void));
553
+ }).pipe(
554
+ Effect.catchTag("SqlError", () =>
555
+ Effect.logError(
556
+ "jobs worker could not release abandoned leases; rows remain reclaimable after lease expiry",
557
+ ),
558
+ ),
559
+ );
558
560
 
559
561
  yield* shutdown.onShutdown(
560
562
  "jobs-worker",
@@ -576,7 +578,7 @@ export const makeScheduler = (
576
578
  ),
577
579
  );
578
580
 
579
- const loop: Effect.Effect<void> = Effect.whileLoop({
581
+ const loop: Effect.Effect<void, JobQueueError> = Effect.whileLoop({
580
582
  while: () => !stopRequested,
581
583
  body: () =>
582
584
  Effect.gen(function* () {
@@ -591,13 +593,17 @@ export const makeScheduler = (
591
593
  yield* Deferred.await(waiter).pipe(Effect.timeout(pollMillis), Effect.ignore);
592
594
  return;
593
595
  }
594
- const rows = yield* Effect.orDie(claim(capacity, leaseMillis));
596
+ const rows = yield* claim(capacity, leaseMillis);
595
597
  if (rows.length === 0) {
596
598
  yield* Effect.sleep(pollMillis);
597
599
  return;
598
600
  }
599
601
  for (const row of rows) {
600
- const fiber = yield* Effect.fork(permits.withPermits(1)(execute(row, leaseMillis)));
602
+ const fiber = yield* Effect.fork(
603
+ permits
604
+ .withPermits(1)(execute(row, leaseMillis))
605
+ .pipe(Effect.tapErrorCause((cause) => Deferred.failCause(workerFailed, cause))),
606
+ );
601
607
  inflight.set(fiber, row);
602
608
  void fiber.addObserver(() => {
603
609
  inflight.delete(fiber);
@@ -610,9 +616,12 @@ export const makeScheduler = (
610
616
  }),
611
617
  step: () => undefined,
612
618
  });
613
- yield* loop;
614
- // Graceful drain: wait for in-flight handlers to finish.
615
- yield* awaitInflight;
619
+ // Observe child failures as well as claims. Otherwise a broken
620
+ // completion/heartbeat or handler defect silently frees a slot.
621
+ yield* loop.pipe(
622
+ Effect.zipRight(awaitInflight),
623
+ Effect.raceFirst(Deferred.await(workerFailed)),
624
+ );
616
625
  yield* Effect.logInfo("jobs worker drained").pipe(
617
626
  Effect.annotateLogs({ drained: inflight.size }),
618
627
  );
package/src/schema.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as SqlClient from "@effect/sql/SqlClient";
2
+ import type { SqlError } from "@effect/sql/SqlError";
2
3
  import { Effect } from "effect";
3
4
 
4
5
  export interface AdapterOptions {
@@ -28,7 +29,7 @@ export const tableNames = (options: AdapterOptions = {}): TableNames => {
28
29
  */
29
30
  export const migrate = (
30
31
  options: AdapterOptions = {},
31
- ): Effect.Effect<void, never, SqlClient.SqlClient> =>
32
+ ): Effect.Effect<void, SqlError, SqlClient.SqlClient> =>
32
33
  Effect.gen(function* () {
33
34
  const sql = yield* SqlClient.SqlClient;
34
35
  const tables = tableNames(options);
@@ -75,4 +76,4 @@ export const migrate = (
75
76
  dead_at TIMESTAMPTZ NOT NULL
76
77
  )
77
78
  `;
78
- }).pipe(Effect.orDie);
79
+ });