experimental-a2 0.0.0 → 0.2.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/CHANGELOG.md +43 -0
- package/dist/ai-server.browser.js +2 -2
- package/dist/ai-server.d.ts +19 -7
- package/dist/ai-server.js +730 -96
- package/dist/ai.d.ts +32 -11
- package/dist/ai.js +253 -75
- package/dist/client.d.ts +1 -1
- package/dist/client.js +4 -4
- package/dist/{contract-B0kAXoaL.js → contract-CG_adnu_.js} +2 -1
- package/dist/{contract-DL8btVd9.d.ts → contract-C_3dIIEU.d.ts} +4 -1
- package/dist/devtools-server.browser.js +2 -2
- package/dist/devtools-server.js +1 -1
- package/dist/http.d.ts +1 -1
- package/dist/http.js +4 -3
- package/dist/idempotent-replay-BMyHrP0L.js +19 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/{internal-Dm8Ejnud.js → internal-D6wNxTck.js} +3 -3
- package/dist/{log-Dg1I8NRr.d.ts → log-ldf5g8Cx.d.ts} +74 -56
- package/dist/log-memory.d.ts +1 -1
- package/dist/log-memory.js +173 -96
- package/dist/{log-polling-RO7kclzR.js → log-polling-6COoN60V.js} +1 -1
- package/dist/log-postgres.d.ts +1 -1
- package/dist/log-postgres.js +235 -192
- package/dist/log-redis.d.ts +1 -1
- package/dist/log-redis.js +453 -263
- package/dist/log-sqlite.d.ts +1 -1
- package/dist/log-sqlite.js +216 -127
- package/dist/otel.d.ts +1 -1
- package/dist/otel.js +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/recovery-vercel.d.ts +2 -2
- package/dist/recovery-vercel.js +9 -10
- package/dist/server-DJgD2YWP.js +877 -0
- package/dist/server.browser.js +4 -4
- package/dist/server.d.ts +46 -27
- package/dist/server.js +1 -1
- package/dist/{telemetry-C78al20p.d.ts → telemetry-Cso0qyHQ.d.ts} +1 -1
- package/dist/{wire-2QpU1EtJ.js → wire-BVsgR8o9.js} +1 -1
- package/docs/01-quickstart.mdx +7 -7
- package/docs/concepts/01-contracts.mdx +22 -22
- package/docs/concepts/02-handlers.mdx +223 -89
- package/docs/concepts/03-durability.mdx +199 -112
- package/docs/concepts/04-state.mdx +27 -1
- package/docs/guides/01-timers.mdx +4 -4
- package/docs/guides/02-cancellation.mdx +32 -4
- package/docs/guides/05-production.mdx +61 -27
- package/docs/guides/06-ai-agents.mdx +151 -70
- package/docs/guides/07-devtools.mdx +6 -3
- package/docs/guides/08-application-data.mdx +5 -6
- package/docs/index.mdx +30 -14
- package/docs/reference/01-api.mdx +305 -70
- package/package.json +31 -31
- package/dist/server-DYsnKTTy.js +0 -780
package/dist/log-postgres.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { t as A2Error } from "./errors-BJRMd-h6.js";
|
|
2
2
|
import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
|
|
3
|
+
import { t as idempotentReplay } from "./idempotent-replay-BMyHrP0L.js";
|
|
3
4
|
import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./log-yJbXUf72.js";
|
|
4
|
-
import { t as pollingStream } from "./log-polling-
|
|
5
|
+
import { t as pollingStream } from "./log-polling-6COoN60V.js";
|
|
5
6
|
//#region src/log-postgres.ts
|
|
6
7
|
/**
|
|
7
|
-
* a2/log-postgres — postgres log backend (the production path).
|
|
8
|
+
* experimental-a2/log-postgres — postgres log backend (the production path).
|
|
8
9
|
*
|
|
9
10
|
* Implements the A2Log interface over any client that speaks the tiny
|
|
10
11
|
* `PostgresClient` shape below — a `pg` Pool matches it structurally
|
|
@@ -30,8 +31,11 @@ create table if not exists a2_events (
|
|
|
30
31
|
event_id text not null,
|
|
31
32
|
created_at bigint not null,
|
|
32
33
|
cause jsonb,
|
|
34
|
+
lane text,
|
|
35
|
+
lane_ready boolean not null default false,
|
|
33
36
|
processed_at bigint,
|
|
34
37
|
processed_by_attempt integer,
|
|
38
|
+
returned_event_ids jsonb,
|
|
35
39
|
first_claimed_at bigint,
|
|
36
40
|
last_claimed_at bigint,
|
|
37
41
|
attempt_count integer not null default 0,
|
|
@@ -40,32 +44,22 @@ create table if not exists a2_events (
|
|
|
40
44
|
last_failed_attempt integer,
|
|
41
45
|
last_error text,
|
|
42
46
|
failed_at bigint,
|
|
47
|
+
claim_holder text,
|
|
48
|
+
claim_expires_at bigint,
|
|
43
49
|
primary key (session_id, idx)
|
|
44
50
|
);
|
|
45
51
|
|
|
46
|
-
-- CREATE TABLE IF NOT EXISTS leaves an older A2 table untouched. Keep
|
|
47
|
-
-- additive lifecycle changes self-healing: an old warm deployment can recreate
|
|
48
|
-
-- its embedded schema after an operator drops the tables during a rollout.
|
|
49
|
-
alter table a2_events add column if not exists cause jsonb;
|
|
50
|
-
alter table a2_events add column if not exists processed_by_attempt integer;
|
|
51
|
-
alter table a2_events add column if not exists first_claimed_at bigint;
|
|
52
|
-
alter table a2_events add column if not exists last_claimed_at bigint;
|
|
53
|
-
alter table a2_events
|
|
54
|
-
add column if not exists attempt_count integer not null default 0;
|
|
55
|
-
alter table a2_events
|
|
56
|
-
add column if not exists failure_count integer not null default 0;
|
|
57
|
-
alter table a2_events add column if not exists last_failed_at bigint;
|
|
58
|
-
alter table a2_events add column if not exists last_failed_attempt integer;
|
|
59
|
-
|
|
60
52
|
create unique index if not exists a2_events_event_id on a2_events (event_id);
|
|
61
53
|
create index if not exists a2_events_unprocessed
|
|
62
54
|
on a2_events (session_id, idx) where processed_at is null;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
55
|
+
create index if not exists a2_events_lane_pending
|
|
56
|
+
on a2_events (session_id, lane, idx) where processed_at is null;
|
|
57
|
+
create index if not exists a2_events_dispatch_ready
|
|
58
|
+
on a2_events (session_id, idx)
|
|
59
|
+
where lane_ready and processed_at is null and failed_at is null;
|
|
60
|
+
create index if not exists a2_events_claim_expiry
|
|
61
|
+
on a2_events (session_id, claim_expires_at)
|
|
62
|
+
where lane_ready and processed_at is null and failed_at is null;
|
|
69
63
|
|
|
70
64
|
create table if not exists a2_snapshots (
|
|
71
65
|
session_id text not null,
|
|
@@ -82,9 +76,11 @@ const toCause = (raw) => {
|
|
|
82
76
|
if (raw === null || raw === void 0) return null;
|
|
83
77
|
const value = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
84
78
|
if (!Number.isInteger(value.index) || Number(value.index) < 1 || !Number.isInteger(value.attempt) || Number(value.attempt) < 1) throw new TypeError("stored event has an invalid cause");
|
|
79
|
+
if (value.batchSize !== void 0 && (!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)) throw new TypeError("stored event has an invalid cause");
|
|
85
80
|
return {
|
|
86
81
|
index: Number(value.index),
|
|
87
|
-
attempt: Number(value.attempt)
|
|
82
|
+
attempt: Number(value.attempt),
|
|
83
|
+
...value.batchSize === void 0 ? {} : { batchSize: Number(value.batchSize) }
|
|
88
84
|
};
|
|
89
85
|
};
|
|
90
86
|
const toStored = (row) => ({
|
|
@@ -95,8 +91,10 @@ const toStored = (row) => ({
|
|
|
95
91
|
sessionId: row["session_id"],
|
|
96
92
|
createdAt: new Date(asNumber(row["created_at"])),
|
|
97
93
|
cause: toCause(row["cause"]),
|
|
94
|
+
lane: row["lane"] ?? null,
|
|
98
95
|
processedAt: toDate(row["processed_at"]),
|
|
99
96
|
processedByAttempt: row["processed_by_attempt"] === null || row["processed_by_attempt"] === void 0 ? null : asNumber(row["processed_by_attempt"]),
|
|
97
|
+
returnedEventIds: row["returned_event_ids"] === null || row["returned_event_ids"] === void 0 ? null : typeof row["returned_event_ids"] === "string" ? JSON.parse(row["returned_event_ids"]) : row["returned_event_ids"],
|
|
100
98
|
firstClaimedAt: toDate(row["first_claimed_at"]),
|
|
101
99
|
lastClaimedAt: toDate(row["last_claimed_at"]),
|
|
102
100
|
attemptCount: asNumber(row["attempt_count"]),
|
|
@@ -104,7 +102,9 @@ const toStored = (row) => ({
|
|
|
104
102
|
lastFailedAt: toDate(row["last_failed_at"]),
|
|
105
103
|
lastFailedAttempt: row["last_failed_attempt"] === null || row["last_failed_attempt"] === void 0 ? null : asNumber(row["last_failed_attempt"]),
|
|
106
104
|
lastError: row["last_error"] ?? null,
|
|
107
|
-
failedAt: toDate(row["failed_at"])
|
|
105
|
+
failedAt: toDate(row["failed_at"]),
|
|
106
|
+
claimHolder: row["claim_holder"] ?? null,
|
|
107
|
+
claimExpiresAt: toDate(row["claim_expires_at"])
|
|
108
108
|
});
|
|
109
109
|
const toEvent = (row) => ({
|
|
110
110
|
id: row["event_id"],
|
|
@@ -151,7 +151,7 @@ function postgres(options = {}) {
|
|
|
151
151
|
let c = options.client;
|
|
152
152
|
if (!c) {
|
|
153
153
|
c = new (await (import("pg").catch(() => {
|
|
154
|
-
throw new A2Error("LOG_NOT_CONFIGURED", "a2/log-postgres with a connectionString needs the 'pg' package (optional peer dependency) — install it, or inject a client");
|
|
154
|
+
throw new A2Error("LOG_NOT_CONFIGURED", "experimental-a2/log-postgres with a connectionString needs the 'pg' package (optional peer dependency) — install it, or inject a client");
|
|
155
155
|
}))).default.Pool({ connectionString: options.connectionString });
|
|
156
156
|
owned = c;
|
|
157
157
|
}
|
|
@@ -196,21 +196,48 @@ function postgres(options = {}) {
|
|
|
196
196
|
};
|
|
197
197
|
return {
|
|
198
198
|
async append(sessionId, events) {
|
|
199
|
-
if (events.length === 0) return
|
|
199
|
+
if (events.length === 0) return wrap(async () => {
|
|
200
|
+
const { rows } = await (await client()).query(`select exists(
|
|
201
|
+
select 1 from a2_events
|
|
202
|
+
where session_id = $1 and processed_at is null
|
|
203
|
+
) as has_pending`, [sessionId]);
|
|
204
|
+
return {
|
|
205
|
+
events: [],
|
|
206
|
+
hasPending: rows[0]?.["has_pending"] === true
|
|
207
|
+
};
|
|
208
|
+
});
|
|
200
209
|
return wrap(() => withTx(async (query) => {
|
|
201
210
|
await query("select pg_advisory_xact_lock(hashtext($1))", [sessionId]);
|
|
202
211
|
const suppliedIds = events.filter((e) => e.id !== void 0).map((e) => e.id);
|
|
203
212
|
if (new Set(suppliedIds).size !== suppliedIds.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
|
|
204
213
|
if (suppliedIds.length > 0) {
|
|
205
|
-
const { rows: existing } = await query(
|
|
214
|
+
const { rows: existing } = await query(`select stored.*,
|
|
215
|
+
exists(
|
|
216
|
+
select 1 from a2_events pending
|
|
217
|
+
where pending.session_id = $2
|
|
218
|
+
and pending.processed_at is null
|
|
219
|
+
) as session_has_pending
|
|
220
|
+
from a2_events stored
|
|
221
|
+
where stored.event_id = any($1)
|
|
222
|
+
order by stored.idx`, [suppliedIds, sessionId]);
|
|
206
223
|
if (existing.length > 0) {
|
|
207
224
|
const foreign = existing.find((row) => row["session_id"] !== sessionId);
|
|
208
225
|
if (foreign) throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${String(foreign["event_id"])}' already exists in another session`);
|
|
209
|
-
if (existing.length === events.length) return
|
|
226
|
+
if (existing.length === events.length) return {
|
|
227
|
+
events: idempotentReplay(events, existing.map(toStored)),
|
|
228
|
+
hasPending: existing[0]?.["session_has_pending"] === true
|
|
229
|
+
};
|
|
210
230
|
throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`);
|
|
211
231
|
}
|
|
212
232
|
}
|
|
213
|
-
const { rows: maxRows } = await query(
|
|
233
|
+
const { rows: maxRows } = await query(`select coalesce(max(idx), 0) as max,
|
|
234
|
+
exists(
|
|
235
|
+
select 1 from a2_events pending
|
|
236
|
+
where pending.session_id = $1
|
|
237
|
+
and pending.processed_at is null
|
|
238
|
+
) as session_has_pending
|
|
239
|
+
from a2_events
|
|
240
|
+
where session_id = $1`, [sessionId]);
|
|
214
241
|
const base = asNumber(maxRows[0]?.["max"]);
|
|
215
242
|
const now = clock.now().getTime();
|
|
216
243
|
const inserted = [];
|
|
@@ -220,15 +247,24 @@ function postgres(options = {}) {
|
|
|
220
247
|
const index = base + 1 + i;
|
|
221
248
|
await query(`insert into a2_events
|
|
222
249
|
(session_id, idx, event_type, payload, event_id, created_at,
|
|
223
|
-
cause)
|
|
224
|
-
values (
|
|
250
|
+
cause, lane, lane_ready, processed_at)
|
|
251
|
+
values (
|
|
252
|
+
$1, $2, $3, $4::jsonb, $5, $6, $7::jsonb, $8,
|
|
253
|
+
$8::text is null or not exists (
|
|
254
|
+
select 1 from a2_events
|
|
255
|
+
where session_id = $1 and lane = $8 and processed_at is null
|
|
256
|
+
),
|
|
257
|
+
$9
|
|
258
|
+
)`, [
|
|
225
259
|
sessionId,
|
|
226
260
|
index,
|
|
227
261
|
e.type,
|
|
228
262
|
jsonb(e.payload),
|
|
229
263
|
id,
|
|
230
264
|
now,
|
|
231
|
-
e.cause ? jsonb(e.cause) : null
|
|
265
|
+
e.cause ? jsonb(e.cause) : null,
|
|
266
|
+
e.lane ?? null,
|
|
267
|
+
e.settled ? now : null
|
|
232
268
|
]);
|
|
233
269
|
inserted.push({
|
|
234
270
|
id,
|
|
@@ -238,8 +274,10 @@ function postgres(options = {}) {
|
|
|
238
274
|
sessionId,
|
|
239
275
|
createdAt: new Date(now),
|
|
240
276
|
cause: e.cause ? { ...e.cause } : null,
|
|
241
|
-
|
|
277
|
+
lane: e.lane ?? null,
|
|
278
|
+
processedAt: e.settled ? new Date(now) : null,
|
|
242
279
|
processedByAttempt: null,
|
|
280
|
+
returnedEventIds: null,
|
|
243
281
|
firstClaimedAt: null,
|
|
244
282
|
lastClaimedAt: null,
|
|
245
283
|
attemptCount: 0,
|
|
@@ -247,10 +285,15 @@ function postgres(options = {}) {
|
|
|
247
285
|
lastFailedAt: null,
|
|
248
286
|
lastFailedAttempt: null,
|
|
249
287
|
lastError: null,
|
|
250
|
-
failedAt: null
|
|
288
|
+
failedAt: null,
|
|
289
|
+
claimHolder: null,
|
|
290
|
+
claimExpiresAt: null
|
|
251
291
|
});
|
|
252
292
|
}
|
|
253
|
-
return
|
|
293
|
+
return {
|
|
294
|
+
events: inserted,
|
|
295
|
+
hasPending: maxRows[0]?.["session_has_pending"] === true || events.some((event) => event.settled !== true)
|
|
296
|
+
};
|
|
254
297
|
}));
|
|
255
298
|
},
|
|
256
299
|
async read(sessionId, opts) {
|
|
@@ -262,146 +305,176 @@ function postgres(options = {}) {
|
|
|
262
305
|
params.push(opts.afterIndex);
|
|
263
306
|
conditions.push(`idx > $${params.length}`);
|
|
264
307
|
}
|
|
265
|
-
if (opts?.unprocessedOnly) conditions.push("processed_at is null");
|
|
266
308
|
const { rows } = await c.query(`select * from a2_events where ${conditions.join(" and ")} order by idx`, params);
|
|
267
309
|
return rows.map(toStored);
|
|
268
310
|
});
|
|
269
311
|
},
|
|
270
|
-
async
|
|
271
|
-
return wrap(async () => {
|
|
272
|
-
const c = await client();
|
|
312
|
+
async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, excludeIndexes = [] }) {
|
|
313
|
+
return wrap(() => withTx(async (query) => {
|
|
273
314
|
const now = clock.now().getTime();
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
first_claimed_at = coalesce(e.first_claimed_at, $4),
|
|
295
|
-
last_claimed_at = $4
|
|
296
|
-
from eligible, won
|
|
297
|
-
where e.session_id = $1
|
|
298
|
-
and e.idx = eligible.idx
|
|
299
|
-
and e.processed_at is null
|
|
300
|
-
returning e.*
|
|
301
|
-
)
|
|
302
|
-
select case
|
|
303
|
-
when exists (select 1 from claimed) then 'claimed'
|
|
304
|
-
when exists (select 1 from eligible) then 'busy'
|
|
305
|
-
else 'settled'
|
|
306
|
-
end as outcome, claimed.*
|
|
307
|
-
from (values (1)) as singleton(n)
|
|
308
|
-
left join claimed on true`, [
|
|
315
|
+
const { rows } = await query(`with eligible as materialized (
|
|
316
|
+
select event.idx
|
|
317
|
+
from a2_events event
|
|
318
|
+
where event.session_id = $1
|
|
319
|
+
and event.processed_at is null
|
|
320
|
+
and event.failed_at is null
|
|
321
|
+
and event.lane_ready
|
|
322
|
+
and (event.claim_expires_at is null or event.claim_expires_at <= $2)
|
|
323
|
+
and not (event.idx = any($5::bigint[]))
|
|
324
|
+
for update of event
|
|
325
|
+
)
|
|
326
|
+
update a2_events claimed set
|
|
327
|
+
attempt_count = claimed.attempt_count + 1,
|
|
328
|
+
first_claimed_at = coalesce(claimed.first_claimed_at, $2),
|
|
329
|
+
last_claimed_at = $2,
|
|
330
|
+
claim_holder = $3,
|
|
331
|
+
claim_expires_at = $4
|
|
332
|
+
from eligible
|
|
333
|
+
where claimed.session_id = $1 and claimed.idx = eligible.idx
|
|
334
|
+
returning claimed.*`, [
|
|
309
335
|
sessionId,
|
|
310
|
-
holder,
|
|
311
|
-
expiresAt,
|
|
312
336
|
now,
|
|
313
|
-
|
|
337
|
+
holder,
|
|
338
|
+
expiresAtMs ?? now + ttlMs,
|
|
339
|
+
[...excludeIndexes]
|
|
314
340
|
]);
|
|
315
|
-
|
|
316
|
-
if (row["outcome"] === "claimed") return {
|
|
341
|
+
if (rows.length > 0) return {
|
|
317
342
|
outcome: "claimed",
|
|
318
|
-
|
|
343
|
+
events: rows.map(toStored).toSorted((a, b) => a.index - b.index)
|
|
319
344
|
};
|
|
320
|
-
|
|
321
|
-
|
|
345
|
+
const { rows: activeRows } = await query(`select min(claim_expires_at) as retry_at
|
|
346
|
+
from a2_events
|
|
347
|
+
where session_id = $1
|
|
348
|
+
and processed_at is null
|
|
349
|
+
and failed_at is null
|
|
350
|
+
and lane_ready
|
|
351
|
+
and claim_expires_at > $2`, [sessionId, now]);
|
|
352
|
+
const retryAt = activeRows[0]?.["retry_at"];
|
|
353
|
+
return retryAt === null || retryAt === void 0 ? { outcome: "settled" } : {
|
|
354
|
+
outcome: "busy",
|
|
355
|
+
retryAt: new Date(asNumber(retryAt))
|
|
356
|
+
};
|
|
357
|
+
}));
|
|
322
358
|
},
|
|
323
|
-
async
|
|
359
|
+
async renewClaims({ sessionId, holder, indexes, ttlMs, expiresAtMs }) {
|
|
360
|
+
if (indexes.length === 0) return [];
|
|
324
361
|
return wrap(async () => {
|
|
325
362
|
const c = await client();
|
|
326
363
|
const now = clock.now().getTime();
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
and e.idx = $3
|
|
338
|
-
and current.processed_at is null
|
|
339
|
-
and current.attempt_count = $4
|
|
340
|
-
returning e.idx
|
|
341
|
-
), head as materialized (
|
|
342
|
-
select e.* from a2_events e, completed
|
|
343
|
-
where e.session_id = $1
|
|
344
|
-
and e.idx > $3
|
|
345
|
-
and e.processed_at is null
|
|
346
|
-
order by e.idx limit 1 for update of e
|
|
347
|
-
), eligible as materialized (
|
|
348
|
-
select * from head
|
|
349
|
-
where failed_at is null
|
|
350
|
-
and ($6::bigint is null or idx <= $6)
|
|
351
|
-
), owned as materialized (
|
|
352
|
-
select l.session_id from a2_leases l, eligible
|
|
353
|
-
where l.session_id = $1
|
|
354
|
-
and l.holder = $2
|
|
355
|
-
and l.expires_at > $5
|
|
356
|
-
for update of l
|
|
357
|
-
), claimed as (
|
|
358
|
-
update a2_events e set
|
|
359
|
-
attempt_count = e.attempt_count + 1,
|
|
360
|
-
first_claimed_at = coalesce(e.first_claimed_at, $5),
|
|
361
|
-
last_claimed_at = $5
|
|
362
|
-
from eligible, owned
|
|
363
|
-
where e.session_id = $1
|
|
364
|
-
and e.idx = eligible.idx
|
|
365
|
-
and e.processed_at is null
|
|
366
|
-
returning e.*
|
|
367
|
-
)
|
|
368
|
-
select case
|
|
369
|
-
when not exists (select 1 from current) then 'missing'
|
|
370
|
-
when not exists (select 1 from completed) then 'superseded'
|
|
371
|
-
when exists (select 1 from claimed) then 'claimed'
|
|
372
|
-
when not exists (select 1 from eligible) then 'settled'
|
|
373
|
-
else 'busy'
|
|
374
|
-
end as outcome, claimed.*
|
|
375
|
-
from (values (1)) as singleton(n)
|
|
376
|
-
left join claimed on true`, [
|
|
364
|
+
const expiresAt = expiresAtMs ?? now + ttlMs;
|
|
365
|
+
const { rows } = await c.query(`update a2_events set claim_expires_at = $1
|
|
366
|
+
where session_id = $2
|
|
367
|
+
and idx = any($3::bigint[])
|
|
368
|
+
and processed_at is null
|
|
369
|
+
and failed_at is null
|
|
370
|
+
and claim_holder = $4
|
|
371
|
+
and claim_expires_at > $5
|
|
372
|
+
returning idx`, [
|
|
373
|
+
expiresAt,
|
|
377
374
|
sessionId,
|
|
375
|
+
indexes,
|
|
378
376
|
holder,
|
|
379
|
-
|
|
380
|
-
attempt,
|
|
381
|
-
now,
|
|
382
|
-
maxIndex ?? null
|
|
377
|
+
now
|
|
383
378
|
]);
|
|
384
|
-
|
|
385
|
-
if (row["outcome"] === "missing") throw new TypeError(`no event at index ${completedIndex} in session '${sessionId}'`);
|
|
386
|
-
if (row["outcome"] === "claimed") return {
|
|
387
|
-
outcome: "claimed",
|
|
388
|
-
event: toStored(row)
|
|
389
|
-
};
|
|
390
|
-
return { outcome: row["outcome"] };
|
|
379
|
+
return rows.map((row) => asNumber(row["idx"])).toSorted((a, b) => a - b);
|
|
391
380
|
});
|
|
392
381
|
},
|
|
393
|
-
async
|
|
394
|
-
|
|
395
|
-
const { rows } = await
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
382
|
+
async completeAttempt({ sessionId, index, attempt, events }) {
|
|
383
|
+
return wrap(() => withTx(async (query) => {
|
|
384
|
+
const { rows: parentRows } = await query(`select * from a2_events
|
|
385
|
+
where session_id = $1 and idx = $2
|
|
386
|
+
for update`, [sessionId, index]);
|
|
387
|
+
const parent = parentRows[0];
|
|
388
|
+
if (!parent) throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
|
|
389
|
+
const ids = events.map((event) => event.id);
|
|
390
|
+
if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains the same event id more than once");
|
|
391
|
+
if (parent["processed_at"] !== null) {
|
|
392
|
+
if (asNumber(parent["processed_by_attempt"]) !== attempt) return { outcome: "superseded" };
|
|
393
|
+
const returnedIds = parent["returned_event_ids"] === null || parent["returned_event_ids"] === void 0 ? null : typeof parent["returned_event_ids"] === "string" ? JSON.parse(parent["returned_event_ids"]) : parent["returned_event_ids"];
|
|
394
|
+
if (returnedIds === null || returnedIds.length !== ids.length || returnedIds.some((id, offset) => id !== ids[offset])) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "completed attempt does not match the returned event batch");
|
|
395
|
+
if (events.length === 0) return {
|
|
396
|
+
outcome: "completed",
|
|
397
|
+
events: []
|
|
398
|
+
};
|
|
399
|
+
const { rows: existing } = await query(`select * from a2_events
|
|
400
|
+
where event_id = any($1::text[])`, [ids]);
|
|
401
|
+
const byId = new Map(existing.map((row) => [row["event_id"], row]));
|
|
402
|
+
const ordered = ids.map((id) => byId.get(id));
|
|
403
|
+
if (ordered.some((row) => {
|
|
404
|
+
const cause = row ? toCause(row["cause"]) : null;
|
|
405
|
+
return !row || row["session_id"] !== sessionId || cause?.index !== index || cause.attempt !== attempt;
|
|
406
|
+
})) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event retry does not match the committed batch");
|
|
407
|
+
return {
|
|
408
|
+
outcome: "completed",
|
|
409
|
+
events: idempotentReplay(events, ordered.map(toStored))
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
if (asNumber(parent["attempt_count"]) !== attempt || parent["claim_holder"] === null || parent["claim_holder"] === void 0 || parent["failed_at"] !== null) return { outcome: "superseded" };
|
|
413
|
+
await query("select pg_advisory_xact_lock(hashtext($1))", [sessionId]);
|
|
414
|
+
if (ids.length > 0) {
|
|
415
|
+
const { rows: existing } = await query("select event_id from a2_events where event_id = any($1::text[])", [ids]);
|
|
416
|
+
if (existing.length > 0) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains an already-appended event id");
|
|
417
|
+
}
|
|
418
|
+
const { rows: maxRows } = await query("select coalesce(max(idx), 0) as max from a2_events where session_id = $1", [sessionId]);
|
|
419
|
+
const base = asNumber(maxRows[0]?.["max"]);
|
|
420
|
+
const now = clock.now().getTime();
|
|
421
|
+
const inserted = [];
|
|
422
|
+
for (let offset = 0; offset < events.length; offset += 1) {
|
|
423
|
+
const event = events[offset];
|
|
424
|
+
const { rows } = await query(`insert into a2_events
|
|
425
|
+
(session_id, idx, event_type, payload, event_id, created_at,
|
|
426
|
+
cause, lane, lane_ready, processed_at)
|
|
427
|
+
values (
|
|
428
|
+
$1, $2, $3, $4::jsonb, $5, $6, $7::jsonb, $8,
|
|
429
|
+
$8::text is null or not exists (
|
|
430
|
+
select 1 from a2_events
|
|
431
|
+
where session_id = $1 and lane = $8 and processed_at is null
|
|
432
|
+
),
|
|
433
|
+
$9
|
|
434
|
+
)
|
|
435
|
+
returning *`, [
|
|
436
|
+
sessionId,
|
|
437
|
+
base + offset + 1,
|
|
438
|
+
event.type,
|
|
439
|
+
jsonb(event.payload),
|
|
440
|
+
event.id,
|
|
441
|
+
now,
|
|
442
|
+
jsonb({
|
|
443
|
+
index,
|
|
444
|
+
attempt
|
|
445
|
+
}),
|
|
446
|
+
event.lane ?? null,
|
|
447
|
+
event.settled ? now : null
|
|
448
|
+
]);
|
|
449
|
+
inserted.push(toStored(rows[0]));
|
|
450
|
+
}
|
|
451
|
+
await query(`update a2_events set
|
|
452
|
+
processed_at = $1,
|
|
453
|
+
processed_by_attempt = $2,
|
|
454
|
+
returned_event_ids = $5::jsonb,
|
|
455
|
+
lane_ready = false,
|
|
456
|
+
claim_holder = null,
|
|
457
|
+
claim_expires_at = null
|
|
458
|
+
where session_id = $3 and idx = $4`, [
|
|
459
|
+
now,
|
|
460
|
+
attempt,
|
|
400
461
|
sessionId,
|
|
401
|
-
index
|
|
462
|
+
index,
|
|
463
|
+
jsonb(ids)
|
|
402
464
|
]);
|
|
403
|
-
|
|
404
|
-
|
|
465
|
+
const parentLane = parent["lane"] ?? null;
|
|
466
|
+
if (parentLane !== null) await query(`update a2_events set lane_ready = true
|
|
467
|
+
where session_id = $1
|
|
468
|
+
and idx = (
|
|
469
|
+
select idx from a2_events
|
|
470
|
+
where session_id = $1 and lane = $2 and processed_at is null
|
|
471
|
+
order by idx limit 1
|
|
472
|
+
)`, [sessionId, parentLane]);
|
|
473
|
+
return {
|
|
474
|
+
outcome: "completed",
|
|
475
|
+
events: inserted
|
|
476
|
+
};
|
|
477
|
+
}));
|
|
405
478
|
},
|
|
406
479
|
async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
|
|
407
480
|
return wrap(async () => {
|
|
@@ -418,13 +491,16 @@ function postgres(options = {}) {
|
|
|
418
491
|
failed_at = case
|
|
419
492
|
when e.failure_count + 1 >= $5 then $6
|
|
420
493
|
else e.failed_at
|
|
421
|
-
end
|
|
494
|
+
end,
|
|
495
|
+
claim_holder = null,
|
|
496
|
+
claim_expires_at = null
|
|
422
497
|
from current
|
|
423
498
|
where e.session_id = $1
|
|
424
499
|
and e.idx = $2
|
|
425
500
|
and current.processed_at is null
|
|
426
501
|
and current.failed_at is null
|
|
427
502
|
and current.attempt_count = $3
|
|
503
|
+
and current.claim_holder is not null
|
|
428
504
|
returning e.failure_count, e.failed_at
|
|
429
505
|
)
|
|
430
506
|
select case
|
|
@@ -440,6 +516,10 @@ function postgres(options = {}) {
|
|
|
440
516
|
when exists (
|
|
441
517
|
select 1 from current where failed_at is not null
|
|
442
518
|
) then 'dead_lettered'
|
|
519
|
+
when exists (
|
|
520
|
+
select 1 from current
|
|
521
|
+
where claim_holder is null and last_failed_attempt = $3
|
|
522
|
+
) then 'failed'
|
|
443
523
|
else 'superseded'
|
|
444
524
|
end as outcome,
|
|
445
525
|
coalesce(
|
|
@@ -461,16 +541,6 @@ function postgres(options = {}) {
|
|
|
461
541
|
};
|
|
462
542
|
});
|
|
463
543
|
},
|
|
464
|
-
async markFailed(sessionId, index) {
|
|
465
|
-
await wrap(async () => {
|
|
466
|
-
const { rows } = await (await client()).query("update a2_events set failed_at = $1 where session_id = $2 and idx = $3 returning idx", [
|
|
467
|
-
clock.now().getTime(),
|
|
468
|
-
sessionId,
|
|
469
|
-
index
|
|
470
|
-
]);
|
|
471
|
-
if (rows.length === 0) throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
|
|
472
|
-
});
|
|
473
|
-
},
|
|
474
544
|
async readState(sessionId, reducerName) {
|
|
475
545
|
return wrap(async () => {
|
|
476
546
|
const { rows } = await (await client()).query(`with snapshot as materialized (
|
|
@@ -590,33 +660,6 @@ function postgres(options = {}) {
|
|
|
590
660
|
});
|
|
591
661
|
return pollingStream(readAfter, opts?.startAt !== void 0 ? { startAt: opts.startAt } : {});
|
|
592
662
|
},
|
|
593
|
-
lease: {
|
|
594
|
-
async acquire({ sessionId, holder, ttlMs, expiresAtMs }) {
|
|
595
|
-
return wrap(async () => {
|
|
596
|
-
const c = await client();
|
|
597
|
-
const now = clock.now().getTime();
|
|
598
|
-
const expiresAt = expiresAtMs ?? now + ttlMs;
|
|
599
|
-
const { rows } = await c.query(`insert into a2_leases (session_id, holder, expires_at)
|
|
600
|
-
values ($1, $2, $3)
|
|
601
|
-
on conflict (session_id) do update set
|
|
602
|
-
holder = excluded.holder,
|
|
603
|
-
expires_at = excluded.expires_at
|
|
604
|
-
where a2_leases.expires_at <= $4 or a2_leases.holder = $2
|
|
605
|
-
returning holder`, [
|
|
606
|
-
sessionId,
|
|
607
|
-
holder,
|
|
608
|
-
expiresAt,
|
|
609
|
-
now
|
|
610
|
-
]);
|
|
611
|
-
return rows.length === 1;
|
|
612
|
-
});
|
|
613
|
-
},
|
|
614
|
-
async release({ sessionId, holder }) {
|
|
615
|
-
await wrap(async () => {
|
|
616
|
-
await (await client()).query("delete from a2_leases where session_id = $1 and holder = $2", [sessionId, holder]);
|
|
617
|
-
});
|
|
618
|
-
}
|
|
619
|
-
},
|
|
620
663
|
async close() {
|
|
621
664
|
const current = connection.peek();
|
|
622
665
|
if (!current) return;
|
package/dist/log-redis.d.ts
CHANGED