@fungi.computer/carrier 0.2.1 → 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 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 uses Almanac's shared pure backoff calculation: 2
219
- seconds initially, doubling to a 5-minute cap, with at most 20 receiver
220
- attempts. The synchronous and SQL adapters share that default policy; explicit
221
- SQL retry policies retain their own delays. A SQLite route with `deadlineMs`
222
- uses its finite lifetime instead of the attempt ceiling. Almanac supplies
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 and `readJob` operation, a
241
- clock, and a durable `scheduleWake`. Post owns its tables. Call `send` inside
242
- the same transaction as the producer's state change and durable initial wake. It
243
- commits the envelope and Watchdog delivery work together. Each producer's events
244
- reach a named address in order. A pending predecessor holds its successor until
245
- it is delivered or dead. Replaying the same stable key keeps the existing work
246
- or its completion receipt. Keep those receipts while the source can replay.
247
-
248
- Use `execute` as the Watchdog executor for Post jobs. It records an attempt and
249
- arms recovery before calling the addressed Durable Object. After the owner ticks
250
- Watchdog, call `enqueueDue` at a wake to inspect settled jobs and enqueue due
251
- retries, then tick Watchdog for the newly queued work. Continue its bounded
252
- pages with the returned cursor. Queued or running jobs stay with Watchdog's
253
- lifecycle. A missing job or row is an error. After a lost response or failed
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 settled failed attempt or puts a dead letter after current queued
261
- work, keeping its prepared fact. Queued and running jobs stay with Watchdog, and
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 preserve their existing schema and retry contract and do not support
292
- route deadlines. Constructing either adapter with a route that sets `deadlineMs`
293
- throws `PostError` with `reason: "relational-deadline-unsupported"` before
294
- database access. Use SQLite-backed Post for deadline routes. Apply
295
- `postD1Schema` or `postPostgresSchema` alongside the matching Watchdog schema
296
- through your migration tool.
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`, `next_attempt_at` and `job_id`, for conditional
302
- source facts created inside that batch. PostgreSQL `post.send(client, envelope)`
303
- writes both rows on the producer's existing transaction client. Neither send API
304
- commits for the producer. A conflicting replay rolls the whole transaction back.
305
-
306
- Wire Watchdog's executor as `(job, signal) => post.execute(job, signal)`.
307
- `enqueueDue` recovers a bounded page of settled delivery attempts, and the host
308
- keeps its durable cron or wake. Preparation, bounded receiver calls, response
309
- parsing and settlement decisions are shared with SQLite Post. Receipts suppress
310
- source replay, failed attempts retry with the same Post policy, permanent
311
- refusal or exhaustion becomes a dead letter, and earlier pending mail blocks
312
- later mail to the same address. A receiver can return `{ status, receipt }` with
313
- JSON receipt metadata. Post persists it with terminal settlement and exposes it
314
- on the delivered receipt or dead letter. That is how an email producer can
315
- record provider acceptance while minting credentials only during delivery. These
316
- async adapters reject synchronous SQLite receipt hooks rather than run them
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
@@ -70,7 +70,12 @@ export class DeliveryInspectionError extends Error {
70
70
  this.code = code;
71
71
  }
72
72
  }
73
- const StoredPost = Schema.Struct({ key: Schema.String, value: PostRowSchema });
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: value.attempts,
160
- nextAttemptAt: value.nextAttemptAt,
166
+ attempts: attempt_count,
167
+ nextAttemptAt: not_before,
161
168
  },
162
169
  ];
163
170
  case "dead":
@@ -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.hasPendingWork.pipe(Effect.mapError(unavailable))));
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, or unsupported policy. */
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, or unsupported policy. */
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;
@@ -0,0 +1,4 @@
1
+ /** Ordinary receiver allowance retained from BirdDog; deadline routes own their lifetime. */
2
+ export const maxDeliveryAttempts = 20;
3
+ /** Retained receiver backoff, now applied by Watchdog. */
4
+ export const postRetryPolicy = { baseMs: 2_000, maxMs: 300_000 };
@@ -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
- /** Execute an already claimed Post job through the registered receiver. */
33
- execute: (job: WatchdogRunningJob, signal?: AbortSignal) => Promise<Readonly<{
34
- kind: "completed";
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 "../post-outbox.js";
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,next_attempt_at,job_id,delivery_json,reason,receipt_json";
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,next_attempt_at,job_id)
54
- SELECT post_key,envelope_json,queue_name,next_attempt_at,job_id FROM (${source.text}) AS admission WHERE true
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 DOUBLE PRECISION) AS next_attempt_at,CAST($5 AS TEXT) AS job_id`,
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: backoffDelay(1, postRetryPolicy),
140
- maxDelayMs: backoffDelay(maxDeliveryAttempts, postRetryPolicy),
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 raw = (await query(`SELECT ${columns} FROM carrier_post_rows WHERE post_key=$1`, [key]))[0];
158
- return raw === undefined ? undefined : publicRow(parseStored(raw));
151
+ const stored = await readStored(key);
152
+ return stored === undefined ? undefined : publicRow(stored);
159
153
  },
160
- execute: async (job, signal) => {
161
- const { postKey } = Schema.decodeUnknownSync(Schema.Struct({ postKey: Schema.String }))(job.payload);
162
- const raw = (await query(`UPDATE carrier_post_rows SET attempts=attempts+1,
163
- next_attempt_at=CAST($1 AS DOUBLE PRECISION)+CASE attempts ${retrySql} ELSE ${String(delay(policy.maxAttempts))} END,attempt_until=$2 WHERE post_key=$3 AND job_id=$4 AND state='pending'
164
- AND attempts<$5 AND NOT EXISTS (SELECT 1 FROM carrier_post_rows AS earlier
165
- WHERE earlier.queue_name=carrier_post_rows.queue_name AND earlier.state='pending'
166
- AND earlier.sequence<carrier_post_rows.sequence) RETURNING ${columns}`, [
167
- options.now(),
168
- options.now() + 10_000,
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
- policy.maxAttempts,
172
- ]))[0];
173
- if (raw === undefined)
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
- const stored = parseStored(raw);
176
- const attempt = publicRow(stored);
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=$1
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, attempt.attempts, policy.maxAttempts);
208
+ const disposition = postDisposition(evidence, route, reservation.count, policy.maxAttempts);
201
209
  if (disposition.state === "keep")
202
- return { kind: "completed" };
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=$1,reason=$2,next_attempt_at=$3,attempt_until=NULL,last_error=$8,receipt_json=$9,settled_at=CASE WHEN $1='pending' THEN NULL ELSE CAST($10 AS DOUBLE PRECISION) END
206
- WHERE post_key=$4 AND job_id=$5 AND attempts=$6 AND state='pending' AND attempt_until>$7`, [
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
- await options.scheduleWake(disposition.state === "pending" ? next : options.now());
224
- return { kind: "completed" };
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, next_attempt_at and job_id. */
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,
@@ -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,