Skip to content

feat(durable): Postgres storage at one round trip per commit, reads from memory - #4

Merged
rgarcia merged 5 commits into
hypeship/durable-postgres-storagefrom
hypeship/durable-postgres-batched-commit
Oct 18, 2026
Merged

rgarcia merged 5 commits into
hypeship/durable-postgres-storagefrom
hypeship/durable-postgres-batched-commit

Conversation

@rgarcia

@rgarcia rgarcia commented Oct 17, 2026 •

Copy link
Copy Markdown

Summary

The Postgres storage adapter mapped each Storage call to one statement and re-read state between every phase. Against a database ~9 ms away that put one plain model turn at 61 round trips and admitting a single input at 22. This PR makes the adapter cost what the harness's design costs: one round trip per checkpoint, and nothing for reads it already knows the answer to. Semantics are unchanged; the conformance suite, including both ownership-loss cases and lossless lone-surrogate identities, passes against a real database.

Five commits, each reviewable alone:

  1. Set-based statements instead of one per record.
  2. Two round trips per commit, no transaction: a JSON pre-read and one data-modifying CTE statement gated on ownership.
  3. Reads from memory. The storage is the Session's only writer, so a record it has read stays valid until it writes that record. Cached and kept current by every commit: conversations; tasks and submissions by id and request key; each conversation's newest entries (a bounded window) and head markers, serving scanEntries, entry, and findLatestHeadMarker with fork ancestry; current document scans and addresses; and materialized current document content.
  4. One round trip per commit through commit_batch, a plpgsql function created by schema version 2. It locks the session row, checks ownership at the sequence the caller expects, validates the batch against stored state, writes every table, and advances the session. Each rule raises the adapter's existing message with its own SQLSTATE (DU001–DU009), which the adapter maps back to its errors. Records and content travel inside the batch as JSON text and are cast on insert: Postgres accepts a lone-surrogate escape when storing json but rejects it in any function that decodes string values, so nothing in the function decodes a record. Historical document() reads are one statement; a request key with no submission is cached as absent.
  5. Per-scope invalidation of the document caches.

Also: PostgresStorage.open takes migrate: false to skip the per-open schema check for deployments that migrate in an explicit step.

Statement counts, log_statement = all on a faux-model run:

Phase Original This PR
open (with migrate: false) 7 2
Root conversation create 37 6
submit one input 22 2
Plain model turn 61 5
Turn with one tool call 189 17

A plain turn is four checkpoints and one document scan. The floor for this design is four; going lower means the harness committing fewer checkpoints per turn.

plpgsql is built into every Postgres and needs only the privileges CREATE TABLE already needs, so this stays a generic adapter: no extensions, no driver assumptions beyond PostgresQueryable, and the function is created, versioned, and tested with the schema.

For comparison, the official SQLite adapter and every third-party Postgres adapter I could find (j-koester/pi-durable-postgres, jonpulsifer/infra, schani/pi-orb, dispatchlabs-ai/dispatch, Jason-vh/kyle) share the original one-statement-per-record shape with no caching.

Testing

  • test/postgres-storage.test.ts with PI_DURABLE_POSTGRES_URL: 54 passed.
  • Full packages/durable suite: 43 files, 908 passed.
  • tsc --noEmit -p tsconfig.build.json and biome check --error-on-warnings clean.

Each record used to cost three round trips (id check, id claim, insert) and
each document action read its record twice. A commit now checks and claims
ids as sets, inserts each table's rows in one multi-row statement, reads the
documents it touches once, and clears and adds revisions together, all as
data-modifying CTEs of the statement that advances the session. Conversation
records are cached per storage, which is the Session's only writer.

Admitting one input goes from 22 queries to 6, creating a conversation from
37 to 13, and a plain model turn from 61 to 28. PostgresStorage.open takes
migrate: false for deployments that migrate in an explicit step.
One statement reads the session row, the batch's claimed ids, and its
documents as JSON; one data-modifying CTE statement writes the batch. Every
CTE is gated on the session still carrying this owner at the sequence the
read observed, and the final UPDATE advances it, so a takeover by another
process writes nothing and is reported as before. A single statement is
atomic, so no transaction brackets the commit.
The storage is the Session's only writer, so a record it has read stays
valid until it writes that record. Tasks and submissions by id and request,
each conversation's newest entries and head markers, current document scans
and addresses, and the materialized current content of documents are cached
and kept current by every commit. Cached values are JSON-encoded so callers
get detached objects, as from the database. The delta version check joins
the commit's pre-read. A plain turn is now 9 round trips, from 61.
@rgarcia rgarcia changed the title feat(durable): write a Postgres commit as one set-based statement feat(durable): make Postgres storage cost two round trips per commit and serve reads from memory Oct 17, 2026
…hema function

A plpgsql function created by schema version 2 locks the session row,
checks ownership at the expected sequence, validates the batch against the
stored state, writes every table, and advances the session. Each rule
raises the adapter's message with its own SQLSTATE, which the adapter maps
back to its errors. Records and content travel inside the batch as JSON
text and are cast on insert, because Postgres rejects lone-surrogate
escapes in any function that decodes string values. Historical document
reads are one statement, and a request key with no submission is cached.
@rgarcia rgarcia changed the title feat(durable): make Postgres storage cost two round trips per commit and serve reads from memory feat(durable): Postgres storage at one round trip per commit, reads from memory Oct 18, 2026
@rgarcia
rgarcia merged commit 95c64b2 into hypeship/durable-postgres-storage Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant