experimental-a2 0.18.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/dist/{actor-Bjce9_5U.d.ts → actor-C7b-1arn.d.ts} +3 -3
- package/dist/{actor-Bjce9_5U.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-react.d.ts +1 -1
- package/dist/actor-react.js +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +2 -2
- package/dist/{ai-DrUaMyRt.d.ts → ai-BMLlI2Ff.d.ts} +2 -2
- package/dist/{ai-DrUaMyRt.d.ts.map → ai-BMLlI2Ff.d.ts.map} +1 -1
- package/dist/{ai-BpOGm-zB.js → ai-D6mGlO8j.js} +2 -2
- package/dist/{ai-BpOGm-zB.js.map → ai-D6mGlO8j.js.map} +1 -1
- package/dist/ai-client.d.ts +1 -1
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.d.ts +1 -1
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +92 -34
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-DvsPeJeB.d.ts → ai-harness-B2suP2Qh.d.ts} +2 -2
- package/dist/{ai-harness-DvsPeJeB.d.ts.map → ai-harness-B2suP2Qh.d.ts.map} +1 -1
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.d.ts.map +1 -1
- package/dist/ai-pi.js +59 -13
- package/dist/ai-pi.js.map +1 -1
- package/dist/ai-react.d.ts +1 -1
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +3 -3
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +10 -6
- package/dist/ai-server.js.map +1 -1
- package/dist/ai-tool-input-DmSqMUQi.js +40 -0
- package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/{client-BReKqwZC.js → client-BhHLoNKF.js} +3 -3
- package/dist/{client-BReKqwZC.js.map → client-BhHLoNKF.js.map} +1 -1
- package/dist/client.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/{internal-B2T3U4Rq.js → internal-CscSY7K_.js} +4 -2
- package/dist/{internal-B2T3U4Rq.js.map → internal-CscSY7K_.js.map} +1 -1
- package/dist/react.js +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +3 -3
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +7 -7
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +9 -10
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-Dw_k_7kO.js → server-D-Y4pqwu.js} +37 -11
- package/dist/server-D-Y4pqwu.js.map +1 -0
- package/dist/{server-En7cbi3v.d.ts → server-DYAATFuT.d.ts} +11 -5
- package/dist/server-DYAATFuT.d.ts.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{session-clear-Bo-XTFEy.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
- package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
- package/dist/{store-Djdv0bnZ.d.ts → store-CyLUy86v.d.ts} +23 -4
- package/dist/store-CyLUy86v.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +25 -6
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-B5FcwJfo.js → store-polling-7WJV0YWP.js} +2 -2
- package/dist/{store-polling-B5FcwJfo.js.map → store-polling-7WJV0YWP.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +55 -25
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-BAwS8RWn.js → store-presence-polling-B1ixeqIS.js} +3 -3
- package/dist/{store-presence-polling-BAwS8RWn.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +3 -3
- package/dist/{store-redis-notify-BRITti_o.js → store-redis-notify-CM0AfLY0.js} +88 -37
- package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +35 -20
- package/dist/store-sqlite.js.map +1 -1
- package/dist/{wire-rSUglYo8.js → wire-BKxH1vbP.js} +2 -2
- package/dist/{wire-rSUglYo8.js.map → wire-BKxH1vbP.js.map} +1 -1
- package/docs/agents/03-conversations.mdx +2 -2
- package/docs/index.mdx +3 -3
- package/docs/reference/02-server.mdx +12 -7
- package/docs/reference/04-ai.mdx +16 -7
- package/docs/reference/05-adapters.mdx +4 -4
- package/docs/server/01-handlers.mdx +1 -1
- package/docs/server/03-cancellation.mdx +3 -2
- package/docs/server/05-production.mdx +2 -1
- package/docs/server/06-guarantees.mdx +20 -11
- package/examples/playground/DEPLOY.md +2 -4
- package/examples/playground/app/a0/[appId]/page.tsx +2 -8
- package/examples/playground/app/a0/devtools.tsx +2 -8
- package/examples/playground/app/a0/server.ts +8 -19
- package/examples/playground/package.json +1 -1
- package/package.json +1 -1
- package/src/ai-agent.ts +10 -3
- package/src/ai-fx.ts +126 -47
- package/src/ai-pi.ts +74 -15
- package/src/ai-tool-input.ts +45 -0
- package/src/internal.ts +6 -0
- package/src/scheduler-qstash.ts +5 -5
- package/src/scheduler-vercel.ts +14 -15
- package/src/server.ts +58 -12
- package/src/store-memory.ts +50 -9
- package/src/store-postgres.ts +79 -28
- package/src/store-redis-core.ts +112 -31
- package/src/store-sqlite.ts +61 -28
- package/src/store.ts +22 -3
- package/dist/server-Dw_k_7kO.js.map +0 -1
- package/dist/server-En7cbi3v.d.ts.map +0 -1
- package/dist/session-clear-Bo-XTFEy.d.ts.map +0 -1
- package/dist/store-Djdv0bnZ.d.ts.map +0 -1
- package/dist/store-redis-notify-BRITti_o.js.map +0 -1
package/src/store-postgres.ts
CHANGED
|
@@ -350,6 +350,34 @@ const createListener = ({
|
|
|
350
350
|
return listenerConnection
|
|
351
351
|
})
|
|
352
352
|
|
|
353
|
+
/** A dead-lettered event's snapshot pins, and history no pin keeps. */
|
|
354
|
+
const releasePins = async ({
|
|
355
|
+
query,
|
|
356
|
+
sessionId,
|
|
357
|
+
index,
|
|
358
|
+
}: {
|
|
359
|
+
query: PostgresConnection['query']
|
|
360
|
+
sessionId: string
|
|
361
|
+
index: number
|
|
362
|
+
}): Promise<void> => {
|
|
363
|
+
await query(
|
|
364
|
+
`delete from a2_snapshot_pins
|
|
365
|
+
where session_id = $1 and event_index = $2`,
|
|
366
|
+
[sessionId, index],
|
|
367
|
+
)
|
|
368
|
+
await query(
|
|
369
|
+
`delete from a2_snapshot_history history
|
|
370
|
+
where history.session_id = $1
|
|
371
|
+
and not exists (
|
|
372
|
+
select 1 from a2_snapshot_pins pin
|
|
373
|
+
where pin.session_id = history.session_id
|
|
374
|
+
and pin.reducer_name = history.reducer_name
|
|
375
|
+
and pin.up_to_index = history.up_to_index
|
|
376
|
+
)`,
|
|
377
|
+
[sessionId],
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
|
|
353
381
|
export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
354
382
|
const clock = options.clock ?? SYSTEM_CLOCK
|
|
355
383
|
const generateId = options.ids ?? RANDOM_IDS
|
|
@@ -942,11 +970,16 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
942
970
|
ttlMs,
|
|
943
971
|
expiresAtMs,
|
|
944
972
|
excludeIndexes = [],
|
|
973
|
+
maxFailures,
|
|
945
974
|
}) {
|
|
946
975
|
return wrap(() =>
|
|
947
976
|
withTx(async (query) => {
|
|
948
977
|
const now = clock.now().getTime()
|
|
949
978
|
const expiresAt = expiresAtMs ?? now + ttlMs
|
|
979
|
+
// A lapsed claim's takeover that spends the last failed attempt
|
|
980
|
+
// dead-letters the event instead of claiming it.
|
|
981
|
+
const exhausted = `(claimed.claim_holder is not null
|
|
982
|
+
and claimed.failure_count + 1 >= $6::int)`
|
|
950
983
|
const { rows } = await query(
|
|
951
984
|
`with eligible as materialized (
|
|
952
985
|
select event.idx
|
|
@@ -962,15 +995,36 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
962
995
|
)
|
|
963
996
|
update a2_events claimed set
|
|
964
997
|
attempt_count = claimed.attempt_count + 1,
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
998
|
+
failure_count = claimed.failure_count
|
|
999
|
+
+ case when claimed.claim_holder is null then 0 else 1 end,
|
|
1000
|
+
failed_at = case when ${exhausted} then $2::bigint else claimed.failed_at end,
|
|
1001
|
+
first_claimed_at = case when ${exhausted}
|
|
1002
|
+
then claimed.first_claimed_at
|
|
1003
|
+
else coalesce(claimed.first_claimed_at, $2::bigint) end,
|
|
1004
|
+
last_claimed_at = case when ${exhausted}
|
|
1005
|
+
then claimed.last_claimed_at else $2::bigint end,
|
|
1006
|
+
claim_holder = case when ${exhausted} then null else $3::text end,
|
|
1007
|
+
claim_expires_at = case when ${exhausted} then null else $4::bigint end
|
|
969
1008
|
from eligible
|
|
970
1009
|
where claimed.session_id = $1 and claimed.idx = eligible.idx
|
|
971
1010
|
returning claimed.*, ${clearIndexOf('$1')} as clear_index`,
|
|
972
|
-
[
|
|
1011
|
+
[
|
|
1012
|
+
sessionId,
|
|
1013
|
+
now,
|
|
1014
|
+
holder,
|
|
1015
|
+
expiresAt,
|
|
1016
|
+
[...excludeIndexes],
|
|
1017
|
+
maxFailures ?? null,
|
|
1018
|
+
],
|
|
973
1019
|
)
|
|
1020
|
+
for (const row of rows) {
|
|
1021
|
+
if (row['failed_at'] !== null)
|
|
1022
|
+
await releasePins({
|
|
1023
|
+
query,
|
|
1024
|
+
sessionId,
|
|
1025
|
+
index: asNumber(row['idx']),
|
|
1026
|
+
})
|
|
1027
|
+
}
|
|
974
1028
|
if (rows.length > 0) {
|
|
975
1029
|
const events = rows
|
|
976
1030
|
.map((row) => {
|
|
@@ -990,8 +1044,9 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
990
1044
|
and failed_at is null
|
|
991
1045
|
and lane_ready
|
|
992
1046
|
and idx > ${clearIndexOf('$1')}
|
|
993
|
-
and claim_expires_at > $2
|
|
994
|
-
|
|
1047
|
+
and claim_expires_at > $2
|
|
1048
|
+
and not (idx = any($3::bigint[]))`,
|
|
1049
|
+
[sessionId, now, [...excludeIndexes]],
|
|
995
1050
|
)
|
|
996
1051
|
const retryAt = activeRows[0]?.['retry_at']
|
|
997
1052
|
return retryAt === null || retryAt === undefined
|
|
@@ -1262,7 +1317,15 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
1262
1317
|
)
|
|
1263
1318
|
},
|
|
1264
1319
|
|
|
1265
|
-
async failAttempt({
|
|
1320
|
+
async failAttempt({
|
|
1321
|
+
sessionId,
|
|
1322
|
+
index,
|
|
1323
|
+
attempt,
|
|
1324
|
+
error,
|
|
1325
|
+
maxFailures,
|
|
1326
|
+
retryAfterMs,
|
|
1327
|
+
}) {
|
|
1328
|
+
const now = clock.now().getTime()
|
|
1266
1329
|
return wrap(() =>
|
|
1267
1330
|
withTx(async (query) => {
|
|
1268
1331
|
const { rows } = await query(
|
|
@@ -1282,7 +1345,10 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
1282
1345
|
else e.failed_at
|
|
1283
1346
|
end,
|
|
1284
1347
|
claim_holder = null,
|
|
1285
|
-
claim_expires_at =
|
|
1348
|
+
claim_expires_at = case
|
|
1349
|
+
when e.failure_count + 1 >= $5 then null
|
|
1350
|
+
else $7::bigint
|
|
1351
|
+
end
|
|
1286
1352
|
from current
|
|
1287
1353
|
where e.session_id = $1
|
|
1288
1354
|
and e.idx = $2
|
|
@@ -1322,7 +1388,8 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
1322
1388
|
attempt,
|
|
1323
1389
|
error,
|
|
1324
1390
|
maxFailures,
|
|
1325
|
-
|
|
1391
|
+
now,
|
|
1392
|
+
retryAfterMs === undefined ? null : now + retryAfterMs,
|
|
1326
1393
|
],
|
|
1327
1394
|
)
|
|
1328
1395
|
const row = rows[0]
|
|
@@ -1337,24 +1404,8 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
1337
1404
|
`no event at index ${index} in session '${sessionId}'`,
|
|
1338
1405
|
)
|
|
1339
1406
|
}
|
|
1340
|
-
if (row['outcome'] === 'dead_lettered')
|
|
1341
|
-
await query
|
|
1342
|
-
`delete from a2_snapshot_pins
|
|
1343
|
-
where session_id = $1 and event_index = $2`,
|
|
1344
|
-
[sessionId, index],
|
|
1345
|
-
)
|
|
1346
|
-
await query(
|
|
1347
|
-
`delete from a2_snapshot_history history
|
|
1348
|
-
where history.session_id = $1
|
|
1349
|
-
and not exists (
|
|
1350
|
-
select 1 from a2_snapshot_pins pin
|
|
1351
|
-
where pin.session_id = history.session_id
|
|
1352
|
-
and pin.reducer_name = history.reducer_name
|
|
1353
|
-
and pin.up_to_index = history.up_to_index
|
|
1354
|
-
)`,
|
|
1355
|
-
[sessionId],
|
|
1356
|
-
)
|
|
1357
|
-
}
|
|
1407
|
+
if (row['outcome'] === 'dead_lettered')
|
|
1408
|
+
await releasePins({ query, sessionId, index })
|
|
1358
1409
|
return {
|
|
1359
1410
|
outcome: row['outcome'] as
|
|
1360
1411
|
'failed' | 'dead_lettered' | 'superseded',
|
package/src/store-redis-core.ts
CHANGED
|
@@ -494,7 +494,7 @@ return { 'ok', tostring(base + 1), pending_after }
|
|
|
494
494
|
/**
|
|
495
495
|
* KEYS: counter, meta, pending, ready, event snapshot pins, snapshot pin counts,
|
|
496
496
|
* metadata split.
|
|
497
|
-
* ARGV: idx, attempt, error, maxFailures, nowMs.
|
|
497
|
+
* ARGV: idx, attempt, error, maxFailures, nowMs, retryAtMs ('' for none).
|
|
498
498
|
*/
|
|
499
499
|
const FAIL_ATTEMPT_LUA = `
|
|
500
500
|
local COUNTER, META, SPLIT = KEYS[1], KEYS[2], KEYS[7]
|
|
@@ -526,8 +526,14 @@ put(meta_key, field .. ':e', ARGV[3])
|
|
|
526
526
|
put(meta_key, field .. ':lf', ARGV[5])
|
|
527
527
|
put(meta_key, field .. ':lfa', ARGV[2])
|
|
528
528
|
if dead then put(meta_key, field .. ':f', ARGV[5]) end
|
|
529
|
+
local hold = not dead and ARGV[6] ~= ''
|
|
530
|
+
if hold then put(meta_key, field .. ':ce', ARGV[6]) end
|
|
529
531
|
flush()
|
|
530
|
-
|
|
532
|
+
if hold then
|
|
533
|
+
redis.call('HDEL', meta_key, field .. ':ch')
|
|
534
|
+
else
|
|
535
|
+
redis.call('HDEL', meta_key, field .. ':ch', field .. ':ce')
|
|
536
|
+
end
|
|
531
537
|
if dead then
|
|
532
538
|
redis.call('ZREM', KEYS[4], field)
|
|
533
539
|
release_snapshot_pins(KEYS[5], KEYS[6])
|
|
@@ -537,23 +543,29 @@ return { 'failed', tostring(failures) }
|
|
|
537
543
|
`
|
|
538
544
|
|
|
539
545
|
/**
|
|
540
|
-
* KEYS: store, counter, meta, pending, ready, metadata split
|
|
541
|
-
*
|
|
546
|
+
* KEYS: store, counter, meta, pending, ready, metadata split, snapshot pin
|
|
547
|
+
* counts.
|
|
548
|
+
* ARGV: holder, nowMs, expiresAtMs, excludedCount, excluded indexes, then
|
|
549
|
+
* maxFailures ('' for none) and the event snapshot-pins key prefix.
|
|
542
550
|
* The ready set is a subset of the pending set, so an empty ready set is
|
|
543
551
|
* enough to report 'settled'. A claimed result ends with the clear boundary.
|
|
544
552
|
*/
|
|
545
553
|
const CLAIM_AVAILABLE_LUA = `
|
|
546
554
|
local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[6]
|
|
547
555
|
${META_LUA}
|
|
556
|
+
${RELEASE_PINS_LUA}
|
|
548
557
|
local ready = redis.call('ZRANGE', KEYS[5], 0, -1)
|
|
549
558
|
local excluded = {}
|
|
559
|
+
local tail = 5 + tonumber(ARGV[4])
|
|
550
560
|
for i = 1, tonumber(ARGV[4]) do excluded[ARGV[4 + i]] = true end
|
|
561
|
+
local max_failures = tonumber(ARGV[tail])
|
|
551
562
|
local candidates = {}
|
|
552
563
|
for _, field in ipairs(ready) do
|
|
553
564
|
if not excluded[field] then candidates[#candidates + 1] = field end
|
|
554
565
|
end
|
|
555
566
|
local rows = read_fields(candidates, { 'ce', 'd', 'fc', 'a', 'e', 'lf', 'lfa' })
|
|
556
567
|
local claimed = {}
|
|
568
|
+
local dead = {}
|
|
557
569
|
local retry_at = nil
|
|
558
570
|
for _, field in ipairs(candidates) do
|
|
559
571
|
local row = rows[field]
|
|
@@ -563,29 +575,63 @@ for _, field in ipairs(candidates) do
|
|
|
563
575
|
else
|
|
564
576
|
local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
|
|
565
577
|
if #entries == 0 then return { 'missing', field } end
|
|
566
|
-
local attempt = tostring(tonumber(row[2] or '0') + 1)
|
|
567
|
-
local first_claimed = row[3] or ARGV[2]
|
|
568
578
|
local meta_key = mkey(field)
|
|
579
|
+
local attempt = tostring(tonumber(row[2] or '0') + 1)
|
|
580
|
+
-- An expiry that is not the current attempt's retry hold is a lapsed
|
|
581
|
+
-- claim: its attempt never finished.
|
|
582
|
+
local lapsed = row[1] and row[7] ~= row[2]
|
|
583
|
+
local failures = tonumber(row[4] or '0')
|
|
584
|
+
if lapsed then
|
|
585
|
+
failures = failures + 1
|
|
586
|
+
put(meta_key, field .. ':a', tostring(failures))
|
|
587
|
+
end
|
|
569
588
|
put(meta_key, field .. ':d', attempt)
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
589
|
+
if lapsed and max_failures and failures >= max_failures then
|
|
590
|
+
-- That takeover spent the last failed attempt: dead-letter, unclaimed.
|
|
591
|
+
-- Advancing the attempt fences the lapsed one; nothing runs.
|
|
592
|
+
put(meta_key, field .. ':f', ARGV[2])
|
|
593
|
+
dead[#dead + 1] = field
|
|
594
|
+
claimed[#claimed + 1] = {
|
|
595
|
+
attempt,
|
|
596
|
+
tostring(failures),
|
|
597
|
+
row[5] or false,
|
|
598
|
+
row[3] or false,
|
|
599
|
+
read_fields({ field }, { 'lc' })[field][1] or false,
|
|
600
|
+
row[6] or false,
|
|
601
|
+
row[7] or false,
|
|
602
|
+
false,
|
|
603
|
+
false,
|
|
604
|
+
entries[1],
|
|
605
|
+
ARGV[2]
|
|
606
|
+
}
|
|
607
|
+
else
|
|
608
|
+
local first_claimed = row[3] or ARGV[2]
|
|
609
|
+
put(meta_key, field .. ':fc', first_claimed)
|
|
610
|
+
put(meta_key, field .. ':lc', ARGV[2])
|
|
611
|
+
put(meta_key, field .. ':ch', ARGV[1])
|
|
612
|
+
put(meta_key, field .. ':ce', ARGV[3])
|
|
613
|
+
claimed[#claimed + 1] = {
|
|
614
|
+
attempt,
|
|
615
|
+
tostring(failures),
|
|
616
|
+
row[5] or false,
|
|
617
|
+
first_claimed,
|
|
618
|
+
ARGV[2],
|
|
619
|
+
row[6] or false,
|
|
620
|
+
row[7] or false,
|
|
621
|
+
ARGV[1],
|
|
622
|
+
ARGV[3],
|
|
623
|
+
entries[1],
|
|
624
|
+
false
|
|
625
|
+
}
|
|
626
|
+
end
|
|
586
627
|
end
|
|
587
628
|
end
|
|
588
629
|
flush()
|
|
630
|
+
for _, field in ipairs(dead) do
|
|
631
|
+
redis.call('HDEL', mkey(field), field .. ':ch', field .. ':ce')
|
|
632
|
+
redis.call('ZREM', KEYS[5], field)
|
|
633
|
+
release_snapshot_pins(ARGV[tail + 1] .. field, KEYS[7])
|
|
634
|
+
end
|
|
589
635
|
if #claimed > 0 then
|
|
590
636
|
local result = { 'claimed', tostring(#claimed) }
|
|
591
637
|
for _, claim in ipairs(claimed) do
|
|
@@ -1371,7 +1417,7 @@ const toClaimAvailableResult = (
|
|
|
1371
1417
|
const count = Number(reply[1])
|
|
1372
1418
|
const events: StoredEvent[] = []
|
|
1373
1419
|
for (let i = 0; i < count; i++) {
|
|
1374
|
-
const offset = 2 + i *
|
|
1420
|
+
const offset = 2 + i * 11
|
|
1375
1421
|
const entry = reply[offset + 9] as StreamEntry
|
|
1376
1422
|
const event = toEvent(sessionId, entry)
|
|
1377
1423
|
const fields = fieldMap(entry[1])
|
|
@@ -1386,10 +1432,17 @@ const toClaimAvailableResult = (
|
|
|
1386
1432
|
reply[offset + 3] === null
|
|
1387
1433
|
? null
|
|
1388
1434
|
: new Date(Number(reply[offset + 3])),
|
|
1389
|
-
lastClaimedAt:
|
|
1435
|
+
lastClaimedAt:
|
|
1436
|
+
reply[offset + 4] === null
|
|
1437
|
+
? null
|
|
1438
|
+
: new Date(Number(reply[offset + 4])),
|
|
1390
1439
|
attemptCount: Number(reply[offset]),
|
|
1391
|
-
claimHolder:
|
|
1392
|
-
|
|
1440
|
+
claimHolder:
|
|
1441
|
+
reply[offset + 7] === null ? null : String(reply[offset + 7]),
|
|
1442
|
+
claimExpiresAt:
|
|
1443
|
+
reply[offset + 8] === null
|
|
1444
|
+
? null
|
|
1445
|
+
: new Date(Number(reply[offset + 8])),
|
|
1393
1446
|
failureCount: Number(reply[offset + 1]),
|
|
1394
1447
|
lastFailedAt:
|
|
1395
1448
|
reply[offset + 5] === null
|
|
@@ -1399,11 +1452,14 @@ const toClaimAvailableResult = (
|
|
|
1399
1452
|
reply[offset + 6] === null ? null : Number(reply[offset + 6]),
|
|
1400
1453
|
lastError:
|
|
1401
1454
|
reply[offset + 2] === null ? null : String(reply[offset + 2]),
|
|
1402
|
-
failedAt:
|
|
1455
|
+
failedAt:
|
|
1456
|
+
reply[offset + 10] === null
|
|
1457
|
+
? null
|
|
1458
|
+
: new Date(Number(reply[offset + 10])),
|
|
1403
1459
|
}),
|
|
1404
1460
|
)
|
|
1405
1461
|
}
|
|
1406
|
-
const clearIndex = Number(reply[2 + count *
|
|
1462
|
+
const clearIndex = Number(reply[2 + count * 11] ?? 0)
|
|
1407
1463
|
if (clearIndex > 0)
|
|
1408
1464
|
for (const event of events) event.clearIndex = clearIndex
|
|
1409
1465
|
return {
|
|
@@ -1942,6 +1998,7 @@ export function createRedisStoreCore(
|
|
|
1942
1998
|
ttlMs,
|
|
1943
1999
|
expiresAtMs,
|
|
1944
2000
|
excludeIndexes,
|
|
2001
|
+
maxFailures,
|
|
1945
2002
|
}) {
|
|
1946
2003
|
return wrap(async () => {
|
|
1947
2004
|
const now = clock.now().getTime()
|
|
@@ -1956,8 +2013,17 @@ export function createRedisStoreCore(
|
|
|
1956
2013
|
pendingKey(sessionId),
|
|
1957
2014
|
readyKey(sessionId),
|
|
1958
2015
|
metaSplitKey(sessionId),
|
|
2016
|
+
snapshotPinCountsKey(sessionId),
|
|
2017
|
+
],
|
|
2018
|
+
[
|
|
2019
|
+
holder,
|
|
2020
|
+
now,
|
|
2021
|
+
expiresAt,
|
|
2022
|
+
excluded.length,
|
|
2023
|
+
...excluded,
|
|
2024
|
+
maxFailures ?? '',
|
|
2025
|
+
snapshotEventPinsPrefix(sessionId),
|
|
1959
2026
|
],
|
|
1960
|
-
[holder, now, expiresAt, excluded.length, ...excluded],
|
|
1961
2027
|
)) as unknown[]
|
|
1962
2028
|
return toClaimAvailableResult(sessionId, reply)
|
|
1963
2029
|
})
|
|
@@ -2093,8 +2159,16 @@ export function createRedisStoreCore(
|
|
|
2093
2159
|
})
|
|
2094
2160
|
},
|
|
2095
2161
|
|
|
2096
|
-
async failAttempt({
|
|
2162
|
+
async failAttempt({
|
|
2163
|
+
sessionId,
|
|
2164
|
+
index,
|
|
2165
|
+
attempt,
|
|
2166
|
+
error,
|
|
2167
|
+
maxFailures,
|
|
2168
|
+
retryAfterMs,
|
|
2169
|
+
}) {
|
|
2097
2170
|
return wrap(async () => {
|
|
2171
|
+
const now = clock.now().getTime()
|
|
2098
2172
|
const reply = (await evalScript(
|
|
2099
2173
|
FAIL_ATTEMPT_LUA,
|
|
2100
2174
|
[
|
|
@@ -2106,7 +2180,14 @@ export function createRedisStoreCore(
|
|
|
2106
2180
|
snapshotPinCountsKey(sessionId),
|
|
2107
2181
|
metaSplitKey(sessionId),
|
|
2108
2182
|
],
|
|
2109
|
-
[
|
|
2183
|
+
[
|
|
2184
|
+
index,
|
|
2185
|
+
attempt,
|
|
2186
|
+
error,
|
|
2187
|
+
maxFailures,
|
|
2188
|
+
now,
|
|
2189
|
+
retryAfterMs === undefined ? '' : now + retryAfterMs,
|
|
2190
|
+
],
|
|
2110
2191
|
)) as string[]
|
|
2111
2192
|
const outcome = reply[0]
|
|
2112
2193
|
if (outcome === 'cleared')
|
package/src/store-sqlite.ts
CHANGED
|
@@ -470,6 +470,22 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
470
470
|
}
|
|
471
471
|
}
|
|
472
472
|
|
|
473
|
+
/** A dead-lettered event's snapshot pins, and history no pin keeps. */
|
|
474
|
+
const releasePins = (sessionId: string, index: number): void => {
|
|
475
|
+
db.prepare(
|
|
476
|
+
'delete from a2_snapshot_pins where session_id = ? and event_index = ?',
|
|
477
|
+
).run(sessionId, index)
|
|
478
|
+
db.prepare(
|
|
479
|
+
`delete from a2_snapshot_history
|
|
480
|
+
where session_id = ?
|
|
481
|
+
and not exists (
|
|
482
|
+
select 1 from a2_snapshot_pins pin
|
|
483
|
+
where pin.session_id = a2_snapshot_history.session_id
|
|
484
|
+
and pin.reducer_name = a2_snapshot_history.reducer_name
|
|
485
|
+
and pin.up_to_index = a2_snapshot_history.up_to_index
|
|
486
|
+
)`,
|
|
487
|
+
).run(sessionId)
|
|
488
|
+
}
|
|
473
489
|
return {
|
|
474
490
|
async append(sessionId, events, appendOptions) {
|
|
475
491
|
if (events.length === 0) {
|
|
@@ -626,6 +642,7 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
626
642
|
ttlMs,
|
|
627
643
|
expiresAtMs,
|
|
628
644
|
excludeIndexes = [],
|
|
645
|
+
maxFailures,
|
|
629
646
|
}) {
|
|
630
647
|
return wrap(() =>
|
|
631
648
|
tx(() => {
|
|
@@ -638,7 +655,7 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
638
655
|
: `and event.idx not in (${excludeIndexes.map(() => '?').join(', ')})`
|
|
639
656
|
const eligible = db
|
|
640
657
|
.prepare(
|
|
641
|
-
`select event.idx
|
|
658
|
+
`select event.idx, event.claim_holder, event.failure_count
|
|
642
659
|
from a2_events event
|
|
643
660
|
where event.session_id = ?
|
|
644
661
|
and event.processed_at is null
|
|
@@ -656,11 +673,15 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
656
673
|
...excludeIndexes,
|
|
657
674
|
) as unknown as Array<{
|
|
658
675
|
idx: number | bigint
|
|
676
|
+
claim_holder: string | null
|
|
677
|
+
failure_count: number | bigint
|
|
659
678
|
}>
|
|
660
679
|
if (eligible.length > 0) {
|
|
661
680
|
const update = db.prepare(
|
|
662
681
|
`update a2_events set
|
|
663
682
|
attempt_count = attempt_count + 1,
|
|
683
|
+
failure_count = failure_count
|
|
684
|
+
+ case when claim_holder is null then 0 else 1 end,
|
|
664
685
|
first_claimed_at = coalesce(first_claimed_at, ?),
|
|
665
686
|
last_claimed_at = ?,
|
|
666
687
|
claim_holder = ?,
|
|
@@ -668,15 +689,30 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
668
689
|
where session_id = ? and idx = ?
|
|
669
690
|
returning *`,
|
|
670
691
|
)
|
|
692
|
+
// Advancing the attempt fences the lapsed one; nothing runs.
|
|
693
|
+
const deadLetter = () =>
|
|
694
|
+
db.prepare(
|
|
695
|
+
`update a2_events set
|
|
696
|
+
attempt_count = attempt_count + 1,
|
|
697
|
+
failure_count = failure_count + 1,
|
|
698
|
+
failed_at = ?,
|
|
699
|
+
claim_holder = null,
|
|
700
|
+
claim_expires_at = null
|
|
701
|
+
where session_id = ? and idx = ?
|
|
702
|
+
returning *`,
|
|
703
|
+
)
|
|
671
704
|
const events = eligible.map((row) => {
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
705
|
+
// A lapsed claim's takeover that spends the last failed attempt.
|
|
706
|
+
const exhausted =
|
|
707
|
+
row.claim_holder !== null &&
|
|
708
|
+
maxFailures !== undefined &&
|
|
709
|
+
Number(row.failure_count) + 1 >= maxFailures
|
|
710
|
+
const claimed = (
|
|
711
|
+
exhausted
|
|
712
|
+
? deadLetter().get(now, sessionId, row.idx)
|
|
713
|
+
: update.get(now, now, holder, expiresAt, sessionId, row.idx)
|
|
679
714
|
) as EventRow
|
|
715
|
+
if (exhausted) releasePins(sessionId, Number(row.idx))
|
|
680
716
|
const stored = toStored(claimed)
|
|
681
717
|
if (clearIndex > 0) stored.clearIndex = clearIndex
|
|
682
718
|
return stored
|
|
@@ -687,15 +723,16 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
687
723
|
const active = db
|
|
688
724
|
.prepare(
|
|
689
725
|
`select min(claim_expires_at) as retry_at
|
|
690
|
-
from a2_events
|
|
726
|
+
from a2_events event
|
|
691
727
|
where session_id = ?
|
|
692
728
|
and processed_at is null
|
|
693
729
|
and failed_at is null
|
|
694
730
|
and lane_ready = 1
|
|
695
731
|
and idx > ?
|
|
696
|
-
and claim_expires_at >
|
|
732
|
+
and claim_expires_at > ?
|
|
733
|
+
${exclusion}`,
|
|
697
734
|
)
|
|
698
|
-
.get(sessionId, clearIndex, now) as {
|
|
735
|
+
.get(sessionId, clearIndex, now, ...excludeIndexes) as {
|
|
699
736
|
retry_at: number | bigint | null
|
|
700
737
|
}
|
|
701
738
|
return active.retry_at === null
|
|
@@ -908,7 +945,14 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
908
945
|
)
|
|
909
946
|
},
|
|
910
947
|
|
|
911
|
-
async failAttempt({
|
|
948
|
+
async failAttempt({
|
|
949
|
+
sessionId,
|
|
950
|
+
index,
|
|
951
|
+
attempt,
|
|
952
|
+
error,
|
|
953
|
+
maxFailures,
|
|
954
|
+
retryAfterMs,
|
|
955
|
+
}) {
|
|
912
956
|
return wrap(() =>
|
|
913
957
|
tx(() => {
|
|
914
958
|
if (index <= clearIndexOf(sessionId)) {
|
|
@@ -954,7 +998,7 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
954
998
|
last_failed_attempt = ?,
|
|
955
999
|
failed_at = ?,
|
|
956
1000
|
claim_holder = null,
|
|
957
|
-
claim_expires_at =
|
|
1001
|
+
claim_expires_at = ?
|
|
958
1002
|
where session_id = ? and idx = ?`,
|
|
959
1003
|
).run(
|
|
960
1004
|
nextFailureCount,
|
|
@@ -962,24 +1006,13 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
962
1006
|
now,
|
|
963
1007
|
attempt,
|
|
964
1008
|
deadLettered ? now : null,
|
|
1009
|
+
deadLettered || retryAfterMs === undefined
|
|
1010
|
+
? null
|
|
1011
|
+
: now + retryAfterMs,
|
|
965
1012
|
sessionId,
|
|
966
1013
|
index,
|
|
967
1014
|
)
|
|
968
|
-
if (deadLettered)
|
|
969
|
-
db.prepare(
|
|
970
|
-
'delete from a2_snapshot_pins where session_id = ? and event_index = ?',
|
|
971
|
-
).run(sessionId, index)
|
|
972
|
-
db.prepare(
|
|
973
|
-
`delete from a2_snapshot_history
|
|
974
|
-
where session_id = ?
|
|
975
|
-
and not exists (
|
|
976
|
-
select 1 from a2_snapshot_pins pin
|
|
977
|
-
where pin.session_id = a2_snapshot_history.session_id
|
|
978
|
-
and pin.reducer_name = a2_snapshot_history.reducer_name
|
|
979
|
-
and pin.up_to_index = a2_snapshot_history.up_to_index
|
|
980
|
-
)`,
|
|
981
|
-
).run(sessionId)
|
|
982
|
-
}
|
|
1015
|
+
if (deadLettered) releasePins(sessionId, index)
|
|
983
1016
|
return {
|
|
984
1017
|
outcome: deadLettered ? 'dead_lettered' : 'failed',
|
|
985
1018
|
failureCount: nextFailureCount,
|
package/src/store.ts
CHANGED
|
@@ -53,9 +53,15 @@ export type StoredEvent = Event & {
|
|
|
53
53
|
attemptCount: number
|
|
54
54
|
/** Current dispatch holder; null when the event is not claimed. */
|
|
55
55
|
claimHolder: string | null
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Adapter clock expiry for the current dispatch claim, or, with no holder,
|
|
58
|
+
* the end of a failed attempt's retry hold.
|
|
59
|
+
*/
|
|
57
60
|
claimExpiresAt: Date | null
|
|
58
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Failed attempts: caught handler failures, and lapsed claims a later claim
|
|
63
|
+
* took over. This alone drives dead-lettering.
|
|
64
|
+
*/
|
|
59
65
|
failureCount: number
|
|
60
66
|
/** Adapter clock time recorded by the most recent caught handler failure. */
|
|
61
67
|
lastFailedAt: Date | null
|
|
@@ -299,6 +305,15 @@ export interface A2Store {
|
|
|
299
305
|
ttlMs: number
|
|
300
306
|
expiresAtMs?: number
|
|
301
307
|
excludeIndexes?: readonly number[]
|
|
308
|
+
/**
|
|
309
|
+
* Taking over a lapsed claim (one whose holder is still recorded, never
|
|
310
|
+
* completed or failed) adds one to `failureCount`. When that reaches
|
|
311
|
+
* `maxFailures`, the event dead-letters instead of being claimed: it
|
|
312
|
+
* comes back in `events` with `failedAt` set and no claim. Its
|
|
313
|
+
* `attemptCount` still advances, fencing the lapsed attempt, and its
|
|
314
|
+
* `lastFailedAttempt` is unchanged.
|
|
315
|
+
*/
|
|
316
|
+
maxFailures?: number
|
|
302
317
|
}): Promise<StoreClaimAvailableResult>
|
|
303
318
|
|
|
304
319
|
/**
|
|
@@ -333,7 +348,10 @@ export interface A2Store {
|
|
|
333
348
|
/**
|
|
334
349
|
* Atomically records a caught failure for one claimed attempt. A stale
|
|
335
350
|
* attempt cannot poison a processed event or a newer dispatch. Accepted
|
|
336
|
-
* failures record the operation's clock time.
|
|
351
|
+
* failures record the operation's clock time. A failure that does not
|
|
352
|
+
* dead-letter releases the claim and, with `retryAfterMs`, keeps the event
|
|
353
|
+
* unclaimable for that long: its claim expiry is set with no holder, so
|
|
354
|
+
* `claimAvailable` reports it `busy` until then.
|
|
337
355
|
*/
|
|
338
356
|
failAttempt(options: {
|
|
339
357
|
sessionId: string
|
|
@@ -341,6 +359,7 @@ export interface A2Store {
|
|
|
341
359
|
attempt: number
|
|
342
360
|
error: string
|
|
343
361
|
maxFailures: number
|
|
362
|
+
retryAfterMs?: number
|
|
344
363
|
}): Promise<FailAttemptResult>
|
|
345
364
|
|
|
346
365
|
/**
|