cairnq 0.4.0 → 0.6.0

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.
Files changed (39) hide show
  1. package/README.md +3 -2
  2. package/dist/_protocol/migrations/postgres/0006_claim_name_index.sql +25 -0
  3. package/dist/_protocol/migrations/sqlite/0006_claim_name_index.sql +26 -0
  4. package/dist/_protocol/sql/postgres/claim_one_name.sql +37 -0
  5. package/dist/_protocol/sql/postgres/claim_one_queue_one_name.sql +34 -0
  6. package/dist/_protocol/sql/postgres/heartbeat_batch.sql +24 -0
  7. package/dist/_protocol/sql/postgres/queue_depth.sql +22 -0
  8. package/dist/_protocol/sql/sqlite/claim_one_name.sql +36 -0
  9. package/dist/_protocol/sql/sqlite/claim_one_queue_one_name.sql +31 -0
  10. package/dist/_protocol/sql/sqlite/heartbeat_batch.sql +29 -0
  11. package/dist/_protocol/sql/sqlite/queue_depth.sql +26 -0
  12. package/dist/backoff.d.ts +31 -0
  13. package/dist/backoff.js +40 -0
  14. package/dist/backpressure.d.ts +59 -0
  15. package/dist/backpressure.js +122 -0
  16. package/dist/client.d.ts +16 -3
  17. package/dist/client.js +19 -5
  18. package/dist/context.d.ts +48 -2
  19. package/dist/context.js +101 -10
  20. package/dist/errors.d.ts +37 -0
  21. package/dist/errors.js +60 -0
  22. package/dist/index.d.ts +7 -3
  23. package/dist/index.js +2 -1
  24. package/dist/store/base.d.ts +73 -0
  25. package/dist/store/base.js +124 -14
  26. package/dist/store/sqlite.d.ts +35 -0
  27. package/dist/store/sqlite.js +163 -3
  28. package/dist/worker.d.ts +238 -13
  29. package/dist/worker.js +512 -120
  30. package/package.json +2 -1
  31. package/src/backoff.ts +53 -0
  32. package/src/backpressure.ts +140 -0
  33. package/src/client.ts +33 -5
  34. package/src/context.ts +116 -9
  35. package/src/errors.ts +66 -0
  36. package/src/index.ts +7 -2
  37. package/src/store/base.ts +136 -13
  38. package/src/store/sqlite.ts +168 -2
  39. package/src/worker.ts +671 -132
package/dist/context.d.ts CHANGED
@@ -1,8 +1,20 @@
1
+ import { type FailReason } from "./errors.js";
1
2
  import { type Task } from "./models.js";
2
3
  import type { SubmitOptions } from "./client.js";
3
4
  import type { TaskStore } from "./store/base.js";
4
5
  import { type TaskDef } from "./task.js";
