@forgezero/runtime 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/queue.d.ts CHANGED
@@ -1,243 +1,102 @@
1
1
  /**
2
- * A keyed work queue parallel across keys, strictly sequential within one.
3
- *
4
- * The problem this solves is narrow and very common: most work can run in any
5
- * order, but some of it must not. Two credits to the same wallet, two writes to
6
- * the same vault entry, two deploys of the same application each pair has to
7
- * happen one after the other, while everything touching a DIFFERENT wallet,
8
- * entry or application should run at full width.
9
- *
10
- * A global lock gives you the ordering and throws away the throughput. A plain
11
- * worker pool gives you the throughput and loses the ordering. The answer is to
12
- * partition by key: the queue is really thousands of tiny FIFOs, and a worker
13
- * claims a KEY rather than a message.
14
- *
15
- * ## Why claim the key, not the message
16
- *
17
- * Claiming a message and processing it is the obvious design and it does not
18
- * order anything: two workers can claim message 1 and message 2 of the same key
19
- * at the same instant and finish in either order. Claiming the KEY makes
20
- * ordering structural while a worker holds `wallet:42`, nobody else can take
21
- * ANY message for it, so its messages can only be processed in the order they
22
- * were written.
23
- *
24
- * ## What this guarantees, and what it does not
25
- *
26
- * GUARANTEED per-key FIFO order; at-least-once delivery; at most one worker
27
- * per key at a time, across the whole cluster; a message that
28
- * exhausts its attempts moves to a dead letter INSTEAD of blocking
29
- * its key forever.
30
- *
31
- * NOT GUARANTEED exactly-once execution. Nothing can guarantee that: a worker
32
- * can complete the side effect and die before recording that it
33
- * did. `dedupeKey` collapses duplicate ENQUEUES, and the lease
34
- * stops concurrent delivery, but a crash between "money moved" and
35
- * "message acknowledged" will redeliver.
36
- *
37
- * ## So for money, read this
38
- *
39
- * The queue is not what makes a financial operation safe — the HANDLER is. Make
40
- * the effect idempotent at the point of effect: a ledger posting keyed on the
41
- * message id and refused by a unique index, or a transfer keyed on an
42
- * idempotency key the payment provider also honours. Then redelivery is free
43
- * and the queue only has to provide ordering and durability, which it does.
44
- *
45
- * Anything that relies on "the queue delivered it once" is relying on something
46
- * no queue provides, including this one, including the expensive ones.
2
+ * Ordered work: sequential within a key, parallel across keys.
3
+ *
4
+ * That sentence is the whole contract, and the previous version buried it under
5
+ * a persistence framework `QueueStore`, leases, standalone/cluster modes and
6
+ * an unfinished cluster adapter that nothing in production ever used. The
7
+ * framework was also wrong on its own terms: a message left `leased` after its
8
+ * lease expired became invisible to both the claim and ready reads, so a stale
9
+ * lease made work unrunnable rather than runnable again.
10
+ *
11
+ * Worse for the property that matters, retry backoff did not hold the key. A
12
+ * task waiting to retry was skipped and a LATER task for the same key ran
13
+ * first, which is precisely the ordering this module exists to guarantee.
14
+ *
15
+ * So persistence is gone. The layer that knows what a pending business request
16
+ * means is the runtime that issued it — ForgeZero's own durability record is an
17
+ * Arango row written in the same transaction as the change it describes, and
18
+ * the outbox drains through here rather than reimplementing ordering.
19
+ *
20
+ * ## What it does guarantee
21
+ *
22
+ * - Tasks for one key run one at a time, in submission order, INCLUDING across
23
+ * a retry: a task that is waiting to try again blocks its key.
24
+ * - Different keys run concurrently, up to `width`.
25
+ * - `run()` returns the handler's own value, typed, so a caller can await work
26
+ * instead of polling for it.
27
+ * - `stop()` awaits what is running and what is queued, with a deadline, and
28
+ * reports what it managed to finish.
47
29
  */
