Skip to content

Keep the app data supervisor's alarm armed across writes - #50

Merged
shuv1337 merged 2 commits into
v2from
shuv/product-gates-20261022
Oct 22, 2026
Merged

shuv1337 merged 2 commits into
v2from
shuv/product-gates-20261022

Conversation

@shuv1337

@shuv1337 shuv1337 commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Round 16, item 2: why 10 writes/s from one app saturates self-host while every process sits at low CPU.

Finding

The queue isn't in the product Durable Object. A tool call writes nothing to the product's storage, so storage.sync() there takes about 0.02 ms, and the PGlite and connection locks wait under 0.1 ms.

The queue is in the apps process. Every app write went through the app's data supervisor (packages/app-data/src/cloudflare.ts), which did five disk syncs per write:

  1. set the alarm;
  2. write a pending mark;
  3. the facet's own commit;
  4. write the new revision;
  5. delete the alarm.

On self-host each sync runs on that process's only thread, so while one is in progress every app in the process stops. That matches production: about 7.7 writes/s, low CPU on every process, and latency that grows with concurrency.

Change

  • A write arms the supervisor's alarm only when this instance has none due.
  • A write no longer writes the pending mark or deletes the alarm. The alarm stays armed while writes run and re-arms when it fires mid-write.
  • An alarm that this instance did not arm was left by an instance that stopped, possibly during a write, so it announces a new revision. A pending mark left by an earlier release is still honoured.
  • With several writes in flight a write now costs about 2.1 syncs: the facet's commit, the revision write, and the alarm's database about once a second.

Measured locally

Native self-host in Docker with 2 CPUs. An LD_PRELOAD shim added a fixed delay to each fsync to stand in for a slower disk. The figures are REST writes:

fsync delay Concurrency Before After
none 1 84/s, median 11 ms 115/s, median 8 ms
none 32 205/s 255/s
20 ms 1 6.6/s, median 146 ms 13.1/s, median 70 ms
20 ms 16 9.8/s, median 1635 ms 16/s, median 1002 ms

Recovery

  • Crash during a write: the alarm is still armed and due within a second, so a new instance wakes and announces a new revision.
  • A request reaches a new instance first: its own write re-arms the alarm and bumps the revision on finish, so subscribers still refetch.
  • Idle eviction while the alarm is pending: the new instance announces an extra revision, which costs one refetch.

No scenario kills the process in the middle of a write, so this path is checked by reasoning only.

Tests

Self-host scenarios pass:

  • live query batching;
  • pending app writes;
  • tool timing of a SQL write and read;
  • workflow sleep preserving completed mutations.

bun run check passes.

app-live-query-batching.spec.ts
app-pending-writes.spec.ts
shared-runtime.spec.ts
app-data-limits.spec.ts
app-superseded-data-version.spec.ts
tool-call-overhead.spec.ts
workflow-durability.spec.ts

Each app write set and deleted the supervisor's alarm and wrote a pending
mark before the write, so one write cost five disk syncs: two alarm changes,
two supervisor commits and the facet's own commit. On self-host every sync
blocks the app process's only thread, so a slow disk capped all app writes.

A write now arms the alarm only when this instance has none due, and does
not write the pending mark or delete the alarm. An alarm this instance did
not arm was left by one that stopped, possibly during a write, so it
announces a new revision, as an earlier release's pending mark still does.
Writes cost two syncs.
@shuv1337
shuv1337 merged commit 69a16cb into v2 Oct 22, 2026
3 checks passed
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