0.22
Event ids are unique per session, not per store, and a presence clear keeps its stamp.
Event ids are unique per session
The same event id in two sessions is now two events. Before, appending an id
that another session already used threw PARTIAL_DUPLICATE_BATCH. Inside one
session nothing changes: an identical retry returns the original rows, a batch
that mixes sent and fresh events throws, and a consumer's retried completion
returns the events it committed.
- Ids you derive need only the session's scope. An
idempotentId()recipe that includes the session still works; one that leaves it out no longer collides with another session's events. - Idempotency keys built from ids.
event.idand an actor'sctx.idare unique within their session. Ids your callers pick, and the ids A2 derives from them, can now repeat in another session, so a key for an external side effect built from one alone can too. When yours can repeat, add the session to the key; see Consumers can run twice. - Clear markers follow the same rule.
- Custom stores check ids against the session's own rows only: the
idempotent-replay and partial-batch checks in
appendandcompleteAttemptignore other sessions. SeeA2Store.
Postgres
0.22's schema.sql replaces the unique index on a2_events (event_id),
a2_events_event_id, with one on a2_events (session_id, event_id),
a2_events_session_event_id. Every row is kept, and building the new index
can't fail: the old one already keeps those pairs unique.
- Apply 0.22's
schema.sql, as the role your app connects with:node_modules/experimental-a2/sql/postgres/schema.sql. - Deploy 0.22.
The first time it runs, the swap locks a2_events against every read and write
until the new index is built, then drops the old one. On a large table, make
both changes by hand instead, with nothing blocked. Run each statement on its
own, outside a transaction:
create unique index concurrently if not exists a2_events_session_event_id
on a2_events (session_id, event_id);A concurrent build that fails leaves an invalid index of the same name behind.
Check that this returns true before you drop the old index; if it doesn't,
drop the new one and build it again:
select indisvalid from pg_index
where indexrelid = 'a2_events_session_event_id'::regclass;drop index concurrently if exists a2_events_event_id;Then apply schema.sql: it finds both done and waits on nothing. It drops the
old index only once the new one is valid.
With Drizzle, update your copy of drizzle-schema.ts:
a2_events_session_event_id on (session_id, event_id) replaces
a2_events_event_id. On a large table, run the statements above by hand
instead, and take them out of the migration Drizzle generates.
SQLite
Nothing to do. The store creates the new index and drops the old one when it opens the file. Once 0.22 has opened a file, don't open it with an earlier release: that release builds the old index again, and can't open the file at all once two sessions share an id.
Redis
Redis names every key differently in this release, so it starts fresh:
point the store at a new keyPrefix, then deploy. Under its new prefix,
0.22 never reads what an earlier release wrote; delete the old prefix's keys
whenever convenient. On a prefix 0.21 wrote, 0.22 refuses to start with
STORE_NOT_CONFIGURED. A keyPrefix can't contain braces.
What changes:
- Scripts lock only their session on Upstash. Every key of a session
shares one hash tag, so each script sets Upstash's
allow-key-lockingand locks its session's keys instead of the whole database. The store asks the server once whether it accepts the flag; Redis and Valkey don't, and run one command at a time anyway. - No script publishes. Each write publishes its wake-up after its script, so a slow subscriber never holds a lock.
clear()works on Upstash. It used to rename the log aside, which Upstash refuses for streams. The marker now joins the same log, and the old lifetime's entries stay below it, unread, untilclear()finishes deleting them. A session that stays empty after its clear keeps only its clear marker, the log holding it, and its log consumers' positions.
Memory
Nothing to do: the memory store starts empty in every process.
Presence clears keep their stamp
A null from setPresence() now leaves a tombstone in the store, carrying the
clear's at, until the field's TTL runs out. An older value that reaches the
store after the clear, such as one sent through another server instance, still
loses. Before, it brought the field back. Nothing to do: Postgres and SQLite
keep tombstones in a2_presence as a JSON null value.
Custom stores with presence keep a tombstone for each null, compare it by
at like a value until it expires, and leave tombstones out of read.