@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 +5 -1
- package/package.json +6 -5
- package/src/index.ts +1 -0
- package/src/layer.ts +17 -3
- package/src/scheduler.ts +48 -39
- package/src/schema.ts +3 -2
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.
|
|
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.
|
|
28
|
-
"@structure-ai/observability": "0.
|
|
29
|
-
"@structure-ai/runtime": "0.
|
|
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
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
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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")
|
|
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)}`)
|
|
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.
|
|
458
|
-
if (failure
|
|
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)
|
|
463
|
+
yield* completeSuccess(row);
|
|
461
464
|
return;
|
|
462
465
|
}
|
|
463
|
-
|
|
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)
|
|
479
|
+
yield* deadLetter(row, error.reason);
|
|
476
480
|
return;
|
|
477
481
|
}
|
|
478
|
-
yield* rescheduleRetry(row, error.reason)
|
|
482
|
+
yield* rescheduleRetry(row, error.reason);
|
|
479
483
|
});
|
|
480
484
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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 = (
|
|
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,
|
|
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(
|
|
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*
|
|
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(
|
|
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
|
-
|
|
614
|
-
//
|
|
615
|
-
yield*
|
|
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,
|
|
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
|
-
})
|
|
79
|
+
});
|