Skip to content

Session.fork publishes its copy in one transaction - #9

Merged
TheFabFab merged 1 commit into
documentor-pgfrom
documentor-batch-fork
Oct 19, 2026
Merged

TheFabFab merged 1 commit into
documentor-pgfrom
documentor-batch-fork

Conversation

@TheFabFab

Copy link
Copy Markdown
Owner

Why

DocuMentor's branch feature calls Session.fork. On production, forking a 234-message chat took ~29 s (production bug report of 2026-10-19). OpenCode copies each message and each part as its own durable publish. Here that is a transaction of 8–9 statements, each a round trip to the agent-state Postgres on the VM: BEGIN, advisory lock, sequence read, event-id check, (part read), row upsert, sequence upsert, event insert, COMMIT.

What changes

  • packages/core/src/event.ts: new EventV2.publishMany(publications, { location? }) (plus Publication and publication) for durable events of one aggregate. Everything runs in one transaction under one advisory lock: one sequence read, the existing projectors per event in order, one sequence upsert, and event inserts in chunks of 200. After the commit there is one durable wake, then one notify per event in sequence order, as publish does. Mixed aggregates or live-only events are refused. It is a copy of commitDurableEvent's non-replay path, not a refactor, so upstream edits to that function rebase cleanly.
  • packages/opencode/src/event-v2-bridge.ts: routes publishMany with the same instance location as publish.
  • packages/opencode/src/session/session.ts: fork builds the same MessageUpdated/PartUpdated data as before and publishes it with publishMany. The copy is all or nothing, and if it fails the newly created session is removed (Effect.onError).
  • documentor/FORK.md records the new divergence.

Measurements

Throwaway benchmark (not committed): 234 messages = 936 rows, local Postgres 18 on disk, with an optional TCP proxy adding 1 ms each way. Before and after were interleaved because commit cost on this disk drifts.

before after
direct 5.9 s / 47.1 s 1.0 s / 1.5 s
2 ms RTT 46.7 s / 77.3 s 8.3 s / 8.7 s
2 ms RTT, 16 KB parts – 6.0 s / 7.2 s

Tests

  • packages/core/test/event-publish-many.test.ts (new):
    • stores the same rows as publishing one by one;
    • continues the aggregate's sequence, including around single publishes;
    • runs every projector before any notification, with typed subscriptions in order;
    • when a projector fails mid-batch, rolls back every row, the sequence and the projector's writes, and notifies no one;
    • refuses mixed or live-only batches;
    • wakes a tailing durable stream;
    • writes a 450-event (multi-chunk) batch.
  • packages/opencode/test/session/fork-batch.test.ts (new):
    • the fork copies every message, part and parent link, with a gap-free log;
    • a fork with concurrent setTitle on another chat in the same schema;
    • a failed copy leaves no session behind. Checked to fail without the cleanup.
  • Existing, all pass locally:
    • core/test/event.test.ts and session-runner-tool-events.test.ts (one-line double update);
    • opencode/test/session/session.test.ts and messages-pagination.test.ts;
    • test/pg/concurrency.test.ts and test/pg/parity.test.ts.

Not covered here

The DocuMentor side (digest pin) and a preview with a long chat come after release.

🤖 Generated with Claude Code

Against a remote Postgres each durable publish is its own transaction of
eight or nine round trips, so forking a 234-message chat (936 rows) took
about 30 s at a 2 ms round trip. EventV2.publishMany publishes durable
events of one aggregate in a single transaction: one lock, one sequence
read, the projectors per event as before, then one sequence write and
chunked event inserts. Listeners are notified per event after the commit.

Same rows, same events, measured interleaved against the old path at a
2 ms round trip: 47-77 s before, 8.3-8.7 s after; 6-7 s with 16 KB parts.

The copy commits all or nothing, and fork removes the new session if it
does not. publishMany is a copy of commitDurableEvent's non-replay path
rather than a refactor of it, so upstream changes there rebase cleanly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

Please edit this PR description to address the above within 72 hours, or it will be automatically closed.

If you believe this was flagged incorrectly, please let a maintainer know.

@github-actions

Copy link
Copy Markdown

Hey! Your PR title Session.fork publishes its copy in one transaction doesn't follow conventional commit format.

Please update it to start with one of:

  • feat: or feat(scope): new feature
  • fix: or fix(scope): bug fix
  • docs: or docs(scope): documentation changes
  • chore: or chore(scope): maintenance tasks
  • refactor: or refactor(scope): code refactoring
  • test: or test(scope): adding or updating tests

Where scope is the package name (e.g., app, desktop, opencode).

See CONTRIBUTING.md for details.

@TheFabFab
TheFabFab merged commit b321d1d into documentor-pg Oct 19, 2026
6 of 11 checks passed
@TheFabFab
TheFabFab deleted the documentor-batch-fork branch October 19, 2026 14:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant