@structure-ai/jobs 0.0.13 → 0.0.15
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 +1 -1
- package/package.json +4 -4
- package/src/scheduler.ts +177 -43
- package/src/schema.ts +7 -1
- package/src/settings.ts +12 -1
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
|
|
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.
|
|
3
|
+
"version": "0.0.15",
|
|
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.
|
|
28
|
-
"@structure-ai/observability": "0.0.
|
|
29
|
-
"@structure-ai/runtime": "0.0.
|
|
27
|
+
"@structure-ai/config": "0.0.15",
|
|
28
|
+
"@structure-ai/observability": "0.0.15",
|
|
29
|
+
"@structure-ai/runtime": "0.0.15",
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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)}
|
|
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)}
|
|
335
|
+
yield* fenced(row, "complete-success", sql`DELETE FROM ${sql(tables.queue)}`);
|
|
273
336
|
return;
|
|
274
337
|
}
|
|
275
|
-
yield*
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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*
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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.
|
|
470
|
-
void fiber.addObserver(() =>
|
|
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*
|
|
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_",
|