experimental-a2 0.20.0 → 0.22.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 +55 -0
- package/README.md +1 -1
- package/dist/{actor-C7b-1arn.d.ts → actor-CqibLgjQ.d.ts} +15 -7
- package/dist/actor-CqibLgjQ.d.ts.map +1 -0
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-client.js +2 -2
- package/dist/actor-react.d.ts +3 -3
- package/dist/actor-react.js +5 -5
- package/dist/{actor-shared-t7w8xIKP.js → actor-shared-DFET5q16.js} +3 -3
- package/dist/actor-shared-DFET5q16.js.map +1 -0
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +54 -43
- package/dist/actor.js.map +1 -1
- package/dist/{ai-Cz3hqVhk.d.ts → ai-DAu77CDr.d.ts} +8 -8
- package/dist/{ai-Cz3hqVhk.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
- package/dist/{ai-mMiWul1T.js → ai-EEfKyc-z.js} +9 -9
- package/dist/ai-EEfKyc-z.js.map +1 -0
- package/dist/ai-client.d.ts +2 -2
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.d.ts +4 -2
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +214 -67
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
- package/dist/{ai-harness-CV6rXbB3.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
- package/dist/{ai-harness-Clbg5ei7.js → ai-harness-dQeJaR1t.js} +7 -3
- package/dist/ai-harness-dQeJaR1t.js.map +1 -0
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.js +3 -3
- package/dist/ai-pi.js.map +1 -1
- package/dist/ai-react.d.ts +5 -5
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +14 -17
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +86 -73
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +2 -2
- package/dist/{client-BhHLoNKF.js → client-BQS4i_V9.js} +29 -6
- package/dist/client-BQS4i_V9.js.map +1 -0
- package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
- package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
- package/dist/client.d.ts +3 -3
- package/dist/client.js +1 -1
- package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
- package/dist/contract-CWukt01d.js.map +1 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
- package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
- package/dist/{internal-CscSY7K_.js → internal-CvsrprnU.js} +40 -16
- package/dist/internal-CvsrprnU.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/otel.js +1 -1
- package/dist/otel.js.map +1 -1
- package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
- package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
- package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
- package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
- package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
- package/dist/runtime-consumer-CIeL33wj.js +12 -0
- package/dist/runtime-consumer-CIeL33wj.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +3 -3
- package/dist/scheduler-task-X1SToDjP.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +3 -3
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-qyb5aJJG.js → server-DtFrWxi3.js} +412 -116
- package/dist/server-DtFrWxi3.js.map +1 -0
- package/dist/{server-DYAATFuT.d.ts → server-qgez0Xny.d.ts} +108 -41
- package/dist/server-qgez0Xny.d.ts.map +1 -0
- package/dist/server.browser.js +1 -1
- package/dist/server.browser.js.map +1 -1
- package/dist/server.d.ts +4 -4
- package/dist/server.js +2 -2
- package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
- package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
- package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
- package/dist/session-clear-wHd5GCpZ.js.map +1 -0
- package/dist/store-D0MhBvg6.d.ts +532 -0
- package/dist/store-D0MhBvg6.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-codec-DwrWlgQx.js +65 -0
- package/dist/store-codec-DwrWlgQx.js.map +1 -0
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +406 -225
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-7WJV0YWP.js → store-polling-BZt2Rf3D.js} +2 -2
- package/dist/{store-polling-7WJV0YWP.js.map → store-polling-BZt2Rf3D.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 +1598 -587
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
- package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +4 -4
- package/dist/store-redis-notify-CsJw_hfD.js +3060 -0
- package/dist/store-redis-notify-CsJw_hfD.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.d.ts.map +1 -1
- package/dist/store-redis.js +4 -5
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +527 -299
- package/dist/store-sqlite.js.map +1 -1
- package/dist/stream-buffer-DrTyiIuo.js +57 -0
- package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
- package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
- package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
- package/dist/{wire-BKxH1vbP.js → wire-CV-Z7wz-.js} +6 -5
- package/dist/wire-CV-Z7wz-.js.map +1 -0
- package/docs/01-quickstart.mdx +21 -15
- package/docs/02-examples.mdx +1 -1
- package/docs/actors/01-introduction.mdx +13 -2
- package/docs/actors/02-concurrency.mdx +1 -1
- package/docs/agents/01-introduction.mdx +1 -1
- package/docs/agents/02-tools.mdx +12 -11
- package/docs/agents/03-conversations.mdx +36 -25
- package/docs/agents/04-harnesses.mdx +14 -7
- package/docs/client/03-presence.mdx +18 -14
- package/docs/contract/01-events.mdx +2 -2
- package/docs/contract/02-state.mdx +18 -14
- package/docs/contract/03-sessions.mdx +32 -25
- package/docs/index.mdx +29 -24
- package/docs/reference/01-core.mdx +7 -6
- package/docs/reference/02-server.mdx +250 -191
- package/docs/reference/04-ai.mdx +33 -23
- package/docs/reference/05-telemetry.mdx +27 -13
- package/docs/reference/06-errors.mdx +25 -19
- package/docs/reference/meta.ts +1 -1
- package/docs/schedulers/01-overview.mdx +8 -8
- package/docs/schedulers/02-vercel-queues.mdx +2 -2
- package/docs/schedulers/03-qstash.mdx +4 -4
- package/docs/server/01-consumers.mdx +418 -0
- package/docs/server/02-timers.mdx +31 -21
- package/docs/server/03-cancellation.mdx +52 -44
- package/docs/server/04-routes.mdx +4 -1
- package/docs/server/05-sql-projections.mdx +391 -0
- package/docs/server/06-guarantees.mdx +61 -51
- package/docs/stores/01-overview.mdx +3 -3
- package/docs/stores/02-redis.mdx +24 -10
- package/docs/stores/03-redis-http.mdx +8 -6
- package/docs/stores/04-postgres.mdx +65 -14
- package/docs/stores/05-sqlite.mdx +5 -3
- package/docs/upgrades/01-overview.mdx +31 -0
- package/docs/upgrades/78-0-22.mdx +115 -0
- package/docs/upgrades/79-0-21.mdx +165 -0
- package/docs/upgrades/80-0-20.mdx +43 -0
- package/docs/upgrades/81-0-19.mdx +33 -0
- package/docs/upgrades/82-0-18.mdx +33 -0
- package/docs/upgrades/83-0-17.mdx +69 -0
- package/docs/upgrades/meta.ts +3 -0
- package/examples/playground/DEPLOY.md +39 -7
- package/examples/playground/app/a0/chat.test.tsx +2 -2
- package/examples/playground/app/a0/sandbox-log.ts +17 -15
- package/examples/playground/app/a0/sandbox.ts +58 -13
- package/examples/playground/app/a0/server.ts +4 -4
- package/examples/playground/app/a2slack/a2slack.css +1067 -0
- package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
- package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
- package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
- package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
- package/examples/playground/app/a2slack/consumers/search.ts +37 -0
- package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
- package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
- package/examples/playground/app/a2slack/db/a2.ts +209 -0
- package/examples/playground/app/a2slack/db/index.ts +73 -0
- package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
- package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
- package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
- package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
- package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
- package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +41 -0
- package/examples/playground/app/a2slack/db/queries.ts +263 -0
- package/examples/playground/app/a2slack/db/schema.ts +145 -0
- package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
- package/examples/playground/app/a2slack/drills/harness.ts +244 -0
- package/examples/playground/app/a2slack/events/route.ts +36 -0
- package/examples/playground/app/a2slack/identity.ts +44 -0
- package/examples/playground/app/a2slack/inbox/route.ts +8 -0
- package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
- package/examples/playground/app/a2slack/lag/page.tsx +101 -0
- package/examples/playground/app/a2slack/lag.ts +73 -0
- package/examples/playground/app/a2slack/layout.tsx +43 -0
- package/examples/playground/app/a2slack/messages/route.ts +17 -0
- package/examples/playground/app/a2slack/model.test.ts +208 -0
- package/examples/playground/app/a2slack/model.ts +369 -0
- package/examples/playground/app/a2slack/models.test.ts +72 -0
- package/examples/playground/app/a2slack/page.tsx +10 -0
- package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
- package/examples/playground/app/a2slack/search/route.ts +40 -0
- package/examples/playground/app/a2slack/search-index.ts +108 -0
- package/examples/playground/app/a2slack/server.test.ts +315 -0
- package/examples/playground/app/a2slack/server.ts +55 -0
- package/examples/playground/app/a2slack/sessions.ts +50 -0
- package/examples/playground/app/a2slack/thread/route.ts +8 -0
- package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
- package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
- package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
- package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
- package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
- package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
- package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
- package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
- package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
- package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
- package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
- package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
- package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
- package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
- package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
- package/examples/playground/app/agent/server.ts +3 -3
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
- package/examples/playground/app/chat/server.ts +45 -35
- package/examples/playground/app/components/demo-icons.tsx +7 -0
- package/examples/playground/app/components/demos.tsx +7 -0
- package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
- package/examples/playground/app/orders/page.tsx +1 -1
- package/examples/playground/app/orders/server.ts +49 -30
- package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
- package/examples/playground/app/recovery/page.tsx +4 -4
- package/examples/playground/app/recovery/server.ts +23 -32
- package/examples/playground/drizzle.config.ts +14 -0
- package/examples/playground/lib/store.ts +1 -1
- package/examples/playground/next.config.ts +6 -1
- package/examples/playground/package.json +12 -1
- package/examples/playground/scripts/migrate.mjs +31 -0
- package/examples/playground/vercel.json +9 -0
- package/examples/playground/vitest.drills.config.ts +32 -0
- package/package.json +5 -2
- package/sql/postgres/drizzle-schema.ts +209 -0
- package/sql/postgres/schema.sql +227 -67
- package/src/actor-shared.ts +10 -2
- package/src/actor.ts +101 -71
- package/src/ai-agent.ts +162 -111
- package/src/ai-control-server.ts +8 -8
- package/src/ai-fx.ts +324 -82
- package/src/ai-harness.ts +17 -2
- package/src/ai-pi.ts +2 -2
- package/src/ai-server.ts +2 -2
- package/src/ai.ts +7 -7
- package/src/client.ts +19 -2
- package/src/contract.ts +1 -1
- package/src/deterministic-id.ts +27 -4
- package/src/errors.ts +8 -5
- package/src/idempotent-replay.ts +0 -1
- package/src/index.ts +1 -1
- package/src/internal.ts +57 -16
- package/src/otel.ts +1 -1
- package/src/postgres-schema.ts +98 -57
- package/src/presence-recovery.ts +18 -11
- package/src/presence-tombstones.ts +52 -0
- package/src/push-envelope.ts +20 -0
- package/src/runtime-consumer.ts +10 -0
- package/src/scheduler-task.ts +2 -2
- package/src/scheduler-vercel.ts +1 -1
- package/src/server.browser.ts +1 -1
- package/src/server.ts +819 -218
- package/src/session-socket.ts +1 -1
- package/src/store-codec.ts +66 -0
- package/src/store-memory.ts +604 -286
- package/src/store-postgres.ts +2006 -857
- package/src/store-presence-polling.ts +59 -34
- package/src/store-redis-core.ts +1961 -1333
- package/src/store-redis-notify.ts +76 -26
- package/src/store-redis.ts +2 -3
- package/src/store-sqlite.ts +915 -516
- package/src/store.ts +241 -102
- package/src/telemetry.ts +2 -1
- package/src/wire.ts +1 -0
- package/dist/actor-C7b-1arn.d.ts.map +0 -1
- package/dist/actor-shared-t7w8xIKP.js.map +0 -1
- package/dist/ai-harness-Clbg5ei7.js.map +0 -1
- package/dist/ai-mMiWul1T.js.map +0 -1
- package/dist/client-BhHLoNKF.js.map +0 -1
- package/dist/contract-BYKASlCV.js.map +0 -1
- package/dist/idempotent-replay-JdgMQRRm.js +0 -21
- package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
- package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
- package/dist/internal-CscSY7K_.js.map +0 -1
- package/dist/server-DYAATFuT.d.ts.map +0 -1
- package/dist/server-qyb5aJJG.js.map +0 -1
- package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
- package/dist/session-clear-DGI4jTQ5.js.map +0 -1
- package/dist/store-CyLUy86v.d.ts +0 -404
- package/dist/store-CyLUy86v.d.ts.map +0 -1
- package/dist/store-codec-DTG0Ftek.js +0 -10
- package/dist/store-codec-DTG0Ftek.js.map +0 -1
- package/dist/store-presence-polling-B1ixeqIS.js +0 -94
- package/dist/store-presence-polling-B1ixeqIS.js.map +0 -1
- package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
- package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
- package/dist/stream-buffer-Cx7AAPGq.js +0 -23
- package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
- package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
- package/dist/wire-BKxH1vbP.js.map +0 -1
- package/docs/server/01-handlers.mdx +0 -227
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Consumers
|
|
3
|
+
description: Named readers of every session's log. Each keeps its own place, runs in its own lanes, and retries its own failures.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
```ts server/consumers.ts
|
|
7
|
+
import { createServer } from 'experimental-a2/server'
|
|
8
|
+
import { orders } from '@/contracts'
|
|
9
|
+
|
|
10
|
+
export const ordersServer = createServer({
|
|
11
|
+
contract: orders,
|
|
12
|
+
consumers: [
|
|
13
|
+
{
|
|
14
|
+
name: 'notify-shop',
|
|
15
|
+
types: ['created'],
|
|
16
|
+
run: async ({ event }) => {
|
|
17
|
+
// your side effect, e.g. email the shop, idempotent via event.id:
|
|
18
|
+
// await sendEmailToShop(event.payload.shopId, { idempotencyKey: event.id })
|
|
19
|
+
return { type: 'shop.notified', payload: {} }
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
})
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
A consumer runs when an event it takes lands: a plain async function that can
|
|
27
|
+
call a database, hit an API, or stream a model response. A2 keeps a durable
|
|
28
|
+
cursor for it in every session, claims the event while `run` works, and
|
|
29
|
+
retries it after a crash.
|
|
30
|
+
|
|
31
|
+
Each entry of `createServer({ consumers })` is a consumer: its `name`, the
|
|
32
|
+
`types` it takes, and `run`, which `event` narrows to those types.
|
|
33
|
+
`createServer` binds the contract to a [store](/stores/overview) and registers
|
|
34
|
+
them. `consumer({ contract, ... }).run(fn)` from `experimental-a2/server`
|
|
35
|
+
defines one on its own, to keep in its own file, to take from a library, or to
|
|
36
|
+
run with [`consume`](#run-a-consumer-now-with-consume); list it the same way.
|
|
37
|
+
Two consumers with the same name throw at construction. `experimental-a2/server` is the only entry point
|
|
38
|
+
that touches a store, and it fails loudly in a browser bundle.
|
|
39
|
+
|
|
40
|
+
## Its name is its identity
|
|
41
|
+
|
|
42
|
+
A2 keys every cursor on the consumer's name. Rename a consumer and A2 treats it
|
|
43
|
+
as new: it starts without the old one's place, failures, or dead letters.
|
|
44
|
+
Remove one from `consumers` and its cursors wait: nothing claims them, and they
|
|
45
|
+
resume where they stopped when the name comes back. During a rolling deploy,
|
|
46
|
+
each deployment runs only the consumers it has. A consumer with a string lane
|
|
47
|
+
reads the log, so it also picks up events a deployment without it appended, the
|
|
48
|
+
next time a deployment that has it appends an event it takes or
|
|
49
|
+
[consumes it](#run-a-consumer-now-with-consume). A consumer with a lane
|
|
50
|
+
function, or with no lane, gets work only for events appended by a deployment
|
|
51
|
+
that has it: their lanes resolve at append.
|
|
52
|
+
|
|
53
|
+
A consumer's lane is part of its identity too. To change it, or to switch
|
|
54
|
+
between a string and a function, give the consumer a new name.
|
|
55
|
+
|
|
56
|
+
Names are up to 128 letters, digits, and `. _ : -`, starting with a letter or
|
|
57
|
+
digit. Names starting with `a2.` belong to A2: [actors](/actors/introduction)
|
|
58
|
+
and [agents](/agents/introduction) run on A2's own consumers, and their dead
|
|
59
|
+
letters name them, as `a2.actor` or `a2.ai.tools`.
|
|
60
|
+
|
|
61
|
+
## Which events it takes
|
|
62
|
+
|
|
63
|
+
`types` lists the event types a consumer runs on. Leave it out to take every
|
|
64
|
+
type. An event no consumer takes is still logged, folded into state, and
|
|
65
|
+
streamed; it starts no work, with no claim or scheduler message.
|
|
66
|
+
|
|
67
|
+
Changing `types` applies to events the consumer hasn't looked at yet. A cursor
|
|
68
|
+
with work looks at events as it reaches them; an idle one looks at each batch
|
|
69
|
+
as it's appended. To run a consumer over history with new types, give it a new
|
|
70
|
+
name and `backfill`.
|
|
71
|
+
|
|
72
|
+
## Servers without consumers
|
|
73
|
+
|
|
74
|
+
```ts server/order-history.ts
|
|
75
|
+
import { createServer } from 'experimental-a2/server'
|
|
76
|
+
import { postgres } from 'experimental-a2/store-postgres'
|
|
77
|
+
import { orders } from '@/contracts'
|
|
78
|
+
|
|
79
|
+
export const orderHistory = createServer({
|
|
80
|
+
contract: orders,
|
|
81
|
+
store: postgres({ connectionString: process.env.DATABASE_URL }),
|
|
82
|
+
})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Consumers are optional. Without any, a server is a durable event log:
|
|
86
|
+
`append`, `history`, reducers, and live streams. Events settle in their append
|
|
87
|
+
transaction, with no drain, claim, or queue message. A consumer you add later
|
|
88
|
+
starts with the events appended after it exists, or with
|
|
89
|
+
[`backfill`](#start-from-the-beginning-with-backfill), from the beginning of
|
|
90
|
+
the log.
|
|
91
|
+
|
|
92
|
+
## Lanes
|
|
93
|
+
|
|
94
|
+
```ts server/lanes.ts
|
|
95
|
+
import { consumer, createServer } from 'experimental-a2/server'
|
|
96
|
+
import { orders } from '@/contracts'
|
|
97
|
+
|
|
98
|
+
// one driver at a time, drivers in parallel
|
|
99
|
+
const dispatch = consumer({
|
|
100
|
+
contract: orders,
|
|
101
|
+
name: 'dispatch',
|
|
102
|
+
types: ['driver.notified'],
|
|
103
|
+
lane: ({ event }) => `driver:${event.payload.driverId}`,
|
|
104
|
+
}).run(async ({ event }) => {
|
|
105
|
+
// your work for this driver, using event.payload.driverId
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
export const lanesServer = createServer({
|
|
109
|
+
contract: orders,
|
|
110
|
+
consumers: [
|
|
111
|
+
// every event it takes, one at a time, in log order
|
|
112
|
+
{
|
|
113
|
+
name: 'order-log',
|
|
114
|
+
lane: 'orders',
|
|
115
|
+
run: async ({ event }) => {
|
|
116
|
+
// your write, keyed by event.index so a retry overwrites it:
|
|
117
|
+
// await db.upsert({ id: event.index, type: event.type })
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
dispatch,
|
|
121
|
+
],
|
|
122
|
+
})
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Without `lane`, a consumer runs every event concurrently, each on its own
|
|
126
|
+
cursor: log order stays stable, run order doesn't, and a failure retries that
|
|
127
|
+
event without holding up the rest. Use a lane when events share a resource and
|
|
128
|
+
must not overlap. A string lane runs everything the consumer takes in log
|
|
129
|
+
order, one at a time. A function returns each event's lane, or `null` to run
|
|
130
|
+
that event concurrently.
|
|
131
|
+
|
|
132
|
+
A2 resolves each consumer's lane when the event is appended and stores it, so
|
|
133
|
+
a deployment can't reinterpret pending work. Lanes belong to one session and
|
|
134
|
+
one consumer: they aren't locks across sessions, and two consumers using the
|
|
135
|
+
same lane name never wait on each other. Within a lane, a failing event holds
|
|
136
|
+
up the events behind it until it succeeds or you resolve it.
|
|
137
|
+
|
|
138
|
+
## One event, many consumers
|
|
139
|
+
|
|
140
|
+
```ts server/fanout.ts
|
|
141
|
+
import { createServer } from 'experimental-a2/server'
|
|
142
|
+
import { orders } from '@/contracts'
|
|
143
|
+
|
|
144
|
+
export const fanoutServer = createServer({
|
|
145
|
+
contract: orders,
|
|
146
|
+
consumers: [
|
|
147
|
+
{
|
|
148
|
+
name: 'email-shop',
|
|
149
|
+
types: ['created'],
|
|
150
|
+
run: async ({ event }) => {
|
|
151
|
+
// await sendEmailToShop(event.payload.shopId, { idempotencyKey: event.id })
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: 'reserve-stock',
|
|
156
|
+
types: ['created'],
|
|
157
|
+
run: async ({ event }) => {
|
|
158
|
+
// await reserve(event.payload.items, { idempotencyKey: event.id })
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
})
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Every consumer that takes an event gets its own cursor for it. They run
|
|
166
|
+
concurrently, retry separately, and dead-letter separately: an email that keeps
|
|
167
|
+
failing never stops the stock reservation.
|
|
168
|
+
|
|
169
|
+
## Start from the beginning with `backfill`
|
|
170
|
+
|
|
171
|
+
```ts server/backfill.ts
|
|
172
|
+
import { consumer, createServer } from 'experimental-a2/server'
|
|
173
|
+
import { orders } from '@/contracts'
|
|
174
|
+
|
|
175
|
+
const ordersTable = consumer({
|
|
176
|
+
contract: orders,
|
|
177
|
+
name: 'orders-table-v1',
|
|
178
|
+
lane: 'orders-table',
|
|
179
|
+
backfill: true,
|
|
180
|
+
}).run(async ({ event }) => {
|
|
181
|
+
// your table, written idempotently by event index:
|
|
182
|
+
// await db.insert(ordersTable).values({ id: event.index, type: event.type }).onConflictDoNothing()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
export const tableServer = createServer({
|
|
186
|
+
contract: orders,
|
|
187
|
+
consumers: [ordersTable],
|
|
188
|
+
})
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
A new consumer starts at the first event appended after it exists. With
|
|
192
|
+
`backfill: true`, it starts at the beginning of the log instead, so it sees
|
|
193
|
+
every event already there. Use it to fill a table or index from history, then
|
|
194
|
+
keep it current, as [SQL projections](/server/sql-projections) does. Change what
|
|
195
|
+
it writes, give it a new name, and it fills the new shape from the start.
|
|
196
|
+
|
|
197
|
+
`backfill` needs a string `lane`: a backfill reads the log in order. It can't
|
|
198
|
+
use `abortOn`: cancellation is for work in progress, and checking it while
|
|
199
|
+
replaying history would read the rest of the log for every event. A
|
|
200
|
+
consumer starts in a session the next time an event it takes is appended
|
|
201
|
+
there, or when you [consume](#run-a-consumer-now-with-consume) it.
|
|
202
|
+
|
|
203
|
+
## Run a consumer now with `consume`
|
|
204
|
+
|
|
205
|
+
```ts server/read.ts
|
|
206
|
+
import { consumer, createServer } from 'experimental-a2/server'
|
|
207
|
+
import { orders } from '@/contracts'
|
|
208
|
+
|
|
209
|
+
const ordersTable = consumer({
|
|
210
|
+
contract: orders,
|
|
211
|
+
name: 'orders-table-v1',
|
|
212
|
+
lane: 'orders-table',
|
|
213
|
+
backfill: true,
|
|
214
|
+
}).run(async ({ event }) => {
|
|
215
|
+
// await db.insert(ordersTable).values({ id: event.index, type: event.type }).onConflictDoNothing()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
export const readServer = createServer({
|
|
219
|
+
contract: orders,
|
|
220
|
+
consumers: [ordersTable],
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
export async function readOrder(orderId: string): Promise<void> {
|
|
224
|
+
// for an order the caller just changed, before reading the table:
|
|
225
|
+
await readServer.session(orderId).consume(ordersTable)
|
|
226
|
+
// every event of the order is in your table now:
|
|
227
|
+
// return db.select().from(ordersTable).where(eq(ordersTable.order, orderId))
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`session.consume(consumer)` runs a consumer with a string `lane` on that
|
|
232
|
+
session, in this invocation, until it has handled every event it takes up to
|
|
233
|
+
the session's head. Pass `{ through: index }` to stop sooner, as soon as an
|
|
234
|
+
event you know about is in. It returns `{ index }`: the consumer has handled
|
|
235
|
+
every event it takes through that index.
|
|
236
|
+
|
|
237
|
+
- **Read your writes.** A table a consumer keeps lags its log a little.
|
|
238
|
+
Consume it before a read that has to include a write you just made.
|
|
239
|
+
- **Start a backfill now.** A new consumer otherwise starts with the next
|
|
240
|
+
append. `consume(ordersTable, { through: 0 })` creates its cursor and runs
|
|
241
|
+
nothing here; the server carries on with the backfill in the background.
|
|
242
|
+
- **Wait in another consumer.** `ctx.session.consume(ordersTable, { through:
|
|
243
|
+
ctx.event.index })` inside one consumer waits until another's table has the
|
|
244
|
+
triggering event, and then reads it. A consumer can't consume itself. Nor
|
|
245
|
+
can it wait on a consumer that is waiting on it: that `consume` throws,
|
|
246
|
+
unless what it asks for is in already.
|
|
247
|
+
|
|
248
|
+
Consuming a session with nothing in it writes nothing, so a read can consume
|
|
249
|
+
whatever session it's asked about.
|
|
250
|
+
|
|
251
|
+
The server's own consumer keeps one cursor, so `consume` shares it with the
|
|
252
|
+
background: when another invocation holds the next event, `consume` waits for
|
|
253
|
+
it. It runs nothing past `through`, which carries on in the background. A
|
|
254
|
+
consumer the server doesn't run is pulled only by `consume`, and keeps its
|
|
255
|
+
place between calls, like a reader's "since I last looked".
|
|
256
|
+
|
|
257
|
+
Failures count against the same ten-attempt budget as everywhere else, and
|
|
258
|
+
`consume` waits out the backoff between them. Once the next event
|
|
259
|
+
dead-letters for that consumer, `consume` throws `CONSUMER_DEAD_LETTERED`.
|
|
260
|
+
Pass `{ signal }` to stop waiting sooner. An event running here when it fires
|
|
261
|
+
finishes on its own.
|
|
262
|
+
|
|
263
|
+
## The context
|
|
264
|
+
|
|
265
|
+
| Property | What it is |
|
|
266
|
+
| ------------- | ---------- |
|
|
267
|
+
| `ctx.event` | The triggering event: `{ id, type, payload, index, sessionId, createdAt }`. With `types`, its type narrows to those. |
|
|
268
|
+
| `ctx.attempt` | The durable 1-based claim of this event for this consumer. |
|
|
269
|
+
| `ctx.session` | This session's handle: `id`, `append`, `schedule`, `clear`, `history`, `state`, `stream`, `consume`, and `setPresence` (presence contracts only). |
|
|
270
|
+
| `ctx.signal` | An `AbortSignal`. Fires on matching `abortOn` events and when the attempt loses its claim (`CLAIM_EXPIRED`, `SUPERSEDED_ATTEMPT`). See [Cancellation](/server/cancellation). |
|
|
271
|
+
|
|
272
|
+
`ctx.attempt` counts claims of one event: the next event in a lane starts at
|
|
273
|
+
1 again. It can skip a number when a process dies between the claim and
|
|
274
|
+
`run`.
|
|
275
|
+
|
|
276
|
+
[`ctx.session.state(reducer)`](/contract/state) folds through
|
|
277
|
+
`ctx.event.index`, so later appends don't change what this event sees. Pass
|
|
278
|
+
`{ through: 'latest' }` to read the current committed frontier instead.
|
|
279
|
+
|
|
280
|
+
## Return what happens after success
|
|
281
|
+
|
|
282
|
+
```ts server/return-many.ts
|
|
283
|
+
import { createServer } from 'experimental-a2/server'
|
|
284
|
+
import { orders } from '@/contracts'
|
|
285
|
+
|
|
286
|
+
export const returnServer = createServer({
|
|
287
|
+
contract: orders,
|
|
288
|
+
consumers: [
|
|
289
|
+
{
|
|
290
|
+
name: 'start-shop',
|
|
291
|
+
types: ['shop.started'],
|
|
292
|
+
run: async () => {
|
|
293
|
+
return orders.batch(
|
|
294
|
+
{ type: 'shop.notified', payload: {} },
|
|
295
|
+
{ type: 'driver.notified', payload: { driverId: 'driver-7' } },
|
|
296
|
+
)
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
],
|
|
300
|
+
})
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Return one event or an array when it should exist only if `run` succeeds. A2
|
|
304
|
+
moves the consumer's cursor past the event and appends the batch in one atomic
|
|
305
|
+
operation, at consecutive positions. If `run` throws or the process dies
|
|
306
|
+
first, none of it exists. Returned events get deterministic ids from the
|
|
307
|
+
trigger, the consumer's name, and their position, so a retry after a lost
|
|
308
|
+
acknowledgement doesn't duplicate them.
|
|
309
|
+
|
|
310
|
+
`contract.batch(...)` is a type helper that keeps each event's literal type;
|
|
311
|
+
A2 still validates at commit.
|
|
312
|
+
|
|
313
|
+
## Append what should happen now
|
|
314
|
+
|
|
315
|
+
```ts server/append-now.ts
|
|
316
|
+
import { createServer } from 'experimental-a2/server'
|
|
317
|
+
import { orders } from '@/contracts'
|
|
318
|
+
|
|
319
|
+
export const appendServer = createServer({
|
|
320
|
+
contract: orders,
|
|
321
|
+
consumers: [
|
|
322
|
+
{
|
|
323
|
+
name: 'accept-order',
|
|
324
|
+
types: ['created'],
|
|
325
|
+
run: async ({ session }) => {
|
|
326
|
+
await session.append('notify-shop', { type: 'shop.notified', payload: {} })
|
|
327
|
+
|
|
328
|
+
// A consumer of shop.notified may already be running here.
|
|
329
|
+
// await continueCurrentWork()
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
],
|
|
333
|
+
})
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
`ctx.session.append(name, ...events)` commits immediately, so the appended
|
|
337
|
+
event can start while `run` is still working. Use it for streaming progress,
|
|
338
|
+
independent fan-out, or any fact that stays true even if `run` later fails.
|
|
339
|
+
Each call is atomic.
|
|
340
|
+
|
|
341
|
+
The first argument names the append within the triggering event. A2 derives
|
|
342
|
+
the ids of events without an `id` from the trigger, the consumer's name, the
|
|
343
|
+
append's name, and each event's position, so a retry gets the existing events
|
|
344
|
+
back, whatever order parallel branches finish in. Keep the name stable;
|
|
345
|
+
reusing it with a different batch conflicts. An explicit `id` wins, so
|
|
346
|
+
different triggers can converge on one fact. Top-level
|
|
347
|
+
`server.session(id).append(...events)` takes no name.
|
|
348
|
+
|
|
349
|
+
## Schedule what happens later
|
|
350
|
+
|
|
351
|
+
`ctx.session.schedule(name, timing, ...events)` appends events later, through
|
|
352
|
+
the [scheduler](/schedulers/overview). In a consumer, a delay counts from the
|
|
353
|
+
triggering event's `createdAt`, and the timer's identity includes the trigger
|
|
354
|
+
and the consumer's name, so a retry arms the same timer. See
|
|
355
|
+
[Timers](/server/timers).
|
|
356
|
+
|
|
357
|
+
## Remember facts in the log
|
|
358
|
+
|
|
359
|
+
Reducer snapshots are a cache: A2 may discard one and fold the log again. When
|
|
360
|
+
`run` learns something a later run needs, such as "this thread was already
|
|
361
|
+
triaged", append it as an event and read it back with a reducer. Then a
|
|
362
|
+
rebuilt snapshot still knows it, and nothing runs twice to find out.
|
|
363
|
+
|
|
364
|
+
## Consumers can run twice
|
|
365
|
+
|
|
366
|
+
Consumers are at-least-once. A crash after your side effect but before A2
|
|
367
|
+
records completion runs the event again.
|
|
368
|
+
|
|
369
|
+
- **External side effects take an idempotency key.** `event.id` is stable
|
|
370
|
+
across attempts and unique within its session. Ids your callers pick, and
|
|
371
|
+
the ids A2 derives from them, can repeat in another session: when yours can,
|
|
372
|
+
add the session to the key, and the contract's name if several contracts
|
|
373
|
+
share it, as in `` `orders:${event.sessionId}:${event.id}` ``.
|
|
374
|
+
- **Named appends are deduplicated.** A retry gets the existing events back.
|
|
375
|
+
- **Returned events commit with completion.** They all exist with the
|
|
376
|
+
cursor's advance, or none do.
|
|
377
|
+
|
|
378
|
+
## When it throws
|
|
379
|
+
|
|
380
|
+
Throwing means "retry me." A2 records the failure on that consumer's cursor and
|
|
381
|
+
retries with backoff. After ten failed attempts the event dead-letters for that
|
|
382
|
+
consumer: its lane waits, while other consumers and lanes continue.
|
|
383
|
+
[Retry or skip it](#resolve-a-dead-letter) once you know why.
|
|
384
|
+
|
|
385
|
+
When someone presses stop, catch the abort and return normally instead. See
|
|
386
|
+
[Cancellation](/server/cancellation).
|
|
387
|
+
|
|
388
|
+
## Resolve a dead letter
|
|
389
|
+
|
|
390
|
+
A [dead-lettered](/server/guarantees#when-a-consumer-keeps-failing) event waits
|
|
391
|
+
for you. List a session's dead letters, then retry or skip each one for the
|
|
392
|
+
consumer it's dead for:
|
|
393
|
+
|
|
394
|
+
```ts
|
|
395
|
+
// anywhere on the server, behind your own operator checks:
|
|
396
|
+
import { ordersServer } from '@/server'
|
|
397
|
+
|
|
398
|
+
const order = ordersServer.session(orderId)
|
|
399
|
+
const [dead] = await order.deadLetters()
|
|
400
|
+
if (dead) {
|
|
401
|
+
// dead.lastError says why (null if every attempt timed out or crashed);
|
|
402
|
+
// deploy the fix first, then:
|
|
403
|
+
await order.redrive(
|
|
404
|
+
{ consumer: dead.consumer, index: dead.index },
|
|
405
|
+
{ action: 'retry' },
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
- **Retry.** `{ action: 'retry' }` runs the consumer again with a fresh
|
|
411
|
+
ten-failure budget. Once it completes, its lane continues.
|
|
412
|
+
- **Skip.** `{ action: 'skip' }` moves the consumer past the event without
|
|
413
|
+
running it, accepting that its effects never happen for that consumer. The
|
|
414
|
+
next event in its lane runs.
|
|
415
|
+
|
|
416
|
+
Each redrive is one atomic store operation, and it wakes the session like an
|
|
417
|
+
append, so the lane resumes without another write. An event that isn't
|
|
418
|
+
dead-lettered anymore is left alone: `redrive` reports `{ redriven: false }`.
|
|
@@ -3,32 +3,42 @@ title: Timers
|
|
|
3
3
|
description: Schedule a typed session event for later, through the same scheduler that handles recovery.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
## Schedule from a
|
|
6
|
+
## Schedule from a consumer
|
|
7
7
|
|
|
8
8
|
```ts server/orders.ts
|
|
9
|
-
import { createServer } from 'experimental-a2/server'
|
|
9
|
+
import { consumer, createServer } from 'experimental-a2/server'
|
|
10
10
|
import { vercelQueues } from 'experimental-a2/scheduler-vercel'
|
|
11
11
|
import { orders } from '@/contracts'
|
|
12
12
|
|
|
13
13
|
export const scheduler = vercelQueues()
|
|
14
14
|
|
|
15
|
+
const expire = consumer({
|
|
16
|
+
contract: orders,
|
|
17
|
+
name: 'expire',
|
|
18
|
+
types: ['expired'],
|
|
19
|
+
}).run(async ({ session }) => {
|
|
20
|
+
const events = await session.history()
|
|
21
|
+
if (events.some((event) => event.type === 'shop.started')) return
|
|
22
|
+
// your expiry side effect goes here
|
|
23
|
+
})
|
|
24
|
+
|
|
15
25
|
export const ordersServer = createServer({
|
|
16
26
|
contract: orders,
|
|
17
27
|
scheduler,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// your expiry side effect goes here
|
|
28
|
+
consumers: [
|
|
29
|
+
{
|
|
30
|
+
name: 'arm-expiry',
|
|
31
|
+
types: ['created'],
|
|
32
|
+
run: async ({ session }) => {
|
|
33
|
+
await session.schedule(
|
|
34
|
+
'expire-order',
|
|
35
|
+
{ delay: '5d' },
|
|
36
|
+
{ type: 'expired', payload: {} },
|
|
37
|
+
)
|
|
38
|
+
},
|
|
30
39
|
},
|
|
31
|
-
|
|
40
|
+
expire,
|
|
41
|
+
],
|
|
32
42
|
})
|
|
33
43
|
```
|
|
34
44
|
|
|
@@ -69,14 +79,14 @@ export async function POST(request: Request) {
|
|
|
69
79
|
|
|
70
80
|
Root sessions have the same method. A root `{ delay: '30s' }` counts from that
|
|
71
81
|
call's clock, so a retry 10 seconds later delivers 10 seconds later too. Use
|
|
72
|
-
`{ at }` when a caller may retry. Inside a
|
|
82
|
+
`{ at }` when a caller may retry. Inside a consumer, a delay counts from the
|
|
73
83
|
triggering event's durable `createdAt`, so retries keep the same target.
|
|
74
84
|
|
|
75
85
|
## Identity and retries
|
|
76
86
|
|
|
77
87
|
`name` identifies one schedule: per contract and session at the root, plus the
|
|
78
|
-
triggering event id in a
|
|
79
|
-
`expire-order`, while retries of one event converge.
|
|
88
|
+
triggering event id and the consumer's name in a consumer. Different events can
|
|
89
|
+
share a name like `expire-order`, while retries of one event converge.
|
|
80
90
|
|
|
81
91
|
Keep the same name, timing, and events on every retry. Omitted event ids are
|
|
82
92
|
derived and stable, so duplicate deliveries append once per session lifetime,
|
|
@@ -95,7 +105,7 @@ fixed ids validates the original input, so a schema transform runs on that
|
|
|
95
105
|
input, not on an earlier output.
|
|
96
106
|
|
|
97
107
|
Scheduled events enter the log only when delivered, with `cause: null`. From
|
|
98
|
-
there they behave like any server append:
|
|
108
|
+
there they behave like any server append: consumers, lanes, reducers, streams,
|
|
99
109
|
and live clients see them normally, and their stable ids absorb at-least-once
|
|
100
110
|
delivery.
|
|
101
111
|
|
|
@@ -112,8 +122,8 @@ guard from the first example: let the event land, read the session, and return
|
|
|
112
122
|
when it no longer applies. The log still records that the timer fired.
|
|
113
123
|
|
|
114
124
|
A2 has no cancellation, reschedule, cron, or timer-listing API. `name` is an
|
|
115
|
-
idempotency key, not a handle. For repeating work, have each
|
|
116
|
-
|
|
125
|
+
idempotency key, not a handle. For repeating work, have the consumer of each
|
|
126
|
+
occurrence schedule the next one.
|
|
117
127
|
|
|
118
128
|
## Provider limits
|
|
119
129
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Cancellation
|
|
3
|
-
description: There is no abort() API. Cancelling is appending, and the running
|
|
3
|
+
description: There is no abort() API. Cancelling is appending, and the running consumer finds out through a signal.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
## Cancelling is appending
|
|
@@ -14,44 +14,51 @@ A user presses stop on a streaming AI response. That's an event: same route,
|
|
|
14
14
|
same auth, same log as every other event. Two things then happen, at two
|
|
15
15
|
speeds.
|
|
16
16
|
|
|
17
|
-
## The running
|
|
17
|
+
## The running consumer stops
|
|
18
18
|
|
|
19
19
|
```ts server/chat.ts
|
|
20
|
-
import { createServer } from 'experimental-a2/server'
|
|
20
|
+
import { consumer, createServer } from 'experimental-a2/server'
|
|
21
21
|
import { chat } from '@/contracts'
|
|
22
22
|
|
|
23
|
+
const generate = consumer({
|
|
24
|
+
contract: chat,
|
|
25
|
+
name: 'generate',
|
|
26
|
+
types: ['generate'],
|
|
27
|
+
abortOn: ['cancelled'],
|
|
28
|
+
}).run(async (ctx) => {
|
|
29
|
+
// your abortable work; hand it ctx.signal:
|
|
30
|
+
// await streamText({ signal: ctx.signal, ... })
|
|
31
|
+
})
|
|
32
|
+
|
|
23
33
|
export const chatServer = createServer({
|
|
24
34
|
contract: chat,
|
|
25
|
-
|
|
26
|
-
generate: {
|
|
27
|
-
abortOn: ['cancelled'],
|
|
28
|
-
handler: async (ctx) => {
|
|
29
|
-
// your abortable work; hand it ctx.signal:
|
|
30
|
-
// await streamText({ signal: ctx.signal, ... })
|
|
31
|
-
},
|
|
32
|
-
},
|
|
33
|
-
},
|
|
35
|
+
consumers: [generate],
|
|
34
36
|
})
|
|
35
37
|
```
|
|
36
38
|
|
|
37
|
-
`abortOn` names the events that interrupt the
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
`abortOn` names the events that interrupt the run. When a `cancelled` event
|
|
40
|
+
lands, or had already landed before the run started, `ctx.signal` fires.
|
|
41
|
+
Consumers without `abortOn` hold no subscription.
|
|
40
42
|
|
|
41
43
|
When one session runs several generations over its lifetime, match the
|
|
42
44
|
instance with a predicate:
|
|
43
45
|
|
|
44
46
|
```ts
|
|
45
|
-
// server/chat.ts, the generate
|
|
46
|
-
|
|
47
|
+
// server/chat.ts, the generate consumer, targeted by instance:
|
|
48
|
+
import { consumer } from 'experimental-a2/server'
|
|
49
|
+
import { chat } from '@/contracts'
|
|
50
|
+
|
|
51
|
+
const generate = consumer({
|
|
52
|
+
contract: chat,
|
|
53
|
+
name: 'generate',
|
|
54
|
+
types: ['generate'],
|
|
47
55
|
abortOn: {
|
|
48
56
|
// only the cancel that names this run fires the signal
|
|
49
57
|
cancelled: (event, trigger) => event.payload.of === trigger.id,
|
|
50
58
|
},
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
59
|
+
}).run(async (ctx) => {
|
|
60
|
+
// ...
|
|
61
|
+
})
|
|
55
62
|
```
|
|
56
63
|
|
|
57
64
|
The pushing side already knows the id it's cancelling: it's in the hook's
|
|
@@ -59,7 +66,7 @@ The pushing side already knows the id it's cancelling: it's in the hook's
|
|
|
59
66
|
|
|
60
67
|
## When a run loses its claim
|
|
61
68
|
|
|
62
|
-
Every
|
|
69
|
+
Every consumer's signal, with or without `abortOn`, also fires when the run
|
|
63
70
|
loses its claim: `CLAIM_EXPIRED` when the lease lapses without renewal
|
|
64
71
|
(decided locally, so it works while the store is unreachable), or
|
|
65
72
|
`SUPERSEDED_ATTEMPT` when a recovery run has claimed the event. Either way, a
|
|
@@ -79,35 +86,36 @@ case:
|
|
|
79
86
|
run that finishes before any successor claims the event still completes.
|
|
80
87
|
- **Any other throw** is a real failure and spends the retry budget.
|
|
81
88
|
|
|
82
|
-
## Clean up after the
|
|
89
|
+
## Clean up after the run
|
|
83
90
|
|
|
84
|
-
A `cancelled`
|
|
85
|
-
the signal is the preemption channel, the event is the durable
|
|
86
|
-
cleanup must wait,
|
|
87
|
-
|
|
91
|
+
A consumer of `cancelled` can run while the interrupted run is still
|
|
92
|
+
unwinding: the signal is the preemption channel, the event is the durable
|
|
93
|
+
record. When cleanup must wait, take both event types in one consumer with a
|
|
94
|
+
string lane. The `cancelled` event still fires the signal on append, but runs
|
|
95
|
+
only after earlier work in the lane:
|
|
88
96
|
|
|
89
97
|
```ts server/chat-with-cleanup.ts
|
|
90
|
-
import { createServer } from 'experimental-a2/server'
|
|
98
|
+
import { consumer, createServer } from 'experimental-a2/server'
|
|
91
99
|
import { chat } from '@/contracts'
|
|
92
100
|
|
|
101
|
+
const generation = consumer({
|
|
102
|
+
contract: chat,
|
|
103
|
+
name: 'generation',
|
|
104
|
+
types: ['generate', 'cancelled'],
|
|
105
|
+
lane: 'generation',
|
|
106
|
+
abortOn: ['cancelled'],
|
|
107
|
+
}).run(async (ctx) => {
|
|
108
|
+
if (ctx.event.type === 'generate') {
|
|
109
|
+
// your abortable work; hand it ctx.signal:
|
|
110
|
+
// await streamText({ signal: ctx.signal, ... })
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
// your cleanup, after generate has returned
|
|
114
|
+
})
|
|
115
|
+
|
|
93
116
|
export const chatServer = createServer({
|
|
94
117
|
contract: chat,
|
|
95
|
-
|
|
96
|
-
generate: {
|
|
97
|
-
lane: 'generation',
|
|
98
|
-
abortOn: ['cancelled'],
|
|
99
|
-
handler: async (ctx) => {
|
|
100
|
-
// your abortable work; hand it ctx.signal:
|
|
101
|
-
// await streamText({ signal: ctx.signal, ... })
|
|
102
|
-
},
|
|
103
|
-
},
|
|
104
|
-
cancelled: {
|
|
105
|
-
lane: 'generation',
|
|
106
|
-
handler: async () => {
|
|
107
|
-
// your cleanup, after generate has returned
|
|
108
|
-
},
|
|
109
|
-
},
|
|
110
|
-
},
|
|
118
|
+
consumers: [generation],
|
|
111
119
|
})
|
|
112
120
|
```
|
|
113
121
|
|
|
@@ -15,7 +15,10 @@ export const POST = ordersServer.fetch
|
|
|
15
15
|
`server.fetch` is bound, so an unprotected route is two exports. A plain GET
|
|
16
16
|
streams one session over SSE, a GET with `gte` and `lte` returns a history
|
|
17
17
|
slice, and POST appends the pushed events. If the contract declares presence,
|
|
18
|
-
the same route streams and accepts it.
|
|
18
|
+
the same route streams and accepts it. A pushed event's `id` is optional: up
|
|
19
|
+
to 256 characters, and never shaped like a UUIDv5, the ids A2 derives for what
|
|
20
|
+
consumers append, return, and schedule, so a caller can't take one first. Use
|
|
21
|
+
`crypto.randomUUID()` or [`idempotentId()`](/reference/core#idempotentidparts).
|
|
19
22
|
|
|
20
23
|
Wrap the call when the route needs authentication or authorization:
|
|
21
24
|
|