48
- export interface Clock {
49
- now(): number;
30
+ export interface QueueTask<T> {
31
+ /** Resolves with the handler's value, or rejects with its final error. */
32
+ readonly result: Promise<T>;
33
+ /** Stable id, so a caller can cancel or look up this task. */
34
+ readonly id: string;
35
+ readonly key: string;
50
36
  }
51
- export declare const systemClock: Clock;
52
- /** `30s` 30000. Throws rather than guessing — a wrong window is silent. */
53
- export declare function durationMs(value: string | number): number;
54
- export type MessageStatus = 'ready' | 'leased' | 'done' | 'dead';
55
- export interface Message<Body = unknown> {
56
- id: string;
57
- /** The partition. Everything sharing it runs in order, one at a time. */
58
- key: string;
59
- body: Body;
60
- /** Monotonic per queue. Decides order within a key. */
61
- sequence: number;
62
- status: MessageStatus;
37
+ export interface RetryPolicy {
38
+ /** How many ATTEMPTS in total, including the first. */
63
39
  attempts: number;
64
- /** Earliest time this may be delivered. Set by a retry backoff. */
65
- availableAtMs: number;
66
- /** Collapses repeat enqueues within a window. See `enqueue`. */
67
- dedupeKey?: string;
68
- lastError?: string;
69
- enqueuedAtMs: number;
70
- completedAtMs?: number;
40
+ /** Milliseconds before attempt n+1. Called with the 1-based attempt made. */
41
+ backoffMs: (attempt: number) => number;
71
42
  }
72
- export interface KeyLease {
73
- key: string;
74
- /** Rises every time the key is granted. A stale holder cannot write. */
75
- fence: number;
76
- untilMs: number;
77
- owner: string;
43
+ export interface QueueOptions {
44
+ /** How many keys may run at once. Ordering within a key is unaffected. */
45
+ width?: number;
46
+ retry?: Partial<RetryPolicy>;
47
+ /** Injected so tests need no timers and no wall clock. */
48
+ sleep?: (ms: number) => Promise<void>;
49
+ now?: () => number;
78
50
  }
79
- /**
80
- * Where the queue lives. Memory for tests, a database for a cluster.
81
- *
82
- * `claimKey` MUST be atomic. A read-then-write implementation hands the same key
83
- * to two workers under exactly the load that makes ordering matter, and the bug
84
- * looks like "the queue occasionally processes out of order" — which is the
85
- * hardest possible thing to reproduce.
86
- */
87
- export interface QueueStore<Body = unknown> {
88
- /** Which deployment shape this store can serve. See `MODES`. */
89
- readonly mode: QueueMode;
90
- append(queue: string, message: Message<Body>): Promise<void>;
91
- /** Existing message with this dedupe key, if the window has not passed. */
92
- findByDedupe(queue: string, dedupeKey: string, sinceMs: number): Promise<Message<Body> | null>;
93
- /**
94
- * Take the next key that has ready work and is not leased. Atomic.
95
- * `exclude` skips keys this worker already holds.
96
- */
97
- claimKey(queue: string, owner: string, ttlMs: number, nowMs: number): Promise<KeyLease | null>;
98
- renewKey(queue: string, lease: KeyLease, ttlMs: number, nowMs: number): Promise<boolean>;
99
- releaseKey(queue: string, lease: KeyLease): Promise<void>;
100
- /** Ready messages for one key, in sequence order. */
101
- readKey(queue: string, key: string, nowMs: number, limit: number): Promise<Message<Body>[]>;
102
- update(queue: string, id: string, patch: Partial<Message<Body>>): Promise<void>;
103
- stats(queue: string): Promise<QueueStats>;
104
- /** Dead-lettered messages, newest first. */
105
- dead(queue: string, limit: number): Promise<Message<Body>[]>;
106
- }
107
- export interface QueueStats {
108
- ready: number;
109
- leased: number;
110
- dead: number;
111
- keys: number;
51
+ export interface DrainReport {
52
+ completed: number;
53
+ failed: number;
54
+ /** Still queued or running when the deadline passed. */
55
+ abandoned: number;
56
+ timedOut: boolean;
112
57
  }
113
- /**
114
- * Standalone and cluster are the same queue with a different store.
115
- *
116
- * standalone one process, state in memory. No coordination, nothing to
117
- * install, and the ordering guarantee still holds — because a
118
- * single process claiming its own keys cannot race itself.
119
- * Everything is lost on restart, which is correct for a
120
- * development machine and wrong for anything holding money.
121
- *
122
- * cluster several processes over shared storage. The guarantee is the
123
- * same and the requirement is stricter: `claimKey` must be
124
- * ATOMIC. A read-then-write implementation hands one key to two
125
- * workers under exactly the load that makes ordering matter, and
126
- * the bug presents as "the queue occasionally processes out of
127
- * order" — the hardest possible thing to reproduce.
128
- *
129
- * The distinction is declared on the store rather than inferred, so a
130
- * deployment can refuse to start when a durable queue was expected and a memory
131
- * one was wired in. That mistake is otherwise invisible until a restart.
132
- */
133
- export declare const MODES: readonly ["standalone", "cluster"];
134
- export type QueueMode = (typeof MODES)[number];
135
- /**
136
- * Single process. Correct for tests and for one instance, and honest about
137
- * being no more — nothing here coordinates across processes.
138
- */
139
- export declare function memoryStore<Body = unknown>(clock?: Clock): QueueStore<Body>;
140
- export interface Handler<Body> {
141
- (message: Message<Body>, context: HandlerContext): Promise<void>;
58
+ export declare class QueueStoppedError extends Error {
59
+ constructor();
142
60
  }
143
- export interface HandlerContext {
144
- /** Still ours? False means the lease expired and nothing further may be written. */
145
- holdsKey(): Promise<boolean>;
146
- log(message: string): void;
147
- attempt: number;
61
+ export declare class TaskCancelledError extends Error {
62
+ constructor();
148
63
  }
149
- export interface QueueOptions<Body> {
150
- name: string;
151
- store: QueueStore<Body>;
152
- handler: Handler<Body>;
153
- /** How many KEYS one worker processes at once. Not messages — see the note. */
154
- concurrency?: number;
155
- /** How long a key lease lasts. Renewed between messages. */
156
- leaseMs?: number;
157
- /** Attempts before a message is dead-lettered. */
158
- maxAttempts?: number;
159
- /** Backoff between attempts. Doubles, capped. */
160
- backoffMs?: number;
161
- maxBackoffMs?: number;
162
- /** Repeat enqueues with the same `dedupeKey` inside this window collapse. */
163
- dedupeWindowMs?: number;
164
- /** Messages taken per key per turn, so one hot key cannot hold a slot forever. */
165
- batch?: number;
166
- clock?: Clock;
167
- owner?: string;
168
- onError?: (message: Message<Body>, error: unknown) => void;
64
+ export declare function createQueue(options?: QueueOptions): {
169
65
  /**
170
- * Refuse to start unless the store is durable.
66
+ * Submit work and await its value.
171
67
  *
172
- * Set it wherever losing queued work on a restart would be a real loss.
173
- * Without it, wiring the memory store into a cluster is a mistake nothing
174
- * reports until the first restart, by which point the work is gone.
68
+ * The handler's return type flows through, so a caller gets a result
69
+ * rather than an id to poll the previous `enqueue` returned only
70
+ * `{ id, duplicate }` and had nowhere to put an answer.
175
71
  */
176
- require?: QueueMode;
177
- }
178
- export interface EnqueueResult {
179
- id: string;
180
- /** True when an identical `dedupeKey` was already queued and this was dropped. */
181
- duplicate: boolean;
182
- }
183
- export declare function createQueue<Body = unknown>(options: QueueOptions<Body>): {
184
- enqueue: (args: {
185
- key: string;
186
- body: Body;
187
- dedupeKey?: string;
188
- delayMs?: number;
189
- }) => Promise<EnqueueResult>;
190
- tick: () => Promise<number>;
191
- /** Drain until nothing is ready. For tests and for a one-shot worker. */
192
- drain(maxPasses?: number): Promise<void>;
193
- /** Poll forever. `stop()` lets in-flight keys finish. */
194
- start(intervalMs?: number): void;
195
- stop(): void;
196
- mode: "standalone" | "cluster";
197
- stats: () => Promise<QueueStats>;
198
- dead: (limit?: number) => Promise<Message<Body>[]>;
199
- /** Put a dead message back at the front of its key. An operator decision. */
200
- revive(id: string): Promise<void>;
201
- };
202
- export type Queue<Body = unknown> = ReturnType<typeof createQueue<Body>>;
203
- export declare const VERSION = "0.1.0";
204
- /**
205
- * The four operations a shared database has to provide, and the one that is
206
- * hard.
207
- *
208
- * Everything except `claimKey` is an ordinary read or write. `claimKey` has to
209
- * find a key with ready work, check that nobody holds it, and take it — as ONE
210
- * atomic step. Split into a read and a write, two workers pass the check
211
- * together and both proceed, and the queue silently stops ordering anything.
212
- *
213
- * Written as a small adapter rather than a driver so the same queue runs over
214
- * ArangoDB, Postgres or Redis, and so this package keeps no dependency.
215
- */
216
- export interface ClusterAdapter<Body = unknown> {
217
- insert(queue: string, message: Message<Body>): Promise<void>;
218
- findDuplicate(queue: string, dedupeKey: string, sinceMs: number): Promise<Message<Body> | null>;
72
+ run<T>(key: string, handler: () => Promise<T> | T): QueueTask<T>;
73
+ /** Remove a task that has not started. Running work is left alone. */
74
+ cancel(id: string): boolean;
75
+ /** Hold one key. Work already running for it finishes. */
76
+ pauseKey(key: string): void;
77
+ resumeKey(key: string): void;
78
+ /** Hold everything. New submissions are accepted and wait. */
79
+ pause(): void;
80
+ resume(): void;
81
+ /** How much is outstanding, for a health endpoint or a drain decision. */
82
+ snapshot(): {
83
+ running: number;
84
+ queued: number;
85
+ keys: number;
86
+ paused: boolean;
87
+ pausedKeys: string[];
88
+ completed: number;
89
+ failed: number;
90
+ };
91
+ /** Resolve when nothing is running and nothing is queued. */
92
+ whenIdle(): Promise<void>;
219
93
  /**
220
- * ATOMIC. Must return a key that has ready work and no live lease, marking it
221
- * leased in the same operation. Returning null means there is nothing to do.
94
+ * Stop accepting work and await what is left, with a deadline.
95
+ *
96
+ * `stop()` used to flip a boolean and return `void`, which is not a
97
+ * shutdown — a process could exit with work in flight and report success.
98
+ * This reports what it actually managed.
222
99
  */
223
- takeKey(args: {
224
- queue: string;
225
- owner: string;
226
- untilMs: number;
227
- nowMs: number;
228
- }): Promise<KeyLease | null>;
229
- extendKey(queue: string, key: string, fence: number, untilMs: number): Promise<boolean>;
230
- dropKey(queue: string, key: string, fence: number): Promise<void>;
231
- readReady(queue: string, key: string, nowMs: number, limit: number): Promise<Message<Body>[]>;
232
- patch(queue: string, id: string, patch: Partial<Message<Body>>): Promise<void>;
233
- counts(queue: string): Promise<QueueStats>;
234
- deadLetter(queue: string, limit: number): Promise<Message<Body>[]>;
235
- }
236
- /**
237
- * A durable store over any shared database.
238
- *
239
- * Declares `mode: 'cluster'`, which is what lets a deployment refuse to start if
240
- * somebody wires the memory store into production by mistake — a mistake that
241
- * is otherwise invisible until the first restart takes the queue with it.
242
- */
243
- export declare function clusterStore<Body = unknown>(adapter: ClusterAdapter<Body>): QueueStore<Body>;
100
+ stop(deadlineMs?: number): Promise<DrainReport>;
101
+ };
102
+ export type Queue = ReturnType<typeof createQueue>;