cairnq 0.7.0 → 0.8.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.
- package/README.md +4 -2
- package/dist/_protocol/migrations/postgres/0007_purge_status_index.sql +11 -0
- package/dist/_protocol/migrations/sqlite/0007_purge_status_index.sql +11 -0
- package/dist/_protocol/sql/postgres/get_status.sql +7 -0
- package/dist/_protocol/sql/postgres/get_status_by_key.sql +7 -0
- package/dist/_protocol/sql/postgres/purge.sql +8 -1
- package/dist/_protocol/sql/sqlite/get_status.sql +6 -0
- package/dist/_protocol/sql/sqlite/get_status_by_key.sql +7 -0
- package/dist/_protocol/sql/sqlite/purge.sql +7 -1
- package/dist/client.d.ts +13 -11
- package/dist/client.js +18 -7
- package/dist/errors.d.ts +4 -2
- package/dist/errors.js +7 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/models.d.ts +14 -2
- package/dist/models.js +12 -1
- package/dist/retention.d.ts +15 -3
- package/dist/retention.js +36 -14
- package/dist/store/base.d.ts +19 -1
- package/dist/store/base.js +35 -9
- package/dist/wait.d.ts +9 -4
- package/dist/wait.js +27 -13
- package/package.json +1 -1
- package/src/client.ts +27 -15
- package/src/errors.ts +7 -4
- package/src/index.ts +4 -3
- package/src/models.ts +24 -3
- package/src/retention.ts +45 -15
- package/src/store/base.ts +54 -9
- package/src/wait.ts +35 -14
package/README.md
CHANGED
|
@@ -75,9 +75,11 @@ const worker = Worker.sqlite("tasks.db", {
|
|
|
75
75
|
});
|
|
76
76
|
|
|
77
77
|
// Nothing else deletes rows, so give the client a retention policy — it sweeps
|
|
78
|
-
// terminal tasks in bounded batches for as long as the handle is open.
|
|
78
|
+
// terminal tasks in bounded batches for as long as the handle is open. A
|
|
79
|
+
// per-status map keeps each status on its own clock (statuses left out are
|
|
80
|
+
// never swept): spent results go in minutes, failures stay for diagnosis.
|
|
79
81
|
const tasks = CairnQ.sqlite("tasks.db", {
|
|
80
|
-
retention: { olderThanMs: 7 * 24 * 3600_000 },
|
|
82
|
+
retention: { olderThanMs: { succeeded: 300_000, failed: 7 * 24 * 3600_000 } },
|
|
81
83
|
});
|
|
82
84
|
```
|
|
83
85
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- Serves purge.sql's optional status filter: without it a filtered sweep walks
|
|
2
|
+
-- cairnq_tasks_completed_idx and visits the table row of every terminal task
|
|
3
|
+
-- older than the cutoff just to discard the wrong statuses — worst exactly in
|
|
4
|
+
-- the tiered configuration the filter exists for (a minutes-scale succeeded
|
|
5
|
+
-- cutoff scanning a day's worth of retained failed rows, every sweep). With
|
|
6
|
+
-- (status, completed_at_ms) each filtered sweep is a bounded range seek already
|
|
7
|
+
-- in completion order. Unfiltered purge keeps using cairnq_tasks_completed_idx.
|
|
8
|
+
create index if not exists cairnq_tasks_status_completed_idx
|
|
9
|
+
on cairnq_tasks (status, completed_at_ms);
|
|
10
|
+
|
|
11
|
+
update cairnq_meta set value = '7' where key = 'schema_version';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- Serves purge.sql's optional status filter: without it a filtered sweep walks
|
|
2
|
+
-- cairnq_tasks_completed_idx and visits the table row of every terminal task
|
|
3
|
+
-- older than the cutoff just to discard the wrong statuses — worst exactly in
|
|
4
|
+
-- the tiered configuration the filter exists for (a minutes-scale succeeded
|
|
5
|
+
-- cutoff scanning a day's worth of retained failed rows, every sweep). With
|
|
6
|
+
-- (status, completed_at_ms) each filtered sweep is a bounded range seek already
|
|
7
|
+
-- in completion order. Unfiltered purge keeps using cairnq_tasks_completed_idx.
|
|
8
|
+
create index if not exists cairnq_tasks_status_completed_idx
|
|
9
|
+
on cairnq_tasks (status, completed_at_ms);
|
|
10
|
+
|
|
11
|
+
update cairnq_meta set value = '7' where key = 'schema_version';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- The status-only probe behind wait/call polling (Postgres dialect). A pending
|
|
2
|
+
-- task's whole row is dead weight to a loop that only asks "is it finished yet"
|
|
3
|
+
-- — with a large payload it re-reads and re-parses megabytes per second of
|
|
4
|
+
-- waiting. The full row is fetched once, via get.sql, when this reports a
|
|
5
|
+
-- terminal status.
|
|
6
|
+
-- params: id
|
|
7
|
+
select id, status from cairnq_tasks where id = :id;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- get_status.sql, following a key instead of an id (Postgres dialect) — the
|
|
2
|
+
-- probe behind wait_by_key. Resolves the key on every read, so a `replace`
|
|
3
|
+
-- landing mid-wait moves the wait onto the new task.
|
|
4
|
+
-- params: key
|
|
5
|
+
select t.id, t.status from cairnq_tasks t
|
|
6
|
+
join cairnq_task_keys k on k.task_id = t.id
|
|
7
|
+
where k.key = :key;
|
|
@@ -11,11 +11,18 @@
|
|
|
11
11
|
-- live task. Locking the rows in the subselect freezes them terminal until the
|
|
12
12
|
-- delete commits; a concurrent retry then re-evaluates against the deleted row
|
|
13
13
|
-- and correctly finds nothing.
|
|
14
|
-
--
|
|
14
|
+
-- The status/name filters are optional (pass NULL to skip; `::text` pins the
|
|
15
|
+
-- param's type, as in list.sql): retention needs are tiered — a succeeded row
|
|
16
|
+
-- is spent once its result is consumed, while a failed one is worth keeping
|
|
17
|
+
-- for diagnosis — and without them the shortest-lived tier sets the retention
|
|
18
|
+
-- for every row.
|
|
19
|
+
-- params: older_than_ms, status, name, limit
|
|
15
20
|
delete from cairnq_tasks
|
|
16
21
|
where id in (
|
|
17
22
|
select id from cairnq_tasks
|
|
18
23
|
where status in ('succeeded', 'failed', 'canceled')
|
|
24
|
+
and (:status::text is null or status = :status)
|
|
25
|
+
and (:name::text is null or name = :name)
|
|
19
26
|
and completed_at_ms is not null
|
|
20
27
|
and completed_at_ms < (extract(epoch from now()) * 1000)::bigint - :older_than_ms
|
|
21
28
|
order by completed_at_ms asc
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- The status-only probe behind wait/call polling. A pending task's whole row is
|
|
2
|
+
-- dead weight to a loop that only asks "is it finished yet" — with a large
|
|
3
|
+
-- payload it re-reads and re-parses megabytes per second of waiting. The full
|
|
4
|
+
-- row is fetched once, via get.sql, when this reports a terminal status.
|
|
5
|
+
-- params: id
|
|
6
|
+
select id, status from cairnq_tasks where id = :id;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- get_status.sql, following a key instead of an id — the probe behind
|
|
2
|
+
-- wait_by_key. Resolves the key on every read, so a `replace` landing mid-wait
|
|
3
|
+
-- moves the wait onto the new task.
|
|
4
|
+
-- params: key
|
|
5
|
+
select t.id, t.status from cairnq_tasks t
|
|
6
|
+
join cairnq_task_keys k on k.task_id = t.id
|
|
7
|
+
where k.key = :key;
|
|
@@ -5,11 +5,17 @@
|
|
|
5
5
|
-- task goes with it via cairnq_task_keys' ON DELETE CASCADE.
|
|
6
6
|
-- The LIMIT lives in a subquery: plain `delete ... limit` needs a non-default
|
|
7
7
|
-- SQLite build option.
|
|
8
|
-
--
|
|
8
|
+
-- The status/name filters are optional (pass NULL to skip, as in list.sql):
|
|
9
|
+
-- retention needs are tiered — a succeeded row is spent once its result is
|
|
10
|
+
-- consumed, while a failed one is worth keeping for diagnosis — and without
|
|
11
|
+
-- them the shortest-lived tier sets the retention for every row.
|
|
12
|
+
-- params: before_ms, status, name, limit
|
|
9
13
|
delete from cairnq_tasks
|
|
10
14
|
where id in (
|
|
11
15
|
select id from cairnq_tasks
|
|
12
16
|
where status in ('succeeded', 'failed', 'canceled')
|
|
17
|
+
and (:status is null or status = :status)
|
|
18
|
+
and (:name is null or name = :name)
|
|
13
19
|
and completed_at_ms is not null
|
|
14
20
|
and completed_at_ms < :before_ms
|
|
15
21
|
order by completed_at_ms asc
|
package/dist/client.d.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type { BackpressureOptions } from "./backpressure.js";
|
|
2
2
|
import { type RetentionOptions } from "./retention.js";
|
|
3
|
-
import { type Task, type TaskStatus } from "./models.js";
|
|
3
|
+
import { type Task, type TaskRef, type TaskStatus } from "./models.js";
|
|
4
4
|
import type { ListInput, PurgeInput, SubmitInput, TaskStore } from "./store/base.js";
|
|
5
5
|
import { type TaskDef } from "./task.js";
|
|
6
|
+
import { type PollOptions } from "./wait.js";
|
|
6
7
|
export type SubmitOptions = Omit<SubmitInput, "name" | "payload">;
|
|
7
|
-
|
|
8
|
+
/** The wait loop's knobs with the timeout optional (default 30s) — the public
|
|
9
|
+
* face of PollOptions, whose comments document each knob. */
|
|
10
|
+
export type WaitOptions = Partial<PollOptions>;
|
|
11
|
+
export interface CallOptions extends SubmitOptions, Omit<WaitOptions, "timeoutMs"> {
|
|
8
12
|
waitTimeoutMs?: number;
|
|
9
|
-
pollMs?: number;
|
|
10
13
|
}
|
|
11
14
|
/** Options this handle configures on the store it wraps, rather than the
|
|
12
15
|
* store's own constructor arguments. */
|
|
@@ -48,6 +51,11 @@ export declare class CairnQ {
|
|
|
48
51
|
queueDepth(queue: string, maxDepth: number): Promise<number>;
|
|
49
52
|
get(taskId: string): Promise<Task | null>;
|
|
50
53
|
getByKey(key: string): Promise<Task | null>;
|
|
54
|
+
/** The status-only probe wait polls on: id + status, no payload. Public for
|
|
55
|
+
* the same reason it exists — a dashboard or poller that only asks "is it
|
|
56
|
+
* finished yet" should not drag the payload back per ask. */
|
|
57
|
+
getStatus(taskId: string): Promise<TaskRef | null>;
|
|
58
|
+
getStatusByKey(key: string): Promise<TaskRef | null>;
|
|
51
59
|
list(input?: ListInput): Promise<Task[]>;
|
|
52
60
|
cancel(taskId: string): Promise<Task | null>;
|
|
53
61
|
cancelByKey(key: string): Promise<Task | null>;
|
|
@@ -68,19 +76,13 @@ export declare class CairnQ {
|
|
|
68
76
|
/** Wait for a task to finish. Resolves with the terminal Task (any status);
|
|
69
77
|
* throws TaskTimeout without stopping the task, so `wait(err.taskId)` picks the
|
|
70
78
|
* same wait back up — from another process, or after a longer deadline. */
|
|
71
|
-
wait(taskId: string, opts?:
|
|
72
|
-
timeoutMs?: number;
|
|
73
|
-
pollMs?: number;
|
|
74
|
-
}): Promise<Task>;
|
|
79
|
+
wait(taskId: string, opts?: WaitOptions): Promise<Task>;
|
|
75
80
|
/** Wait for whatever task the `key` currently points at — the cross-process
|
|
76
81
|
* form of picking a wait back up, when the id was never in hand or the process
|
|
77
82
|
* that held it is gone. Re-resolves the key on each poll, so a `replace`
|
|
78
83
|
* landing mid-wait moves the wait onto the new task, and a key with no task
|
|
79
84
|
* yet is waited for rather than rejected. */
|
|
80
|
-
waitByKey(key: string, opts?:
|
|
81
|
-
timeoutMs?: number;
|
|
82
|
-
pollMs?: number;
|
|
83
|
-
}): Promise<Task>;
|
|
85
|
+
waitByKey(key: string, opts?: WaitOptions): Promise<Task>;
|
|
84
86
|
/** submit + wait. Resolves with the result on success; rejects with
|
|
85
87
|
* TaskFailed / TaskCanceled / TaskTimeout otherwise. Pass a TaskDef and the
|
|
86
88
|
* resolved value is typed as its Result.
|
package/dist/client.js
CHANGED
|
@@ -4,7 +4,7 @@ import { isFailed, isSucceeded } from "./models.js";
|
|
|
4
4
|
import { SQLiteStore } from "./store/sqlite.js";
|
|
5
5
|
import { PostgresStore } from "./store/postgres.js";
|
|
6
6
|
import { taskName } from "./task.js";
|
|
7
|
-
import { pollWait, pollWaitByKey } from "./wait.js";
|
|
7
|
+
import { DEFAULT_WAIT_TIMEOUT_MS, pollWait, pollWaitByKey } from "./wait.js";
|
|
8
8
|
/** API-side handle. Thin wrapper over a TaskStore + SDK-orchestrated wait/call. */
|
|
9
9
|
export class CairnQ {
|
|
10
10
|
_store;
|
|
@@ -63,6 +63,15 @@ export class CairnQ {
|
|
|
63
63
|
getByKey(key) {
|
|
64
64
|
return this._store.getByKey(key);
|
|
65
65
|
}
|
|
66
|
+
/** The status-only probe wait polls on: id + status, no payload. Public for
|
|
67
|
+
* the same reason it exists — a dashboard or poller that only asks "is it
|
|
68
|
+
* finished yet" should not drag the payload back per ask. */
|
|
69
|
+
getStatus(taskId) {
|
|
70
|
+
return this._store.getStatus(taskId);
|
|
71
|
+
}
|
|
72
|
+
getStatusByKey(key) {
|
|
73
|
+
return this._store.getStatusByKey(key);
|
|
74
|
+
}
|
|
66
75
|
list(input) {
|
|
67
76
|
return this._store.list(input);
|
|
68
77
|
}
|
|
@@ -94,9 +103,11 @@ export class CairnQ {
|
|
|
94
103
|
* throws TaskTimeout without stopping the task, so `wait(err.taskId)` picks the
|
|
95
104
|
* same wait back up — from another process, or after a longer deadline. */
|
|
96
105
|
wait(taskId, opts = {}) {
|
|
106
|
+
// `??`, not a spread default: a caller forwarding `timeoutMs: undefined`
|
|
107
|
+
// (call() does) must still get the default, and a spread would override it.
|
|
97
108
|
return pollWait(this._store, taskId, {
|
|
98
|
-
|
|
99
|
-
|
|
109
|
+
...opts,
|
|
110
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS,
|
|
100
111
|
});
|
|
101
112
|
}
|
|
102
113
|
/** Wait for whatever task the `key` currently points at — the cross-process
|
|
@@ -106,14 +117,14 @@ export class CairnQ {
|
|
|
106
117
|
* yet is waited for rather than rejected. */
|
|
107
118
|
waitByKey(key, opts = {}) {
|
|
108
119
|
return pollWaitByKey(this._store, key, {
|
|
109
|
-
|
|
110
|
-
|
|
120
|
+
...opts,
|
|
121
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS,
|
|
111
122
|
});
|
|
112
123
|
}
|
|
113
124
|
async call(task, payload, opts = {}) {
|
|
114
|
-
const { waitTimeoutMs
|
|
125
|
+
const { waitTimeoutMs, pollMs, maxPollMs, ...submit } = opts;
|
|
115
126
|
const created = await this.submit(taskName(task), payload, submit);
|
|
116
|
-
const final = await
|
|
127
|
+
const final = await this.wait(created.id, { timeoutMs: waitTimeoutMs, pollMs, maxPollMs });
|
|
117
128
|
if (isSucceeded(final))
|
|
118
129
|
return final.result;
|
|
119
130
|
if (isFailed(final))
|
package/dist/errors.d.ts
CHANGED
|
@@ -80,10 +80,12 @@ export declare class TaskCanceled extends CairnQError {
|
|
|
80
80
|
* anywhere. Nothing inside the blocked handler can observe that, which is why it
|
|
81
81
|
* is reported through `onError` alongside the other things the run loop survived.
|
|
82
82
|
*
|
|
83
|
-
* The cause is
|
|
83
|
+
* The usual cause is synchronous work in a handler: a tight loop, a large
|
|
84
84
|
* JSON.parse, a `*Sync` filesystem or crypto call. Node has one loop and no way
|
|
85
85
|
* to preempt it — move the work to a worker thread, a child process, or an async
|
|
86
|
-
* API that yields.
|
|
86
|
+
* API that yields. The other cause is a worker simply oversubscribed for its
|
|
87
|
+
* `leaseMs` — nothing is blocking, there is just more work than turns — which the
|
|
88
|
+
* same report covers, because the lease is at equal risk either way.
|
|
87
89
|
*/
|
|
88
90
|
export declare class EventLoopBlocked extends CairnQError {
|
|
89
91
|
readonly lateMs: number;
|
package/dist/errors.js
CHANGED
|
@@ -150,10 +150,12 @@ export class TaskCanceled extends CairnQError {
|
|
|
150
150
|
* anywhere. Nothing inside the blocked handler can observe that, which is why it
|
|
151
151
|
* is reported through `onError` alongside the other things the run loop survived.
|
|
152
152
|
*
|
|
153
|
-
* The cause is
|
|
153
|
+
* The usual cause is synchronous work in a handler: a tight loop, a large
|
|
154
154
|
* JSON.parse, a `*Sync` filesystem or crypto call. Node has one loop and no way
|
|
155
155
|
* to preempt it — move the work to a worker thread, a child process, or an async
|
|
156
|
-
* API that yields.
|
|
156
|
+
* API that yields. The other cause is a worker simply oversubscribed for its
|
|
157
|
+
* `leaseMs` — nothing is blocking, there is just more work than turns — which the
|
|
158
|
+
* same report covers, because the lease is at equal risk either way.
|
|
157
159
|
*/
|
|
158
160
|
export class EventLoopBlocked extends CairnQError {
|
|
159
161
|
lateMs;
|
|
@@ -161,8 +163,9 @@ export class EventLoopBlocked extends CairnQError {
|
|
|
161
163
|
leaseMs;
|
|
162
164
|
constructor(lateMs, intervalMs, leaseMs) {
|
|
163
165
|
super(`heartbeat beat was ${lateMs}ms late (interval ${intervalMs}ms, lease ${leaseMs}ms): ` +
|
|
164
|
-
`the event loop was blocked long enough to miss a beat
|
|
165
|
-
`
|
|
166
|
+
`the event loop was blocked long enough to miss a beat, so this worker's leases ` +
|
|
167
|
+
`are at risk. Usually synchronous work in a handler (move it off the loop); ` +
|
|
168
|
+
`otherwise the worker is oversubscribed for its leaseMs.`);
|
|
166
169
|
this.lateMs = lateMs;
|
|
167
170
|
this.intervalMs = intervalMs;
|
|
168
171
|
this.leaseMs = leaseMs;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export { CairnQ } from "./client.js";
|
|
2
|
-
export type { CallOptions, ClientOptions, SubmitOptions } from "./client.js";
|
|
2
|
+
export type { CallOptions, ClientOptions, SubmitOptions, WaitOptions } from "./client.js";
|
|
3
3
|
export { QueueDepthGate } from "./backpressure.js";
|
|
4
4
|
export type { BackpressureOptions, QueueDepthLimit } from "./backpressure.js";
|
|
5
5
|
export { RetentionSweeper } from "./retention.js";
|
|
6
|
-
export type { RetentionOptions } from "./retention.js";
|
|
6
|
+
export type { RetentionCutoffs, RetentionOptions } from "./retention.js";
|
|
7
7
|
export { Worker } from "./worker.js";
|
|
8
8
|
export type { BatchHandler, Handler, TypedHandler, WorkerOptions } from "./worker.js";
|
|
9
9
|
export { TaskContext } from "./context.js";
|
|
@@ -14,7 +14,7 @@ export { SQLiteStore } from "./store/sqlite.js";
|
|
|
14
14
|
export { PostgresStore } from "./store/postgres.js";
|
|
15
15
|
export { TaskStore } from "./store/base.js";
|
|
16
16
|
export type { ListInput, PurgeInput, SubmitInput, Conflict } from "./store/base.js";
|
|
17
|
-
export type { Task, TaskStatus } from "./models.js";
|
|
18
|
-
export { STATUSES, isTerminal, cancelRequested, isQueued, isRunning, isSucceeded, isFailed, isCanceled, } from "./models.js";
|
|
17
|
+
export type { Task, TaskRef, TaskStatus, TerminalStatus } from "./models.js";
|
|
18
|
+
export { STATUSES, isTerminal, isTerminalStatus, cancelRequested, isQueued, isRunning, isSucceeded, isFailed, isCanceled, } from "./models.js";
|
|
19
19
|
export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, EventLoopBlocked, ProtocolVersionMismatch, SerializationError, } from "./errors.js";
|
|
20
20
|
export type { FailReason } from "./errors.js";
|
package/dist/index.js
CHANGED
|
@@ -7,5 +7,5 @@ export { defineTask } from "./task.js";
|
|
|
7
7
|
export { SQLiteStore } from "./store/sqlite.js";
|
|
8
8
|
export { PostgresStore } from "./store/postgres.js";
|
|
9
9
|
export { TaskStore } from "./store/base.js";
|
|
10
|
-
export { STATUSES, isTerminal, cancelRequested, isQueued, isRunning, isSucceeded, isFailed, isCanceled, } from "./models.js";
|
|
10
|
+
export { STATUSES, isTerminal, isTerminalStatus, cancelRequested, isQueued, isRunning, isSucceeded, isFailed, isCanceled, } from "./models.js";
|
|
11
11
|
export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, EventLoopBlocked, ProtocolVersionMismatch, SerializationError, } from "./errors.js";
|
package/dist/models.d.ts
CHANGED
|
@@ -25,9 +25,21 @@ export interface Task {
|
|
|
25
25
|
updated_at_ms: number;
|
|
26
26
|
completed_at_ms: number | null;
|
|
27
27
|
}
|
|
28
|
-
|
|
28
|
+
/** The id + status pair the wait loop polls on (see get_status.sql) — a probe,
|
|
29
|
+
* not a snapshot: everything else about the task is deliberately not read. */
|
|
30
|
+
export interface TaskRef {
|
|
31
|
+
id: string;
|
|
32
|
+
status: TaskStatus;
|
|
33
|
+
}
|
|
34
|
+
export declare const TERMINAL: readonly ["succeeded", "failed", "canceled"];
|
|
35
|
+
export type TerminalStatus = (typeof TERMINAL)[number];
|
|
36
|
+
export declare function isTerminalStatus(status: TaskStatus): status is TerminalStatus;
|
|
37
|
+
/** Map a probe row (see get_status.sql) to a TaskRef — the ref twin of
|
|
38
|
+
* rowToTask, so the row shape stays models' knowledge alone. */
|
|
39
|
+
export declare function rowToRef(row: Record<string, unknown>): TaskRef;
|
|
29
40
|
export declare function rowToTask(row: Record<string, unknown>): Task;
|
|
30
|
-
|
|
41
|
+
/** Accepts anything carrying a status — a Task or a TaskRef probe. */
|
|
42
|
+
export declare function isTerminal(task: Pick<Task, "status">): boolean;
|
|
31
43
|
export declare function cancelRequested(task: Task): boolean;
|
|
32
44
|
export declare const isQueued: (task: Task) => boolean;
|
|
33
45
|
export declare const isRunning: (task: Task) => boolean;
|
package/dist/models.js
CHANGED
|
@@ -4,7 +4,17 @@
|
|
|
4
4
|
// conformance suite pins this set against.
|
|
5
5
|
export const STATUSES = ["queued", "running", "succeeded", "failed", "canceled"];
|
|
6
6
|
const JSON_COLUMNS = ["payload", "result", "error", "metadata"];
|
|
7
|
+
// As a const tuple so TerminalStatus derives from it — the same declare-once
|
|
8
|
+
// pattern as STATUSES/TaskStatus above.
|
|
7
9
|
export const TERMINAL = ["succeeded", "failed", "canceled"];
|
|
10
|
+
export function isTerminalStatus(status) {
|
|
11
|
+
return TERMINAL.includes(status);
|
|
12
|
+
}
|
|
13
|
+
/** Map a probe row (see get_status.sql) to a TaskRef — the ref twin of
|
|
14
|
+
* rowToTask, so the row shape stays models' knowledge alone. */
|
|
15
|
+
export function rowToRef(row) {
|
|
16
|
+
return { id: row.id, status: row.status };
|
|
17
|
+
}
|
|
8
18
|
export function rowToTask(row) {
|
|
9
19
|
const t = { ...row };
|
|
10
20
|
for (const col of JSON_COLUMNS) {
|
|
@@ -16,8 +26,9 @@ export function rowToTask(row) {
|
|
|
16
26
|
}
|
|
17
27
|
return t;
|
|
18
28
|
}
|
|
29
|
+
/** Accepts anything carrying a status — a Task or a TaskRef probe. */
|
|
19
30
|
export function isTerminal(task) {
|
|
20
|
-
return
|
|
31
|
+
return isTerminalStatus(task.status);
|
|
21
32
|
}
|
|
22
33
|
export function cancelRequested(task) {
|
|
23
34
|
return task.cancel_requested_at_ms != null;
|
package/dist/retention.d.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { TerminalStatus } from "./models.js";
|
|
2
|
+
import { type TaskStore } from "./store/base.js";
|
|
3
|
+
/** Per-status cutoffs. A status left out is never swept — granular retention is
|
|
4
|
+
* an explicit statement of what may go, not a default for what wasn't named. */
|
|
5
|
+
export type RetentionCutoffs = Partial<Record<TerminalStatus, number>>;
|
|
2
6
|
export interface RetentionOptions {
|
|
3
7
|
/**
|
|
4
8
|
* How long a terminal task is kept after it finished. Required: there is no
|
|
5
9
|
* safe default for how long someone else's results stay readable.
|
|
10
|
+
*
|
|
11
|
+
* A number keeps every terminal status the same time. Retention needs are
|
|
12
|
+
* often tiered — a succeeded row is spent once its result is consumed, while
|
|
13
|
+
* a failed one is worth keeping for diagnosis — so a per-status map sets a
|
|
14
|
+
* cutoff per status instead: `{ succeeded: 300_000, failed: 86_400_000 }`.
|
|
6
15
|
*/
|
|
7
|
-
olderThanMs: number;
|
|
16
|
+
olderThanMs: number | RetentionCutoffs;
|
|
8
17
|
/** Time between sweeps. Default 3_600_000 (one hour). */
|
|
9
18
|
intervalMs?: number;
|
|
10
19
|
/** Rows deleted per statement while draining. Default 1_000. */
|
|
@@ -42,7 +51,10 @@ export declare class RetentionSweeper {
|
|
|
42
51
|
/** The loop itself, awaited by stop() so no purge outlives the store. */
|
|
43
52
|
private loop;
|
|
44
53
|
private readonly intervalMs;
|
|
45
|
-
|
|
54
|
+
/** Rows per purge statement while draining — see DEFAULT_LIMIT. */
|
|
55
|
+
private readonly limit;
|
|
56
|
+
/** One purge per cutoff: a lone entry for a number, one per status for a map. */
|
|
57
|
+
private readonly purgeInputs;
|
|
46
58
|
constructor(store: TaskStore, opts: RetentionOptions);
|
|
47
59
|
start(): void;
|
|
48
60
|
/** Stop sweeping and wait for the sweep in flight, if any. */
|
package/dist/retention.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validatePurgeInput } from "./store/base.js";
|
|
1
2
|
/** Sweep every hour unless asked otherwise — often enough that a queue with a
|
|
2
3
|
* day of retention never carries more than an hour of extra rows, rare enough
|
|
3
4
|
* that the sweep is invisible next to the task traffic. */
|
|
@@ -31,18 +32,35 @@ export class RetentionSweeper {
|
|
|
31
32
|
/** The loop itself, awaited by stop() so no purge outlives the store. */
|
|
32
33
|
loop = null;
|
|
33
34
|
intervalMs;
|
|
34
|
-
|
|
35
|
+
/** Rows per purge statement while draining — see DEFAULT_LIMIT. */
|
|
36
|
+
limit;
|
|
37
|
+
/** One purge per cutoff: a lone entry for a number, one per status for a map. */
|
|
38
|
+
purgeInputs;
|
|
35
39
|
constructor(store, opts) {
|
|
36
40
|
this.store = store;
|
|
37
41
|
this.opts = opts;
|
|
38
|
-
if (!Number.isFinite(opts.olderThanMs) || opts.olderThanMs < 0) {
|
|
39
|
-
throw new Error(`retention.olderThanMs must be >= 0, got ${opts.olderThanMs}`);
|
|
40
|
-
}
|
|
41
42
|
this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
42
43
|
if (!Number.isFinite(this.intervalMs) || this.intervalMs < 1) {
|
|
43
44
|
throw new Error(`retention.intervalMs must be >= 1, got ${this.intervalMs}`);
|
|
44
45
|
}
|
|
45
|
-
this.
|
|
46
|
+
this.limit = opts.limit ?? DEFAULT_LIMIT;
|
|
47
|
+
const cutoffs = typeof opts.olderThanMs === "number"
|
|
48
|
+
? [[undefined, opts.olderThanMs]]
|
|
49
|
+
: Object.entries(opts.olderThanMs);
|
|
50
|
+
// An empty map retains nothing and sweeps nothing — almost certainly a bug
|
|
51
|
+
// upstream of this call, so refuse it rather than silently never purging.
|
|
52
|
+
if (!cutoffs.length) {
|
|
53
|
+
throw new Error("retention.olderThanMs must name at least one status");
|
|
54
|
+
}
|
|
55
|
+
this.purgeInputs = cutoffs.map(([status, ms]) => ({
|
|
56
|
+
olderThanMs: ms,
|
|
57
|
+
status,
|
|
58
|
+
limit: this.limit,
|
|
59
|
+
}));
|
|
60
|
+
// Fail fast on the store's own purge rules (terminal status, cutoff >= 0):
|
|
61
|
+
// the sweep runs an hour from now, and its errors only surface via onError.
|
|
62
|
+
for (const input of this.purgeInputs)
|
|
63
|
+
validatePurgeInput(input);
|
|
46
64
|
}
|
|
47
65
|
start() {
|
|
48
66
|
if (this.active)
|
|
@@ -86,17 +104,21 @@ export class RetentionSweeper {
|
|
|
86
104
|
* demand — after a backfill, or from a maintenance command.
|
|
87
105
|
*/
|
|
88
106
|
async sweep() {
|
|
89
|
-
const limit = this.purgeInput.limit;
|
|
90
107
|
let deleted = 0;
|
|
91
|
-
for (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
108
|
+
for (const input of this.purgeInputs) {
|
|
109
|
+
for (;;) {
|
|
110
|
+
const ids = await this.store.purge(input);
|
|
111
|
+
deleted += ids.length;
|
|
112
|
+
if (this.stopping)
|
|
113
|
+
return deleted;
|
|
114
|
+
if (ids.length < this.limit)
|
|
115
|
+
break;
|
|
116
|
+
// Hand the loop back between batches: a large drain must not starve the
|
|
117
|
+
// submits and claims sharing this process.
|
|
118
|
+
await this.sleep(0);
|
|
119
|
+
}
|
|
99
120
|
}
|
|
121
|
+
return deleted;
|
|
100
122
|
}
|
|
101
123
|
/** Sleep, interruptible by stop(). Unref'd: retention is housekeeping, and a
|
|
102
124
|
* pending sweep must never be the reason a process refuses to exit. */
|
package/dist/store/base.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Task, type TaskStatus } from "../models.js";
|
|
1
|
+
import { type Task, type TaskRef, type TaskStatus } from "../models.js";
|
|
2
2
|
import { type BackpressureOptions } from "../backpressure.js";
|
|
3
3
|
/** Encode a value for a protocol JSON column, raising SerializationError on
|
|
4
4
|
* anything JSON cannot represent. Refuses what JSON.stringify would silently
|
|
@@ -39,8 +39,19 @@ export interface ListInput {
|
|
|
39
39
|
limit?: number;
|
|
40
40
|
offset?: number;
|
|
41
41
|
}
|
|
42
|
+
/** Validate a purge's inputs. Shared with RetentionSweeper, which fail-fasts at
|
|
43
|
+
* construction on the same rules an hourly sweep would otherwise only surface
|
|
44
|
+
* through its onError hook — one statement of the rules, two callers. */
|
|
45
|
+
export declare function validatePurgeInput(input: PurgeInput): void;
|
|
42
46
|
export interface PurgeInput {
|
|
43
47
|
olderThanMs?: number;
|
|
48
|
+
/** Restrict the sweep to one terminal status. Retention needs are tiered —
|
|
49
|
+
* succeeded rows are spent once their result is consumed, failed ones are
|
|
50
|
+
* worth keeping for diagnosis — and without this the shortest-lived tier
|
|
51
|
+
* sets the retention for every row. Absent means all terminal statuses. */
|
|
52
|
+
status?: TaskStatus;
|
|
53
|
+
/** Restrict the sweep to one task name. Absent means all names. */
|
|
54
|
+
name?: string;
|
|
44
55
|
limit?: number;
|
|
45
56
|
}
|
|
46
57
|
export type Params = Record<string, unknown>;
|
|
@@ -109,6 +120,7 @@ export declare abstract class TaskStore {
|
|
|
109
120
|
*/
|
|
110
121
|
private ownedWrite;
|
|
111
122
|
private static one;
|
|
123
|
+
private static oneRef;
|
|
112
124
|
/**
|
|
113
125
|
* Bound how deep a queue may get before `submit` blocks. Off unless set.
|
|
114
126
|
*
|
|
@@ -121,6 +133,12 @@ export declare abstract class TaskStore {
|
|
|
121
133
|
submit(input: SubmitInput): Promise<Task>;
|
|
122
134
|
get(taskId: string): Promise<Task | null>;
|
|
123
135
|
getByKey(key: string): Promise<Task | null>;
|
|
136
|
+
/** The wait loop's probe: id + status alone, so polling a task with a large
|
|
137
|
+
* payload does not re-read and re-parse that payload on every beat. */
|
|
138
|
+
getStatus(taskId: string): Promise<TaskRef | null>;
|
|
139
|
+
/** getStatus, following a key instead of an id — re-resolved per call, so a
|
|
140
|
+
* `replace` moves the probe onto the new task. */
|
|
141
|
+
getStatusByKey(key: string): Promise<TaskRef | null>;
|
|
124
142
|
list(input?: ListInput): Promise<Task[]>;
|
|
125
143
|
cancel(taskId: string): Promise<Task | null>;
|
|
126
144
|
retry(taskId: string, opts?: {
|
package/dist/store/base.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { newId } from "../ids.js";
|
|
2
2
|
import { AlreadyExists, errorEnvelope, LostLease, ProtocolVersionMismatch, SerializationError, } from "../errors.js";
|
|
3
|
-
import { rowToTask, STATUSES,
|
|
3
|
+
import { isTerminalStatus, rowToRef, rowToTask, STATUSES, } from "../models.js";
|
|
4
4
|
import { QueueDepthGate } from "../backpressure.js";
|
|
5
5
|
const rejectMangled = function (_key, v) {
|
|
6
6
|
if (typeof v === "number" && !Number.isFinite(v)) {
|
|
@@ -65,13 +65,29 @@ const CONFLICTS = ["reuse", "reuse-succeeded", "reject", "replace"];
|
|
|
65
65
|
function reusable(conflict, status) {
|
|
66
66
|
if (conflict === "replace")
|
|
67
67
|
return false;
|
|
68
|
-
if (!
|
|
68
|
+
if (!isTerminalStatus(status))
|
|
69
69
|
return true;
|
|
70
70
|
return conflict === "reuse-succeeded" && status === "succeeded";
|
|
71
71
|
}
|
|
72
72
|
/** The queue a submit lands on when it names none. Owned here, where the
|
|
73
73
|
* default is applied, so nothing above has to re-derive it. */
|
|
74
74
|
export const DEFAULT_QUEUE = "default";
|
|
75
|
+
/** Validate a purge's inputs. Shared with RetentionSweeper, which fail-fasts at
|
|
76
|
+
* construction on the same rules an hourly sweep would otherwise only surface
|
|
77
|
+
* through its onError hook — one statement of the rules, two callers. */
|
|
78
|
+
export function validatePurgeInput(input) {
|
|
79
|
+
if (input.olderThanMs != null && (!Number.isFinite(input.olderThanMs) || input.olderThanMs < 0)) {
|
|
80
|
+
throw new Error(`olderThanMs must be >= 0, got ${input.olderThanMs}`);
|
|
81
|
+
}
|
|
82
|
+
if (input.limit != null && input.limit < 1) {
|
|
83
|
+
throw new Error(`limit must be >= 1, got ${input.limit}`);
|
|
84
|
+
}
|
|
85
|
+
// Terminal only: purge never deletes live work, so accepting `queued` here
|
|
86
|
+
// would be accepting a filter that silently matches nothing.
|
|
87
|
+
if (input.status != null && !isTerminalStatus(input.status)) {
|
|
88
|
+
throw new Error(`status must be terminal, got ${input.status}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
75
91
|
export const LEASE_EXPIRED_ERROR_JSON = dumpJson(errorEnvelope({
|
|
76
92
|
type: "LeaseExpired",
|
|
77
93
|
code: "lease_expired",
|
|
@@ -159,6 +175,9 @@ export class TaskStore {
|
|
|
159
175
|
static one(rows) {
|
|
160
176
|
return rows.length ? rowToTask(rows[0]) : null;
|
|
161
177
|
}
|
|
178
|
+
static oneRef(rows) {
|
|
179
|
+
return rows.length ? rowToRef(rows[0]) : null;
|
|
180
|
+
}
|
|
162
181
|
// ------------------------------------------------------------- client side
|
|
163
182
|
/**
|
|
164
183
|
* Bound how deep a queue may get before `submit` blocks. Off unless set.
|
|
@@ -232,7 +251,7 @@ export class TaskStore {
|
|
|
232
251
|
// fresh one inserted below. Cancel only what is still live: a terminal
|
|
233
252
|
// task has nothing to stop, and cancelling it would rewrite a settled
|
|
234
253
|
// row (and hand a `canceled` back to whoever is waiting on it).
|
|
235
|
-
if (!
|
|
254
|
+
if (!isTerminalStatus(current.status)) {
|
|
236
255
|
await fetch("cancel", { id: existing[0].task_id });
|
|
237
256
|
}
|
|
238
257
|
}
|
|
@@ -248,6 +267,16 @@ export class TaskStore {
|
|
|
248
267
|
async getByKey(key) {
|
|
249
268
|
return TaskStore.one(await this.fetch("get_by_key", { key }));
|
|
250
269
|
}
|
|
270
|
+
/** The wait loop's probe: id + status alone, so polling a task with a large
|
|
271
|
+
* payload does not re-read and re-parse that payload on every beat. */
|
|
272
|
+
async getStatus(taskId) {
|
|
273
|
+
return TaskStore.oneRef(await this.fetch("get_status", { id: taskId }));
|
|
274
|
+
}
|
|
275
|
+
/** getStatus, following a key instead of an id — re-resolved per call, so a
|
|
276
|
+
* `replace` moves the probe onto the new task. */
|
|
277
|
+
async getStatusByKey(key) {
|
|
278
|
+
return TaskStore.oneRef(await this.fetch("get_status_by_key", { key }));
|
|
279
|
+
}
|
|
251
280
|
async list(input = {}) {
|
|
252
281
|
// Validate up front, like submit's conflict guard: a typo'd status otherwise
|
|
253
282
|
// matches nothing and returns [] indistinguishably from "no such tasks".
|
|
@@ -302,14 +331,11 @@ export class TaskStore {
|
|
|
302
331
|
* call it in a loop until it returns fewer than `limit`.
|
|
303
332
|
*/
|
|
304
333
|
async purge(input = {}) {
|
|
305
|
-
|
|
306
|
-
throw new Error(`olderThanMs must be >= 0, got ${input.olderThanMs}`);
|
|
307
|
-
}
|
|
308
|
-
if (input.limit != null && input.limit < 1) {
|
|
309
|
-
throw new Error(`limit must be >= 1, got ${input.limit}`);
|
|
310
|
-
}
|
|
334
|
+
validatePurgeInput(input);
|
|
311
335
|
const rows = await this.fetch("purge", {
|
|
312
336
|
older_than_ms: input.olderThanMs ?? 0,
|
|
337
|
+
status: input.status ?? null,
|
|
338
|
+
name: input.name ?? null,
|
|
313
339
|
limit: input.limit ?? 1_000,
|
|
314
340
|
});
|
|
315
341
|
return rows.map((r) => r.id);
|
package/dist/wait.d.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { type Task } from "./models.js";
|
|
2
2
|
import type { TaskStore } from "./store/base.js";
|
|
3
|
+
export declare const DEFAULT_WAIT_TIMEOUT_MS = 30000;
|
|
3
4
|
export declare const DEFAULT_POLL_MS = 100;
|
|
4
5
|
export declare const MAX_POLL_MS = 500;
|
|
5
6
|
export interface PollOptions {
|
|
6
7
|
timeoutMs: number;
|
|
8
|
+
/** The first poll interval (default 100). */
|
|
7
9
|
pollMs?: number;
|
|
10
|
+
/** Ceiling the poll interval backs off to (default 500). Worth raising for a
|
|
11
|
+
* task known to take minutes — fewer reads — or lowering when shaving the
|
|
12
|
+
* average half-interval of completion-detection latency matters. */
|
|
8
13
|
maxPollMs?: number;
|
|
9
14
|
}
|
|
10
15
|
/**
|
|
@@ -16,14 +21,14 @@ export interface PollOptions {
|
|
|
16
21
|
* Math.floor(1 * 1.5) === 1 would otherwise never grow past 1.
|
|
17
22
|
*/
|
|
18
23
|
export declare function nextPollMs(current: number, maxMs: number): number;
|
|
19
|
-
/** Poll
|
|
20
|
-
* Throws TaskTimeout, leaving the task running. `pollMs` is the
|
|
21
|
-
* it backs off towards `maxPollMs`. */
|
|
24
|
+
/** Poll the task's status until terminal or timeout. Returns the terminal Task
|
|
25
|
+
* (any status). Throws TaskTimeout, leaving the task running. `pollMs` is the
|
|
26
|
+
* *first* interval; it backs off towards `maxPollMs`. */
|
|
22
27
|
export declare function pollWait(store: TaskStore, taskId: string, opts: PollOptions): Promise<Task>;
|
|
23
28
|
/**
|
|
24
29
|
* The same wait, following a key instead of an id.
|
|
25
30
|
*
|
|
26
|
-
* The key is re-resolved on every
|
|
31
|
+
* The key is re-resolved on every probe, because that is what a key means: a
|
|
27
32
|
* pointer to the task that is *current* under it. A `replace` landing mid-wait
|
|
28
33
|
* moves the wait onto the new task rather than reporting the cancellation of the
|
|
29
34
|
* old one, and a key that points at nothing yet is simply not finished — it
|
package/dist/wait.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { TaskTimeout } from "./errors.js";
|
|
2
2
|
import { nowMs } from "./ids.js";
|
|
3
3
|
import { isTerminal } from "./models.js";
|
|
4
|
+
export const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
|
|
4
5
|
export const DEFAULT_POLL_MS = 100;
|
|
5
6
|
export const MAX_POLL_MS = 500;
|
|
6
7
|
const GROWTH = 1.5;
|
|
@@ -17,36 +18,49 @@ export function nextPollMs(current, maxMs) {
|
|
|
17
18
|
return Math.min(maxMs, Math.max(current + 1, Math.floor(current * GROWTH)));
|
|
18
19
|
}
|
|
19
20
|
/**
|
|
20
|
-
* Poll `
|
|
21
|
+
* Poll `probe` until it reports a terminal status, then return the full task
|
|
22
|
+
* via `read`; or throw once the timeout elapses.
|
|
23
|
+
*
|
|
24
|
+
* The loop's repeated read is the status-only `probe` (see get_status.sql): a
|
|
25
|
+
* waiting caller asks nothing but "is it finished yet", and re-reading the whole
|
|
26
|
+
* row would drag the payload back — and re-parse it — on every beat for the life
|
|
27
|
+
* of the wait. The full row is read once, when the probe turns terminal or, on
|
|
28
|
+
* the timeout beat, for the error's snapshot. Between the probe and that read
|
|
29
|
+
* the row can vanish (purge) or the key repoint (`replace`); a read that comes
|
|
30
|
+
* back empty or non-terminal is simply not finished, and the loop keeps polling.
|
|
21
31
|
*
|
|
22
32
|
* `wake` is what the loop sleeps on between reads: a store with a push channel
|
|
23
|
-
* (Postgres) cuts it short when the task goes terminal, but the re-
|
|
33
|
+
* (Postgres) cuts it short when the task goes terminal, but the re-probe is the
|
|
24
34
|
* source of truth either way, so a plain sleep is always a correct answer.
|
|
25
35
|
*/
|
|
26
|
-
async function poll(read, wake, subject, key, { timeoutMs, pollMs = DEFAULT_POLL_MS, maxPollMs = MAX_POLL_MS }) {
|
|
36
|
+
async function poll(probe, read, wake, subject, key, { timeoutMs, pollMs = DEFAULT_POLL_MS, maxPollMs = MAX_POLL_MS }) {
|
|
27
37
|
const deadline = nowMs() + timeoutMs;
|
|
28
38
|
let interval = pollMs;
|
|
29
39
|
for (;;) {
|
|
30
|
-
const
|
|
40
|
+
const ref = await probe();
|
|
41
|
+
const remaining = deadline - nowMs();
|
|
42
|
+
// The one full-read site: when the probe says finished, or on the timeout
|
|
43
|
+
// beat for the error's stuck-in-what-state snapshot. No ref means no row,
|
|
44
|
+
// so there is nothing for a read to add to either case.
|
|
45
|
+
const task = ref && (isTerminal(ref) || remaining <= 0) ? await read() : null;
|
|
31
46
|
if (task && isTerminal(task))
|
|
32
47
|
return task;
|
|
33
|
-
const remaining = deadline - nowMs();
|
|
34
48
|
if (remaining <= 0)
|
|
35
|
-
throw new TaskTimeout(
|
|
36
|
-
await wake(
|
|
49
|
+
throw new TaskTimeout(ref?.id ?? subject, { timeoutMs, task, key });
|
|
50
|
+
await wake(ref, Math.min(interval, remaining));
|
|
37
51
|
interval = nextPollMs(interval, maxPollMs);
|
|
38
52
|
}
|
|
39
53
|
}
|
|
40
|
-
/** Poll
|
|
41
|
-
* Throws TaskTimeout, leaving the task running. `pollMs` is the
|
|
42
|
-
* it backs off towards `maxPollMs`. */
|
|
54
|
+
/** Poll the task's status until terminal or timeout. Returns the terminal Task
|
|
55
|
+
* (any status). Throws TaskTimeout, leaving the task running. `pollMs` is the
|
|
56
|
+
* *first* interval; it backs off towards `maxPollMs`. */
|
|
43
57
|
export function pollWait(store, taskId, opts) {
|
|
44
|
-
return poll(() => store.get(taskId), (
|
|
58
|
+
return poll(() => store.getStatus(taskId), () => store.get(taskId), (_ref, ms) => store.taskDoneWake(taskId, ms), taskId, null, opts);
|
|
45
59
|
}
|
|
46
60
|
/**
|
|
47
61
|
* The same wait, following a key instead of an id.
|
|
48
62
|
*
|
|
49
|
-
* The key is re-resolved on every
|
|
63
|
+
* The key is re-resolved on every probe, because that is what a key means: a
|
|
50
64
|
* pointer to the task that is *current* under it. A `replace` landing mid-wait
|
|
51
65
|
* moves the wait onto the new task rather than reporting the cancellation of the
|
|
52
66
|
* old one, and a key that points at nothing yet is simply not finished — it
|
|
@@ -57,5 +71,5 @@ export function pollWait(store, taskId, opts) {
|
|
|
57
71
|
* plain sleeps; once it resolves, the store's push channel applies as usual.
|
|
58
72
|
*/
|
|
59
73
|
export function pollWaitByKey(store, key, opts) {
|
|
60
|
-
return poll(() => store.getByKey(key), (
|
|
74
|
+
return poll(() => store.getStatusByKey(key), () => store.getByKey(key), (ref, ms) => (ref ? store.taskDoneWake(ref.id, ms) : sleep(ms)), key, key, opts);
|
|
61
75
|
}
|
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import type { BackpressureOptions } from "./backpressure.js";
|
|
2
2
|
import { type RetentionOptions, RetentionSweeper } from "./retention.js";
|
|
3
3
|
import { TaskCanceled, TaskFailed } from "./errors.js";
|
|
4
|
-
import { isFailed, isSucceeded, type Task, type TaskStatus } from "./models.js";
|
|
4
|
+
import { isFailed, isSucceeded, type Task, type TaskRef, type TaskStatus } from "./models.js";
|
|
5
5
|
import { SQLiteStore } from "./store/sqlite.js";
|
|
6
6
|
import { PostgresStore } from "./store/postgres.js";
|
|
7
7
|
import type { ListInput, PurgeInput, SubmitInput, TaskStore } from "./store/base.js";
|
|
8
8
|
import { type TaskDef, taskName } from "./task.js";
|
|
9
|
-
import { pollWait, pollWaitByKey } from "./wait.js";
|
|
9
|
+
import { DEFAULT_WAIT_TIMEOUT_MS, type PollOptions, pollWait, pollWaitByKey } from "./wait.js";
|
|
10
10
|
|
|
11
11
|
export type SubmitOptions = Omit<SubmitInput, "name" | "payload">;
|
|
12
|
-
|
|
12
|
+
/** The wait loop's knobs with the timeout optional (default 30s) — the public
|
|
13
|
+
* face of PollOptions, whose comments document each knob. */
|
|
14
|
+
export type WaitOptions = Partial<PollOptions>;
|
|
15
|
+
export interface CallOptions extends SubmitOptions, Omit<WaitOptions, "timeoutMs"> {
|
|
13
16
|
waitTimeoutMs?: number;
|
|
14
|
-
pollMs?: number;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
/** Options this handle configures on the store it wraps, rather than the
|
|
@@ -99,6 +101,17 @@ export class CairnQ {
|
|
|
99
101
|
return this._store.getByKey(key);
|
|
100
102
|
}
|
|
101
103
|
|
|
104
|
+
/** The status-only probe wait polls on: id + status, no payload. Public for
|
|
105
|
+
* the same reason it exists — a dashboard or poller that only asks "is it
|
|
106
|
+
* finished yet" should not drag the payload back per ask. */
|
|
107
|
+
getStatus(taskId: string): Promise<TaskRef | null> {
|
|
108
|
+
return this._store.getStatus(taskId);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
getStatusByKey(key: string): Promise<TaskRef | null> {
|
|
112
|
+
return this._store.getStatusByKey(key);
|
|
113
|
+
}
|
|
114
|
+
|
|
102
115
|
list(input?: ListInput): Promise<Task[]> {
|
|
103
116
|
return this._store.list(input);
|
|
104
117
|
}
|
|
@@ -136,13 +149,12 @@ export class CairnQ {
|
|
|
136
149
|
/** Wait for a task to finish. Resolves with the terminal Task (any status);
|
|
137
150
|
* throws TaskTimeout without stopping the task, so `wait(err.taskId)` picks the
|
|
138
151
|
* same wait back up — from another process, or after a longer deadline. */
|
|
139
|
-
wait(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
): Promise<Task> {
|
|
152
|
+
wait(taskId: string, opts: WaitOptions = {}): Promise<Task> {
|
|
153
|
+
// `??`, not a spread default: a caller forwarding `timeoutMs: undefined`
|
|
154
|
+
// (call() does) must still get the default, and a spread would override it.
|
|
143
155
|
return pollWait(this._store, taskId, {
|
|
144
|
-
|
|
145
|
-
|
|
156
|
+
...opts,
|
|
157
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS,
|
|
146
158
|
});
|
|
147
159
|
}
|
|
148
160
|
|
|
@@ -151,10 +163,10 @@ export class CairnQ {
|
|
|
151
163
|
* that held it is gone. Re-resolves the key on each poll, so a `replace`
|
|
152
164
|
* landing mid-wait moves the wait onto the new task, and a key with no task
|
|
153
165
|
* yet is waited for rather than rejected. */
|
|
154
|
-
waitByKey(key: string, opts:
|
|
166
|
+
waitByKey(key: string, opts: WaitOptions = {}): Promise<Task> {
|
|
155
167
|
return pollWaitByKey(this._store, key, {
|
|
156
|
-
|
|
157
|
-
|
|
168
|
+
...opts,
|
|
169
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS,
|
|
158
170
|
});
|
|
159
171
|
}
|
|
160
172
|
|
|
@@ -168,9 +180,9 @@ export class CairnQ {
|
|
|
168
180
|
async call(name: string, payload?: unknown, opts?: CallOptions): Promise<unknown>;
|
|
169
181
|
async call<P, R>(task: TaskDef<P, R>, payload?: P, opts?: CallOptions): Promise<R>;
|
|
170
182
|
async call(task: string | TaskDef, payload?: unknown, opts: CallOptions = {}): Promise<unknown> {
|
|
171
|
-
const { waitTimeoutMs
|
|
183
|
+
const { waitTimeoutMs, pollMs, maxPollMs, ...submit } = opts;
|
|
172
184
|
const created = await this.submit(taskName(task), payload, submit);
|
|
173
|
-
const final = await
|
|
185
|
+
const final = await this.wait(created.id, { timeoutMs: waitTimeoutMs, pollMs, maxPollMs });
|
|
174
186
|
if (isSucceeded(final)) return final.result;
|
|
175
187
|
if (isFailed(final)) throw new TaskFailed(final.error);
|
|
176
188
|
throw new TaskCanceled(final.id);
|
package/src/errors.ts
CHANGED
|
@@ -170,10 +170,12 @@ export class TaskCanceled extends CairnQError {
|
|
|
170
170
|
* anywhere. Nothing inside the blocked handler can observe that, which is why it
|
|
171
171
|
* is reported through `onError` alongside the other things the run loop survived.
|
|
172
172
|
*
|
|
173
|
-
* The cause is
|
|
173
|
+
* The usual cause is synchronous work in a handler: a tight loop, a large
|
|
174
174
|
* JSON.parse, a `*Sync` filesystem or crypto call. Node has one loop and no way
|
|
175
175
|
* to preempt it — move the work to a worker thread, a child process, or an async
|
|
176
|
-
* API that yields.
|
|
176
|
+
* API that yields. The other cause is a worker simply oversubscribed for its
|
|
177
|
+
* `leaseMs` — nothing is blocking, there is just more work than turns — which the
|
|
178
|
+
* same report covers, because the lease is at equal risk either way.
|
|
177
179
|
*/
|
|
178
180
|
export class EventLoopBlocked extends CairnQError {
|
|
179
181
|
constructor(
|
|
@@ -183,8 +185,9 @@ export class EventLoopBlocked extends CairnQError {
|
|
|
183
185
|
) {
|
|
184
186
|
super(
|
|
185
187
|
`heartbeat beat was ${lateMs}ms late (interval ${intervalMs}ms, lease ${leaseMs}ms): ` +
|
|
186
|
-
`the event loop was blocked long enough to miss a beat
|
|
187
|
-
`
|
|
188
|
+
`the event loop was blocked long enough to miss a beat, so this worker's leases ` +
|
|
189
|
+
`are at risk. Usually synchronous work in a handler (move it off the loop); ` +
|
|
190
|
+
`otherwise the worker is oversubscribed for its leaseMs.`,
|
|
188
191
|
);
|
|
189
192
|
this.name = "EventLoopBlocked";
|
|
190
193
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export { CairnQ } from "./client.js";
|
|
2
|
-
export type { CallOptions, ClientOptions, SubmitOptions } from "./client.js";
|
|
2
|
+
export type { CallOptions, ClientOptions, SubmitOptions, WaitOptions } from "./client.js";
|
|
3
3
|
export { QueueDepthGate } from "./backpressure.js";
|
|
4
4
|
export type { BackpressureOptions, QueueDepthLimit } from "./backpressure.js";
|
|
5
5
|
export { RetentionSweeper } from "./retention.js";
|
|
6
|
-
export type { RetentionOptions } from "./retention.js";
|
|
6
|
+
export type { RetentionCutoffs, RetentionOptions } from "./retention.js";
|
|
7
7
|
export { Worker } from "./worker.js";
|
|
8
8
|
export type { BatchHandler, Handler, TypedHandler, WorkerOptions } from "./worker.js";
|
|
9
9
|
export { TaskContext } from "./context.js";
|
|
@@ -14,10 +14,11 @@ export { SQLiteStore } from "./store/sqlite.js";
|
|
|
14
14
|
export { PostgresStore } from "./store/postgres.js";
|
|
15
15
|
export { TaskStore } from "./store/base.js";
|
|
16
16
|
export type { ListInput, PurgeInput, SubmitInput, Conflict } from "./store/base.js";
|
|
17
|
-
export type { Task, TaskStatus } from "./models.js";
|
|
17
|
+
export type { Task, TaskRef, TaskStatus, TerminalStatus } from "./models.js";
|
|
18
18
|
export {
|
|
19
19
|
STATUSES,
|
|
20
20
|
isTerminal,
|
|
21
|
+
isTerminalStatus,
|
|
21
22
|
cancelRequested,
|
|
22
23
|
isQueued,
|
|
23
24
|
isRunning,
|
package/src/models.ts
CHANGED
|
@@ -31,8 +31,28 @@ export interface Task {
|
|
|
31
31
|
completed_at_ms: number | null;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** The id + status pair the wait loop polls on (see get_status.sql) — a probe,
|
|
35
|
+
* not a snapshot: everything else about the task is deliberately not read. */
|
|
36
|
+
export interface TaskRef {
|
|
37
|
+
id: string;
|
|
38
|
+
status: TaskStatus;
|
|
39
|
+
}
|
|
40
|
+
|
|
34
41
|
const JSON_COLUMNS = ["payload", "result", "error", "metadata"] as const;
|
|
35
|
-
|
|
42
|
+
// As a const tuple so TerminalStatus derives from it — the same declare-once
|
|
43
|
+
// pattern as STATUSES/TaskStatus above.
|
|
44
|
+
export const TERMINAL = ["succeeded", "failed", "canceled"] as const;
|
|
45
|
+
export type TerminalStatus = (typeof TERMINAL)[number];
|
|
46
|
+
|
|
47
|
+
export function isTerminalStatus(status: TaskStatus): status is TerminalStatus {
|
|
48
|
+
return (TERMINAL as readonly TaskStatus[]).includes(status);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Map a probe row (see get_status.sql) to a TaskRef — the ref twin of
|
|
52
|
+
* rowToTask, so the row shape stays models' knowledge alone. */
|
|
53
|
+
export function rowToRef(row: Record<string, unknown>): TaskRef {
|
|
54
|
+
return { id: row.id as string, status: row.status as TaskStatus };
|
|
55
|
+
}
|
|
36
56
|
|
|
37
57
|
export function rowToTask(row: Record<string, unknown>): Task {
|
|
38
58
|
const t: Record<string, unknown> = { ...row };
|
|
@@ -46,8 +66,9 @@ export function rowToTask(row: Record<string, unknown>): Task {
|
|
|
46
66
|
return t as unknown as Task;
|
|
47
67
|
}
|
|
48
68
|
|
|
49
|
-
|
|
50
|
-
|
|
69
|
+
/** Accepts anything carrying a status — a Task or a TaskRef probe. */
|
|
70
|
+
export function isTerminal(task: Pick<Task, "status">): boolean {
|
|
71
|
+
return isTerminalStatus(task.status);
|
|
51
72
|
}
|
|
52
73
|
|
|
53
74
|
export function cancelRequested(task: Task): boolean {
|
package/src/retention.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { TaskStatus, TerminalStatus } from "./models.js";
|
|
2
|
+
import { validatePurgeInput, type PurgeInput, type TaskStore } from "./store/base.js";
|
|
2
3
|
|
|
3
4
|
/** Sweep every hour unless asked otherwise — often enough that a queue with a
|
|
4
5
|
* day of retention never carries more than an hour of extra rows, rare enough
|
|
@@ -8,12 +9,21 @@ const DEFAULT_INTERVAL_MS = 3_600_000;
|
|
|
8
9
|
* a backlog drains in few statements, small enough that each is a short write. */
|
|
9
10
|
const DEFAULT_LIMIT = 1_000;
|
|
10
11
|
|
|
12
|
+
/** Per-status cutoffs. A status left out is never swept — granular retention is
|
|
13
|
+
* an explicit statement of what may go, not a default for what wasn't named. */
|
|
14
|
+
export type RetentionCutoffs = Partial<Record<TerminalStatus, number>>;
|
|
15
|
+
|
|
11
16
|
export interface RetentionOptions {
|
|
12
17
|
/**
|
|
13
18
|
* How long a terminal task is kept after it finished. Required: there is no
|
|
14
19
|
* safe default for how long someone else's results stay readable.
|
|
20
|
+
*
|
|
21
|
+
* A number keeps every terminal status the same time. Retention needs are
|
|
22
|
+
* often tiered — a succeeded row is spent once its result is consumed, while
|
|
23
|
+
* a failed one is worth keeping for diagnosis — so a per-status map sets a
|
|
24
|
+
* cutoff per status instead: `{ succeeded: 300_000, failed: 86_400_000 }`.
|
|
15
25
|
*/
|
|
16
|
-
olderThanMs: number;
|
|
26
|
+
olderThanMs: number | RetentionCutoffs;
|
|
17
27
|
/** Time between sweeps. Default 3_600_000 (one hour). */
|
|
18
28
|
intervalMs?: number;
|
|
19
29
|
/** Rows deleted per statement while draining. Default 1_000. */
|
|
@@ -50,20 +60,37 @@ export class RetentionSweeper {
|
|
|
50
60
|
/** The loop itself, awaited by stop() so no purge outlives the store. */
|
|
51
61
|
private loop: Promise<void> | null = null;
|
|
52
62
|
private readonly intervalMs: number;
|
|
53
|
-
|
|
63
|
+
/** Rows per purge statement while draining — see DEFAULT_LIMIT. */
|
|
64
|
+
private readonly limit: number;
|
|
65
|
+
/** One purge per cutoff: a lone entry for a number, one per status for a map. */
|
|
66
|
+
private readonly purgeInputs: PurgeInput[];
|
|
54
67
|
|
|
55
68
|
constructor(
|
|
56
69
|
private readonly store: TaskStore,
|
|
57
70
|
private readonly opts: RetentionOptions,
|
|
58
71
|
) {
|
|
59
|
-
if (!Number.isFinite(opts.olderThanMs) || opts.olderThanMs < 0) {
|
|
60
|
-
throw new Error(`retention.olderThanMs must be >= 0, got ${opts.olderThanMs}`);
|
|
61
|
-
}
|
|
62
72
|
this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
63
73
|
if (!Number.isFinite(this.intervalMs) || this.intervalMs < 1) {
|
|
64
74
|
throw new Error(`retention.intervalMs must be >= 1, got ${this.intervalMs}`);
|
|
65
75
|
}
|
|
66
|
-
this.
|
|
76
|
+
this.limit = opts.limit ?? DEFAULT_LIMIT;
|
|
77
|
+
const cutoffs: [TaskStatus | undefined, number][] =
|
|
78
|
+
typeof opts.olderThanMs === "number"
|
|
79
|
+
? [[undefined, opts.olderThanMs]]
|
|
80
|
+
: (Object.entries(opts.olderThanMs) as [TaskStatus, number][]);
|
|
81
|
+
// An empty map retains nothing and sweeps nothing — almost certainly a bug
|
|
82
|
+
// upstream of this call, so refuse it rather than silently never purging.
|
|
83
|
+
if (!cutoffs.length) {
|
|
84
|
+
throw new Error("retention.olderThanMs must name at least one status");
|
|
85
|
+
}
|
|
86
|
+
this.purgeInputs = cutoffs.map(([status, ms]) => ({
|
|
87
|
+
olderThanMs: ms,
|
|
88
|
+
status,
|
|
89
|
+
limit: this.limit,
|
|
90
|
+
}));
|
|
91
|
+
// Fail fast on the store's own purge rules (terminal status, cutoff >= 0):
|
|
92
|
+
// the sweep runs an hour from now, and its errors only surface via onError.
|
|
93
|
+
for (const input of this.purgeInputs) validatePurgeInput(input);
|
|
67
94
|
}
|
|
68
95
|
|
|
69
96
|
start(): void {
|
|
@@ -107,16 +134,19 @@ export class RetentionSweeper {
|
|
|
107
134
|
* demand — after a backfill, or from a maintenance command.
|
|
108
135
|
*/
|
|
109
136
|
async sweep(): Promise<number> {
|
|
110
|
-
const limit = this.purgeInput.limit as number;
|
|
111
137
|
let deleted = 0;
|
|
112
|
-
for (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
138
|
+
for (const input of this.purgeInputs) {
|
|
139
|
+
for (;;) {
|
|
140
|
+
const ids = await this.store.purge(input);
|
|
141
|
+
deleted += ids.length;
|
|
142
|
+
if (this.stopping) return deleted;
|
|
143
|
+
if (ids.length < this.limit) break;
|
|
144
|
+
// Hand the loop back between batches: a large drain must not starve the
|
|
145
|
+
// submits and claims sharing this process.
|
|
146
|
+
await this.sleep(0);
|
|
147
|
+
}
|
|
119
148
|
}
|
|
149
|
+
return deleted;
|
|
120
150
|
}
|
|
121
151
|
|
|
122
152
|
/** Sleep, interruptible by stop(). Unref'd: retention is housekeeping, and a
|
package/src/store/base.ts
CHANGED
|
@@ -6,7 +6,15 @@ import {
|
|
|
6
6
|
ProtocolVersionMismatch,
|
|
7
7
|
SerializationError,
|
|
8
8
|
} from "../errors.js";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
isTerminalStatus,
|
|
11
|
+
rowToRef,
|
|
12
|
+
rowToTask,
|
|
13
|
+
STATUSES,
|
|
14
|
+
type Task,
|
|
15
|
+
type TaskRef,
|
|
16
|
+
type TaskStatus,
|
|
17
|
+
} from "../models.js";
|
|
10
18
|
import { type BackpressureOptions, QueueDepthGate } from "../backpressure.js";
|
|
11
19
|
|
|
12
20
|
const rejectMangled = function (this: unknown, _key: string, v: unknown): unknown {
|
|
@@ -77,7 +85,7 @@ export type Conflict = (typeof CONFLICTS)[number];
|
|
|
77
85
|
*/
|
|
78
86
|
function reusable(conflict: Conflict, status: TaskStatus): boolean {
|
|
79
87
|
if (conflict === "replace") return false;
|
|
80
|
-
if (!
|
|
88
|
+
if (!isTerminalStatus(status)) return true;
|
|
81
89
|
return conflict === "reuse-succeeded" && status === "succeeded";
|
|
82
90
|
}
|
|
83
91
|
|
|
@@ -110,8 +118,32 @@ export interface ListInput {
|
|
|
110
118
|
offset?: number;
|
|
111
119
|
}
|
|
112
120
|
|
|
121
|
+
/** Validate a purge's inputs. Shared with RetentionSweeper, which fail-fasts at
|
|
122
|
+
* construction on the same rules an hourly sweep would otherwise only surface
|
|
123
|
+
* through its onError hook — one statement of the rules, two callers. */
|
|
124
|
+
export function validatePurgeInput(input: PurgeInput): void {
|
|
125
|
+
if (input.olderThanMs != null && (!Number.isFinite(input.olderThanMs) || input.olderThanMs < 0)) {
|
|
126
|
+
throw new Error(`olderThanMs must be >= 0, got ${input.olderThanMs}`);
|
|
127
|
+
}
|
|
128
|
+
if (input.limit != null && input.limit < 1) {
|
|
129
|
+
throw new Error(`limit must be >= 1, got ${input.limit}`);
|
|
130
|
+
}
|
|
131
|
+
// Terminal only: purge never deletes live work, so accepting `queued` here
|
|
132
|
+
// would be accepting a filter that silently matches nothing.
|
|
133
|
+
if (input.status != null && !isTerminalStatus(input.status)) {
|
|
134
|
+
throw new Error(`status must be terminal, got ${input.status}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
113
138
|
export interface PurgeInput {
|
|
114
139
|
olderThanMs?: number;
|
|
140
|
+
/** Restrict the sweep to one terminal status. Retention needs are tiered —
|
|
141
|
+
* succeeded rows are spent once their result is consumed, failed ones are
|
|
142
|
+
* worth keeping for diagnosis — and without this the shortest-lived tier
|
|
143
|
+
* sets the retention for every row. Absent means all terminal statuses. */
|
|
144
|
+
status?: TaskStatus;
|
|
145
|
+
/** Restrict the sweep to one task name. Absent means all names. */
|
|
146
|
+
name?: string;
|
|
115
147
|
limit?: number;
|
|
116
148
|
}
|
|
117
149
|
|
|
@@ -234,6 +266,10 @@ export abstract class TaskStore {
|
|
|
234
266
|
return rows.length ? rowToTask(rows[0]) : null;
|
|
235
267
|
}
|
|
236
268
|
|
|
269
|
+
private static oneRef(rows: any[]): TaskRef | null {
|
|
270
|
+
return rows.length ? rowToRef(rows[0]) : null;
|
|
271
|
+
}
|
|
272
|
+
|
|
237
273
|
// ------------------------------------------------------------- client side
|
|
238
274
|
/**
|
|
239
275
|
* Bound how deep a queue may get before `submit` blocks. Off unless set.
|
|
@@ -305,7 +341,7 @@ export abstract class TaskStore {
|
|
|
305
341
|
// fresh one inserted below. Cancel only what is still live: a terminal
|
|
306
342
|
// task has nothing to stop, and cancelling it would rewrite a settled
|
|
307
343
|
// row (and hand a `canceled` back to whoever is waiting on it).
|
|
308
|
-
if (!
|
|
344
|
+
if (!isTerminalStatus(current.status as TaskStatus)) {
|
|
309
345
|
await fetch("cancel", { id: existing[0].task_id });
|
|
310
346
|
}
|
|
311
347
|
}
|
|
@@ -324,6 +360,18 @@ export abstract class TaskStore {
|
|
|
324
360
|
return TaskStore.one(await this.fetch("get_by_key", { key }));
|
|
325
361
|
}
|
|
326
362
|
|
|
363
|
+
/** The wait loop's probe: id + status alone, so polling a task with a large
|
|
364
|
+
* payload does not re-read and re-parse that payload on every beat. */
|
|
365
|
+
async getStatus(taskId: string): Promise<TaskRef | null> {
|
|
366
|
+
return TaskStore.oneRef(await this.fetch("get_status", { id: taskId }));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** getStatus, following a key instead of an id — re-resolved per call, so a
|
|
370
|
+
* `replace` moves the probe onto the new task. */
|
|
371
|
+
async getStatusByKey(key: string): Promise<TaskRef | null> {
|
|
372
|
+
return TaskStore.oneRef(await this.fetch("get_status_by_key", { key }));
|
|
373
|
+
}
|
|
374
|
+
|
|
327
375
|
async list(input: ListInput = {}): Promise<Task[]> {
|
|
328
376
|
// Validate up front, like submit's conflict guard: a typo'd status otherwise
|
|
329
377
|
// matches nothing and returns [] indistinguishably from "no such tasks".
|
|
@@ -385,14 +433,11 @@ export abstract class TaskStore {
|
|
|
385
433
|
* call it in a loop until it returns fewer than `limit`.
|
|
386
434
|
*/
|
|
387
435
|
async purge(input: PurgeInput = {}): Promise<string[]> {
|
|
388
|
-
|
|
389
|
-
throw new Error(`olderThanMs must be >= 0, got ${input.olderThanMs}`);
|
|
390
|
-
}
|
|
391
|
-
if (input.limit != null && input.limit < 1) {
|
|
392
|
-
throw new Error(`limit must be >= 1, got ${input.limit}`);
|
|
393
|
-
}
|
|
436
|
+
validatePurgeInput(input);
|
|
394
437
|
const rows = await this.fetch("purge", {
|
|
395
438
|
older_than_ms: input.olderThanMs ?? 0,
|
|
439
|
+
status: input.status ?? null,
|
|
440
|
+
name: input.name ?? null,
|
|
396
441
|
limit: input.limit ?? 1_000,
|
|
397
442
|
});
|
|
398
443
|
return rows.map((r) => r.id as string);
|
package/src/wait.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { TaskTimeout } from "./errors.js";
|
|
2
2
|
import { nowMs } from "./ids.js";
|
|
3
|
-
import { isTerminal, type Task } from "./models.js";
|
|
3
|
+
import { isTerminal, type Task, type TaskRef } from "./models.js";
|
|
4
4
|
import type { TaskStore } from "./store/base.js";
|
|
5
5
|
|
|
6
|
+
export const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
|
|
6
7
|
export const DEFAULT_POLL_MS = 100;
|
|
7
8
|
export const MAX_POLL_MS = 500;
|
|
8
9
|
const GROWTH = 1.5;
|
|
@@ -11,7 +12,11 @@ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve,
|
|
|
11
12
|
|
|
12
13
|
export interface PollOptions {
|
|
13
14
|
timeoutMs: number;
|
|
15
|
+
/** The first poll interval (default 100). */
|
|
14
16
|
pollMs?: number;
|
|
17
|
+
/** Ceiling the poll interval backs off to (default 500). Worth raising for a
|
|
18
|
+
* task known to take minutes — fewer reads — or lowering when shaving the
|
|
19
|
+
* average half-interval of completion-detection latency matters. */
|
|
15
20
|
maxPollMs?: number;
|
|
16
21
|
}
|
|
17
22
|
|
|
@@ -28,15 +33,25 @@ export function nextPollMs(current: number, maxMs: number): number {
|
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
/**
|
|
31
|
-
* Poll `
|
|
36
|
+
* Poll `probe` until it reports a terminal status, then return the full task
|
|
37
|
+
* via `read`; or throw once the timeout elapses.
|
|
38
|
+
*
|
|
39
|
+
* The loop's repeated read is the status-only `probe` (see get_status.sql): a
|
|
40
|
+
* waiting caller asks nothing but "is it finished yet", and re-reading the whole
|
|
41
|
+
* row would drag the payload back — and re-parse it — on every beat for the life
|
|
42
|
+
* of the wait. The full row is read once, when the probe turns terminal or, on
|
|
43
|
+
* the timeout beat, for the error's snapshot. Between the probe and that read
|
|
44
|
+
* the row can vanish (purge) or the key repoint (`replace`); a read that comes
|
|
45
|
+
* back empty or non-terminal is simply not finished, and the loop keeps polling.
|
|
32
46
|
*
|
|
33
47
|
* `wake` is what the loop sleeps on between reads: a store with a push channel
|
|
34
|
-
* (Postgres) cuts it short when the task goes terminal, but the re-
|
|
48
|
+
* (Postgres) cuts it short when the task goes terminal, but the re-probe is the
|
|
35
49
|
* source of truth either way, so a plain sleep is always a correct answer.
|
|
36
50
|
*/
|
|
37
51
|
async function poll(
|
|
52
|
+
probe: () => Promise<TaskRef | null>,
|
|
38
53
|
read: () => Promise<Task | null>,
|
|
39
|
-
wake: (
|
|
54
|
+
wake: (ref: TaskRef | null, ms: number) => Promise<void>,
|
|
40
55
|
subject: string,
|
|
41
56
|
key: string | null,
|
|
42
57
|
{ timeoutMs, pollMs = DEFAULT_POLL_MS, maxPollMs = MAX_POLL_MS }: PollOptions,
|
|
@@ -44,22 +59,27 @@ async function poll(
|
|
|
44
59
|
const deadline = nowMs() + timeoutMs;
|
|
45
60
|
let interval = pollMs;
|
|
46
61
|
for (;;) {
|
|
47
|
-
const
|
|
48
|
-
if (task && isTerminal(task)) return task;
|
|
62
|
+
const ref = await probe();
|
|
49
63
|
const remaining = deadline - nowMs();
|
|
50
|
-
|
|
51
|
-
|
|
64
|
+
// The one full-read site: when the probe says finished, or on the timeout
|
|
65
|
+
// beat for the error's stuck-in-what-state snapshot. No ref means no row,
|
|
66
|
+
// so there is nothing for a read to add to either case.
|
|
67
|
+
const task = ref && (isTerminal(ref) || remaining <= 0) ? await read() : null;
|
|
68
|
+
if (task && isTerminal(task)) return task;
|
|
69
|
+
if (remaining <= 0) throw new TaskTimeout(ref?.id ?? subject, { timeoutMs, task, key });
|
|
70
|
+
await wake(ref, Math.min(interval, remaining));
|
|
52
71
|
interval = nextPollMs(interval, maxPollMs);
|
|
53
72
|
}
|
|
54
73
|
}
|
|
55
74
|
|
|
56
|
-
/** Poll
|
|
57
|
-
* Throws TaskTimeout, leaving the task running. `pollMs` is the
|
|
58
|
-
* it backs off towards `maxPollMs`. */
|
|
75
|
+
/** Poll the task's status until terminal or timeout. Returns the terminal Task
|
|
76
|
+
* (any status). Throws TaskTimeout, leaving the task running. `pollMs` is the
|
|
77
|
+
* *first* interval; it backs off towards `maxPollMs`. */
|
|
59
78
|
export function pollWait(store: TaskStore, taskId: string, opts: PollOptions): Promise<Task> {
|
|
60
79
|
return poll(
|
|
80
|
+
() => store.getStatus(taskId),
|
|
61
81
|
() => store.get(taskId),
|
|
62
|
-
(
|
|
82
|
+
(_ref, ms) => store.taskDoneWake(taskId, ms),
|
|
63
83
|
taskId,
|
|
64
84
|
null,
|
|
65
85
|
opts,
|
|
@@ -69,7 +89,7 @@ export function pollWait(store: TaskStore, taskId: string, opts: PollOptions): P
|
|
|
69
89
|
/**
|
|
70
90
|
* The same wait, following a key instead of an id.
|
|
71
91
|
*
|
|
72
|
-
* The key is re-resolved on every
|
|
92
|
+
* The key is re-resolved on every probe, because that is what a key means: a
|
|
73
93
|
* pointer to the task that is *current* under it. A `replace` landing mid-wait
|
|
74
94
|
* moves the wait onto the new task rather than reporting the cancellation of the
|
|
75
95
|
* old one, and a key that points at nothing yet is simply not finished — it
|
|
@@ -81,8 +101,9 @@ export function pollWait(store: TaskStore, taskId: string, opts: PollOptions): P
|
|
|
81
101
|
*/
|
|
82
102
|
export function pollWaitByKey(store: TaskStore, key: string, opts: PollOptions): Promise<Task> {
|
|
83
103
|
return poll(
|
|
104
|
+
() => store.getStatusByKey(key),
|
|
84
105
|
() => store.getByKey(key),
|
|
85
|
-
(
|
|
106
|
+
(ref, ms) => (ref ? store.taskDoneWake(ref.id, ms) : sleep(ms)),
|
|
86
107
|
key,
|
|
87
108
|
key,
|
|
88
109
|
opts,
|