5
- /** Handed to a task handler. Worker-side capabilities mirror the Python SDK. */
6
+ export interface TaskContextOptions {
7
+ retryBackoffMs?: number;
8
+ retryBackoffMaxMs?: number;
9
+ }
10
+ /**
11
+ * Handed to a task handler. Worker-side capabilities mirror the Python SDK.
12
+ *
13
+ * One of these per task, whether a handler is delivered one task or a batch: a
14
+ * batch handler receives a `TaskContext[]`, so a single-task handler's `ctx` is
15
+ * literally the batch-of-one element. Lease, cancellation and settlement are per
16
+ * task, which is why they live here rather than on anything batch-shaped.
17
+ */
6
18
  export declare class TaskContext {
7
19
  private readonly store;
8
20
  private readonly task;
@@ -11,7 +23,10 @@ export declare class TaskContext {
11
23
  private readonly abort;
12
24
  private leaseLost;
13
25
  private cancelSeen;
14
- constructor(store: TaskStore, task: Task, workerId: string, leaseMs: number);
26
+ private isSettled;
27
+ private readonly backoffMs;
28
+ private readonly backoffMaxMs;
29
+ constructor(store: TaskStore, task: Task, workerId: string, leaseMs: number, opts?: TaskContextOptions);
15
30
  get taskId(): string;
16
31
  get name(): string;
17
32
  get queue(): string;
@@ -20,6 +35,14 @@ export declare class TaskContext {
20
35
  get rootId(): string | null;
21
36
  get correlationId(): string | null;
22
37
  get payload(): any;
38
+ /**
39
+ * True once this task reached a terminal state — whether the handler settled
40
+ * it with succeed()/fail() or the worker settled it on the handler's behalf.
41
+ * The heartbeat and the settlement paths both read it.
42
+ */
43
+ get settled(): boolean;
44
+ /** @internal Called by the worker when it finalizes this task itself. */
45
+ markSettled(): void;
23
46
  /**
24
47
  * True once this worker has lost the task's lease — it expired and another
25
48
  * worker reclaimed it. Nothing this handler writes will be recorded any more
@@ -32,11 +55,34 @@ export declare class TaskContext {
32
55
  /** @internal Called by the worker when an owned write reports a lost lease. */
33
56
  markLeaseLost(): void;
34
57
  private observe;
58
+ /**
59
+ * @internal The same observation from just the flag, for a caller that read it
60
+ * without materializing a Task — the shared heartbeat, whose statement returns
61
+ * only the id and the cancel column precisely so it does not have to drag
62
+ * every payload back on every beat.
63
+ */
64
+ observeCancel(cancelRequested: boolean): void;
35
65
  private owned;
36
66
  progress(value: number | null, message?: string | null): Promise<Task>;
37
67
  heartbeat(): Promise<Task>;
38
68
  /** Cooperative cancel check. Free once a heartbeat has already seen the flag. */
39
69
  canceled(): Promise<boolean>;
70
+ /**
71
+ * Finalize this task as succeeded, now, without waiting for the handler to
72
+ * return. `complete` semantics: a cancel requested while it ran wins and the
73
+ * task finalizes as canceled instead, its result discarded. Returns null if
74
+ * this task was already settled.
75
+ */
76
+ succeed(result?: unknown): Promise<Task | null>;
77
+ /**
78
+ * Finalize this task as failed, now. `error` may be a string reason, an Error,
79
+ * a TaskError (which carries its own retryability), or a ready envelope.
80
+ * Retryable failures get the worker's backoff and are re-queued while attempts
81
+ * remain, exactly as a thrown error would be. Returns null if already settled.
82
+ */
83
+ fail(error?: FailReason, opts?: {
84
+ retryable?: boolean;
85
+ }): Promise<Task | null>;
40
86
  /** Submit a child task; parent/root/correlation are wired automatically. */
41
87
  submit(name: string, payload?: unknown, opts?: SubmitOptions): Promise<Task>;
42
88
  submit<P, R>(task: TaskDef<P, R>, payload?: P, opts?: SubmitOptions): Promise<Task>;
package/dist/context.js CHANGED
@@ -1,8 +1,16 @@
1
- import { LostLease } from "./errors.js";
1
+ import { DEFAULT_RETRY_BACKOFF_MAX_MS, DEFAULT_RETRY_BACKOFF_MS, failDelayMs } from "./backoff.js";
2
+ import { asEnvelope, LostLease } from "./errors.js";
2
3
  import { cancelRequested } from "./models.js";
3
4
  import { taskName } from "./task.js";
4
5
  import { pollWait } from "./wait.js";
5
- /** Handed to a task handler. Worker-side capabilities mirror the Python SDK. */
6
+ /**
7
+ * Handed to a task handler. Worker-side capabilities mirror the Python SDK.
8
+ *
9
+ * One of these per task, whether a handler is delivered one task or a batch: a
10
+ * batch handler receives a `TaskContext[]`, so a single-task handler's `ctx` is
11
+ * literally the batch-of-one element. Lease, cancellation and settlement are per
12
+ * task, which is why they live here rather than on anything batch-shaped.
13
+ */
6
14
  export class TaskContext {
7
15
  store;
8
16
  task;
@@ -13,11 +21,20 @@ export class TaskContext {
13
21
  // Cancellation is monotonic: once the DB has told us a cancel was requested it
14
22
  // can't be taken back, so canceled() can answer from this without a re-read.
15
23
  cancelSeen = false;
16
- constructor(store, task, workerId, leaseMs) {
24
+ // Set once this task reached a terminal state through succeed()/fail(). The
25
+ // worker reads it to know which tasks a batch handler already decided, so it
26
+ // neither settles them twice nor keeps renewing their leases — the bookkeeping
27
+ // every ack/nack-style handler otherwise has to carry itself.
28
+ isSettled = false;
29
+ backoffMs;
30
+ backoffMaxMs;
31
+ constructor(store, task, workerId, leaseMs, opts = {}) {
17
32
  this.store = store;
18
33
  this.task = task;
19
34
  this.workerId = workerId;
20
35
  this.leaseMs = leaseMs;
36
+ this.backoffMs = opts.retryBackoffMs ?? DEFAULT_RETRY_BACKOFF_MS;
37
+ this.backoffMaxMs = opts.retryBackoffMaxMs ?? DEFAULT_RETRY_BACKOFF_MAX_MS;
21
38
  }
22
39
  get taskId() {
23
40
  return this.task.id;
@@ -43,6 +60,18 @@ export class TaskContext {
43
60
  get payload() {
44
61
  return this.task.payload;
45
62
  }
63
+ /**
64
+ * True once this task reached a terminal state — whether the handler settled
65
+ * it with succeed()/fail() or the worker settled it on the handler's behalf.
66
+ * The heartbeat and the settlement paths both read it.
67
+ */
68
+ get settled() {
69
+ return this.isSettled;
70
+ }
71
+ /** @internal Called by the worker when it finalizes this task itself. */
72
+ markSettled() {
73
+ this.isSettled = true;
74
+ }
46
75
  /**
47
76
  * True once this worker has lost the task's lease — it expired and another
48
77
  * worker reclaimed it. Nothing this handler writes will be recorded any more
@@ -66,18 +95,37 @@ export class TaskContext {
66
95
  // Every owned write returns the current row, so cancellation and lease loss
67
96
  // ride along on writes the handler was making anyway.
68
97
  observe(task) {
69
- if (cancelRequested(task))
70
- this.cancelSeen = true;
98
+ this.observeCancel(cancelRequested(task));
71
99
  return task;
72
100
  }
101
+ /**
102
+ * @internal The same observation from just the flag, for a caller that read it
103
+ * without materializing a Task — the shared heartbeat, whose statement returns
104
+ * only the id and the cancel column precisely so it does not have to drag
105
+ * every payload back on every beat.
106
+ */
107
+ observeCancel(cancelRequested) {
108
+ if (cancelRequested)
109
+ this.cancelSeen = true;
110
+ }
73
111
  async owned(write) {
74
- // Short-circuit once the lease is known lost: nothing this context writes
75
- // may be recorded any more. Locally, not just via the store's ownership
76
- // check — after an abandoned (timed-out) attempt the same worker may
77
- // re-claim this task under the same workerId, and a zombie handler's write
78
- // would then pass ownership against the NEW attempt.
112
+ // One gate for every write through this context, so "may I still write?" is
113
+ // answered in one place rather than at each call site.
114
+ //
115
+ // Lease lost: nothing this context writes may be recorded any more. Checked
116
+ // locally, not just via the store's ownership check — after an abandoned
117
+ // (timed-out) attempt the same worker may re-claim this task under the same
118
+ // workerId, and a zombie handler's write would then pass ownership against
119
+ // the NEW attempt.
79
120
  if (this.leaseLost)
80
121
  throw new LostLease(this.task.id);
122
+ // Settled: the task is terminal, so the statement would match no row and come
123
+ // back as a lost lease — telling the handler "another worker took this" when
124
+ // the truth is "you already finished it", and flipping lostLease on the way.
125
+ // Refuse here instead, without the round trip and without corrupting the
126
+ // lease state.
127
+ if (this.isSettled)
128
+ throw new LostLease(this.task.id);
81
129
  try {
82
130
  return this.observe(await write());
83
131
  }
@@ -113,6 +161,49 @@ export class TaskContext {
113
161
  this.cancelSeen = true;
114
162
  return this.cancelSeen || t.status === "canceled";
115
163
  }
164
+ // ------------------------------------------------------------- settlement
165
+ // Finalizing a task is normally the worker's job, decided by whether the
166
+ // handler returned or threw. These two let a handler decide one task itself,
167
+ // which is what a batch needs: four of 256 tasks failing for four different
168
+ // reasons is the ordinary case, not the edge one, and it cannot be expressed
169
+ // by a single return value or a single throw.
170
+ //
171
+ // Settling twice is a no-op rather than an error. Handlers built on ack/nack
172
+ // queues all end up carrying a `finalizedIds` set to guarantee exactly that;
173
+ // holding it here instead is the point.
174
+ /**
175
+ * Finalize this task as succeeded, now, without waiting for the handler to
176
+ * return. `complete` semantics: a cancel requested while it ran wins and the
177
+ * task finalizes as canceled instead, its result discarded. Returns null if
178
+ * this task was already settled.
179
+ */
180
+ async succeed(result = null) {
181
+ if (this.isSettled)
182
+ return null;
183
+ const task = await this.owned(() => this.store.complete({ taskId: this.task.id, workerId: this.workerId, result }));
184
+ this.markSettled();
185
+ return task;
186
+ }
187
+ /**
188
+ * Finalize this task as failed, now. `error` may be a string reason, an Error,
189
+ * a TaskError (which carries its own retryability), or a ready envelope.
190
+ * Retryable failures get the worker's backoff and are re-queued while attempts
191
+ * remain, exactly as a thrown error would be. Returns null if already settled.
192
+ */
193
+ async fail(error = "task failed", opts = {}) {
194
+ if (this.isSettled)
195
+ return null;
196
+ const [envelope, retryable] = asEnvelope(error, opts.retryable ?? true);
197
+ const task = await this.owned(() => this.store.fail({
198
+ taskId: this.task.id,
199
+ workerId: this.workerId,
200
+ error: envelope,
201
+ retryable,
202
+ delayMs: failDelayMs(this.task.attempt, retryable, this.backoffMs, this.backoffMaxMs),
203
+ }));
204
+ this.markSettled();
205
+ return task;
206
+ }
116
207
  async submit(task, payload, opts = {}) {
117
208
  return this.store.submit({
118
209
  name: taskName(task),
package/dist/errors.d.ts CHANGED
@@ -9,6 +9,15 @@ export declare function errorEnvelope(e: {
9
9
  retryable: boolean;
10
10
  details?: Record<string, unknown>;
11
11
  }): Record<string, unknown>;
12
+ /**
13
+ * How an arbitrary thrown value becomes an envelope. Split out from `asEnvelope`
14
+ * below because the worker also reaches it directly, for a thrown plain object —
15
+ * which `asEnvelope` reads as a ready envelope, the right call for `ctx.fail` and
16
+ * the wrong one for something that was thrown. Both must agree on `code` and on
17
+ * deriving `type` from the error's name, or the same error reads differently
18
+ * depending on which way it was recorded.
19
+ */
20
+ export declare function exceptionEnvelope(err: unknown, retryable?: boolean): Record<string, unknown>;
12
21
  export declare class CairnQError extends Error {
13
22
  constructor(message?: string);
14
23
  }
@@ -16,6 +25,16 @@ export declare class AlreadyExists extends CairnQError {
16
25
  key: string;
17
26
  constructor(key: string);
18
27
  }
28
+ /** A gated submit waited out `maxWaitMs` without the queue draining below its
29
+ * depth limit. Nothing was enqueued. Distinct from a slow submit on purpose: a
30
+ * queue this far behind is a capacity problem, and a caller that silently
31
+ * retries forever converts it into an invisible one. */
32
+ export declare class QueueFull extends CairnQError {
33
+ queue: string;
34
+ maxDepth: number;
35
+ waitedMs: number;
36
+ constructor(queue: string, maxDepth: number, waitedMs: number);
37
+ }
19
38
  /** wait/call did not reach a terminal status in time. The task keeps running.
20
39
  * `task` is the last snapshot wait() observed (null if get() found nothing), and
21
40
  * the message says what state it was stuck in — a queued-never-claimed task is
@@ -74,3 +93,21 @@ export declare class TaskError extends CairnQError {
74
93
  });
75
94
  envelope(): Record<string, unknown>;
76
95
  }
96
+ /** What a handler may pass to `ctx.fail`. */
97
+ export type FailReason = string | Error | TaskError | Record<string, unknown>;
98
+ /**
99
+ * Normalize anything that can end a task into [envelope, retryable].
100
+ *
101
+ * Shared by both ways a failure is recorded — a handler passing a reason to
102
+ * `ctx.fail`, and the worker classifying an error that ended an attempt — so the
103
+ * two cannot disagree about what a given error means. It lives here, beside the
104
+ * envelope constructors it dispatches to, rather than in the module that happens
105
+ * to expose it to handlers.
106
+ *
107
+ * A handler failing one task of a batch has a reason, not an exception object:
108
+ * `item.fail("no source records", { retryable: false })` is the shape the real
109
+ * code wants. A TaskError carries its own retryability and wins over the option;
110
+ * everything else takes the caller's. A ready envelope passes through, which is
111
+ * how the worker hands in the ones it composes itself.
112
+ */
113
+ export declare function asEnvelope(error: FailReason, retryable: boolean): [Record<string, unknown>, boolean];
package/dist/errors.js CHANGED
@@ -12,6 +12,23 @@ export function errorEnvelope(e) {
12
12
  details: e.details ?? {},
13
13
  };
14
14
  }
15
+ /**
16
+ * How an arbitrary thrown value becomes an envelope. Split out from `asEnvelope`
17
+ * below because the worker also reaches it directly, for a thrown plain object —
18
+ * which `asEnvelope` reads as a ready envelope, the right call for `ctx.fail` and
19
+ * the wrong one for something that was thrown. Both must agree on `code` and on
20
+ * deriving `type` from the error's name, or the same error reads differently
21
+ * depending on which way it was recorded.
22
+ */
23
+ export function exceptionEnvelope(err, retryable = true) {
24
+ const e = err;
25
+ return errorEnvelope({
26
+ type: e?.name ?? "Error",
27
+ code: "handler_error",
28
+ message: String(e?.message ?? err),
29
+ retryable,
30
+ });
31
+ }
15
32
  export class CairnQError extends Error {
16
33
  constructor(message) {
17
34
  super(message);
@@ -29,6 +46,23 @@ export class AlreadyExists extends CairnQError {
29
46
  this.name = "AlreadyExists";
30
47
  }
31
48
  }
49
+ /** A gated submit waited out `maxWaitMs` without the queue draining below its
50
+ * depth limit. Nothing was enqueued. Distinct from a slow submit on purpose: a
51
+ * queue this far behind is a capacity problem, and a caller that silently
52
+ * retries forever converts it into an invisible one. */
53
+ export class QueueFull extends CairnQError {
54
+ queue;
55
+ maxDepth;
56
+ waitedMs;
57
+ constructor(queue, maxDepth, waitedMs) {
58
+ super(`queue ${queue} still holds ${maxDepth} or more queued tasks after ` +
59
+ `${waitedMs}ms; refusing to enqueue more`);
60
+ this.queue = queue;
61
+ this.maxDepth = maxDepth;
62
+ this.waitedMs = waitedMs;
63
+ this.name = "QueueFull";
64
+ }
65
+ }
32
66
  /** One line of "why hasn't this finished" from the last snapshot wait()
33
67
  * observed. No worker running, no handler for the name, wrong queue, and two
34
68
  * processes on different database files all look identical from the API side —
@@ -144,3 +178,29 @@ export class TaskError extends CairnQError {
144
178
  });
145
179
  }
146
180
  }
181
+ /**
182
+ * Normalize anything that can end a task into [envelope, retryable].
183
+ *
184
+ * Shared by both ways a failure is recorded — a handler passing a reason to
185
+ * `ctx.fail`, and the worker classifying an error that ended an attempt — so the
186
+ * two cannot disagree about what a given error means. It lives here, beside the
187
+ * envelope constructors it dispatches to, rather than in the module that happens
188
+ * to expose it to handlers.
189
+ *
190
+ * A handler failing one task of a batch has a reason, not an exception object:
191
+ * `item.fail("no source records", { retryable: false })` is the shape the real
192
+ * code wants. A TaskError carries its own retryability and wins over the option;
193
+ * everything else takes the caller's. A ready envelope passes through, which is
194
+ * how the worker hands in the ones it composes itself.
195
+ */
196
+ export function asEnvelope(error, retryable) {
197
+ if (error instanceof TaskError)
198
+ return [error.envelope(), error.retryable];
199
+ if (error instanceof Error)
200
+ return [exceptionEnvelope(error, retryable), retryable];
201
+ if (typeof error === "object" && error !== null)
202
+ return [error, retryable];
203
+ // A bare reason is a TaskError in everything but the throwing, so let
204
+ // TaskError own its own type/code defaults rather than restating them.
205
+ return [new TaskError(String(error), { retryable }).envelope(), retryable];
206
+ }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  export { CairnQ } from "./client.js";
2
- export type { CallOptions, SubmitOptions } from "./client.js";
2
+ export type { CallOptions, ClientOptions, SubmitOptions } from "./client.js";
3
+ export { QueueDepthGate } from "./backpressure.js";
4
+ export type { BackpressureOptions, QueueDepthLimit } from "./backpressure.js";
3
5
  export { Worker } from "./worker.js";
4
- export type { Handler, TypedHandler, WorkerOptions } from "./worker.js";
6
+ export type { BatchHandler, Handler, TypedHandler, WorkerOptions } from "./worker.js";
5
7
  export { TaskContext } from "./context.js";
8
+ export type { TaskContextOptions } from "./context.js";
6
9
  export { defineTask } from "./task.js";
7
10
  export type { TaskDef } from "./task.js";
8
11
  export { SQLiteStore } from "./store/sqlite.js";
@@ -11,4 +14,5 @@ export { TaskStore } from "./store/base.js";
11
14
  export type { ListInput, PurgeInput, SubmitInput, Conflict } from "./store/base.js";
12
15
  export type { Task, TaskStatus } from "./models.js";
13
16
  export { STATUSES, isTerminal, cancelRequested, isQueued, isRunning, isSucceeded, isFailed, isCanceled, } from "./models.js";
14
- export { CairnQError, AlreadyExists, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, ProtocolVersionMismatch, SerializationError, } from "./errors.js";
17
+ export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, ProtocolVersionMismatch, SerializationError, } from "./errors.js";
18
+ export type { FailReason } from "./errors.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { CairnQ } from "./client.js";
2
+ export { QueueDepthGate } from "./backpressure.js";
2
3
  export { Worker } from "./worker.js";
3
4
  export { TaskContext } from "./context.js";
4
5
  export { defineTask } from "./task.js";
@@ -6,4 +7,4 @@ export { SQLiteStore } from "./store/sqlite.js";
6
7
  export { PostgresStore } from "./store/postgres.js";
7
8
  export { TaskStore } from "./store/base.js";
8
9
  export { STATUSES, isTerminal, cancelRequested, isQueued, isRunning, isSucceeded, isFailed, isCanceled, } from "./models.js";
9
- export { CairnQError, AlreadyExists, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, ProtocolVersionMismatch, SerializationError, } from "./errors.js";
10
+ export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, ProtocolVersionMismatch, SerializationError, } from "./errors.js";
@@ -1,4 +1,5 @@
1
1
  import { type Task, type TaskStatus } from "../models.js";
2
+ import { type BackpressureOptions } from "../backpressure.js";
2
3
  /** Encode a value for a protocol JSON column, raising SerializationError on
3
4
  * anything JSON cannot represent. Refuses what JSON.stringify would silently
4
5
  * mangle into `null`: NaN/Infinity anywhere, undefined/function/symbol inside an
@@ -12,6 +13,9 @@ export declare function dumpJson(value: unknown): string;
12
13
  export declare function checkProtocolVersion(version: number): void;
13
14
  declare const CONFLICTS: readonly ["reuse", "reject", "replace"];
14
15
  export type Conflict = (typeof CONFLICTS)[number];
16
+ /** The queue a submit lands on when it names none. Owned here, where the
17
+ * default is applied, so nothing above has to re-derive it. */
18
+ export declare const DEFAULT_QUEUE = "default";
15
19
  export interface SubmitInput {
16
20
  name: string;
17
21
  payload: unknown;
@@ -69,6 +73,8 @@ export declare function statementParams(sql: string): readonly string[];
69
73
  * behavior; the shared SQL already stops them from drifting in wording.
70
74
  */
71
75
  export declare abstract class TaskStore {
76
+ /** Set by useBackpressure; null means submit is ungated. */
77
+ private gate;
72
78
  abstract connect(): Promise<void>;
73
79
  abstract close(): Promise<void>;
74
80
  abstract protocolVersion(): Promise<number>;
@@ -103,6 +109,15 @@ export declare abstract class TaskStore {
103
109
  */
104
110
  private ownedWrite;
105
111
  private static one;
112
+ /**
113
+ * Bound how deep a queue may get before `submit` blocks. Off unless set.
114
+ *
115
+ * It hangs here rather than on `CairnQ` because the store is the one choke
116
+ * point every submit passes through — a handler spawning children via
117
+ * `TaskContext.submit` is the shape most likely to outrun its workers, and
118
+ * gating only the client would leave exactly that path unbounded.
119
+ */
120
+ useBackpressure(opts: BackpressureOptions): void;
106
121
  submit(input: SubmitInput): Promise<Task>;
107
122
  get(taskId: string): Promise<Task | null>;
108
123
  getByKey(key: string): Promise<Task | null>;
@@ -136,6 +151,15 @@ export declare abstract class TaskStore {
136
151
  * them.
137
152
  */
138
153
  stats(): Promise<Record<string, Record<TaskStatus, number>>>;
154
+ /**
155
+ * How many more tasks fit on `queue` under `maxDepth` — 0 once it is full.
156
+ *
157
+ * The cheap half of backpressure: bounded at `maxDepth` index entries, unlike
158
+ * `stats()`, which aggregates the whole table (terminal rows included) and so
159
+ * costs more the longer a database has been running. Use it directly to shed
160
+ * load or shape a producer; `QueueDepthGate` builds the blocking form on top.
161
+ */
162
+ queueDepth(queue: string, maxDepth: number): Promise<number>;
139
163
  /**
140
164
  * Take up to `limit` claimable tasks. `names` restricts the claim to task names
141
165
  * this caller can actually run — a worker passes its registered handlers.
@@ -150,11 +174,60 @@ export declare abstract class TaskStore {
150
174
  limit?: number;
151
175
  names?: string[];
152
176
  }): Promise<Task[]>;
177
+ /**
178
+ * Open one claim transaction and let the caller draw from it repeatedly.
179
+ *
180
+ * The transaction is what has to live here: the read-only probe that keeps an
181
+ * idle worker off SQLite's single write lock, the `recover_leases` whose
182
+ * reclaimed leases must be visible to the claims that follow and to nobody in
183
+ * between, and the write lock itself. *What* gets claimed under it is the
184
+ * caller's business — a worker drawing a separate quota per task name is
185
+ * scheduling policy, and this layer has no vocabulary for the "handler call"
186
+ * that policy is denominated in. It knows queues, names, limits and rows.
187
+ *
188
+ * `plan` is handed a `claim(names, limit)` it may call any number of times,
189
+ * each a separate statement under the same lock and the same recovery, and
190
+ * each free to size itself from what the previous one returned. That feedback
191
+ * is the reason this is a callback rather than a list of quotas: a caller
192
+ * dividing a budget up front has to guess, and every share handed to a name
193
+ * with nothing queued is a slot left idle until the next poll.
194
+ *
195
+ * `plan` runs with the write lock held, so it must await nothing but that
196
+ * callback.
197
+ *
198
+ * `names` is the union `plan` might ask for — the probe and the recovery are
199
+ * filtered by it. Returns undefined when the probe finds nothing claimable, in
200
+ * which case `plan` never runs and no transaction is opened.
201
+ */
202
+ claimSession<T>(input: {
203
+ queues: string[];
204
+ workerId: string;
205
+ leaseMs?: number;
206
+ names: string[] | null;
207
+ }, plan: (claim: (names: string[] | null, limit: number) => Promise<Task[]>) => Promise<T>): Promise<T | undefined>;
153
208
  heartbeat(input: {
154
209
  taskId: string;
155
210
  workerId: string;
156
211
  leaseMs?: number;
157
212
  }): Promise<Task>;
213
+ /**
214
+ * Renew several leases in one statement. Returns `taskId -> cancel requested`
215
+ * for the tasks this worker still holds.
216
+ *
217
+ * Deliberately not an ownedWrite: ownership is per task here, so there is no
218
+ * single answer to "did it work". A task **absent** from the result lost its
219
+ * lease, and the caller decides what that means for that one task rather than
220
+ * failing the whole beat.
221
+ *
222
+ * It returns flags rather than Tasks because nothing downstream needs a task:
223
+ * the caller renews leases and observes cancellation, and whole rows would drag
224
+ * every payload back on every beat for the life of the call.
225
+ */
226
+ heartbeatBatch(input: {
227
+ taskIds: string[];
228
+ workerId: string;
229
+ leaseMs?: number;
230
+ }): Promise<Map<string, boolean>>;
158
231
  progress(input: {
159
232
  taskId: string;
160
233
  workerId: string;