experimental-a2 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/dist/ai-server.browser.js +2 -2
- package/dist/ai-server.d.ts +19 -7
- package/dist/ai-server.js +730 -96
- package/dist/ai.d.ts +32 -11
- package/dist/ai.js +253 -75
- package/dist/client.d.ts +1 -1
- package/dist/client.js +4 -4
- package/dist/{contract-B0kAXoaL.js → contract-CG_adnu_.js} +2 -1
- package/dist/{contract-DL8btVd9.d.ts → contract-C_3dIIEU.d.ts} +4 -1
- package/dist/devtools-server.browser.js +2 -2
- package/dist/devtools-server.js +1 -1
- package/dist/http.d.ts +1 -1
- package/dist/http.js +4 -3
- package/dist/idempotent-replay-BMyHrP0L.js +19 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/{internal-Dm8Ejnud.js → internal-D6wNxTck.js} +3 -3
- package/dist/{log-Dg1I8NRr.d.ts → log-ldf5g8Cx.d.ts} +74 -56
- package/dist/log-memory.d.ts +1 -1
- package/dist/log-memory.js +173 -96
- package/dist/{log-polling-RO7kclzR.js → log-polling-6COoN60V.js} +1 -1
- package/dist/log-postgres.d.ts +1 -1
- package/dist/log-postgres.js +235 -192
- package/dist/log-redis.d.ts +1 -1
- package/dist/log-redis.js +453 -263
- package/dist/log-sqlite.d.ts +1 -1
- package/dist/log-sqlite.js +216 -127
- package/dist/otel.d.ts +1 -1
- package/dist/otel.js +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/recovery-vercel.d.ts +2 -2
- package/dist/recovery-vercel.js +9 -10
- package/dist/server-BWffWe5A.js +867 -0
- package/dist/server.browser.js +4 -4
- package/dist/server.d.ts +42 -27
- package/dist/server.js +1 -1
- package/dist/{telemetry-C78al20p.d.ts → telemetry-Cso0qyHQ.d.ts} +1 -1
- package/dist/{wire-2QpU1EtJ.js → wire-BVsgR8o9.js} +1 -1
- package/docs/01-quickstart.mdx +7 -7
- package/docs/concepts/01-contracts.mdx +22 -22
- package/docs/concepts/02-handlers.mdx +223 -89
- package/docs/concepts/03-durability.mdx +191 -112
- package/docs/concepts/04-state.mdx +27 -1
- package/docs/guides/01-timers.mdx +4 -4
- package/docs/guides/02-cancellation.mdx +32 -4
- package/docs/guides/05-production.mdx +36 -27
- package/docs/guides/06-ai-agents.mdx +151 -70
- package/docs/guides/07-devtools.mdx +6 -3
- package/docs/guides/08-application-data.mdx +5 -6
- package/docs/index.mdx +30 -14
- package/docs/reference/01-api.mdx +280 -70
- package/package.json +31 -31
- package/dist/server-DYsnKTTy.js +0 -780
|
@@ -9,47 +9,44 @@ description: Appends are effectively-once, handlers are at-least-once, and retri
|
|
|
9
9
|
`append` returns, the event is in the log.
|
|
10
10
|
- **Handlers are at-least-once.** They may run again after a crash, so
|
|
11
11
|
[side effects use idempotency keys](/concepts/handlers#handlers-can-run-twice).
|
|
12
|
-
- **
|
|
13
|
-
|
|
12
|
+
- **The log stays ordered.** Every session has one immutable event order.
|
|
13
|
+
- **Handlers are concurrent by default.** Independent events can run at the
|
|
14
|
+
same time. Events in the same lane run one at a time in log order.
|
|
14
15
|
|
|
15
16
|
## The correctness model
|
|
16
17
|
|
|
17
|
-
The log says what needs work.
|
|
18
|
-
marker says what finished. The watchdog says when to look again.
|
|
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.
|
|
19
20
|
|
|
20
21
|
```text
|
|
21
|
-
|
|
22
|
-
| Wakeups
|
|
23
|
-
|
|
|
24
|
-
|
|
25
|
-
| watchdog |
|
|
26
|
-
| state / stream |
|
|
27
|
-
+--------+--------+
|
|
22
|
+
+------------------+
|
|
23
|
+
| Wakeups |
|
|
24
|
+
| append, watchdog |
|
|
25
|
+
+--------+---------+
|
|
28
26
|
|
|
|
29
27
|
v
|
|
30
|
-
|
|
31
|
-
| drain(session)
|
|
32
|
-
|
|
28
|
+
+------------------+
|
|
29
|
+
| drain(session) |
|
|
30
|
+
+--------+---------+
|
|
33
31
|
|
|
|
34
32
|
v
|
|
35
|
-
|
|
36
|
-
|
|
|
37
|
-
|
|
33
|
+
+------------------+
|
|
34
|
+
| claimAvailable |
|
|
35
|
+
+--------+---------+
|
|
38
36
|
|
|
|
39
|
-
|
|
40
|
-
|
|
|
41
|
-
v
|
|
42
|
-
|
|
43
|
-
|
|
|
44
|
-
|
|
|
45
|
-
|
|
46
|
-
+----------+
|
|
37
|
+
+----+----------------+
|
|
38
|
+
| |
|
|
39
|
+
v v
|
|
40
|
+
+-------------+ +-------------------+
|
|
41
|
+
| busy or | | run every claimed |
|
|
42
|
+
| settled | | event concurrently|
|
|
43
|
+
+-------------+ +-------------------+
|
|
47
44
|
```
|
|
48
45
|
|
|
49
46
|
A queue message names a session. It carries no event index or continuation
|
|
50
47
|
state. Every wakeup runs the same drain, and the log decides what remains.
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
Recovery and explicit `server.drain()` inspect the full session. Reads never
|
|
49
|
+
dispatch handlers.
|
|
53
50
|
|
|
54
51
|
:::note[Recovery is not an event]
|
|
55
52
|
A2 does not append `recovered` or `continued`. Recovery retries the same event.
|
|
@@ -59,72 +56,149 @@ Your log contains only application facts.
|
|
|
59
56
|
## One event, many attempts
|
|
60
57
|
|
|
61
58
|
An event is pending until it has a processed marker. Attempt tracking is part
|
|
62
|
-
of the operations that already claim and
|
|
59
|
+
of the operations that already claim and complete that event:
|
|
63
60
|
|
|
64
61
|
```text
|
|
65
62
|
+----------------------------+ one backend operation
|
|
66
|
-
|
|
|
67
|
-
|
|
|
63
|
+
| claimAvailable |
|
|
64
|
+
| eligible events + attempts |
|
|
68
65
|
+-------------+--------------+
|
|
69
66
|
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
v
|
|
82
|
-
+----------------------------+
|
|
83
|
-
| handler 2 |
|
|
84
|
-
+----------------------------+
|
|
67
|
+
+------+------+
|
|
68
|
+
| |
|
|
69
|
+
v v
|
|
70
|
+
+-------------+ +-------------+
|
|
71
|
+
| handler A | | handler B |
|
|
72
|
+
+------+------+ +------+------+
|
|
73
|
+
| |
|
|
74
|
+
v v
|
|
75
|
+
+-------------+ +-------------+ one operation per event
|
|
76
|
+
| complete A | | complete B |
|
|
77
|
+
+-------------+ +-------------+
|
|
85
78
|
```
|
|
86
79
|
|
|
87
80
|
`ctx.attempt` is the durable, 1-based claim ordinal. A kill after the claim can
|
|
88
81
|
consume an ordinal before user code sees it, but adds no caught failure. A
|
|
89
82
|
retry gets the same event id, index, type, and payload with a larger attempt.
|
|
90
83
|
|
|
91
|
-
Use `event.id` to deduplicate external effects. `ctx.append` derives
|
|
92
|
-
child ids
|
|
93
|
-
[
|
|
84
|
+
Use `event.id` to deduplicate external effects. `ctx.session.append` derives
|
|
85
|
+
stable child ids from its handler-local name. See
|
|
86
|
+
[Handlers can run twice](/concepts/handlers#handlers-can-run-twice).
|
|
87
|
+
|
|
88
|
+
Each claim stores its holder and expiry on the event. Claiming an expired event
|
|
89
|
+
increments its attempt before a new handler starts. Completion and failure are
|
|
90
|
+
fenced by that ordinal, so an older attempt cannot overwrite a newer one.
|
|
91
|
+
|
|
92
|
+
## Immediate and dependent events
|
|
93
|
+
|
|
94
|
+
Handlers have two ways to publish more events:
|
|
95
|
+
|
|
96
|
+
- `ctx.session.append(name, ...events)` commits immediately. Its events may run
|
|
97
|
+
before the current handler finishes. The stable name makes the append
|
|
98
|
+
idempotent across retries.
|
|
99
|
+
- `return event` or `return [eventA, eventB]` publishes only when the handler
|
|
100
|
+
succeeds. A2 completes the parent and appends the whole returned batch in
|
|
101
|
+
one transaction.
|
|
102
|
+
|
|
103
|
+
```ts server/pipeline.ts
|
|
104
|
+
import { z } from 'zod'
|
|
105
|
+
import * as a2 from 'experimental-a2'
|
|
106
|
+
import { createServer } from 'experimental-a2/server'
|
|
107
|
+
|
|
108
|
+
const pipeline = a2.contract({
|
|
109
|
+
name: 'pipeline',
|
|
110
|
+
events: {
|
|
111
|
+
'build.requested': z.object({ buildId: z.string() }),
|
|
112
|
+
'build.completed': z.object({ buildId: z.string() }),
|
|
113
|
+
'deploy.requested': z.object({ buildId: z.string() }),
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
export const pipelineServer = createServer({
|
|
118
|
+
contract: pipeline,
|
|
119
|
+
handlers: {
|
|
120
|
+
'build.requested': async ({ event }) => {
|
|
121
|
+
// your side effect, idempotent via event.id:
|
|
122
|
+
// await build(event.payload, { idempotencyKey: event.id })
|
|
123
|
+
return [
|
|
124
|
+
{ type: 'build.completed', payload: { buildId: event.payload.buildId } },
|
|
125
|
+
{ type: 'deploy.requested', payload: { buildId: event.payload.buildId } },
|
|
126
|
+
]
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
If the handler throws, neither returned event exists. If the completion
|
|
133
|
+
transaction commits but its acknowledgment is lost, A2 retries with the same
|
|
134
|
+
ordered child ids and returns the already-committed rows. A partial or changed
|
|
135
|
+
batch is rejected.
|
|
136
|
+
|
|
137
|
+
This transaction covers only A2's log. An HTTP call, database write, Bash
|
|
138
|
+
command, or other external effect cannot join it. If an effect succeeds and
|
|
139
|
+
the process dies before completion commits, the handler runs it again. Use
|
|
140
|
+
`event.id` as an idempotency key or add fencing in the external system.
|
|
141
|
+
|
|
142
|
+
## Lanes
|
|
143
|
+
|
|
144
|
+
A lane is a session-scoped serial group. A2 resolves the lane when it appends
|
|
145
|
+
the event and stores the value beside it.
|
|
146
|
+
|
|
147
|
+
- Events without a lane are independently eligible.
|
|
148
|
+
- Only the lowest-index unfinished event in a lane is eligible.
|
|
149
|
+
- Different lanes run concurrently.
|
|
150
|
+
- A failure blocks later events in that lane, not the whole session.
|
|
151
|
+
|
|
152
|
+
Persisting the value matters for recovery. A deployment that changes a lane
|
|
153
|
+
resolver affects new events, not pending ones already in the log.
|
|
154
|
+
|
|
155
|
+
A lane controls A2 dispatch, not the outside world. A worker can perform an
|
|
156
|
+
external effect and disappear before recording completion. Its successor then
|
|
157
|
+
retries the same event. External idempotency is still required.
|
|
158
|
+
|
|
159
|
+
A2 claims every currently eligible event. It does not add a global concurrency
|
|
160
|
+
limit or priority queue. Use lanes for ordered mutual exclusion. Put capacity
|
|
161
|
+
limits near the constrained resource, such as an API client, connection pool,
|
|
162
|
+
or execution runtime.
|
|
94
163
|
|
|
95
164
|
## Append and recovery
|
|
96
165
|
|
|
97
|
-
A top-level append commits first
|
|
98
|
-
watchdog arm in parallel. The drain never waits
|
|
99
|
-
arm for at most two seconds; on failure, its
|
|
100
|
-
`a2.append.armed = false`, but committed work continues.
|
|
101
|
-
active drain and adds no arm.
|
|
166
|
+
A top-level append commits first. When pending handler work remains, it starts
|
|
167
|
+
the inline drain and optional watchdog arm in parallel. The drain never waits
|
|
168
|
+
for the queue. Append joins the arm for at most two seconds; on failure, its
|
|
169
|
+
telemetry span records `a2.append.armed = false`, but committed work continues.
|
|
170
|
+
`ctx.session.append` rides the active session drain and adds no arm. Returned
|
|
171
|
+
events enter the log as part of completion, then become eligible immediately.
|
|
172
|
+
|
|
173
|
+
An event type without a handler settles in the append transaction with no
|
|
174
|
+
dispatch attempt. It starts no drain or recovery arm when the session has no
|
|
175
|
+
older pending work. If older handled work is pending, the append still wakes
|
|
176
|
+
the session-wide healing path.
|
|
102
177
|
|
|
103
178
|
## Causal trees are durable
|
|
104
179
|
|
|
105
|
-
Every event written by `ctx.append`
|
|
106
|
-
triggering event's index and current dispatch ordinal.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
after writing a child, idempotency preserves that child's original cause.
|
|
180
|
+
Every event written by `ctx.session.append` or returned by a handler stores one
|
|
181
|
+
atomic `cause` with the triggering event's index and current dispatch ordinal.
|
|
182
|
+
Top-level appends store null. Several children from one handler are siblings.
|
|
183
|
+
Nested handler output creates further levels. If a handler retries after
|
|
184
|
+
writing a child, idempotency preserves that child's original cause.
|
|
111
185
|
|
|
112
186
|
The edge is part of the existing append batch and survives process restarts.
|
|
113
|
-
The stored event also carries `firstClaimedAt`, `lastClaimedAt`,
|
|
114
|
-
`
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
cannot rewrite a newer completion. The durable
|
|
118
|
-
causal forest and
|
|
119
|
-
|
|
187
|
+
The stored event also carries `lane`, `firstClaimedAt`, `lastClaimedAt`,
|
|
188
|
+
`claimHolder`, `claimExpiresAt`, `lastFailedAt`, `lastFailedAttempt`,
|
|
189
|
+
`processedAt`, `processedByAttempt`, and `returnedEventIds`. Timestamps are
|
|
190
|
+
adapter clock values captured for atomic log operations, not exact database
|
|
191
|
+
commit times. A stale worker cannot rewrite a newer completion. The durable
|
|
192
|
+
log can rebuild a causal forest and useful lifecycle boundaries without one
|
|
193
|
+
row per attempt.
|
|
120
194
|
|
|
121
|
-
##
|
|
195
|
+
## Claim and watchdog timing
|
|
122
196
|
|
|
123
|
-
A heartbeat renews the
|
|
124
|
-
window. For illustration, use a five-second
|
|
125
|
-
one-second grace:
|
|
197
|
+
A heartbeat renews every live claim owned by the drain and arms a watchdog
|
|
198
|
+
after each claim window. For illustration, use a five-second claim, two-second
|
|
199
|
+
heartbeat, and one-second grace:
|
|
126
200
|
|
|
127
|
-
| Time | Live worker |
|
|
201
|
+
| Time | Live worker | Event claim | Watchdog |
|
|
128
202
|
| ---: | --- | --- | --- |
|
|
129
203
|
| t0 | Claim attempt 1 | Through t5 | Arm t6 |
|
|
130
204
|
| t2 | Heartbeat | Through t7 | Arm t8 |
|
|
@@ -135,15 +209,17 @@ one-second grace:
|
|
|
135
209
|
| t9 | | Expires | t10 remains scheduled |
|
|
136
210
|
| t10 | Claim attempt 2 | New window | Deliver t10: wins |
|
|
137
211
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
212
|
+
Claim renewal and queue sends do not block each other. An expired claim cannot
|
|
213
|
+
be renewed. It becomes eligible for a new claim, which increments the attempt
|
|
214
|
+
and fences the previous worker. A busy watchdog ensures a later watchdog
|
|
215
|
+
before acknowledging; if that send fails, it does not acknowledge. If a
|
|
216
|
+
deadline is available, A2 caps the current claim window at it. The deadline
|
|
217
|
+
never gates handler dispatch.
|
|
142
218
|
|
|
143
219
|
## A chain that crosses the function timeout
|
|
144
220
|
|
|
145
|
-
Four eight-second handlers do not fit together in a
|
|
146
|
-
each fits in a fresh one:
|
|
221
|
+
Four eight-second handlers returned one after another do not fit together in a
|
|
222
|
+
20-second invocation, but each fits in a fresh one:
|
|
147
223
|
|
|
148
224
|
| Time | Work | Durable state |
|
|
149
225
|
| ---: | --- | --- |
|
|
@@ -161,55 +237,57 @@ One handler that always exceeds a fresh invocation cannot finish this way. A2
|
|
|
161
237
|
cannot checkpoint arbitrary async code. Split the work into smaller events or
|
|
162
238
|
increase the function duration.
|
|
163
239
|
|
|
164
|
-
## Crash
|
|
240
|
+
## Crash around a returned batch
|
|
165
241
|
|
|
166
242
|
```text
|
|
167
|
-
|
|
168
|
-
| Invocation A
|
|
169
|
-
| event 2, attempt 1
|
|
170
|
-
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
| Invocation B |
|
|
185
|
-
| event 2, attempt 2 |
|
|
186
|
-
| append reuses event 3 |
|
|
187
|
-
+-----------+------------+
|
|
188
|
-
|
|
|
189
|
-
v
|
|
190
|
-
complete 2, drain 3
|
|
243
|
+
+-------------------------+
|
|
244
|
+
| Invocation A |
|
|
245
|
+
| event 2, attempt 1 |
|
|
246
|
+
| handler returns event 3 |
|
|
247
|
+
+------------+------------+
|
|
248
|
+
|
|
|
249
|
+
completeAttempt
|
|
250
|
+
|
|
|
251
|
+
+-----+------+
|
|
252
|
+
| |
|
|
253
|
+
v v
|
|
254
|
+
transaction SIGKILL before
|
|
255
|
+
commits transaction
|
|
256
|
+
| |
|
|
257
|
+
v v
|
|
258
|
+
event 2 done event 2 pending
|
|
259
|
+
event 3 exists event 3 absent
|
|
191
260
|
```
|
|
192
261
|
|
|
193
|
-
|
|
194
|
-
|
|
262
|
+
If the commit acknowledgment is lost, a retry with the exact returned ids gets
|
|
263
|
+
event 3 back without duplicating it. If the transaction did not commit, event
|
|
264
|
+
2 retries and produces the same deterministic returned id. External effects
|
|
265
|
+
still use event 2's id as their idempotency key.
|
|
266
|
+
|
|
267
|
+
With `ctx.session.append`, the child commits before its parent finishes. It
|
|
268
|
+
remains in the log if the parent crashes and can run concurrently unless both
|
|
269
|
+
events use the same lane.
|
|
195
270
|
|
|
196
271
|
## Why this stays correct
|
|
197
272
|
|
|
198
273
|
| Property | Durable rule |
|
|
199
274
|
| --- | --- |
|
|
200
275
|
| Commit | Store the event before any wakeup. |
|
|
201
|
-
|
|
|
202
|
-
| Ownership |
|
|
203
|
-
|
|
|
276
|
+
| Concurrency | Claim every eligible unlaned event and one unfinished head per lane. |
|
|
277
|
+
| Ownership | Store an expiring claim on each event. |
|
|
278
|
+
| Completion | Complete the parent and append its returned batch atomically. |
|
|
279
|
+
| Fencing | Only the current attempt can complete or record a failure. |
|
|
204
280
|
| Retry | Without a processed marker, the same event remains pending. |
|
|
205
|
-
| Identity | Event and
|
|
281
|
+
| Identity | Event, immediate child, and returned child ids stay stable across retries. |
|
|
206
282
|
| Liveness | A watchdog, later append, or explicit drain re-enters `drain(session)`. |
|
|
207
283
|
|
|
208
284
|
## When a handler keeps failing
|
|
209
285
|
|
|
210
|
-
A caught failure increments `failureCount
|
|
211
|
-
|
|
212
|
-
|
|
286
|
+
A caught failure increments `failureCount`, clears that event's claim, and
|
|
287
|
+
leaves other eligible events running. A stale failure changes nothing. Ten
|
|
288
|
+
caught failures dead-letter the event. Later events in its lane remain blocked;
|
|
289
|
+
unlaned events and other lanes continue. Hard kills do not consume this budget.
|
|
290
|
+
Resolution is manual: fix and retry, or skip.
|
|
213
291
|
|
|
214
292
|
## Limits
|
|
215
293
|
|
|
@@ -218,7 +296,8 @@ do not consume this budget. Resolution is manual: fix and retry, or skip.
|
|
|
218
296
|
| Process dies before the first arm is durable | Retry with the same ids, append again later, or call `drain()`. |
|
|
219
297
|
| No recovery configured | A later top-level append or explicit `drain()` wakes the session. Reads never do. |
|
|
220
298
|
| Recovery dies after claiming but before arming | Its unacknowledged queue delivery is the slower fallback. |
|
|
221
|
-
| Log
|
|
299
|
+
| Log backend unavailable | Safe progress stops until it returns. |
|
|
300
|
+
| External effect succeeds before A2 completion | The handler may repeat it. Use idempotency or external fencing. |
|
|
222
301
|
|
|
223
302
|
## What append never throws for
|
|
224
303
|
|
|
@@ -67,6 +67,25 @@ const { state, index } = await ordersServer
|
|
|
67
67
|
.state(ordersReducer)
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
+
Handlers receive the same session interface for their triggering session:
|
|
71
|
+
|
|
72
|
+
```ts server.ts
|
|
73
|
+
import { createServer } from 'experimental-a2/server'
|
|
74
|
+
import { orders } from './contracts'
|
|
75
|
+
import { ordersReducer } from './reducer'
|
|
76
|
+
|
|
77
|
+
export const ordersServer = createServer({
|
|
78
|
+
contract: orders,
|
|
79
|
+
handlers: {
|
|
80
|
+
created: async ({ session }) => {
|
|
81
|
+
const { state, index } = await session.state(ordersReducer)
|
|
82
|
+
void state
|
|
83
|
+
void index
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
```
|
|
88
|
+
|
|
70
89
|
A reducer is a name (its identity, more on that below) plus the values that
|
|
71
90
|
anchor it: `initialState` (the seed), an optional `stateSchema`, and the
|
|
72
91
|
pure `fold`, `(state, event) => state`. It's derived *from* the
|
|
@@ -83,7 +102,14 @@ The snapshot and its remaining event tail come back in one consistent log
|
|
|
83
102
|
operation. A missing snapshot reads the full log. A snapshot rejected by
|
|
84
103
|
`stateSchema`, or a failed cache read, falls back to the full log and rebuilds
|
|
85
104
|
from truth. `state()` is observational: it never runs handlers or waits for
|
|
86
|
-
pending work to finish.
|
|
105
|
+
pending work to finish. Inside a handler, the returned index includes the
|
|
106
|
+
triggering event and may include later events that committed before the read.
|
|
107
|
+
|
|
108
|
+
The read and a following `ctx.session.append(name, ...events)` are separate
|
|
109
|
+
operations. Concurrent appends and retries can move the frontier between them.
|
|
110
|
+
For joins, use a monotone readiness check (once ready, always ready) and give
|
|
111
|
+
the output event a stable explicit `id`. Repeated attempts then converge on the
|
|
112
|
+
same append.
|
|
87
113
|
|
|
88
114
|
`stateSchema` declares the state's shape once. Without it, the state type
|
|
89
115
|
is inferred from `initialState`, fine while every fold arm returns the
|
|
@@ -25,14 +25,14 @@ import { orders } from '@/contracts'
|
|
|
25
25
|
export const ordersServer = createServer({
|
|
26
26
|
contract: orders,
|
|
27
27
|
handlers: {
|
|
28
|
-
created: async ({
|
|
28
|
+
created: async ({ session }) => {
|
|
29
29
|
// schedule with anything that can deliver an HTTP call later:
|
|
30
30
|
// QStash, cron, a provider webhook:
|
|
31
31
|
//
|
|
32
32
|
// await scheduleHttpCall({
|
|
33
33
|
// delay: '5d',
|
|
34
34
|
// url: '/api/append',
|
|
35
|
-
// body: { sessionId:
|
|
35
|
+
// body: { sessionId: session.id, type: 'expired', payload: {} },
|
|
36
36
|
// })
|
|
37
37
|
},
|
|
38
38
|
},
|
|
@@ -70,8 +70,8 @@ import { orders } from '@/contracts'
|
|
|
70
70
|
export const ordersServer = createServer({
|
|
71
71
|
contract: orders,
|
|
72
72
|
handlers: {
|
|
73
|
-
expired: async ({
|
|
74
|
-
const events = await history()
|
|
73
|
+
expired: async ({ session }) => {
|
|
74
|
+
const events = await session.history()
|
|
75
75
|
if (events.some((e) => e.type === 'shop.started')) return // stale, ignore
|
|
76
76
|
// ...actually expire the order
|
|
77
77
|
},
|
|
@@ -65,10 +65,38 @@ generate: {
|
|
|
65
65
|
The pushing side already knows the id it's cancelling; it's in the
|
|
66
66
|
`events` feed the hook exposes.
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
Handlers are concurrent by default. A `cancelled` handler may therefore run
|
|
69
|
+
while the interrupted handler is still unwinding. The signal is the
|
|
70
|
+
preemption channel; the event is the durable record.
|
|
71
|
+
|
|
72
|
+
When cleanup must wait for the interrupted handler, put both event types in
|
|
73
|
+
one lane. The event fires the signal as soon as it is appended, but its own
|
|
74
|
+
handler waits for earlier work in that lane:
|
|
75
|
+
|
|
76
|
+
```ts server/chat-with-cleanup.ts
|
|
77
|
+
import { createServer } from 'experimental-a2/server'
|
|
78
|
+
import { chat } from '@/contracts'
|
|
79
|
+
|
|
80
|
+
export const chatServer = createServer({
|
|
81
|
+
contract: chat,
|
|
82
|
+
handlers: {
|
|
83
|
+
generate: {
|
|
84
|
+
lane: 'generation',
|
|
85
|
+
abortOn: ['cancelled'],
|
|
86
|
+
handler: async (ctx) => {
|
|
87
|
+
// your abortable work; hand it ctx.signal:
|
|
88
|
+
// await streamText({ signal: ctx.signal, ... })
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
cancelled: {
|
|
92
|
+
lane: 'generation',
|
|
93
|
+
handler: async () => {
|
|
94
|
+
// your cleanup, after generate has returned
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
```
|
|
72
100
|
|
|
73
101
|
## The view cuts
|
|
74
102
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Going to production
|
|
3
|
-
description: Point the log at
|
|
3
|
+
description: Point the log at durable storage, add recovery for handler work, and know what to do when an event dead-letters.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
## Two pieces
|
|
7
7
|
|
|
8
8
|
Development needs zero setup: SQLite appears under `.a2/`, and tests run
|
|
9
|
-
in memory.
|
|
10
|
-
|
|
9
|
+
in memory. Every production server needs a durable log. Servers with handlers
|
|
10
|
+
also need recovery. Neither choice changes your event contract.
|
|
11
11
|
|
|
12
12
|
## 1. Choose a log
|
|
13
13
|
|
|
@@ -30,7 +30,8 @@ export const ordersServer = createServer({
|
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
The Postgres backend uses real transactions; appends serialize per
|
|
33
|
-
session on an advisory lock,
|
|
33
|
+
session on an advisory lock, while handler claims remain concurrent. The live
|
|
34
|
+
stream polls the log with an
|
|
34
35
|
activity-adaptive cadence: 25ms while a session is producing events
|
|
35
36
|
(a token stream reads smoothly, not in clumps), backing off to 250ms
|
|
36
37
|
when it goes quiet (a LISTEN/NOTIFY upgrade could still land without
|
|
@@ -43,7 +44,7 @@ This configures storage for A2's session logs. It does not connect A2 to your
|
|
|
43
44
|
application tables or make them part of the append transaction. See
|
|
44
45
|
[A2 and your database](/guides/application-data) for that boundary.
|
|
45
46
|
|
|
46
|
-
## 2. Add recovery
|
|
47
|
+
## 2. Add recovery for handlers
|
|
47
48
|
|
|
48
49
|
Recovery is what puts a clock on healing. `experimental-a2/recovery-vercel` rides
|
|
49
50
|
Vercel Queues (`@vercel/queue` is a peer
|
|
@@ -94,33 +95,35 @@ export const POST = recovery.handler(ordersServer, billingServer)
|
|
|
94
95
|
The trigger makes the route private. Only queue infrastructure can invoke
|
|
95
96
|
it, so it needs no auth of its own.
|
|
96
97
|
|
|
97
|
-
One route, one job.
|
|
98
|
-
"drain this session" arm alongside its inline
|
|
99
|
-
wait for the queue, while `append` joins the
|
|
100
|
-
before it returns. An unresponsive queue
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
98
|
+
One route, one job. A top-level append that leaves or finds pending handler
|
|
99
|
+
work starts a delayed, coalesced "drain this session" arm alongside its inline
|
|
100
|
+
handler. The handler does not wait for the queue, while `append` joins the
|
|
101
|
+
initial arm for up to two seconds before it returns. An unresponsive queue
|
|
102
|
+
therefore cannot hold the append open indefinitely. Claim renewals arm another
|
|
103
|
+
watchdog for just after the current window. Live handlers keep moving their
|
|
104
|
+
per-event claims and the watchdog forward. A killed holder stops heartbeating,
|
|
105
|
+
its claims expire, and the next watchdog retries those pending events. When
|
|
106
|
+
Vercel exposes the
|
|
107
|
+
function deadline, A2 caps the final claim window there so timeout recovery
|
|
108
|
+
starts promptly. A failing handler keeps the current message and redelivers
|
|
109
|
+
with backoff. Work outside its lane continues.
|
|
110
|
+
|
|
111
|
+
Due times are rounded to one-second slots. Top-level arms, claim renewals, and
|
|
110
112
|
racing deliveries targeting the same slot deduplicate into one queue message.
|
|
111
113
|
Busy deliveries continue the current message's heartbeat-aligned slot series,
|
|
112
114
|
so they do not create an independent stream of watchdog callbacks.
|
|
113
|
-
Events appended by handlers ride their current execution window
|
|
114
|
-
recovery operation of their own.
|
|
115
|
+
Events appended or returned by handlers ride their current execution window
|
|
116
|
+
and add no recovery operation of their own.
|
|
115
117
|
|
|
116
118
|
No cron, no sweep, no notification bookkeeping. The queue message is
|
|
117
119
|
the recovery state, and the log is the only thing it consults.
|
|
118
120
|
|
|
119
121
|
## 3. When an event dead-letters
|
|
120
122
|
|
|
121
|
-
After ten caught handler failures, A2 stops retrying an event
|
|
122
|
-
|
|
123
|
-
explains
|
|
123
|
+
After ten caught handler failures, A2 stops retrying an event. It blocks later
|
|
124
|
+
events in the same lane; unlaned events and other lanes continue.
|
|
125
|
+
[Durability](/concepts/durability#when-a-handler-keeps-failing) explains this
|
|
126
|
+
boundary. Resolution is manual, and has
|
|
124
127
|
exactly two shapes:
|
|
125
128
|
|
|
126
129
|
- **Fix and retry.** Deploy the handler fix, clear the event's failure
|
|
@@ -155,14 +158,20 @@ path lands in a single trace: the request → `a2.append` → the inline
|
|
|
155
158
|
make: the whole causal chain, visually. Handler failures mark their `a2.event`
|
|
156
159
|
span with the exception, each dispatch reports `ctx.attempt` as
|
|
157
160
|
`a2.event.attempt`, and `a2.event.outcome = dead_lettered` is the attribute to
|
|
158
|
-
alert on when
|
|
161
|
+
alert on when an event [needs manual
|
|
159
162
|
resolution](#3-when-an-event-dead-letters). See the
|
|
160
163
|
[API reference](/reference/api#a2otel) for the span catalogue.
|
|
161
164
|
|
|
162
165
|
## Running without a queue
|
|
163
166
|
|
|
164
|
-
|
|
165
|
-
|
|
167
|
+
If none of a contract's event types have handlers, skip `recovery`. Those
|
|
168
|
+
events settle in their append transaction, so they create no drains, claims,
|
|
169
|
+
queue messages, or recovery callbacks. The server is a durable event log for
|
|
170
|
+
history, reducers, and live sync. That is a complete production configuration,
|
|
171
|
+
not degraded recovery, because there is no reaction to recover.
|
|
172
|
+
|
|
173
|
+
With handlers, skipping `recovery` means the only wakeups are a top-level
|
|
174
|
+
append or explicit `server.drain()`. Reads never wake the session.
|
|
166
175
|
|
|
167
176
|
That's a real configuration, not a broken one. Fine for internal tools
|
|
168
177
|
and low-stakes apps where "heals on the next write" is acceptable. But
|
|
@@ -174,6 +183,6 @@ For production, configure recovery.
|
|
|
174
183
|
| Piece | Done when |
|
|
175
184
|
| ------------------- | -------------------------------------------------------------------- |
|
|
176
185
|
| Log | `log: postgres(...)` on every server |
|
|
177
|
-
| Recovery | one shared `vercelQueues()`, route
|
|
186
|
+
| Recovery | for servers with handlers: one shared `vercelQueues()`, route, and trigger |
|
|
178
187
|
| Idempotent handlers | external side effects take `event.id` as an idempotency key |
|
|
179
188
|
| Client split | contracts/reducers isomorphic; only `experimental-a2/server` touches backends |
|