experimental-a2 0.17.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 +32 -0
- package/README.md +1 -1
- package/dist/{actor-DBbe5xeE.d.ts → actor-C7b-1arn.d.ts} +5 -5
- package/dist/{actor-DBbe5xeE.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 +3 -3
- package/dist/actor-react.js +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +2 -2
- package/dist/{ai-vpGtcaCB.d.ts → ai-BMLlI2Ff.d.ts} +3 -3
- package/dist/{ai-vpGtcaCB.d.ts.map → ai-BMLlI2Ff.d.ts.map} +1 -1
- package/dist/{ai-UrWfKeuv.js → ai-D6mGlO8j.js} +2 -2
- package/dist/{ai-UrWfKeuv.js.map → ai-D6mGlO8j.js.map} +1 -1
- package/dist/ai-client.d.ts +2 -2
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.browser.d.ts +1 -0
- package/dist/ai-fx.browser.js +6 -0
- package/dist/ai-fx.browser.js.map +1 -0
- package/dist/ai-fx.d.ts +10 -0
- package/dist/ai-fx.d.ts.map +1 -0
- package/dist/ai-fx.js +692 -0
- package/dist/ai-fx.js.map +1 -0
- package/dist/ai-harness-B2suP2Qh.d.ts +26 -0
- package/dist/ai-harness-B2suP2Qh.d.ts.map +1 -0
- package/dist/ai-harness-BuLh_EYi.js +273 -0
- package/dist/ai-harness-BuLh_EYi.js.map +1 -0
- package/dist/ai-pi.browser.d.ts +1 -0
- package/dist/ai-pi.browser.js +6 -0
- package/dist/ai-pi.browser.js.map +1 -0
- package/dist/ai-pi.d.ts +17 -0
- package/dist/ai-pi.d.ts.map +1 -0
- package/dist/ai-pi.js +950 -0
- package/dist/ai-pi.js.map +1 -0
- package/dist/ai-react.d.ts +5 -5
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +47 -25
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +113 -40
- 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/bounded-cache-CPHWF-8h.js +41 -0
- package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
- package/dist/{client-COaiaDub.js → client-BhHLoNKF.js} +3 -3
- package/dist/{client-COaiaDub.js.map → client-BhHLoNKF.js.map} +1 -1
- package/dist/{client-qRnRUzBS.d.ts → client-h_DKFAjj.d.ts} +3 -3
- package/dist/{client-qRnRUzBS.d.ts.map → client-h_DKFAjj.d.ts.map} +1 -1
- package/dist/client.d.ts +3 -3
- package/dist/client.js +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/{initial-snapshot-BrLso5x_.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +2 -2
- package/dist/{initial-snapshot-BrLso5x_.d.ts.map → initial-snapshot-Bx-c0pDP.d.ts.map} +1 -1
- package/dist/{internal-CbVvEwad.js → internal-CscSY7K_.js} +11 -3
- package/dist/internal-CscSY7K_.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/{react-BbJ-g6Uu.d.ts → react-BBXgHWFc.d.ts} +4 -4
- package/dist/{react-BbJ-g6Uu.d.ts.map → react-BBXgHWFc.d.ts.map} +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/reducer-BAXc8JyF.js.map +1 -1
- package/dist/{reducer-BYu6DT7h.d.ts → reducer-CqA59Q6U.d.ts} +2 -2
- package/dist/{reducer-BYu6DT7h.d.ts.map → reducer-CqA59Q6U.d.ts.map} +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-Rj64fNvX.js → server-D-Y4pqwu.js} +126 -67
- package/dist/server-D-Y4pqwu.js.map +1 -0
- package/dist/{server-CGM8WzRG.d.ts → server-DYAATFuT.d.ts} +42 -7
- package/dist/server-DYAATFuT.d.ts.map +1 -0
- package/dist/server.d.ts +4 -4
- package/dist/server.js +1 -1
- package/dist/{session-clear-Dshpuhff.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
- package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
- package/dist/{store-Gpkq7WBo.d.ts → store-CyLUy86v.d.ts} +53 -5
- 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 +56 -11
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-4_y8lETm.js → store-polling-7WJV0YWP.js} +2 -2
- package/dist/{store-polling-4_y8lETm.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 +106 -25
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-B1ixeqIS.js} +3 -3
- package/dist/{store-presence-polling-Q1C-k2dd.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-tqWNnMRu.js → store-redis-notify-CM0AfLY0.js} +271 -101
- 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 +66 -21
- package/dist/store-sqlite.js.map +1 -1
- package/dist/{telemetry-cZH-cibj.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
- package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
- package/dist/{wire-DjfHOvdW.js → wire-BKxH1vbP.js} +2 -2
- package/dist/{wire-DjfHOvdW.js.map → wire-BKxH1vbP.js.map} +1 -1
- package/docs/01-quickstart.mdx +49 -83
- package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
- package/docs/actors/01-introduction.mdx +48 -86
- package/docs/actors/02-concurrency.mdx +43 -70
- package/docs/actors/03-timers.mdx +28 -42
- package/docs/actors/04-routes.mdx +110 -150
- package/docs/actors/meta.ts +1 -1
- package/docs/agents/01-introduction.mdx +257 -0
- package/docs/agents/02-tools.mdx +247 -0
- package/docs/agents/03-conversations.mdx +538 -0
- package/docs/agents/04-harnesses.mdx +136 -0
- package/docs/agents/meta.ts +1 -0
- package/docs/client/01-react.mdx +294 -0
- package/docs/client/02-transports.mdx +86 -0
- package/docs/client/03-presence.mdx +224 -0
- package/docs/client/meta.ts +1 -0
- package/docs/contract/01-events.mdx +57 -0
- package/docs/contract/02-state.mdx +142 -0
- package/docs/contract/03-sessions.mdx +175 -0
- package/docs/index.mdx +109 -145
- package/docs/reference/01-core.mdx +139 -0
- package/docs/reference/02-server.mdx +860 -0
- package/docs/reference/03-client.mdx +301 -0
- package/docs/reference/04-ai.mdx +731 -0
- package/docs/reference/05-adapters.mdx +351 -0
- package/docs/reference/06-errors.mdx +138 -0
- package/docs/reference/meta.ts +1 -1
- package/docs/server/01-handlers.mdx +200 -0
- package/docs/server/02-timers.mdx +130 -0
- package/docs/server/03-cancellation.mdx +142 -0
- package/docs/server/04-routes.mdx +243 -0
- package/docs/server/05-production.mdx +323 -0
- package/docs/server/06-guarantees.mdx +132 -0
- package/examples/playground/DEPLOY.md +3 -5
- package/examples/playground/app/a0/[appId]/page.tsx +12 -13
- package/examples/playground/app/a0/app-list.tsx +9 -2
- package/examples/playground/app/a0/app-view.tsx +9 -1
- package/examples/playground/app/a0/apps/[appId]/route.ts +10 -5
- package/examples/playground/app/a0/apps.test.ts +12 -0
- package/examples/playground/app/a0/apps.ts +22 -7
- package/examples/playground/app/a0/chat.tsx +47 -1
- package/examples/playground/app/a0/devtools.tsx +2 -8
- package/examples/playground/app/a0/fx/events/route.ts +9 -0
- package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
- package/examples/playground/app/a0/model.test.ts +18 -1
- package/examples/playground/app/a0/model.ts +47 -10
- package/examples/playground/app/a0/no-photon.cjs +4 -0
- package/examples/playground/app/a0/no-quickjs.cjs +3 -0
- package/examples/playground/app/a0/pi/events/route.ts +9 -0
- package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
- package/examples/playground/app/a0/sandbox-log.ts +10 -4
- package/examples/playground/app/a0/sandbox-session.ts +152 -0
- package/examples/playground/app/a0/scheduler/route.ts +2 -2
- package/examples/playground/app/a0/server.ts +98 -54
- package/examples/playground/app/a0/session.ts +17 -5
- package/examples/playground/app/a0/shell.tsx +11 -3
- package/examples/playground/app/agent/ingress.test.ts +6 -3
- package/examples/playground/app/agent/server.ts +9 -6
- package/examples/playground/app/agent/telemetry.test.ts +71 -50
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +1 -1
- package/examples/playground/app/chat/server.test.ts +30 -26
- package/examples/playground/app/chat/server.ts +46 -46
- package/examples/playground/next.config.ts +17 -5
- package/examples/playground/package.json +4 -1
- package/examples/playground/vercel.json +18 -0
- package/package.json +27 -3
- package/src/ai-agent.ts +271 -90
- package/src/ai-fx.browser.ts +5 -0
- package/src/ai-fx.ts +912 -0
- package/src/ai-harness.ts +518 -0
- package/src/ai-model-metadata.ts +11 -6
- package/src/ai-pi.browser.ts +5 -0
- package/src/ai-pi.ts +1322 -0
- package/src/ai-sdk-step.ts +2 -2
- package/src/ai-server.ts +3 -1
- package/src/ai-tool-input.ts +45 -0
- package/src/internal.ts +13 -0
- package/src/libfx.d.ts +34 -0
- package/src/reducer.ts +1 -1
- package/src/scheduler-qstash.ts +5 -5
- package/src/scheduler-vercel.ts +14 -15
- package/src/server.ts +233 -29
- package/src/store-memory.ts +92 -16
- package/src/store-postgres.ts +156 -25
- package/src/store-redis-core.ts +322 -110
- package/src/store-redis-notify.ts +2 -0
- package/src/store-sqlite.ts +121 -28
- package/src/store.ts +55 -3
- package/src/telemetry.ts +1 -0
- package/dist/internal-CbVvEwad.js.map +0 -1
- package/dist/server-CGM8WzRG.d.ts.map +0 -1
- package/dist/server-Rj64fNvX.js.map +0 -1
- package/dist/session-clear-Dshpuhff.d.ts.map +0 -1
- package/dist/store-Gpkq7WBo.d.ts.map +0 -1
- package/dist/store-redis-notify-tqWNnMRu.js.map +0 -1
- package/dist/telemetry-cZH-cibj.d.ts.map +0 -1
- package/docs/concepts/01-contracts.mdx +0 -237
- package/docs/concepts/02-handlers.mdx +0 -284
- package/docs/concepts/03-durability.mdx +0 -314
- package/docs/concepts/04-state.mdx +0 -209
- package/docs/guides/01-timers.mdx +0 -193
- package/docs/guides/02-cancellation.mdx +0 -161
- package/docs/guides/03-react.mdx +0 -324
- package/docs/guides/04-local-first.mdx +0 -47
- package/docs/guides/05-production.mdx +0 -534
- package/docs/guides/06-ai-agents.mdx +0 -1408
- package/docs/guides/08-application-data.mdx +0 -113
- package/docs/guides/09-presence.mdx +0 -262
- package/docs/guides/10-transports.mdx +0 -221
- package/docs/reference/01-api.mdx +0 -2358
- package/docs/reference/02-errors.mdx +0 -150
- /package/docs/{concepts → contract}/meta.ts +0 -0
- /package/docs/{guides → server}/meta.ts +0 -0
|
@@ -1,284 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Handlers
|
|
3
|
-
description: Concurrent reactions with atomic returned events and explicit serial lanes.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
```ts server/orders.ts
|
|
7
|
-
import { createServer } from 'experimental-a2/server'
|
|
8
|
-
import { orders } from '@/contracts'
|
|
9
|
-
|
|
10
|
-
export const ordersServer = createServer({
|
|
11
|
-
contract: orders,
|
|
12
|
-
handlers: {
|
|
13
|
-
created: async ({ event }) => {
|
|
14
|
-
// your side effect, e.g. email the shop, idempotent via event.id:
|
|
15
|
-
// await sendEmailToShop(event.payload, { idempotencyKey: event.id })
|
|
16
|
-
return { type: 'shop.notified', payload: {} }
|
|
17
|
-
},
|
|
18
|
-
},
|
|
19
|
-
})
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
A handler runs when its event lands. It is a plain async function: call a
|
|
23
|
-
database, hit an API, stream a model response. A2 durably claims the event,
|
|
24
|
-
renews that claim while the function runs, and retries after a crash.
|
|
25
|
-
|
|
26
|
-
Handlers live in the server's construction, not on sessions. One complete
|
|
27
|
-
table serves every session. A handler cannot be silently missing because the
|
|
28
|
-
module that registered it was never imported.
|
|
29
|
-
|
|
30
|
-
## Events without handlers
|
|
31
|
-
|
|
32
|
-
Handlers are optional per event type. An event without one still gets its full
|
|
33
|
-
place in the log. You can read it with `history()`, fold it into state, and
|
|
34
|
-
stream it to a live client. A2 settles it in the append transaction, with no
|
|
35
|
-
dispatch claim or scheduler message.
|
|
36
|
-
|
|
37
|
-
```ts server/order-history.ts
|
|
38
|
-
import { createServer } from 'experimental-a2/server'
|
|
39
|
-
import { postgres } from 'experimental-a2/store-postgres'
|
|
40
|
-
import { orders } from '@/contracts'
|
|
41
|
-
|
|
42
|
-
export const orderHistory = createServer({
|
|
43
|
-
contract: orders,
|
|
44
|
-
store: postgres({ connectionString: process.env.DATABASE_URL }),
|
|
45
|
-
})
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
A contract can mix handled and unhandled event types. Adding a handler later
|
|
49
|
-
does not replay events that were already settled. Model replay as a new event
|
|
50
|
-
or run an explicit migration when that is the behavior you want.
|
|
51
|
-
|
|
52
|
-
## The context
|
|
53
|
-
|
|
54
|
-
Every handler receives one argument:
|
|
55
|
-
|
|
56
|
-
| Property | What it is |
|
|
57
|
-
| ------------- | ---------- |
|
|
58
|
-
| `ctx.event` | The triggering event: `{ id, type, payload, index, sessionId, createdAt }`. |
|
|
59
|
-
| `ctx.attempt` | The durable 1-based dispatch claim for this event. |
|
|
60
|
-
| `ctx.session` | This session's handle: `id`, `append`, `history`, `state`, and `stream`. |
|
|
61
|
-
| `ctx.signal` | An `AbortSignal`. Fires when the attempt's lease lapses (`CLAIM_EXPIRED`), when a recovered run provably supersedes it (`SUPERSEDED_ATTEMPT`), and on matching `abortOn` events. See [Cancellation](/guides/cancellation). |
|
|
62
|
-
|
|
63
|
-
`ctx.attempt` starts at `1` and increments on every durable claim. It may skip
|
|
64
|
-
when a process dies between the claim and handler entry.
|
|
65
|
-
|
|
66
|
-
`ctx.session.id` is the same value as `ctx.event.sessionId`. Use
|
|
67
|
-
`ctx.session.history()` when a handler needs raw facts. Use
|
|
68
|
-
[`state()`](/concepts/state) for a cached computed view of a long session.
|
|
69
|
-
The handler-scoped state read folds through `ctx.event.index` by default, so
|
|
70
|
-
later concurrent appends do not change what that event sees. Use
|
|
71
|
-
`ctx.session.state(reducer, { through: 'latest' })` when the handler
|
|
72
|
-
intentionally joins against the current committed frontier.
|
|
73
|
-
|
|
74
|
-
## Return what happens after success
|
|
75
|
-
|
|
76
|
-
Return an event when it depends on successful handler completion:
|
|
77
|
-
|
|
78
|
-
```ts server/return-one.ts
|
|
79
|
-
import { createServer } from 'experimental-a2/server'
|
|
80
|
-
import { orders } from '@/contracts'
|
|
81
|
-
|
|
82
|
-
export const assignments = createServer({
|
|
83
|
-
contract: orders,
|
|
84
|
-
handlers: {
|
|
85
|
-
'shop.started': async () => {
|
|
86
|
-
return { type: 'driver.notified', payload: { driverId: 'driver-7' } }
|
|
87
|
-
},
|
|
88
|
-
},
|
|
89
|
-
})
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
A handler can return one event or an array:
|
|
93
|
-
|
|
94
|
-
```ts server/return-many.ts
|
|
95
|
-
import { createServer } from 'experimental-a2/server'
|
|
96
|
-
import { orders } from '@/contracts'
|
|
97
|
-
|
|
98
|
-
export const fanout = createServer({
|
|
99
|
-
contract: orders,
|
|
100
|
-
handlers: {
|
|
101
|
-
'shop.started': async () => {
|
|
102
|
-
return orders.batch(
|
|
103
|
-
{ type: 'shop.notified', payload: {} },
|
|
104
|
-
{ type: 'driver.notified', payload: { driverId: 'driver-7' } },
|
|
105
|
-
)
|
|
106
|
-
},
|
|
107
|
-
},
|
|
108
|
-
})
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
`contract.batch(...)` keeps each event's literal type when an async handler
|
|
112
|
-
returns different event types. It is a type helper; validation still happens
|
|
113
|
-
when A2 commits the returned events.
|
|
114
|
-
|
|
115
|
-
A2 marks the triggering event processed and appends the returned batch in one
|
|
116
|
-
atomic storage operation. The batch is all-or-nothing and occupies consecutive
|
|
117
|
-
log positions. If the handler throws or the process dies first, none of the
|
|
118
|
-
returned events exist.
|
|
119
|
-
|
|
120
|
-
Returned events receive deterministic ids. If A2 commits the operation but
|
|
121
|
-
loses its acknowledgement, the retry recognizes the exact returned batch and
|
|
122
|
-
does not duplicate it.
|
|
123
|
-
|
|
124
|
-
## Append what should happen now
|
|
125
|
-
|
|
126
|
-
`ctx.session.append()` has different timing. It commits immediately, so the appended
|
|
127
|
-
event can start while its parent handler is still running:
|
|
128
|
-
|
|
129
|
-
```ts server/append-now.ts
|
|
130
|
-
import { createServer } from 'experimental-a2/server'
|
|
131
|
-
import { orders } from '@/contracts'
|
|
132
|
-
|
|
133
|
-
export const immediate = createServer({
|
|
134
|
-
contract: orders,
|
|
135
|
-
handlers: {
|
|
136
|
-
created: async ({ session }) => {
|
|
137
|
-
await session.append('notify-shop', {
|
|
138
|
-
type: 'shop.notified',
|
|
139
|
-
payload: {},
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
// The shop.notified handler may already be running here.
|
|
143
|
-
// await continueCurrentWork()
|
|
144
|
-
},
|
|
145
|
-
'shop.notified': async () => {},
|
|
146
|
-
},
|
|
147
|
-
})
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
Use it for streaming progress, independent fan-out, or any fact that remains
|
|
151
|
-
true even if the current handler later fails. A multi-event
|
|
152
|
-
`ctx.session.append()` is atomic per call.
|
|
153
|
-
|
|
154
|
-
The first argument names this immediate append within the triggering event.
|
|
155
|
-
It is the handler-local idempotency key for events whose `id` is omitted. Keep
|
|
156
|
-
it stable across retries:
|
|
157
|
-
|
|
158
|
-
```ts
|
|
159
|
-
// inside a handler:
|
|
160
|
-
await session.append(
|
|
161
|
-
'publish-build',
|
|
162
|
-
{ type: 'artifact.created', payload: artifact },
|
|
163
|
-
{ type: 'build.completed', payload: result },
|
|
164
|
-
)
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
A2 combines the triggering event id, this name, and each unnamed event's
|
|
168
|
-
position to derive deterministic child ids. Parallel branches can finish in
|
|
169
|
-
any order without changing their identities. Reusing a name with the same
|
|
170
|
-
generated-id batch returns the existing events. Changing that batch conflicts
|
|
171
|
-
with the rows already stored under those ids. An explicit event `id` wins over
|
|
172
|
-
the generated id, which is useful when different triggering events must
|
|
173
|
-
converge on one fact. Top-level `server.session(id).append(...events)` takes no
|
|
174
|
-
name because it is not scoped to a handler.
|
|
175
|
-
|
|
176
|
-
The distinction is small and important:
|
|
177
|
-
|
|
178
|
-
| Syntax | The event becomes durable |
|
|
179
|
-
| ------ | ------------------------- |
|
|
180
|
-
| `return event` | Atomically when the handler completes successfully |
|
|
181
|
-
| `return [a, b]` | Both atomically when the handler completes successfully |
|
|
182
|
-
| `await session.append(name, event)` | Immediately, before the handler completes |
|
|
183
|
-
|
|
184
|
-
## Concurrent by default
|
|
185
|
-
|
|
186
|
-
Every eligible event has its own durable claim. Independent handlers in the
|
|
187
|
-
same session start without waiting for earlier handlers:
|
|
188
|
-
|
|
189
|
-
```ts
|
|
190
|
-
// anywhere on the server:
|
|
191
|
-
await ordersServer.session('order-42').append(
|
|
192
|
-
{ type: 'created', payload: { shopId: 'shop-7', items: [] } },
|
|
193
|
-
{ type: 'shop.started', payload: {} },
|
|
194
|
-
)
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
The log order remains stable, but handler start and completion order do not.
|
|
198
|
-
A failure retries that event without holding up unlaned events or other lanes.
|
|
199
|
-
|
|
200
|
-
## Lanes
|
|
201
|
-
|
|
202
|
-
Use a lane when events share a resource and must not overlap:
|
|
203
|
-
|
|
204
|
-
```ts server/serial-orders.ts
|
|
205
|
-
import { createServer } from 'experimental-a2/server'
|
|
206
|
-
import { orders } from '@/contracts'
|
|
207
|
-
|
|
208
|
-
export const serialOrders = createServer({
|
|
209
|
-
contract: orders,
|
|
210
|
-
handlers: {
|
|
211
|
-
created: {
|
|
212
|
-
lane: 'order-work',
|
|
213
|
-
handler: async () => {},
|
|
214
|
-
},
|
|
215
|
-
'shop.started': {
|
|
216
|
-
lane: 'order-work',
|
|
217
|
-
handler: async () => {},
|
|
218
|
-
},
|
|
219
|
-
},
|
|
220
|
-
})
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
Within one session, events with the same lane run one at a time in log order.
|
|
224
|
-
Different lanes and unlaned events remain concurrent. A constant lane like
|
|
225
|
-
`'order-work'` recreates a serial queue for the event types that use it.
|
|
226
|
-
|
|
227
|
-
A lane can also be derived from the validated event:
|
|
228
|
-
|
|
229
|
-
```ts server/drivers.ts
|
|
230
|
-
import { createServer } from 'experimental-a2/server'
|
|
231
|
-
import { orders } from '@/contracts'
|
|
232
|
-
|
|
233
|
-
export const drivers = createServer({
|
|
234
|
-
contract: orders,
|
|
235
|
-
handlers: {
|
|
236
|
-
'driver.notified': {
|
|
237
|
-
lane: ({ event }) => `driver:${event.payload.driverId}`,
|
|
238
|
-
handler: async ({ event }) => {
|
|
239
|
-
// your work for this driver, using event.payload.driverId
|
|
240
|
-
},
|
|
241
|
-
},
|
|
242
|
-
},
|
|
243
|
-
})
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
The resolved lane is stored with the event when it is appended. The scheduler uses
|
|
247
|
-
that stored value, so a deployment cannot reinterpret pending work. Lane keys
|
|
248
|
-
are scoped to one session. They are not distributed locks across sessions.
|
|
249
|
-
|
|
250
|
-
If a lane's first unfinished event fails, later events in that lane wait.
|
|
251
|
-
Other lanes continue. After ten caught failures the event dead-letters and
|
|
252
|
-
keeps blocking its lane until manual resolution.
|
|
253
|
-
|
|
254
|
-
## Handlers can run twice
|
|
255
|
-
|
|
256
|
-
Handlers are at-least-once. A crash after your side effect but before A2
|
|
257
|
-
commits completion means the handler can run again.
|
|
258
|
-
|
|
259
|
-
1. **External side effects take an idempotency key.** `event.id` is stable
|
|
260
|
-
across attempts. Pass it to anything that should not happen twice.
|
|
261
|
-
|
|
262
|
-
2. **Immediate handler appends are deduplicated.**
|
|
263
|
-
`ctx.session.append(name, ...events)` derives ids from the triggering event,
|
|
264
|
-
the stable handler-local name, and each event's position. A retry gets the
|
|
265
|
-
existing rows back. A changed generated-id batch conflicts with those
|
|
266
|
-
existing rows.
|
|
267
|
-
|
|
268
|
-
3. **Returned events commit with completion.** They either all exist with the
|
|
269
|
-
processed marker or none exist.
|
|
270
|
-
|
|
271
|
-
A handler should give every independent immediate append a stable name. The
|
|
272
|
-
name removes any dependence on the order in which concurrent branches call
|
|
273
|
-
`append`. Use explicit event ids when several different triggering events must
|
|
274
|
-
converge on one shared fact.
|
|
275
|
-
|
|
276
|
-
## When handlers throw
|
|
277
|
-
|
|
278
|
-
Throwing means "retry me." A2 records the failure and retries with backoff.
|
|
279
|
-
The failed event blocks only its lane. After ten caught failures it
|
|
280
|
-
dead-letters for [manual resolution](/concepts/durability#when-a-handler-keeps-failing).
|
|
281
|
-
|
|
282
|
-
An interrupted handler should catch and return normally. Throwing is the
|
|
283
|
-
wrong response to someone pressing stop. See
|
|
284
|
-
[Cancellation](/guides/cancellation).
|
|
@@ -1,314 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Durability
|
|
3
|
-
description: Appends are effectively-once, handlers are at-least-once, and retries preserve event identity across crashes.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
## The contract
|
|
7
|
-
|
|
8
|
-
- **Appends are effectively-once.** Event ids deduplicate writes. A batch commits
|
|
9
|
-
before its handlers start; they may start before `append` resolves.
|
|
10
|
-
- **Handlers are at-least-once.** They may run again after a crash, so
|
|
11
|
-
[side effects use idempotency keys](/concepts/handlers#handlers-can-run-twice).
|
|
12
|
-
- **The log stays ordered.** Every session has one immutable append order.
|
|
13
|
-
- **Handlers are concurrent by default.** Log order does not serialize them.
|
|
14
|
-
Only events in the same lane run one at a time in log order.
|
|
15
|
-
|
|
16
|
-
## The correctness model
|
|
17
|
-
|
|
18
|
-
The log says what needs work. A per-event claim says who may work now. The
|
|
19
|
-
processed marker says what finished. The watchdog says when to look again.
|
|
20
|
-
|
|
21
|
-
```text
|
|
22
|
-
+------------------+
|
|
23
|
-
| Wakeups |
|
|
24
|
-
| append, watchdog |
|
|
25
|
-
+--------+---------+
|
|
26
|
-
|
|
|
27
|
-
v
|
|
28
|
-
+------------------+
|
|
29
|
-
| drain(session) |
|
|
30
|
-
+--------+---------+
|
|
31
|
-
|
|
|
32
|
-
v
|
|
33
|
-
+------------------+
|
|
34
|
-
| claimAvailable |
|
|
35
|
-
+--------+---------+
|
|
36
|
-
|
|
|
37
|
-
+----+----------------+
|
|
38
|
-
| |
|
|
39
|
-
v v
|
|
40
|
-
+-------------+ +-------------------+
|
|
41
|
-
| busy or | | run every claimed |
|
|
42
|
-
| settled | | event concurrently|
|
|
43
|
-
+-------------+ +-------------------+
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
A queue message identifies a session. Every wakeup runs the same drain, and
|
|
47
|
-
the log decides what remains. The scheduler and explicit `server.drain()` inspect
|
|
48
|
-
the full session. Reads never dispatch handlers.
|
|
49
|
-
|
|
50
|
-
:::note[Recovery is not an event]
|
|
51
|
-
A2 does not append `recovered` or `continued`. Recovery retries the same event.
|
|
52
|
-
Your log contains only application facts.
|
|
53
|
-
:::
|
|
54
|
-
|
|
55
|
-
## One event, many attempts
|
|
56
|
-
|
|
57
|
-
An event is pending until it has a processed marker. Attempt tracking is part
|
|
58
|
-
of the operations that already claim and complete that event:
|
|
59
|
-
|
|
60
|
-
```text
|
|
61
|
-
+----------------------------+ one backend operation
|
|
62
|
-
| claimAvailable |
|
|
63
|
-
| eligible events + attempts |
|
|
64
|
-
+-------------+--------------+
|
|
65
|
-
|
|
|
66
|
-
+------+------+
|
|
67
|
-
| |
|
|
68
|
-
v v
|
|
69
|
-
+-------------+ +-------------+
|
|
70
|
-
| handler A | | handler B |
|
|
71
|
-
+------+------+ +------+------+
|
|
72
|
-
| |
|
|
73
|
-
v v
|
|
74
|
-
+-------------+ +-------------+ one operation per event
|
|
75
|
-
| complete A | | complete B |
|
|
76
|
-
+-------------+ +-------------+
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
`ctx.attempt` is the durable, 1-based claim ordinal. A kill after the claim can
|
|
80
|
-
consume an ordinal before user code sees it, but adds no caught failure. A
|
|
81
|
-
retry gets the same event id, index, type, and payload with a larger attempt.
|
|
82
|
-
|
|
83
|
-
Use `event.id` to deduplicate external effects. `ctx.session.append` derives
|
|
84
|
-
stable child ids from its handler-local name. See
|
|
85
|
-
[Handlers can run twice](/concepts/handlers#handlers-can-run-twice).
|
|
86
|
-
|
|
87
|
-
Each claim stores its holder and expiry on the event. Claiming an expired event
|
|
88
|
-
increments its attempt before a new handler starts. Completion and failure are
|
|
89
|
-
fenced by that ordinal, so an older attempt cannot overwrite a newer one.
|
|
90
|
-
|
|
91
|
-
## Immediate and dependent events
|
|
92
|
-
|
|
93
|
-
Handlers have two ways to publish more events:
|
|
94
|
-
|
|
95
|
-
- `ctx.session.append(name, ...events)` commits immediately. Its events may run
|
|
96
|
-
before the current handler finishes. The stable name makes the append
|
|
97
|
-
idempotent across retries.
|
|
98
|
-
- `return event` or `return [eventA, eventB]` publishes only when the handler
|
|
99
|
-
succeeds. A2 completes the parent and appends the whole returned batch in
|
|
100
|
-
one transaction.
|
|
101
|
-
|
|
102
|
-
```ts server/pipeline.ts
|
|
103
|
-
import { z } from 'zod'
|
|
104
|
-
import * as a2 from 'experimental-a2'
|
|
105
|
-
import { createServer } from 'experimental-a2/server'
|
|
106
|
-
|
|
107
|
-
const pipeline = a2.contract({
|
|
108
|
-
name: 'pipeline',
|
|
109
|
-
events: {
|
|
110
|
-
'build.requested': z.object({ buildId: z.string() }),
|
|
111
|
-
'build.completed': z.object({ buildId: z.string() }),
|
|
112
|
-
'deploy.requested': z.object({ buildId: z.string() }),
|
|
113
|
-
},
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
export const pipelineServer = createServer({
|
|
117
|
-
contract: pipeline,
|
|
118
|
-
handlers: {
|
|
119
|
-
'build.requested': async ({ event }) => {
|
|
120
|
-
// your side effect, idempotent via event.id:
|
|
121
|
-
// await build(event.payload, { idempotencyKey: event.id })
|
|
122
|
-
return [
|
|
123
|
-
{ type: 'build.completed', payload: { buildId: event.payload.buildId } },
|
|
124
|
-
{ type: 'deploy.requested', payload: { buildId: event.payload.buildId } },
|
|
125
|
-
]
|
|
126
|
-
},
|
|
127
|
-
},
|
|
128
|
-
})
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
If the handler throws, neither returned event exists. If the completion
|
|
132
|
-
transaction commits but its acknowledgment is lost, A2 retries with the same
|
|
133
|
-
ordered child ids and returns the already-committed rows. A partial or changed
|
|
134
|
-
batch is rejected.
|
|
135
|
-
|
|
136
|
-
This transaction covers only A2's log. An HTTP call, database write, Bash
|
|
137
|
-
command, or other external effect cannot join it. If an effect succeeds and
|
|
138
|
-
the process dies before completion commits, the handler runs it again. Use
|
|
139
|
-
`event.id` as an idempotency key or add fencing in the external system.
|
|
140
|
-
|
|
141
|
-
## Lanes
|
|
142
|
-
|
|
143
|
-
A lane is a session-scoped serial group. A2 resolves the lane when it appends
|
|
144
|
-
the event and stores the value beside it.
|
|
145
|
-
|
|
146
|
-
- Events without a lane are independently eligible.
|
|
147
|
-
- Only the lowest-index unfinished event in a lane is eligible.
|
|
148
|
-
- Different lanes run concurrently.
|
|
149
|
-
- A failure blocks later events in that lane, not the whole session.
|
|
150
|
-
|
|
151
|
-
Persisting the value matters for recovery. A deployment that changes a lane
|
|
152
|
-
resolver affects new events, not pending ones already in the log.
|
|
153
|
-
|
|
154
|
-
A lane controls A2 dispatch, not the outside world. A worker can perform an
|
|
155
|
-
external effect and disappear before recording completion. Its successor then
|
|
156
|
-
retries the same event. External idempotency is still required.
|
|
157
|
-
|
|
158
|
-
A2 claims every currently eligible event. It does not add a global concurrency
|
|
159
|
-
limit or priority queue. Use lanes for ordered mutual exclusion. Put capacity
|
|
160
|
-
limits near the constrained resource, such as an API client, connection pool,
|
|
161
|
-
or execution runtime.
|
|
162
|
-
|
|
163
|
-
## Append and scheduling
|
|
164
|
-
|
|
165
|
-
A top-level append commits first. Only then does A2 start the inline drain and
|
|
166
|
-
optional watchdog arm in parallel. A handler may start before `append` returns;
|
|
167
|
-
the call joins the arm for at most two seconds, not the handler. The drain never
|
|
168
|
-
waits for the queue. Arm failure records `a2.append.armed = false`, while
|
|
169
|
-
`ctx.session.append` rides the active drain and adds no arm. Returned events
|
|
170
|
-
enter the log with completion, then become eligible immediately.
|
|
171
|
-
|
|
172
|
-
`session.append.dispatch(...events)` chooses the other execution path. It
|
|
173
|
-
commits first, skips the current invocation's inline drain, and awaits an
|
|
174
|
-
immediate scheduler send. It requires a configured scheduler. Queue delivery then
|
|
175
|
-
claims the same durable events through the normal drain path. If the send
|
|
176
|
-
fails, retry with the same explicit event IDs; the append itself may already
|
|
177
|
-
have committed. The scheduler message is a wakeup, not worker affinity. An
|
|
178
|
-
already-active drain may claim the new work first.
|
|
179
|
-
|
|
180
|
-
An event type without a handler settles in the append transaction with no
|
|
181
|
-
dispatch attempt. It starts no drain or scheduler arm when the session has no
|
|
182
|
-
older pending work. If older handled work is pending, the append still wakes
|
|
183
|
-
the session-wide healing path.
|
|
184
|
-
|
|
185
|
-
## Causal trees are durable
|
|
186
|
-
|
|
187
|
-
Every event written by `ctx.session.append` or returned by a handler stores one
|
|
188
|
-
atomic `cause` with the triggering event's index and current dispatch ordinal.
|
|
189
|
-
Top-level appends store null. Several children from one handler are siblings.
|
|
190
|
-
Nested handler output creates further levels. If a handler retries after
|
|
191
|
-
writing a child, idempotency preserves that child's original cause.
|
|
192
|
-
|
|
193
|
-
The edge is part of the existing append batch and survives process restarts.
|
|
194
|
-
The stored event also carries `lane`, `firstClaimedAt`, `lastClaimedAt`,
|
|
195
|
-
`claimHolder`, `claimExpiresAt`, `lastFailedAt`, `lastFailedAttempt`,
|
|
196
|
-
`processedAt`, `processedByAttempt`, and `returnedEventIds`. Timestamps are
|
|
197
|
-
adapter clock values captured for atomic store operations, not exact database
|
|
198
|
-
commit times. A stale worker cannot rewrite a newer completion. The durable
|
|
199
|
-
log can rebuild a causal forest and useful lifecycle boundaries without one
|
|
200
|
-
row per attempt.
|
|
201
|
-
|
|
202
|
-
## Claim and watchdog timing
|
|
203
|
-
|
|
204
|
-
A heartbeat renews every live claim owned by the drain and arms a watchdog
|
|
205
|
-
after each claim window. For illustration, use a five-second claim, two-second
|
|
206
|
-
heartbeat, and one-second grace:
|
|
207
|
-
|
|
208
|
-
| Time | Live worker | Event claim | Watchdog |
|
|
209
|
-
| ---: | --- | --- | --- |
|
|
210
|
-
| t0 | Claim attempt 1 | Through t5 | Arm t6 |
|
|
211
|
-
| t2 | Heartbeat | Through t7 | Arm t8 |
|
|
212
|
-
| t4 | Heartbeat | Through t9 | Arm t10 |
|
|
213
|
-
| t6 | Handler runs | Through t9 | Deliver t6: `busy`; ensure a later slot |
|
|
214
|
-
| t7 | Process dies | Abandoned | No more arms |
|
|
215
|
-
| t8 | | Through t9 | Deliver t8: `busy`; ensure a later slot |
|
|
216
|
-
| t9 | | Expires | t10 remains scheduled |
|
|
217
|
-
| t10 | Claim attempt 2 | New window | Deliver t10: wins |
|
|
218
|
-
|
|
219
|
-
Claim renewal and queue sends do not block each other. An expired claim cannot
|
|
220
|
-
be renewed. It becomes eligible for a new claim, which increments the attempt
|
|
221
|
-
and fences the previous worker. A busy watchdog ensures a later watchdog
|
|
222
|
-
before acknowledging; if that send fails, it does not acknowledge. If a
|
|
223
|
-
deadline is available, A2 caps the current claim window at it. The deadline
|
|
224
|
-
never gates handler dispatch.
|
|
225
|
-
|
|
226
|
-
## A chain that crosses the function timeout
|
|
227
|
-
|
|
228
|
-
Four eight-second handlers returned one after another do not fit together in a
|
|
229
|
-
20-second invocation, but each fits in a fresh one:
|
|
230
|
-
|
|
231
|
-
| Time | Work | Durable state |
|
|
232
|
-
| ---: | --- | --- |
|
|
233
|
-
| t0 | Handler 1 starts | Attempt 1 |
|
|
234
|
-
| t8 | Handler 1 finishes; handler 2 starts | Event 1 processed |
|
|
235
|
-
| t16 | Handler 2 finishes; handler 3 starts | Events 1 and 2 processed |
|
|
236
|
-
| t20 | Function is killed | Event 3 pending; `failureCount = 0` |
|
|
237
|
-
| t21 | Handler 3 starts again | Same event, attempt 2 |
|
|
238
|
-
| t29 | Handler 3 finishes; handler 4 starts | Events 1 through 3 processed |
|
|
239
|
-
| t37 | Handler 4 finishes | Session settled |
|
|
240
|
-
|
|
241
|
-
The retry starts handler 3 again from its entry point. Keep each handler within
|
|
242
|
-
a fresh invocation. Split longer work into smaller events or increase the
|
|
243
|
-
function duration.
|
|
244
|
-
|
|
245
|
-
## Crash around a returned batch
|
|
246
|
-
|
|
247
|
-
```text
|
|
248
|
-
+-------------------------+
|
|
249
|
-
| Invocation A |
|
|
250
|
-
| event 2, attempt 1 |
|
|
251
|
-
| handler returns event 3 |
|
|
252
|
-
+------------+------------+
|
|
253
|
-
|
|
|
254
|
-
completeAttempt
|
|
255
|
-
|
|
|
256
|
-
+-----+------+
|
|
257
|
-
| |
|
|
258
|
-
v v
|
|
259
|
-
transaction SIGKILL before
|
|
260
|
-
commits transaction
|
|
261
|
-
| |
|
|
262
|
-
v v
|
|
263
|
-
event 2 done event 2 pending
|
|
264
|
-
event 3 exists event 3 absent
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
If the commit acknowledgment is lost, a retry with the exact returned ids gets
|
|
268
|
-
event 3 back without duplicating it. If the transaction did not commit, event
|
|
269
|
-
2 retries and produces the same deterministic returned id. External effects
|
|
270
|
-
still use event 2's id as their idempotency key.
|
|
271
|
-
|
|
272
|
-
With `ctx.session.append`, the child commits before its parent finishes. It
|
|
273
|
-
remains in the log if the parent crashes and can run concurrently unless both
|
|
274
|
-
events use the same lane.
|
|
275
|
-
|
|
276
|
-
## Why this stays correct
|
|
277
|
-
|
|
278
|
-
| Property | Durable rule |
|
|
279
|
-
| --- | --- |
|
|
280
|
-
| Commit | Store the event before any wakeup. |
|
|
281
|
-
| Concurrency | Claim every eligible unlaned event and one unfinished head per lane. |
|
|
282
|
-
| Ownership | Store an expiring claim on each event. |
|
|
283
|
-
| Completion | Complete the parent and append its returned batch atomically. |
|
|
284
|
-
| Fencing | Only the current attempt can complete or record a failure. |
|
|
285
|
-
| Retry | Without a processed marker, the same event remains pending. |
|
|
286
|
-
| Identity | Event, immediate child, and returned child ids stay stable across retries. |
|
|
287
|
-
| Liveness | A watchdog, later append, or explicit drain re-enters `drain(session)`. |
|
|
288
|
-
|
|
289
|
-
## When a handler keeps failing
|
|
290
|
-
|
|
291
|
-
A caught failure increments `failureCount`, clears that event's claim, and
|
|
292
|
-
leaves other eligible events running. A stale failure changes nothing. Ten
|
|
293
|
-
caught failures dead-letter the event. Later events in its lane remain blocked;
|
|
294
|
-
unlaned events and other lanes continue. Hard kills do not consume this budget.
|
|
295
|
-
Resolution is manual: fix and retry, or skip.
|
|
296
|
-
|
|
297
|
-
## Limits
|
|
298
|
-
|
|
299
|
-
| Situation | Result |
|
|
300
|
-
| --- | --- |
|
|
301
|
-
| Process dies before the first arm is durable | Retry with the same ids, append again later, or call `drain()`. |
|
|
302
|
-
| No scheduler configured | A later top-level append or explicit `drain()` wakes the session. Reads never do. |
|
|
303
|
-
| Scheduler invocation dies after claiming but before arming | Its unacknowledged queue delivery is the slower fallback. |
|
|
304
|
-
| Store backend unavailable | Safe progress stops until it returns. |
|
|
305
|
-
| External effect succeeds before A2 completion | The handler may repeat it. Use idempotency or external fencing. |
|
|
306
|
-
|
|
307
|
-
## What append never throws for
|
|
308
|
-
|
|
309
|
-
Append success means the event is durable, not that all handlers finished. An
|
|
310
|
-
identical batch retry returns rows with the same ids. A mixed batch throws
|
|
311
|
-
[`PARTIAL_DUPLICATE_BATCH`](/reference/errors).
|
|
312
|
-
|
|
313
|
-
For the queue route and deployment configuration, see
|
|
314
|
-
[Going to production](/guides/production#2-add-a-scheduler-for-handlers).
|