@fungi.computer/carrier 0.2.0 → 0.2.2
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 +48 -73
- package/dist/inspection.js +12 -5
- package/dist/internal/owner.js +3 -1
- package/dist/internal/post-contract.d.ts +3 -3
- package/dist/internal/post-contract.js +1 -1
- package/dist/internal/post-policy.d.ts +5 -0
- package/dist/internal/post-policy.js +4 -0
- package/dist/internal/post-sql.d.ts +5 -10
- package/dist/internal/post-sql.js +65 -96
- package/dist/post-d1.d.ts +1 -1
- package/dist/post-d1.js +1 -7
- package/dist/post-postgres.js +0 -17
- package/dist/post.d.ts +7 -19
- package/dist/post.js +78 -209
- package/docs/api/README.md +0 -1
- package/docs/api/inspection.md +1 -1
- package/docs/api/post-d1.md +6 -15
- package/docs/api/post-postgres.md +3 -3
- package/docs/api/post.md +15 -132
- package/package.json +3 -7
- package/dist/post-outbox.d.ts +0 -52
- package/dist/post-outbox.js +0 -152
- package/dist/testing.d.ts +0 -16
- package/dist/testing.js +0 -20
- package/docs/api/testing.md +0 -46
package/README.md
CHANGED
|
@@ -92,23 +92,6 @@ from the Durable Object alarm.
|
|
|
92
92
|
|
|
93
93
|
In a Durable Object, `ctx.storage` is already the SQLite owner.
|
|
94
94
|
|
|
95
|
-
### Memory outbox for tests
|
|
96
|
-
|
|
97
|
-
`createMemoryOutbox()` stores encoded string rows in process memory for tests
|
|
98
|
-
and quick starts. It provides `put`, `read`, key-ordered `list(limit, afterKey)`
|
|
99
|
-
and `delete`. Each instance is independent; it is not durable storage. BirdDog’s
|
|
100
|
-
[quick start](https://docs.fungi.computer/bird-dog/quickstart/) uses this
|
|
101
|
-
helper.
|
|
102
|
-
|
|
103
|
-
```ts
|
|
104
|
-
import { createMemoryOutbox } from "@fungi.computer/carrier/testing";
|
|
105
|
-
|
|
106
|
-
const outbox = createMemoryOutbox();
|
|
107
|
-
await outbox.put("mail:one", "encoded mail");
|
|
108
|
-
console.log(await outbox.list(1));
|
|
109
|
-
// [{ key: "mail:one", value: "encoded mail" }]
|
|
110
|
-
```
|
|
111
|
-
|
|
112
95
|
## Core concepts
|
|
113
96
|
|
|
114
97
|
**Emails and Messages.** One Email has shared content and an envelope: `from`,
|
|
@@ -212,15 +195,18 @@ dead-letter reason. They omit Post addresses and events, email bodies, headers
|
|
|
212
195
|
and recipient addresses. Delivered Post rows keep only their key and state.
|
|
213
196
|
Email `attempts` counts definite provider rejections, so an accepted or
|
|
214
197
|
ambiguous first dispatch has zero. Post `attempts` counts receiver invocations.
|
|
198
|
+
Carrier's stored Post `attempts` is receipt-only evidence after a terminal
|
|
199
|
+
outcome. While delivery is pending, Watchdog's `attempt_count` is authoritative;
|
|
200
|
+
pending inspection reads that count from Watchdog.
|
|
215
201
|
|
|
216
202
|
## Post
|
|
217
203
|
|
|
218
|
-
Post's ordinary retry policy
|
|
219
|
-
seconds initially, doubling to a 5-minute cap, with at most 20
|
|
220
|
-
attempts. The synchronous and SQL adapters share that default policy;
|
|
221
|
-
SQL retry policies retain their own delays. A SQLite route with
|
|
222
|
-
uses its finite lifetime instead of the attempt ceiling. Almanac
|
|
223
|
-
arithmetic only, not a second scheduler or retry owner.
|
|
204
|
+
Watchdog applies Post's ordinary retry policy using Almanac's backoff
|
|
205
|
+
calculation: 2 seconds initially, doubling to a 5-minute cap, with at most 20
|
|
206
|
+
receiver attempts. The synchronous and SQL adapters share that default policy;
|
|
207
|
+
explicit SQL retry policies retain their own delays. A SQLite route with
|
|
208
|
+
`deadlineMs` uses its finite lifetime instead of the attempt ceiling. Almanac
|
|
209
|
+
supplies arithmetic only, not a second scheduler or retry owner.
|
|
224
210
|
|
|
225
211
|
`@fungi.computer/carrier/post` delivers typed, point-to-point envelopes between
|
|
226
212
|
named Durable Objects. `definePostRoute` pairs each address schema and event
|
|
@@ -237,29 +223,27 @@ admission receipt commit together. Keys must identify the producing event across
|
|
|
237
223
|
all senders to that receiver.
|
|
238
224
|
|
|
239
225
|
Construct `createPost` with the producer's structural SQLite `owner`, typed
|
|
240
|
-
`routes`, Watchdog's `transactional` projection
|
|
241
|
-
|
|
242
|
-
the
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
receipt write, a retry carries the same envelope key and the receiver reports
|
|
255
|
-
`duplicate`.
|
|
226
|
+
`routes`, Watchdog's `transactional` projection, a clock, and a durable
|
|
227
|
+
`scheduleWake`. Post owns its tables. Call `send` inside the same transaction as
|
|
228
|
+
the producer's state change and durable initial wake. It commits the envelope
|
|
229
|
+
and Watchdog delivery work together. Each producer's events reach a named
|
|
230
|
+
address in order. A pending predecessor holds its successor until it is
|
|
231
|
+
delivered or dead. Replaying the same stable key keeps the existing work or its
|
|
232
|
+
completion receipt. Keep those receipts while the source can replay.
|
|
233
|
+
|
|
234
|
+
Wire Watchdog's `retryPolicy` to `post.retryPolicy` and its executor as
|
|
235
|
+
`(job, signal, attempt) => post.execute(job, attempt, signal)`. Watchdog
|
|
236
|
+
reserves the attempt and fallback deadline before receiver I/O. A retry retains
|
|
237
|
+
the same job and envelope key; `wakeState(now)` reports readiness and the next
|
|
238
|
+
deadline. Delivery recovery never borrows the Agent crash budget. A receiver
|
|
239
|
+
must admit repeated keys idempotently after a lost response or receipt write.
|
|
256
240
|
|
|
257
241
|
`latest(kind, address)` reads the newest staged event or its receipt for that
|
|
258
242
|
recipient without scanning other recipients. A member or operator command can
|
|
259
243
|
call `retry(key)` inside the producer's transaction with its awaited wake. It
|
|
260
|
-
expedites a
|
|
261
|
-
work
|
|
262
|
-
a delivered receipt is never reopened.
|
|
244
|
+
expedites a queued retry without resetting its attempt count, or gives a dead
|
|
245
|
+
letter new work after the current queue while keeping its prepared fact. Queued
|
|
246
|
+
and running jobs stay with Watchdog, and a delivered receipt is never reopened.
|
|
263
247
|
|
|
264
248
|
A route can prepare a wire fact from its staged intent with
|
|
265
249
|
`prepare: { event: wireSchema, run(address, intent, signal) }`. Preparation and
|
|
@@ -276,45 +260,36 @@ dead-letter envelope, its reason and its attempt count. `deadLetters` returns
|
|
|
276
260
|
`letters` and `nextCursor`, including a continuation through pages that contain
|
|
277
261
|
only receipts.
|
|
278
262
|
|
|
279
|
-
The same retry and receipt machinery is exported as `makePostOutbox` for a
|
|
280
|
-
string-backed outbox. [BirdDog](../bird-dog/README.md) uses it for agent reply
|
|
281
|
-
delivery. `postOutboxPending` reports whether such an outbox still owes any
|
|
282
|
-
message a delivery attempt; receipts and exhausted dead letters owe nothing. Its
|
|
283
|
-
`nextWakeAt()` reads the earliest owed delivery across the retained rows,
|
|
284
|
-
including future retries. Delivered receipts, exhausted dead letters and
|
|
285
|
-
malformed rows do not request a wake.
|
|
286
|
-
|
|
287
263
|
## Post transactions on D1 and PostgreSQL
|
|
288
264
|
|
|
289
265
|
`@fungi.computer/carrier/post-d1` and `/post-postgres` bind the ordinary Post
|
|
290
266
|
address and event registry to Watchdog's database adapters. These relational
|
|
291
|
-
adapters
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
`
|
|
296
|
-
|
|
267
|
+
adapters use the same Watchdog attempt owner and do not support route deadlines.
|
|
268
|
+
Constructing either adapter with a route that sets `deadlineMs` throws
|
|
269
|
+
`PostError` with `reason: "relational-deadline-unsupported"` before database
|
|
270
|
+
access. Use SQLite-backed Post for deadline routes. Apply `postD1Schema` or
|
|
271
|
+
`postPostgresSchema` alongside the matching Watchdog schema through your
|
|
272
|
+
migration tool.
|
|
297
273
|
|
|
298
274
|
On D1, `post.send(envelope)` returns the envelope and job statements. Include
|
|
299
275
|
both in the same `database.batch` as the producer fact. `sendFromSelect` accepts
|
|
300
276
|
a trusted native SQLite `?NNN`-bound SELECT yielding `post_key`,
|
|
301
|
-
`envelope_json`, `queue_name
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
Wire
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
outside a transaction.
|
|
277
|
+
`envelope_json`, `queue_name` and `job_id`, for conditional source facts created
|
|
278
|
+
inside that batch. PostgreSQL `post.send(client, envelope)` writes both rows on
|
|
279
|
+
the producer's existing transaction client. Neither send API commits for the
|
|
280
|
+
producer. A conflicting replay rolls the whole transaction back.
|
|
281
|
+
|
|
282
|
+
Wire the same `retryPolicy` and three-argument executor as native SQLite Post.
|
|
283
|
+
The host retains its durable cron or wake and ticks Watchdog directly.
|
|
284
|
+
Preparation, bounded receiver calls, response parsing and settlement decisions
|
|
285
|
+
are shared with SQLite Post. Receipts suppress source replay, failed attempts
|
|
286
|
+
retry with the same Post policy, permanent refusal or exhaustion becomes a dead
|
|
287
|
+
letter, and earlier pending mail blocks later mail to the same address. A
|
|
288
|
+
receiver can return `{ status, receipt }` with JSON receipt metadata. Post
|
|
289
|
+
persists it with terminal settlement and exposes it on the delivered receipt or
|
|
290
|
+
dead letter. That is how an email producer can record provider acceptance while
|
|
291
|
+
minting credentials only during delivery. These async adapters reject
|
|
292
|
+
synchronous SQLite receipt hooks rather than run them outside a transaction.
|
|
318
293
|
|
|
319
294
|
`purgeD1Post` and `purgePostgresPost` return parameterized statements selected
|
|
320
295
|
by trusted producer SQL yielding `queue_name`. Compose all of them in the same
|
package/dist/inspection.js
CHANGED
|
@@ -70,7 +70,12 @@ export class DeliveryInspectionError extends Error {
|
|
|
70
70
|
this.code = code;
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
-
const StoredPost = Schema.Struct({
|
|
73
|
+
const StoredPost = Schema.Struct({
|
|
74
|
+
key: Schema.String,
|
|
75
|
+
value: PostRowSchema,
|
|
76
|
+
attempt_count: Schema.NullOr(Schema.Int),
|
|
77
|
+
not_before: Schema.NullOr(Schema.Number),
|
|
78
|
+
});
|
|
74
79
|
const StoredEmail = Schema.Struct({
|
|
75
80
|
id: Key,
|
|
76
81
|
status: CarrierStatusSchema,
|
|
@@ -141,9 +146,9 @@ export function createDeliveryInspection(sql) {
|
|
|
141
146
|
if (!hasTable(sql, "carrier_post_rows"))
|
|
142
147
|
return { status: "uninitialized" };
|
|
143
148
|
const rows = Schema.decodeUnknownSync(Schema.Array(StoredPost))(sql
|
|
144
|
-
.exec("SELECT key,value FROM carrier_post_rows WHERE key LIKE 'post:%' AND key>? ORDER BY key LIMIT ?", `post:${query.after ?? ""}`, query.limit)
|
|
149
|
+
.exec("SELECT p.key,p.value,w.attempt_count,w.not_before FROM carrier_post_rows p LEFT JOIN watchdog_jobs w ON w.job_id=json_extract(p.value,'$.jobId') WHERE p.key LIKE 'post:%' AND p.key>? ORDER BY p.key LIMIT ?", `post:${query.after ?? ""}`, query.limit)
|
|
145
150
|
.toArray());
|
|
146
|
-
const records = rows.flatMap(({ key, value }) => {
|
|
151
|
+
const records = rows.flatMap(({ key, value, attempt_count, not_before }) => {
|
|
147
152
|
if (query.view === "deadLetters" && value.state !== "dead")
|
|
148
153
|
return [];
|
|
149
154
|
const receiptKey = key.slice("post:".length);
|
|
@@ -151,13 +156,15 @@ export function createDeliveryInspection(sql) {
|
|
|
151
156
|
case "delivered":
|
|
152
157
|
return [{ key: receiptKey, state: value.state }];
|
|
153
158
|
case "pending":
|
|
159
|
+
if (attempt_count === null || not_before === null)
|
|
160
|
+
throw new DeliveryInspectionError("unavailable");
|
|
154
161
|
return [
|
|
155
162
|
{
|
|
156
163
|
key: receiptKey,
|
|
157
164
|
state: value.state,
|
|
158
165
|
kind: value.envelope.kind,
|
|
159
|
-
attempts:
|
|
160
|
-
nextAttemptAt:
|
|
166
|
+
attempts: attempt_count,
|
|
167
|
+
nextAttemptAt: not_before,
|
|
161
168
|
},
|
|
162
169
|
];
|
|
163
170
|
case "dead":
|
package/dist/internal/owner.js
CHANGED
|
@@ -413,7 +413,9 @@ export function openOwner(options) {
|
|
|
413
413
|
.exec("SELECT 1 FROM carrier_emails WHERE status IN ('queued','dispatching') LIMIT 1")
|
|
414
414
|
.toArray().length !== 0);
|
|
415
415
|
return (domainPending ||
|
|
416
|
-
(yield* watchdog
|
|
416
|
+
(yield* watchdog
|
|
417
|
+
.wakeState(options.now())
|
|
418
|
+
.pipe(Effect.mapError(unavailable))).pending);
|
|
417
419
|
}),
|
|
418
420
|
};
|
|
419
421
|
}
|
|
@@ -18,11 +18,11 @@ export declare const PostDeliveryResultSchema: Schema.Union<readonly [Schema.Lit
|
|
|
18
18
|
declare const PostError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
19
19
|
readonly _tag: "PostError";
|
|
20
20
|
} & Readonly<A>;
|
|
21
|
-
/** Post rejected an unknown route, conflicting key,
|
|
21
|
+
/** Post rejected an unknown route, conflicting key, unsupported policy or store. */
|
|
22
22
|
export declare class PostError extends PostError_base<{
|
|
23
23
|
readonly message: string;
|
|
24
|
-
readonly reason?: "relational-deadline-unsupported";
|
|
24
|
+
readonly reason?: "relational-deadline-unsupported" | "pre-scheduler-store";
|
|
25
25
|
}> {
|
|
26
|
-
constructor(message: string, reason?: "relational-deadline-unsupported");
|
|
26
|
+
constructor(message: string, reason?: "relational-deadline-unsupported" | "pre-scheduler-store");
|
|
27
27
|
}
|
|
28
28
|
export {};
|
|
@@ -14,7 +14,7 @@ export const PostDeliveryResultSchema = Schema.Union([
|
|
|
14
14
|
receipt: Schema.Json,
|
|
15
15
|
}),
|
|
16
16
|
]);
|
|
17
|
-
/** Post rejected an unknown route, conflicting key,
|
|
17
|
+
/** Post rejected an unknown route, conflicting key, unsupported policy or store. */
|
|
18
18
|
export class PostError extends Data.TaggedError("PostError") {
|
|
19
19
|
constructor(message, reason) {
|
|
20
20
|
super(reason === undefined ? { message } : { message, reason });
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { RetryPolicy } from "@fungi.computer/almanac";
|
|
2
|
+
/** Ordinary receiver allowance retained from BirdDog; deadline routes own their lifetime. */
|
|
3
|
+
export declare const maxDeliveryAttempts = 20;
|
|
4
|
+
/** Retained receiver backoff, now applied by Watchdog. */
|
|
5
|
+
export declare const postRetryPolicy: RetryPolicy;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { WatchdogRunningJob } from "@fungi.computer/watchdog";
|
|
1
|
+
import type { WatchdogRunningJob, WatchdogAttempt, WatchdogExecutionDecision, WatchdogRetryPolicy } from "@fungi.computer/watchdog";
|
|
2
2
|
import { type PostEnvelope, type PostRegistryRoute, type PostRow } from "../post.js";
|
|
3
3
|
export type Value = string | number | null;
|
|
4
4
|
export type Statement = Readonly<{
|
|
@@ -8,7 +8,6 @@ export type Statement = Readonly<{
|
|
|
8
8
|
export type Row = Record<string, Value>;
|
|
9
9
|
export type PostSql = Readonly<{
|
|
10
10
|
query: (statement: Statement) => Promise<readonly Row[]>;
|
|
11
|
-
commit: (statements: readonly Statement[], key: string, jobId: string) => Promise<void>;
|
|
12
11
|
}>;
|
|
13
12
|
/** SQL native Carrier runtime configuration; route decisions remain in Post. */
|
|
14
13
|
export type SqlPostOptions<R extends Record<string, PostRegistryRoute>> = Readonly<{
|
|
@@ -24,17 +23,13 @@ export type SqlPostOptions<R extends Record<string, PostRegistryRoute>> = Readon
|
|
|
24
23
|
}>;
|
|
25
24
|
/** Clock shared with the database's Watchdog. */
|
|
26
25
|
now: () => number;
|
|
27
|
-
/** Arm a durable wake after committed progress. */
|
|
28
|
-
scheduleWake: (at: number) => Promise<void>;
|
|
29
26
|
}>;
|
|
30
27
|
/** Async inspection and Watchdog executor shared by D1 and PostgreSQL. */
|
|
31
28
|
export type SqlPostRuntime = Readonly<{
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
/** Recover a bounded page of settled attempts into new Watchdog jobs. */
|
|
37
|
-
enqueueDue: (limit?: number, afterKey?: string) => Promise<string | undefined>;
|
|
29
|
+
/** Resolve the delivery budget before Watchdog reserves an attempt. */
|
|
30
|
+
retryPolicy: (job: WatchdogRunningJob) => Promise<WatchdogRetryPolicy>;
|
|
31
|
+
/** Execute an already claimed Post job with its reserved delivery attempt. */
|
|
32
|
+
execute: (job: WatchdogRunningJob, attempt: WatchdogAttempt | undefined, signal?: AbortSignal) => Promise<WatchdogExecutionDecision>;
|
|
38
33
|
/** Read one producer receipt or pending envelope. */
|
|
39
34
|
read: (key: string) => Promise<PostRow | undefined>;
|
|
40
35
|
}>;
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import * as Schema from "effect/Schema";
|
|
2
|
-
import { backoffDelay } from "@fungi.computer/almanac";
|
|
3
2
|
import { defineIdFamily } from "@fungi.computer/cairn";
|
|
4
3
|
import { PostEnvelopeSchema, PostError, } from "../post.js";
|
|
5
4
|
import { deliverPost, postDisposition, deliveryReceipt, } from "./post-execution.js";
|
|
6
|
-
import { maxDeliveryAttempts, postRetryPolicy } from "
|
|
5
|
+
import { maxDeliveryAttempts, postRetryPolicy } from "./post-policy.js";
|
|
7
6
|
const ids = defineIdFamily({
|
|
8
7
|
family: "post",
|
|
9
8
|
version: 1,
|
|
@@ -17,17 +16,12 @@ const Stored = Schema.Struct({
|
|
|
17
16
|
queue_name: Schema.String,
|
|
18
17
|
state: Schema.Literals(["pending", "delivered", "dead"]),
|
|
19
18
|
attempts: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
20
|
-
next_attempt_at: Schema.Number,
|
|
21
19
|
job_id: Schema.String,
|
|
22
20
|
delivery_json: Schema.NullOr(Schema.String),
|
|
23
21
|
receipt_json: Schema.NullOr(ReceiptJson),
|
|
24
22
|
reason: Schema.NullOr(Schema.Literals(["refused", "exhausted"])),
|
|
25
23
|
});
|
|
26
|
-
const columns = "post_key,envelope_json,queue_name,state,attempts,
|
|
27
|
-
const JobReference = Schema.Struct({
|
|
28
|
-
source_job: Schema.NullOr(Schema.String),
|
|
29
|
-
});
|
|
30
|
-
const pageSize = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(64));
|
|
24
|
+
const columns = "post_key,envelope_json,queue_name,state,attempts,job_id,delivery_json,reason,receipt_json";
|
|
31
25
|
export function postSchema(postgres) {
|
|
32
26
|
return [
|
|
33
27
|
`CREATE TABLE IF NOT EXISTS carrier_post_rows (
|
|
@@ -36,7 +30,6 @@ export function postSchema(postgres) {
|
|
|
36
30
|
envelope_json TEXT NOT NULL,queue_name TEXT NOT NULL,
|
|
37
31
|
state TEXT NOT NULL DEFAULT 'pending' CHECK(state IN ('pending','delivered','dead')),
|
|
38
32
|
attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts>=0),
|
|
39
|
-
next_attempt_at ${postgres ? "DOUBLE PRECISION" : "INTEGER"} NOT NULL,
|
|
40
33
|
job_id TEXT NOT NULL,delivery_json TEXT,
|
|
41
34
|
reason TEXT CHECK(reason IN ('refused','exhausted')),
|
|
42
35
|
receipt_json TEXT,
|
|
@@ -44,14 +37,13 @@ export function postSchema(postgres) {
|
|
|
44
37
|
last_error TEXT CHECK(last_error IS NULL OR length(last_error)<=2048),
|
|
45
38
|
attempt_until ${postgres ? "DOUBLE PRECISION" : "INTEGER"}
|
|
46
39
|
)`,
|
|
47
|
-
"CREATE INDEX IF NOT EXISTS idx_post_due ON carrier_post_rows(state,next_attempt_at,post_key)",
|
|
48
40
|
"CREATE INDEX IF NOT EXISTS idx_post_address ON carrier_post_rows(queue_name,state,sequence)",
|
|
49
41
|
];
|
|
50
42
|
}
|
|
51
43
|
export function admitPostSelect(source) {
|
|
52
44
|
return {
|
|
53
|
-
text: `INSERT INTO carrier_post_rows(post_key,envelope_json,queue_name,
|
|
54
|
-
SELECT post_key,envelope_json,queue_name,
|
|
45
|
+
text: `INSERT INTO carrier_post_rows(post_key,envelope_json,queue_name,job_id)
|
|
46
|
+
SELECT post_key,envelope_json,queue_name,job_id FROM (${source.text}) AS admission WHERE true
|
|
55
47
|
ON CONFLICT(post_key) DO UPDATE SET post_key=NULL
|
|
56
48
|
WHERE carrier_post_rows.envelope_json<>excluded.envelope_json OR carrier_post_rows.queue_name<>excluded.queue_name`,
|
|
57
49
|
values: source.values,
|
|
@@ -64,7 +56,7 @@ export function jobSelect(key, postgres, expectedJobId) {
|
|
|
64
56
|
: "json_object('postKey',post_key)";
|
|
65
57
|
return {
|
|
66
58
|
text: `SELECT job_id,queue_name,'post' AS lane,0 AS priority,${payload} AS payload_json,
|
|
67
|
-
CAST(NULL AS INTEGER) AS recovery_max FROM carrier_post_rows
|
|
59
|
+
CAST(NULL AS INTEGER) AS recovery_max,CAST(NULL AS INTEGER) AS recovery_window_ms,CAST(0 AS INTEGER) AS not_before FROM carrier_post_rows
|
|
68
60
|
WHERE post_key=${bind}1 AND state='pending' ${expectedJobId === undefined ? "" : `AND job_id=${bind}2`}`,
|
|
69
61
|
values: expectedJobId === undefined ? [key] : [key, expectedJobId],
|
|
70
62
|
};
|
|
@@ -81,12 +73,11 @@ export function preparePost(options, input) {
|
|
|
81
73
|
key: envelope.key,
|
|
82
74
|
statement: admitPostSelect({
|
|
83
75
|
text: `SELECT CAST($1 AS TEXT) AS post_key,CAST($2 AS TEXT) AS envelope_json,
|
|
84
|
-
CAST($3 AS TEXT) AS queue_name,CAST($4 AS
|
|
76
|
+
CAST($3 AS TEXT) AS queue_name,CAST($4 AS TEXT) AS job_id`,
|
|
85
77
|
values: [
|
|
86
78
|
envelope.key,
|
|
87
79
|
Schema.encodeSync(EnvelopeJson)(envelope),
|
|
88
80
|
queue,
|
|
89
|
-
options.now(),
|
|
90
81
|
jobId,
|
|
91
82
|
],
|
|
92
83
|
}),
|
|
@@ -121,8 +112,6 @@ function publicRow(stored) {
|
|
|
121
112
|
return {
|
|
122
113
|
state: "pending",
|
|
123
114
|
envelope: stored.envelope,
|
|
124
|
-
attempts: stored.attempts,
|
|
125
|
-
nextAttemptAt: stored.next_attempt_at,
|
|
126
115
|
jobId: stored.job_id,
|
|
127
116
|
delivery: stored.delivery,
|
|
128
117
|
};
|
|
@@ -136,14 +125,9 @@ export function openSqlPost(database, options) {
|
|
|
136
125
|
maxDelayMs: Schema.Int.check(Schema.isGreaterThan(0)),
|
|
137
126
|
}))(options.retry ?? {
|
|
138
127
|
maxAttempts: maxDeliveryAttempts,
|
|
139
|
-
baseDelayMs:
|
|
140
|
-
maxDelayMs:
|
|
141
|
-
});
|
|
142
|
-
const delay = (attempt) => backoffDelay(attempt, {
|
|
143
|
-
baseMs: policy.baseDelayMs,
|
|
144
|
-
maxMs: policy.maxDelayMs,
|
|
128
|
+
baseDelayMs: postRetryPolicy.baseMs,
|
|
129
|
+
maxDelayMs: postRetryPolicy.maxMs,
|
|
145
130
|
});
|
|
146
|
-
const retrySql = Array.from({ length: policy.maxAttempts }, (_, index) => `WHEN ${String(index)} THEN ${String(delay(index + 1))}`).join(" ");
|
|
147
131
|
for (const route of Object.values(options.routes)) {
|
|
148
132
|
// Synchronous owner hooks cannot participate in an asynchronous transaction.
|
|
149
133
|
if (route.recordDelivered !== undefined ||
|
|
@@ -152,65 +136,84 @@ export function openSqlPost(database, options) {
|
|
|
152
136
|
throw new PostError("SQL Post requires transaction-bound receipt projections");
|
|
153
137
|
}
|
|
154
138
|
const query = (text, values = []) => database.query({ text, values });
|
|
139
|
+
const readStored = async (key) => {
|
|
140
|
+
const raw = (await query(`SELECT ${columns} FROM carrier_post_rows WHERE post_key=$1`, [key]))[0];
|
|
141
|
+
return raw === undefined ? undefined : parseStored(raw);
|
|
142
|
+
};
|
|
143
|
+
const postKeyOf = (job) => Schema.decodeUnknownSync(Schema.Struct({ postKey: Schema.String }))(job.payload).postKey;
|
|
144
|
+
// Watchdog owns the attempt generation and lease. The retained ten-second
|
|
145
|
+
// receiver fence rejects delayed writes from a timed-out preparation/call.
|
|
146
|
+
const claimed = `post_key=$1 AND job_id=$2 AND state='pending' AND EXISTS (
|
|
147
|
+
SELECT 1 FROM watchdog_jobs w WHERE w.job_id=carrier_post_rows.job_id
|
|
148
|
+
AND w.state='running' AND w.attempt_count=$3 AND w.lease_until>$4)`;
|
|
155
149
|
return {
|
|
156
150
|
read: async (key) => {
|
|
157
|
-
const
|
|
158
|
-
return
|
|
151
|
+
const stored = await readStored(key);
|
|
152
|
+
return stored === undefined ? undefined : publicRow(stored);
|
|
159
153
|
},
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
154
|
+
retryPolicy: async (job) => {
|
|
155
|
+
const row = await readStored(postKeyOf(job));
|
|
156
|
+
if (row === undefined)
|
|
157
|
+
throw new PostError("delivery row is missing");
|
|
158
|
+
return {
|
|
159
|
+
baseMs: policy.baseDelayMs,
|
|
160
|
+
maxMs: policy.maxDelayMs,
|
|
161
|
+
limit: {
|
|
162
|
+
maxAttempts: row.state === "pending" && row.job_id === job.jobId
|
|
163
|
+
? policy.maxAttempts
|
|
164
|
+
: 0,
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
execute: async (job, reservation, signal) => {
|
|
169
|
+
if (reservation === undefined)
|
|
170
|
+
throw new PostError("delivery attempt is missing");
|
|
171
|
+
const postKey = postKeyOf(job);
|
|
172
|
+
const current = await readStored(postKey);
|
|
173
|
+
if (current === undefined)
|
|
174
|
+
throw new PostError("delivery row is missing");
|
|
175
|
+
if (current.state !== "pending" || current.job_id !== job.jobId)
|
|
176
|
+
return { kind: "completed" };
|
|
177
|
+
const bindings = () => [
|
|
169
178
|
postKey,
|
|
170
179
|
job.jobId,
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
180
|
+
reservation.count,
|
|
181
|
+
options.now(),
|
|
182
|
+
];
|
|
183
|
+
if (reservation.status === "exhausted") {
|
|
184
|
+
const settled = await query(`UPDATE carrier_post_rows SET state='dead',reason='exhausted',attempts=$3,attempt_until=NULL,settled_at=$4 WHERE ${claimed} RETURNING post_key`, bindings());
|
|
185
|
+
if (settled.length !== 1)
|
|
186
|
+
throw new PostError("receipt was not committed");
|
|
174
187
|
return { kind: "completed" };
|
|
175
|
-
|
|
176
|
-
const
|
|
188
|
+
}
|
|
189
|
+
const raw = (await query(`UPDATE carrier_post_rows SET attempt_until=$5 WHERE ${claimed} RETURNING ${columns}`, [...bindings(), options.now() + 10_000]))[0];
|
|
190
|
+
if (raw === undefined)
|
|
191
|
+
throw new PostError("attempt claim is unavailable");
|
|
192
|
+
const attempt = publicRow(parseStored(raw));
|
|
177
193
|
if (attempt.state !== "pending")
|
|
178
194
|
throw new PostError("attempt is not pending");
|
|
179
195
|
const route = options.routes[attempt.envelope.kind];
|
|
180
196
|
if (route === undefined)
|
|
181
197
|
throw new PostError("unknown address kind");
|
|
182
|
-
await options.scheduleWake(attempt.nextAttemptAt);
|
|
183
198
|
const evidence = await deliverPost({
|
|
184
199
|
signal,
|
|
185
200
|
attempt,
|
|
186
201
|
route,
|
|
187
202
|
savePrepared: async (envelope) => {
|
|
188
|
-
const saved = await query(`UPDATE carrier_post_rows SET delivery_json=$
|
|
189
|
-
WHERE post_key=$2 AND job_id=$3 AND attempts=$4 AND state='pending' AND attempt_until>$5 RETURNING post_key`, [
|
|
190
|
-
Schema.encodeSync(EnvelopeJson)(envelope),
|
|
191
|
-
postKey,
|
|
192
|
-
job.jobId,
|
|
193
|
-
attempt.attempts,
|
|
194
|
-
options.now(),
|
|
195
|
-
]);
|
|
203
|
+
const saved = await query(`UPDATE carrier_post_rows SET delivery_json=$5 WHERE ${claimed} AND attempt_until>$4 RETURNING post_key`, [...bindings(), Schema.encodeSync(EnvelopeJson)(envelope)]);
|
|
196
204
|
if (saved.length === 0)
|
|
197
205
|
throw new PostError("preparation superseded");
|
|
198
206
|
},
|
|
199
207
|
});
|
|
200
|
-
const disposition = postDisposition(evidence, route,
|
|
208
|
+
const disposition = postDisposition(evidence, route, reservation.count, policy.maxAttempts);
|
|
201
209
|
if (disposition.state === "keep")
|
|
202
|
-
return { kind: "
|
|
203
|
-
const next = options.now() + delay(attempt.attempts);
|
|
210
|
+
return { kind: "retry" };
|
|
204
211
|
const receipt = deliveryReceipt(evidence);
|
|
205
|
-
await query(`UPDATE carrier_post_rows SET state=$
|
|
206
|
-
WHERE
|
|
212
|
+
const settled = await query(`UPDATE carrier_post_rows SET state=$5,reason=$6,attempts=CASE WHEN $5='pending' THEN 0 ELSE $3 END,attempt_until=NULL,last_error=$7,receipt_json=$8,settled_at=CASE WHEN $5='pending' THEN NULL ELSE CAST($4 AS DOUBLE PRECISION) END
|
|
213
|
+
WHERE ${claimed} AND attempt_until>$4 RETURNING post_key`, [
|
|
214
|
+
...bindings(),
|
|
207
215
|
disposition.state,
|
|
208
216
|
disposition.state === "dead" ? disposition.reason : null,
|
|
209
|
-
next,
|
|
210
|
-
postKey,
|
|
211
|
-
job.jobId,
|
|
212
|
-
attempt.attempts,
|
|
213
|
-
options.now(),
|
|
214
217
|
evidence.result === "unavailable" &&
|
|
215
218
|
options.failureDetail !== undefined
|
|
216
219
|
? options.failureDetail(evidence.failure).slice(0, 2048)
|
|
@@ -218,44 +221,10 @@ export function openSqlPost(database, options) {
|
|
|
218
221
|
receipt === undefined
|
|
219
222
|
? null
|
|
220
223
|
: Schema.encodeSync(ReceiptJson)(receipt),
|
|
221
|
-
options.now(),
|
|
222
224
|
]);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
enqueueDue: async (limit = 64, afterKey = "") => {
|
|
227
|
-
Schema.decodeUnknownSync(pageSize)(limit);
|
|
228
|
-
const rows = await query(`SELECT ${columns
|
|
229
|
-
.split(",")
|
|
230
|
-
.map((column) => `p.${column}`)
|
|
231
|
-
.join(",")},w.job_id AS source_job
|
|
232
|
-
FROM carrier_post_rows p LEFT JOIN watchdog_jobs w ON p.job_id=w.job_id
|
|
233
|
-
WHERE p.state='pending' AND (w.job_id IS NULL OR (w.state='settled' AND p.next_attempt_at<=$1)) AND p.post_key>$2
|
|
234
|
-
ORDER BY p.post_key LIMIT $3`, [options.now(), afterKey, limit]);
|
|
235
|
-
let cursor;
|
|
236
|
-
for (const raw of rows) {
|
|
237
|
-
if (Schema.decodeUnknownSync(JobReference)(raw).source_job === null)
|
|
238
|
-
throw new PostError("delivery job is missing");
|
|
239
|
-
const row = parseStored(raw);
|
|
240
|
-
cursor = row.post_key;
|
|
241
|
-
const nextJobId = ids.work.generate();
|
|
242
|
-
await database.commit([
|
|
243
|
-
{
|
|
244
|
-
text: `UPDATE carrier_post_rows SET job_id=$1,state=CASE WHEN attempts>=$2 THEN 'dead' ELSE 'pending' END,
|
|
245
|
-
reason=CASE WHEN attempts>=$2 THEN 'exhausted' ELSE NULL END,
|
|
246
|
-
settled_at=CASE WHEN attempts>=$2 THEN CAST($5 AS DOUBLE PRECISION) ELSE NULL END
|
|
247
|
-
WHERE post_key=$3 AND job_id=$4 AND state='pending' AND next_attempt_at<=$5`,
|
|
248
|
-
values: [
|
|
249
|
-
nextJobId,
|
|
250
|
-
policy.maxAttempts,
|
|
251
|
-
row.post_key,
|
|
252
|
-
row.job_id,
|
|
253
|
-
options.now(),
|
|
254
|
-
],
|
|
255
|
-
},
|
|
256
|
-
], row.post_key, nextJobId);
|
|
257
|
-
}
|
|
258
|
-
return rows.length === limit ? cursor : undefined;
|
|
225
|
+
if (settled.length !== 1)
|
|
226
|
+
throw new PostError("receipt was not committed");
|
|
227
|
+
return { kind: disposition.state === "pending" ? "retry" : "completed" };
|
|
259
228
|
},
|
|
260
229
|
};
|
|
261
230
|
}
|
package/dist/post-d1.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { type D1Watchdog } from "@fungi.computer/watchdog/d1";
|
|
|
3
3
|
import type { PostEnvelope, PostRegistryRoute } from "./post.js";
|
|
4
4
|
import { type SqlPostOptions, type SqlPostRuntime } from "./internal/post-sql.js";
|
|
5
5
|
export type { SqlPostOptions, SqlPostRuntime } from "./internal/post-sql.js";
|
|
6
|
-
/** Trusted SELECT yielding post_key, envelope_json, queue_name
|
|
6
|
+
/** Trusted SELECT yielding post_key, envelope_json, queue_name and job_id. */
|
|
7
7
|
export type D1PostSource = Readonly<{
|
|
8
8
|
/** Trusted native SELECT text with SQLite ?NNN bindings. */
|
|
9
9
|
text: string;
|
package/dist/post-d1.js
CHANGED
|
@@ -10,12 +10,6 @@ export function createD1Post(options) {
|
|
|
10
10
|
.bind(...statement.values);
|
|
11
11
|
const runtime = openSqlPost({
|
|
12
12
|
query: async (statement) => (await prepare(statement).all()).results,
|
|
13
|
-
commit: async (statements, key, jobId) => {
|
|
14
|
-
await options.database.batch([
|
|
15
|
-
...statements.map(prepare),
|
|
16
|
-
options.watchdog.enqueueFromSelect(jobSelect(key, false, jobId)),
|
|
17
|
-
]);
|
|
18
|
-
},
|
|
19
13
|
}, options);
|
|
20
14
|
return {
|
|
21
15
|
...runtime,
|
|
@@ -35,7 +29,7 @@ export function prepareD1PostAdmission(database, source) {
|
|
|
35
29
|
database.prepare(admitPostSelect(source).text).bind(...source.values),
|
|
36
30
|
prepareD1Admission(database).enqueueFromSelect({
|
|
37
31
|
text: `SELECT p.job_id,p.queue_name,'post' AS lane,0 AS priority,
|
|
38
|
-
json_object('postKey',p.post_key) AS payload_json,CAST(NULL AS INTEGER) AS recovery_max
|
|
32
|
+
json_object('postKey',p.post_key) AS payload_json,CAST(NULL AS INTEGER) AS recovery_max,CAST(NULL AS INTEGER) AS recovery_window_ms,CAST(0 AS INTEGER) AS not_before
|
|
39
33
|
FROM carrier_post_rows p JOIN (${source.text}) AS source ON p.post_key=source.post_key
|
|
40
34
|
WHERE p.state='pending'`,
|
|
41
35
|
values: source.values,
|
package/dist/post-postgres.js
CHANGED
|
@@ -32,23 +32,6 @@ export function createPostgresPost(options) {
|
|
|
32
32
|
query: async (statement) => (await options.database.query(statement.text, [
|
|
33
33
|
...statement.values,
|
|
34
34
|
])).rows,
|
|
35
|
-
commit: async (statements, key, jobId) => {
|
|
36
|
-
const client = await options.database.connect();
|
|
37
|
-
try {
|
|
38
|
-
await client.query("BEGIN");
|
|
39
|
-
for (const statement of statements)
|
|
40
|
-
await client.query(statement.text, [...statement.values]);
|
|
41
|
-
await enqueue(client, jobSelect(key, true, jobId));
|
|
42
|
-
await client.query("COMMIT");
|
|
43
|
-
}
|
|
44
|
-
catch (error) {
|
|
45
|
-
await client.query("ROLLBACK");
|
|
46
|
-
throw error;
|
|
47
|
-
}
|
|
48
|
-
finally {
|
|
49
|
-
client.release();
|
|
50
|
-
}
|
|
51
|
-
},
|
|
52
35
|
}, options);
|
|
53
36
|
return {
|
|
54
37
|
...runtime,
|