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.
Files changed (114) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/{actor-Bjce9_5U.d.ts → actor-C7b-1arn.d.ts} +3 -3
  3. package/dist/{actor-Bjce9_5U.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
  4. package/dist/actor-client.d.ts +1 -1
  5. package/dist/actor-react.d.ts +1 -1
  6. package/dist/actor-react.js +1 -1
  7. package/dist/actor.d.ts +1 -1
  8. package/dist/actor.js +2 -2
  9. package/dist/{ai-DrUaMyRt.d.ts → ai-BMLlI2Ff.d.ts} +2 -2
  10. package/dist/{ai-DrUaMyRt.d.ts.map → ai-BMLlI2Ff.d.ts.map} +1 -1
  11. package/dist/{ai-BpOGm-zB.js → ai-D6mGlO8j.js} +2 -2
  12. package/dist/{ai-BpOGm-zB.js.map → ai-D6mGlO8j.js.map} +1 -1
  13. package/dist/ai-client.d.ts +1 -1
  14. package/dist/ai-client.js +2 -2
  15. package/dist/ai-fx.d.ts +1 -1
  16. package/dist/ai-fx.d.ts.map +1 -1
  17. package/dist/ai-fx.js +92 -34
  18. package/dist/ai-fx.js.map +1 -1
  19. package/dist/{ai-harness-DvsPeJeB.d.ts → ai-harness-B2suP2Qh.d.ts} +2 -2
  20. package/dist/{ai-harness-DvsPeJeB.d.ts.map → ai-harness-B2suP2Qh.d.ts.map} +1 -1
  21. package/dist/ai-pi.d.ts +1 -1
  22. package/dist/ai-pi.d.ts.map +1 -1
  23. package/dist/ai-pi.js +59 -13
  24. package/dist/ai-pi.js.map +1 -1
  25. package/dist/ai-react.d.ts +1 -1
  26. package/dist/ai-react.js +2 -2
  27. package/dist/ai-server.d.ts +3 -3
  28. package/dist/ai-server.d.ts.map +1 -1
  29. package/dist/ai-server.js +10 -6
  30. package/dist/ai-server.js.map +1 -1
  31. package/dist/ai-tool-input-DmSqMUQi.js +40 -0
  32. package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
  33. package/dist/ai.d.ts +1 -1
  34. package/dist/ai.js +1 -1
  35. package/dist/{client-BReKqwZC.js → client-BhHLoNKF.js} +3 -3
  36. package/dist/{client-BReKqwZC.js.map → client-BhHLoNKF.js.map} +1 -1
  37. package/dist/client.js +1 -1
  38. package/dist/index.d.ts +2 -2
  39. package/dist/{internal-B2T3U4Rq.js → internal-CscSY7K_.js} +4 -2
  40. package/dist/{internal-B2T3U4Rq.js.map → internal-CscSY7K_.js.map} +1 -1
  41. package/dist/react.js +1 -1
  42. package/dist/scheduler-qstash.d.ts +2 -2
  43. package/dist/scheduler-qstash.js +3 -3
  44. package/dist/scheduler-qstash.js.map +1 -1
  45. package/dist/scheduler-vercel.d.ts +7 -7
  46. package/dist/scheduler-vercel.d.ts.map +1 -1
  47. package/dist/scheduler-vercel.js +9 -10
  48. package/dist/scheduler-vercel.js.map +1 -1
  49. package/dist/{server-Dw_k_7kO.js → server-D-Y4pqwu.js} +37 -11
  50. package/dist/server-D-Y4pqwu.js.map +1 -0
  51. package/dist/{server-En7cbi3v.d.ts → server-DYAATFuT.d.ts} +11 -5
  52. package/dist/server-DYAATFuT.d.ts.map +1 -0
  53. package/dist/server.d.ts +2 -2
  54. package/dist/server.js +1 -1
  55. package/dist/{session-clear-Bo-XTFEy.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
  56. package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
  57. package/dist/{store-Djdv0bnZ.d.ts → store-CyLUy86v.d.ts} +23 -4
  58. package/dist/store-CyLUy86v.d.ts.map +1 -0
  59. package/dist/store-N8PXxDAS.js.map +1 -1
  60. package/dist/store-memory.d.ts +1 -1
  61. package/dist/store-memory.js +25 -6
  62. package/dist/store-memory.js.map +1 -1
  63. package/dist/{store-polling-B5FcwJfo.js → store-polling-7WJV0YWP.js} +2 -2
  64. package/dist/{store-polling-B5FcwJfo.js.map → store-polling-7WJV0YWP.js.map} +1 -1
  65. package/dist/store-postgres.d.ts +1 -1
  66. package/dist/store-postgres.d.ts.map +1 -1
  67. package/dist/store-postgres.js +55 -25
  68. package/dist/store-postgres.js.map +1 -1
  69. package/dist/{store-presence-polling-BAwS8RWn.js → store-presence-polling-B1ixeqIS.js} +3 -3
  70. package/dist/{store-presence-polling-BAwS8RWn.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
  71. package/dist/store-redis-http.d.ts +1 -1
  72. package/dist/store-redis-http.js +3 -3
  73. package/dist/{store-redis-notify-BRITti_o.js → store-redis-notify-CM0AfLY0.js} +88 -37
  74. package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
  75. package/dist/store-redis.d.ts +1 -1
  76. package/dist/store-redis.js +1 -1
  77. package/dist/store-sqlite.d.ts +1 -1
  78. package/dist/store-sqlite.js +35 -20
  79. package/dist/store-sqlite.js.map +1 -1
  80. package/dist/{wire-rSUglYo8.js → wire-BKxH1vbP.js} +2 -2
  81. package/dist/{wire-rSUglYo8.js.map → wire-BKxH1vbP.js.map} +1 -1
  82. package/docs/agents/03-conversations.mdx +2 -2
  83. package/docs/index.mdx +3 -3
  84. package/docs/reference/02-server.mdx +12 -7
  85. package/docs/reference/04-ai.mdx +16 -7
  86. package/docs/reference/05-adapters.mdx +4 -4
  87. package/docs/server/01-handlers.mdx +1 -1
  88. package/docs/server/03-cancellation.mdx +3 -2
  89. package/docs/server/05-production.mdx +2 -1
  90. package/docs/server/06-guarantees.mdx +20 -11
  91. package/examples/playground/DEPLOY.md +2 -4
  92. package/examples/playground/app/a0/[appId]/page.tsx +2 -8
  93. package/examples/playground/app/a0/devtools.tsx +2 -8
  94. package/examples/playground/app/a0/server.ts +8 -19
  95. package/examples/playground/package.json +1 -1
  96. package/package.json +1 -1
  97. package/src/ai-agent.ts +10 -3
  98. package/src/ai-fx.ts +126 -47
  99. package/src/ai-pi.ts +74 -15
  100. package/src/ai-tool-input.ts +45 -0
  101. package/src/internal.ts +6 -0
  102. package/src/scheduler-qstash.ts +5 -5
  103. package/src/scheduler-vercel.ts +14 -15
  104. package/src/server.ts +58 -12
  105. package/src/store-memory.ts +50 -9
  106. package/src/store-postgres.ts +79 -28
  107. package/src/store-redis-core.ts +112 -31
  108. package/src/store-sqlite.ts +61 -28
  109. package/src/store.ts +22 -3
  110. package/dist/server-Dw_k_7kO.js.map +0 -1
  111. package/dist/server-En7cbi3v.d.ts.map +0 -1
  112. package/dist/session-clear-Bo-XTFEy.d.ts.map +0 -1
  113. package/dist/store-Djdv0bnZ.d.ts.map +0 -1
  114. package/dist/store-redis-notify-BRITti_o.js.map +0 -1
@@ -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
- first_claimed_at = coalesce(claimed.first_claimed_at, $2),
966
- last_claimed_at = $2,
967
- claim_holder = $3,
968
- claim_expires_at = $4
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
- [sessionId, now, holder, expiresAt, [...excludeIndexes]],
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
- [sessionId, now],
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({ sessionId, index, attempt, error, maxFailures }) {
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 = null
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
- clock.now().getTime(),
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',
@@ -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
- redis.call('HDEL', meta_key, field .. ':ch', field .. ':ce')
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
- * ARGV: holder, nowMs, expiresAtMs, excludedCount, excluded indexes.
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
- put(meta_key, field .. ':fc', first_claimed)
571
- put(meta_key, field .. ':lc', ARGV[2])
572
- put(meta_key, field .. ':ch', ARGV[1])
573
- put(meta_key, field .. ':ce', ARGV[3])
574
- claimed[#claimed + 1] = {
575
- attempt,
576
- row[4] or '0',
577
- row[5] or false,
578
- first_claimed,
579
- ARGV[2],
580
- row[6] or false,
581
- row[7] or false,
582
- ARGV[1],
583
- ARGV[3],
584
- entries[1]
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 * 10
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: new Date(Number(reply[offset + 4])),
1435
+ lastClaimedAt:
1436
+ reply[offset + 4] === null
1437
+ ? null
1438
+ : new Date(Number(reply[offset + 4])),
1390
1439
  attemptCount: Number(reply[offset]),
1391
- claimHolder: String(reply[offset + 7]),
1392
- claimExpiresAt: new Date(Number(reply[offset + 8])),
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: null,
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 * 10] ?? 0)
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({ sessionId, index, attempt, error, maxFailures }) {
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
- [index, attempt, error, maxFailures, clock.now().getTime()],
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')
@@ -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
- const claimed = update.get(
673
- now,
674
- now,
675
- holder,
676
- expiresAt,
677
- sessionId,
678
- row.idx,
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({ sessionId, index, attempt, error, maxFailures }) {
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 = null
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
- /** Adapter clock expiry for the current dispatch claim. */
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
- /** Caught handler failures. This alone drives dead-lettering. */
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
  /**