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,708 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drills for a2slack's consumers and projections, against a throwaway Redis
|
|
3
|
+
* and Postgres: a load run, a rebuild, a poisoned event, a Postgres outage,
|
|
4
|
+
* two versions of a consumer at once, and the load run again with A2 on
|
|
5
|
+
* Postgres. Each prints what it measured, and the whole run is written to
|
|
6
|
+
* .a2/drills/. Sizes come from DRILL_MESSAGES, DRILL_CONCURRENCY,
|
|
7
|
+
* DRILL_SUBSCRIBERS, and DRILL_SERVERS.
|
|
8
|
+
*
|
|
9
|
+
* pnpm --filter playground drills
|
|
10
|
+
*/
|
|
11
|
+
// oxlint-disable no-await-in-loop, no-console -- drills run steps in order and report as they go
|
|
12
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { sql } from 'drizzle-orm'
|
|
14
|
+
import type { A2Store, Consumer } from 'experimental-a2/server'
|
|
15
|
+
import { afterAll, beforeAll, expect, it } from 'vitest'
|
|
16
|
+
import {
|
|
17
|
+
idle,
|
|
18
|
+
recorder,
|
|
19
|
+
sleep,
|
|
20
|
+
startServices,
|
|
21
|
+
summarize,
|
|
22
|
+
timerScheduler,
|
|
23
|
+
until,
|
|
24
|
+
type Run,
|
|
25
|
+
type Services,
|
|
26
|
+
} from './harness'
|
|
27
|
+
|
|
28
|
+
const MESSAGES = Number(process.env.DRILL_MESSAGES ?? 10_000)
|
|
29
|
+
const CONCURRENCY = Number(process.env.DRILL_CONCURRENCY ?? 20)
|
|
30
|
+
const SUBSCRIBERS = Number(process.env.DRILL_SUBSCRIBERS ?? 20)
|
|
31
|
+
const SERVERS = Number(process.env.DRILL_SERVERS ?? 4)
|
|
32
|
+
|
|
33
|
+
const CHANNELS = Array.from({ length: 20 }, (_, i) => `room-${i + 1}`)
|
|
34
|
+
const USERS = Array.from({ length: 50 }, (_, i) => ({
|
|
35
|
+
id: `user-${i + 1}`,
|
|
36
|
+
name: `user${i + 1}`,
|
|
37
|
+
}))
|
|
38
|
+
const WORDS =
|
|
39
|
+
'deploy review ship merge plan draft launch bug fix test build release ' +
|
|
40
|
+
'sync cache queue index search thread channel message reply mention ' +
|
|
41
|
+
'design schema table cursor lane log event store redis postgres drizzle'
|
|
42
|
+
const words = WORDS.split(' ')
|
|
43
|
+
|
|
44
|
+
const pick = <T>(items: readonly T[]): T =>
|
|
45
|
+
items[Math.floor(Math.random() * items.length)]!
|
|
46
|
+
|
|
47
|
+
const text = (): string =>
|
|
48
|
+
Array.from({ length: 4 + Math.floor(Math.random() * 8) }, () =>
|
|
49
|
+
pick(words),
|
|
50
|
+
).join(' ')
|
|
51
|
+
|
|
52
|
+
let services: Services
|
|
53
|
+
const results: Record<string, unknown> = {}
|
|
54
|
+
|
|
55
|
+
// Loaded once DATABASE_URL points at the drill's Postgres.
|
|
56
|
+
let a2slack: Awaited<ReturnType<typeof load>>
|
|
57
|
+
|
|
58
|
+
async function load() {
|
|
59
|
+
const [
|
|
60
|
+
{ redis },
|
|
61
|
+
{ postgres },
|
|
62
|
+
{ consumer, createServer },
|
|
63
|
+
{ drizzle },
|
|
64
|
+
{ migrate },
|
|
65
|
+
model,
|
|
66
|
+
feedModule,
|
|
67
|
+
{ notify },
|
|
68
|
+
{ welcome },
|
|
69
|
+
queries,
|
|
70
|
+
{ database },
|
|
71
|
+
] = await Promise.all([
|
|
72
|
+
import('experimental-a2/store-redis'),
|
|
73
|
+
import('experimental-a2/store-postgres'),
|
|
74
|
+
import('experimental-a2/server'),
|
|
75
|
+
import('drizzle-orm/node-postgres'),
|
|
76
|
+
import('drizzle-orm/node-postgres/migrator'),
|
|
77
|
+
import('../model'),
|
|
78
|
+
import('../consumers/feed'),
|
|
79
|
+
import('../consumers/notify'),
|
|
80
|
+
import('../consumers/welcome'),
|
|
81
|
+
import('../db/queries'),
|
|
82
|
+
import('../db'),
|
|
83
|
+
])
|
|
84
|
+
return {
|
|
85
|
+
redis,
|
|
86
|
+
postgres,
|
|
87
|
+
consumer,
|
|
88
|
+
createServer,
|
|
89
|
+
drizzle,
|
|
90
|
+
migrate,
|
|
91
|
+
model,
|
|
92
|
+
feed: feedModule.feed,
|
|
93
|
+
feedEvent: feedModule.feedEvent,
|
|
94
|
+
notify,
|
|
95
|
+
welcome,
|
|
96
|
+
queries,
|
|
97
|
+
database,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type Workspace = (typeof a2slack)['model']['workspace']
|
|
102
|
+
|
|
103
|
+
const servers = (
|
|
104
|
+
store: A2Store,
|
|
105
|
+
consumers: Consumer<Workspace['events']>[],
|
|
106
|
+
telemetry: ReturnType<typeof recorder>['telemetry'],
|
|
107
|
+
many = SERVERS,
|
|
108
|
+
) =>
|
|
109
|
+
Array.from({ length: many }, () => {
|
|
110
|
+
const { scheduler, deliverTo } = timerScheduler()
|
|
111
|
+
const server = a2slack.createServer({
|
|
112
|
+
contract: a2slack.model.workspace,
|
|
113
|
+
store,
|
|
114
|
+
scheduler,
|
|
115
|
+
consumers,
|
|
116
|
+
telemetry,
|
|
117
|
+
})
|
|
118
|
+
deliverTo(server)
|
|
119
|
+
return server
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
type WorkspaceServer = ReturnType<typeof servers>[number]
|
|
123
|
+
|
|
124
|
+
const session = (server: WorkspaceServer) =>
|
|
125
|
+
server.session(a2slack.model.WORKSPACE)
|
|
126
|
+
|
|
127
|
+
/** Every table a2slack keeps, emptied, for a run that starts a new log. */
|
|
128
|
+
async function emptyTables(): Promise<void> {
|
|
129
|
+
const db = await a2slack.database()
|
|
130
|
+
await db.execute(
|
|
131
|
+
sql.raw(
|
|
132
|
+
'truncate a2slack_channels, a2slack_messages, a2slack_notifications, a2slack_reactions, a2slack_reads, a2slack_positions, a2slack_people, a2slack_thread_members',
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The rows of a raw query; the app's database type leaves them untyped. */
|
|
138
|
+
async function rows<T>(query: string): Promise<T[]> {
|
|
139
|
+
const db = await a2slack.database()
|
|
140
|
+
return ((await db.execute(sql.raw(query))) as unknown as { rows: T[] }).rows
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function count(table: string): Promise<number> {
|
|
144
|
+
const [row] = await rows<{ n: number }>(
|
|
145
|
+
`select count(*)::int as n from ${table}`,
|
|
146
|
+
)
|
|
147
|
+
return row!.n
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Counts in the log of what the tables should hold. */
|
|
151
|
+
async function logCounts(server: WorkspaceServer) {
|
|
152
|
+
const events = await session(server).history()
|
|
153
|
+
const reactions = new Set<string>()
|
|
154
|
+
for (const event of events) {
|
|
155
|
+
if (event.type === 'reaction.added')
|
|
156
|
+
reactions.add(
|
|
157
|
+
`${event.payload.messageId}|${event.payload.emoji}|${event.payload.by.id}`,
|
|
158
|
+
)
|
|
159
|
+
if (event.type === 'reaction.removed')
|
|
160
|
+
reactions.delete(
|
|
161
|
+
`${event.payload.messageId}|${event.payload.emoji}|${event.payload.by.id}`,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
head: events.at(-1)?.index ?? 0,
|
|
166
|
+
messages: events.filter((event) => event.type === 'message.posted').length,
|
|
167
|
+
notifications: events.filter((event) => event.type === 'notification.sent')
|
|
168
|
+
.length,
|
|
169
|
+
channels: new Set(
|
|
170
|
+
events.flatMap((event) =>
|
|
171
|
+
event.type === 'channel.created' ? [event.payload.id] : [],
|
|
172
|
+
),
|
|
173
|
+
).size,
|
|
174
|
+
reactions: reactions.size,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function tableCounts() {
|
|
179
|
+
return {
|
|
180
|
+
messages: await count('a2slack_messages'),
|
|
181
|
+
notifications: await count('a2slack_notifications'),
|
|
182
|
+
channels: await count('a2slack_channels'),
|
|
183
|
+
reactions: await count('a2slack_reactions'),
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Live subscribers to the workspace: how long each event took to reach them. */
|
|
188
|
+
function subscribe(
|
|
189
|
+
from: WorkspaceServer[],
|
|
190
|
+
subscribers: number,
|
|
191
|
+
startAfter: number,
|
|
192
|
+
) {
|
|
193
|
+
const delays: number[] = []
|
|
194
|
+
const last = Array.from({ length: subscribers }, () => 0)
|
|
195
|
+
const streams = last.map((_, s) => {
|
|
196
|
+
const stream = session(from[s % from.length]!).stream({ startAfter })
|
|
197
|
+
return stream[Symbol.asyncIterator]()
|
|
198
|
+
})
|
|
199
|
+
const done = Promise.all(
|
|
200
|
+
streams.map(async (stream, s) => {
|
|
201
|
+
for (;;) {
|
|
202
|
+
const next = await stream.next()
|
|
203
|
+
if (next.done) return
|
|
204
|
+
const event = next.value
|
|
205
|
+
if (event.type === 'a2.cleared') continue
|
|
206
|
+
if (event.type === 'message.posted')
|
|
207
|
+
delays.push(Date.now() - event.createdAt.getTime())
|
|
208
|
+
last[s] = event.index
|
|
209
|
+
}
|
|
210
|
+
}),
|
|
211
|
+
)
|
|
212
|
+
return {
|
|
213
|
+
delays,
|
|
214
|
+
last,
|
|
215
|
+
/** Ends every subscription, even one waiting for its next event. */
|
|
216
|
+
async close() {
|
|
217
|
+
await Promise.all(streams.map((stream) => stream.return?.()))
|
|
218
|
+
await done
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Posts `messages` messages and reactions from `concurrency` writers. */
|
|
224
|
+
async function post(
|
|
225
|
+
to: WorkspaceServer[],
|
|
226
|
+
messages: number,
|
|
227
|
+
concurrency: number,
|
|
228
|
+
{
|
|
229
|
+
onSent,
|
|
230
|
+
prefix = '',
|
|
231
|
+
}: { onSent?: (n: number) => Promise<void> | void; prefix?: string } = {},
|
|
232
|
+
) {
|
|
233
|
+
const latencies: number[] = []
|
|
234
|
+
const roots = new Map<string, string[]>()
|
|
235
|
+
const recent: { id: string; channel: string }[] = []
|
|
236
|
+
let next = 0
|
|
237
|
+
const started = performance.now()
|
|
238
|
+
await Promise.all(
|
|
239
|
+
Array.from({ length: concurrency }, async () => {
|
|
240
|
+
for (;;) {
|
|
241
|
+
const n = next
|
|
242
|
+
next += 1
|
|
243
|
+
if (n >= messages) return
|
|
244
|
+
const target = session(to[n % to.length]!)
|
|
245
|
+
const author = pick(USERS)
|
|
246
|
+
const channel = pick(CHANNELS)
|
|
247
|
+
const t0 = performance.now()
|
|
248
|
+
if (Math.random() < 0.05 && recent.length > 0) {
|
|
249
|
+
const message = pick(recent)
|
|
250
|
+
await target.append({
|
|
251
|
+
type: 'reaction.added',
|
|
252
|
+
payload: {
|
|
253
|
+
channel: message.channel,
|
|
254
|
+
messageId: message.id,
|
|
255
|
+
emoji: pick(['👍', '🎉', '👀']),
|
|
256
|
+
by: author,
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
} else {
|
|
260
|
+
const thread = roots.get(channel) ?? []
|
|
261
|
+
const threadId =
|
|
262
|
+
Math.random() < 0.15 && thread.length > 0 ? pick(thread) : undefined
|
|
263
|
+
const mention = Math.random() < 0.1 ? ` @${pick(USERS).name}` : ''
|
|
264
|
+
const [posted] = await target.append({
|
|
265
|
+
type: 'message.posted',
|
|
266
|
+
payload: {
|
|
267
|
+
channel,
|
|
268
|
+
author,
|
|
269
|
+
text: `${prefix}${text()}${mention}`,
|
|
270
|
+
...(threadId ? { threadId } : {}),
|
|
271
|
+
},
|
|
272
|
+
})
|
|
273
|
+
if (!threadId) {
|
|
274
|
+
thread.push(posted!.id)
|
|
275
|
+
if (thread.length > 50) thread.shift()
|
|
276
|
+
roots.set(channel, thread)
|
|
277
|
+
}
|
|
278
|
+
recent.push({ id: posted!.id, channel })
|
|
279
|
+
if (recent.length > 200) recent.shift()
|
|
280
|
+
}
|
|
281
|
+
latencies.push(performance.now() - t0)
|
|
282
|
+
await onSent?.(n)
|
|
283
|
+
}
|
|
284
|
+
}),
|
|
285
|
+
)
|
|
286
|
+
const endedAt = performance.now()
|
|
287
|
+
return { latencies, seconds: (endedAt - started) / 1000, endedAt }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Completion lag per consumer: append to the end of its successful run. */
|
|
291
|
+
function lagByConsumer(runs: readonly Run[]) {
|
|
292
|
+
const by = new Map<string, number[]>()
|
|
293
|
+
const failures = new Map<string, number>()
|
|
294
|
+
for (const run of runs) {
|
|
295
|
+
if (run.failed) {
|
|
296
|
+
failures.set(run.consumer, (failures.get(run.consumer) ?? 0) + 1)
|
|
297
|
+
continue
|
|
298
|
+
}
|
|
299
|
+
const lags = by.get(run.consumer) ?? []
|
|
300
|
+
lags.push(run.ageMs + run.durationMs)
|
|
301
|
+
by.set(run.consumer, lags)
|
|
302
|
+
}
|
|
303
|
+
return Object.fromEntries(
|
|
304
|
+
[...by].map(([consumer, lags]) => [
|
|
305
|
+
consumer,
|
|
306
|
+
{
|
|
307
|
+
lagMs: summarize(lags),
|
|
308
|
+
runMs: summarize(
|
|
309
|
+
runs
|
|
310
|
+
.filter((run) => run.consumer === consumer && !run.failed)
|
|
311
|
+
.map((run) => run.durationMs),
|
|
312
|
+
),
|
|
313
|
+
failures: failures.get(consumer) ?? 0,
|
|
314
|
+
},
|
|
315
|
+
]),
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function timed(times: number, fn: () => Promise<unknown>) {
|
|
320
|
+
const durations: number[] = []
|
|
321
|
+
for (let i = 0; i < times; i += 1) {
|
|
322
|
+
const t0 = performance.now()
|
|
323
|
+
await fn()
|
|
324
|
+
durations.push(performance.now() - t0)
|
|
325
|
+
}
|
|
326
|
+
return summarize(durations)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** A full load run on `store`: post, follow, catch up, check, and read. */
|
|
330
|
+
async function loadRun(store: A2Store, label: string) {
|
|
331
|
+
const { telemetry, runs } = recorder()
|
|
332
|
+
const to = servers(
|
|
333
|
+
store,
|
|
334
|
+
[a2slack.welcome, a2slack.feed, a2slack.notify],
|
|
335
|
+
telemetry,
|
|
336
|
+
)
|
|
337
|
+
for (const channel of CHANNELS)
|
|
338
|
+
await session(to[0]!).append({
|
|
339
|
+
type: 'channel.created',
|
|
340
|
+
payload: { id: channel, by: pick(USERS) },
|
|
341
|
+
})
|
|
342
|
+
await until(
|
|
343
|
+
() => idle(store, ['welcome', 'a2slack-feed', 'a2slack-notify']),
|
|
344
|
+
{
|
|
345
|
+
timeoutMs: 60_000,
|
|
346
|
+
},
|
|
347
|
+
)
|
|
348
|
+
const before = await logCounts(to[0]!)
|
|
349
|
+
const followers = subscribe(to, SUBSCRIBERS, before.head)
|
|
350
|
+
|
|
351
|
+
const posted = await post(to, MESSAGES, CONCURRENCY)
|
|
352
|
+
const catchUpMs = await until(
|
|
353
|
+
() => idle(store, ['welcome', 'a2slack-feed', 'a2slack-notify']),
|
|
354
|
+
{ timeoutMs: 10 * 60_000, everyMs: 100 },
|
|
355
|
+
)
|
|
356
|
+
const log = await logCounts(to[0]!)
|
|
357
|
+
const followMs = await until(
|
|
358
|
+
() => followers.last.every((index) => index >= log.head),
|
|
359
|
+
{ timeoutMs: 60_000 },
|
|
360
|
+
)
|
|
361
|
+
await followers.close()
|
|
362
|
+
const tables = await tableCounts()
|
|
363
|
+
const db = await a2slack.database()
|
|
364
|
+
const feedPosition = await a2slack.queries.position(db, 'a2slack-feed')
|
|
365
|
+
|
|
366
|
+
const [root] = await rows<{ id: string }>(
|
|
367
|
+
"select id from a2slack_messages where thread_id is null and channel = 'room-1' order by log_index desc limit 1",
|
|
368
|
+
)
|
|
369
|
+
const anyRoot = root?.id
|
|
370
|
+
const reads = {
|
|
371
|
+
messagePage: await timed(10, () =>
|
|
372
|
+
a2slack.queries.messagePage(pick(CHANNELS)),
|
|
373
|
+
),
|
|
374
|
+
threadPage: await timed(10, () =>
|
|
375
|
+
a2slack.queries.threadPage(anyRoot ?? ''),
|
|
376
|
+
),
|
|
377
|
+
inboxPage: await timed(10, () => a2slack.queries.inboxPage(pick(USERS).id)),
|
|
378
|
+
search: await timed(10, () => a2slack.queries.searchFeed(pick(words))),
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const result = {
|
|
382
|
+
messages: MESSAGES,
|
|
383
|
+
concurrency: CONCURRENCY,
|
|
384
|
+
subscribers: SUBSCRIBERS,
|
|
385
|
+
servers: SERVERS,
|
|
386
|
+
events: log.head,
|
|
387
|
+
appendsPerSecond: Math.round(MESSAGES / posted.seconds),
|
|
388
|
+
appendMs: summarize(posted.latencies),
|
|
389
|
+
catchUpAfterLastAppendMs: Math.round(catchUpMs),
|
|
390
|
+
totalSeconds: Math.round(posted.seconds + catchUpMs / 1000),
|
|
391
|
+
consumers: lagByConsumer(runs),
|
|
392
|
+
stream: {
|
|
393
|
+
deliveryMs: summarize(followers.delays),
|
|
394
|
+
allSubscribersAtHeadMs: Math.round(followMs),
|
|
395
|
+
},
|
|
396
|
+
exactlyOnce: {
|
|
397
|
+
log,
|
|
398
|
+
tables,
|
|
399
|
+
feedPosition,
|
|
400
|
+
ok:
|
|
401
|
+
tables.messages === log.messages &&
|
|
402
|
+
tables.notifications === log.notifications &&
|
|
403
|
+
tables.channels === log.channels &&
|
|
404
|
+
tables.reactions === log.reactions &&
|
|
405
|
+
feedPosition === log.head,
|
|
406
|
+
},
|
|
407
|
+
readsMs: reads,
|
|
408
|
+
}
|
|
409
|
+
results[label] = result
|
|
410
|
+
console.log(`\n${label}\n${JSON.stringify(result, null, 2)}`)
|
|
411
|
+
return { result, to, log, tables }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
beforeAll(async () => {
|
|
415
|
+
services = await startServices()
|
|
416
|
+
process.env.DATABASE_URL = services.databaseUrl
|
|
417
|
+
a2slack = await load()
|
|
418
|
+
const db = a2slack.drizzle({ connection: services.databaseUrl })
|
|
419
|
+
await a2slack.migrate(db, {
|
|
420
|
+
migrationsFolder: new URL('../db/migrations', import.meta.url).pathname,
|
|
421
|
+
migrationsTable: 'a2slack_migrations',
|
|
422
|
+
migrationsSchema: 'public',
|
|
423
|
+
})
|
|
424
|
+
await db.$client.end()
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
afterAll(() => {
|
|
428
|
+
const dir = new URL('../../../.a2/drills/', import.meta.url).pathname
|
|
429
|
+
mkdirSync(dir, { recursive: true })
|
|
430
|
+
const file = `${dir}a2slack-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
|
|
431
|
+
writeFileSync(file, JSON.stringify(results, null, 2))
|
|
432
|
+
console.log(`\nresults ${file}`)
|
|
433
|
+
services?.close()
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
let redisStore: A2Store
|
|
437
|
+
|
|
438
|
+
beforeAll(() => {
|
|
439
|
+
redisStore = a2slack.redis({ url: services.redisUrl, keyPrefix: 'drill' })
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
it('load: A2 on Redis, the tables in Postgres', async () => {
|
|
443
|
+
const { result } = await loadRun(redisStore, 'load (Redis)')
|
|
444
|
+
expect(result.exactlyOnce.ok).toBe(true)
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('rebuild: a renamed feed fills empty tables from the whole log', async () => {
|
|
448
|
+
const before = await tableCounts()
|
|
449
|
+
const db = await a2slack.database()
|
|
450
|
+
await db.execute(
|
|
451
|
+
sql.raw(
|
|
452
|
+
"truncate a2slack_channels, a2slack_messages, a2slack_notifications, a2slack_reactions, a2slack_reads; delete from a2slack_positions where consumer = 'a2slack-feed'",
|
|
453
|
+
),
|
|
454
|
+
)
|
|
455
|
+
const { telemetry, runs } = recorder()
|
|
456
|
+
const feedV2 = a2slack
|
|
457
|
+
.consumer({
|
|
458
|
+
contract: a2slack.model.workspace,
|
|
459
|
+
name: 'a2slack-feed-v2',
|
|
460
|
+
lane: 'feed-v2',
|
|
461
|
+
backfill: true,
|
|
462
|
+
})
|
|
463
|
+
.run(async ({ event }) => {
|
|
464
|
+
await a2slack.feedEvent(await a2slack.database(), event)
|
|
465
|
+
})
|
|
466
|
+
const [server] = servers(redisStore, [feedV2], telemetry, 1)
|
|
467
|
+
const t0 = performance.now()
|
|
468
|
+
const consumed = await session(server!).consume(feedV2)
|
|
469
|
+
const ms = performance.now() - t0
|
|
470
|
+
const after = await tableCounts()
|
|
471
|
+
const result = {
|
|
472
|
+
events: consumed.index,
|
|
473
|
+
ms: Math.round(ms),
|
|
474
|
+
eventsPerSecond: Math.round(consumed.index / (ms / 1000)),
|
|
475
|
+
runMs: summarize(runs.map((run) => run.durationMs)),
|
|
476
|
+
before,
|
|
477
|
+
after,
|
|
478
|
+
ok: JSON.stringify(before) === JSON.stringify(after),
|
|
479
|
+
}
|
|
480
|
+
results.rebuild = result
|
|
481
|
+
console.log(`\nrebuild\n${JSON.stringify(result, null, 2)}`)
|
|
482
|
+
expect(result.ok).toBe(true)
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
it('poison: one bad event dead-letters its consumer alone, and redrive resumes it', async () => {
|
|
486
|
+
const db = await a2slack.database()
|
|
487
|
+
await db.execute(
|
|
488
|
+
sql.raw(
|
|
489
|
+
'create table if not exists drill_seen (event_id text primary key)',
|
|
490
|
+
),
|
|
491
|
+
)
|
|
492
|
+
let fixed = false
|
|
493
|
+
const seen = a2slack
|
|
494
|
+
.consumer({
|
|
495
|
+
contract: a2slack.model.workspace,
|
|
496
|
+
name: 'drill-seen',
|
|
497
|
+
types: ['message.posted'],
|
|
498
|
+
lane: 'seen',
|
|
499
|
+
})
|
|
500
|
+
.run(async ({ event }) => {
|
|
501
|
+
if (event.payload.text.includes('POISON') && !fixed)
|
|
502
|
+
throw new Error('poisoned')
|
|
503
|
+
await (
|
|
504
|
+
await a2slack.database()
|
|
505
|
+
).execute(
|
|
506
|
+
sql`insert into drill_seen (event_id) values (${event.id}) on conflict do nothing`,
|
|
507
|
+
)
|
|
508
|
+
})
|
|
509
|
+
const { telemetry, runs } = recorder()
|
|
510
|
+
const to = servers(
|
|
511
|
+
redisStore,
|
|
512
|
+
[a2slack.feed, a2slack.notify, seen],
|
|
513
|
+
telemetry,
|
|
514
|
+
2,
|
|
515
|
+
)
|
|
516
|
+
await session(to[0]!).consume(seen, { through: 0 })
|
|
517
|
+
// The consumer starts with the first event appended after it exists.
|
|
518
|
+
const { index: startedAt } = await session(to[0]!).state()
|
|
519
|
+
await post(to, 50, 5)
|
|
520
|
+
const [poison] = await session(to[0]!).append({
|
|
521
|
+
type: 'message.posted',
|
|
522
|
+
payload: { channel: 'room-1', author: USERS[0]!, text: 'POISON pill' },
|
|
523
|
+
})
|
|
524
|
+
await post(to, 50, 5)
|
|
525
|
+
|
|
526
|
+
const t0 = performance.now()
|
|
527
|
+
await until(() => idle(redisStore, ['a2slack-feed', 'a2slack-notify']), {
|
|
528
|
+
timeoutMs: 60_000,
|
|
529
|
+
})
|
|
530
|
+
const othersCaughtUpMs = performance.now() - t0
|
|
531
|
+
const stuck = (await redisStore.cursors('a2slack\u001fworkspace')).find(
|
|
532
|
+
(cursor) => cursor.consumer === 'drill-seen',
|
|
533
|
+
)
|
|
534
|
+
let probes = 0
|
|
535
|
+
const deadLetteredMs = await until(
|
|
536
|
+
async () => {
|
|
537
|
+
probes += 1
|
|
538
|
+
if (process.env.DRILL_DEBUG && probes % 10 === 0) {
|
|
539
|
+
const cursor = (
|
|
540
|
+
await redisStore.cursors('a2slack\u001fworkspace')
|
|
541
|
+
).find((c) => c.consumer === 'drill-seen')
|
|
542
|
+
console.log(
|
|
543
|
+
'drill-seen',
|
|
544
|
+
JSON.stringify({
|
|
545
|
+
position: cursor?.position,
|
|
546
|
+
head: cursor?.head,
|
|
547
|
+
attempts: cursor?.attemptCount,
|
|
548
|
+
failures: cursor?.failureCount,
|
|
549
|
+
holder: cursor?.claimHolder,
|
|
550
|
+
expires: cursor?.claimExpiresAt,
|
|
551
|
+
now: new Date(),
|
|
552
|
+
}),
|
|
553
|
+
)
|
|
554
|
+
}
|
|
555
|
+
return (await session(to[0]!).deadLetters()).some(
|
|
556
|
+
(letter) => letter.consumer === 'drill-seen',
|
|
557
|
+
)
|
|
558
|
+
},
|
|
559
|
+
{ timeoutMs: 10 * 60_000, everyMs: 1000 },
|
|
560
|
+
)
|
|
561
|
+
const consumeError = await session(to[0]!)
|
|
562
|
+
.consume(seen)
|
|
563
|
+
.then(() => null)
|
|
564
|
+
.catch((error: { code?: string }) => error.code)
|
|
565
|
+
|
|
566
|
+
fixed = true
|
|
567
|
+
const redriven = await session(to[0]!).redrive(
|
|
568
|
+
{ consumer: 'drill-seen', index: poison!.index },
|
|
569
|
+
{ action: 'retry' },
|
|
570
|
+
)
|
|
571
|
+
await session(to[0]!).consume(seen)
|
|
572
|
+
const seenRows = await count('drill_seen')
|
|
573
|
+
// Every message posted once the consumer existed, the poison included.
|
|
574
|
+
const posted = (await session(to[0]!).history({ gte: startedAt + 1 })).filter(
|
|
575
|
+
(event) => event.type === 'message.posted',
|
|
576
|
+
).length
|
|
577
|
+
const result = {
|
|
578
|
+
othersCaughtUpWhileStuckMs: Math.round(othersCaughtUpMs),
|
|
579
|
+
stuckAt: {
|
|
580
|
+
position: stuck?.position,
|
|
581
|
+
head: stuck?.head,
|
|
582
|
+
poison: poison!.index,
|
|
583
|
+
},
|
|
584
|
+
deadLetteredAfterMs: Math.round(deadLetteredMs + othersCaughtUpMs),
|
|
585
|
+
failedAttempts: runs.filter(
|
|
586
|
+
(run) => run.consumer === 'drill-seen' && run.failed,
|
|
587
|
+
).length,
|
|
588
|
+
consumeWhileDeadLettered: consumeError,
|
|
589
|
+
redriven: redriven.redriven,
|
|
590
|
+
rowsAfterRedrive: seenRows,
|
|
591
|
+
messagesPosted: posted,
|
|
592
|
+
ok:
|
|
593
|
+
seenRows === posted &&
|
|
594
|
+
redriven.redriven &&
|
|
595
|
+
consumeError === 'CONSUMER_DEAD_LETTERED',
|
|
596
|
+
}
|
|
597
|
+
results.poison = result
|
|
598
|
+
console.log(`\npoison\n${JSON.stringify(result, null, 2)}`)
|
|
599
|
+
expect(result.ok).toBe(true)
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
it('outage: Postgres goes away mid-load, and every event still lands once', async () => {
|
|
603
|
+
const { telemetry, runs } = recorder()
|
|
604
|
+
const to = servers(
|
|
605
|
+
redisStore,
|
|
606
|
+
[a2slack.welcome, a2slack.feed, a2slack.notify],
|
|
607
|
+
telemetry,
|
|
608
|
+
)
|
|
609
|
+
let downAt = 0
|
|
610
|
+
let upAt = 0
|
|
611
|
+
const outage = (async () => {
|
|
612
|
+
await sleep(2_000)
|
|
613
|
+
services.stopPostgres()
|
|
614
|
+
downAt = performance.now()
|
|
615
|
+
await sleep(15_000)
|
|
616
|
+
services.startPostgres()
|
|
617
|
+
upAt = performance.now()
|
|
618
|
+
})()
|
|
619
|
+
// About 100 appends a second for 30s: they go on through the outage and
|
|
620
|
+
// after it, so drains keep starting.
|
|
621
|
+
const posted = await post(to, 3_000, 10, {
|
|
622
|
+
onSent: async () => {
|
|
623
|
+
await sleep(100)
|
|
624
|
+
},
|
|
625
|
+
})
|
|
626
|
+
await outage
|
|
627
|
+
await until(
|
|
628
|
+
() => idle(redisStore, ['welcome', 'a2slack-feed', 'a2slack-notify']),
|
|
629
|
+
{ timeoutMs: 5 * 60_000, everyMs: 200 },
|
|
630
|
+
)
|
|
631
|
+
const idleAt = performance.now()
|
|
632
|
+
const log = await logCounts(to[0]!)
|
|
633
|
+
const tables = await tableCounts()
|
|
634
|
+
const db = await a2slack.database()
|
|
635
|
+
const feedPosition = await a2slack.queries.position(db, 'a2slack-feed')
|
|
636
|
+
const deadLetters = await session(to[0]!).deadLetters()
|
|
637
|
+
const result = {
|
|
638
|
+
appended: 3_000,
|
|
639
|
+
appendMs: summarize(posted.latencies),
|
|
640
|
+
postgresDownMs: Math.round(upAt - downAt),
|
|
641
|
+
appendsEndedAfterRestartMs: Math.round(posted.endedAt - upAt),
|
|
642
|
+
idleAfterRestartMs: Math.round(idleAt - upAt),
|
|
643
|
+
failedRuns: runs.filter((run) => run.failed).length,
|
|
644
|
+
deadLetters: deadLetters.length,
|
|
645
|
+
exactlyOnce: {
|
|
646
|
+
log,
|
|
647
|
+
tables,
|
|
648
|
+
feedPosition,
|
|
649
|
+
ok:
|
|
650
|
+
tables.messages === log.messages &&
|
|
651
|
+
tables.notifications === log.notifications &&
|
|
652
|
+
tables.reactions === log.reactions &&
|
|
653
|
+
feedPosition === log.head,
|
|
654
|
+
},
|
|
655
|
+
}
|
|
656
|
+
results.outage = result
|
|
657
|
+
console.log(`\noutage\n${JSON.stringify(result, null, 2)}`)
|
|
658
|
+
expect(result.exactlyOnce.ok).toBe(true)
|
|
659
|
+
expect(deadLetters).toEqual([])
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
it('versions: two deployments run different code under one consumer name', async () => {
|
|
663
|
+
const db = await a2slack.database()
|
|
664
|
+
await db.execute(
|
|
665
|
+
sql.raw(
|
|
666
|
+
'create table if not exists drill_versions (event_id text primary key, version text not null)',
|
|
667
|
+
),
|
|
668
|
+
)
|
|
669
|
+
const version = (tag: string) =>
|
|
670
|
+
a2slack
|
|
671
|
+
.consumer({
|
|
672
|
+
contract: a2slack.model.workspace,
|
|
673
|
+
name: 'drill-version',
|
|
674
|
+
types: ['message.posted'],
|
|
675
|
+
lane: 'version',
|
|
676
|
+
})
|
|
677
|
+
.run(async ({ event }) => {
|
|
678
|
+
await (
|
|
679
|
+
await a2slack.database()
|
|
680
|
+
).execute(
|
|
681
|
+
sql`insert into drill_versions (event_id, version) values (${event.id}, ${tag}) on conflict do nothing`,
|
|
682
|
+
)
|
|
683
|
+
})
|
|
684
|
+
const { telemetry } = recorder()
|
|
685
|
+
const v1 = version('v1')
|
|
686
|
+
const [oldServer] = servers(redisStore, [v1], telemetry, 1)
|
|
687
|
+
const [newServer] = servers(redisStore, [version('v2')], telemetry, 1)
|
|
688
|
+
await session(oldServer!).consume(v1, { through: 0 })
|
|
689
|
+
await post([oldServer!, newServer!], 200, 10)
|
|
690
|
+
await until(() => idle(redisStore, ['drill-version']), { timeoutMs: 60_000 })
|
|
691
|
+
const handled = await rows<{ version: string; n: number }>(
|
|
692
|
+
'select version, count(*)::int as n from drill_versions group by version order by version',
|
|
693
|
+
)
|
|
694
|
+
const result = {
|
|
695
|
+
events: 200,
|
|
696
|
+
handledBy: Object.fromEntries(handled.map((row) => [row.version, row.n])),
|
|
697
|
+
}
|
|
698
|
+
results.versions = result
|
|
699
|
+
console.log(`\nversions\n${JSON.stringify(result, null, 2)}`)
|
|
700
|
+
expect(handled.reduce((sum, row) => sum + row.n, 0)).toBeGreaterThan(0)
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
it('load: A2 on Postgres, beside its tables', async () => {
|
|
704
|
+
await emptyTables()
|
|
705
|
+
const store = a2slack.postgres({ connectionString: services.databaseUrl })
|
|
706
|
+
const { result } = await loadRun(store, 'load (Postgres)')
|
|
707
|
+
expect(result.exactlyOnce.ok).toBe(true)
|
|
708
|
+
})
|