Skip to content

perf(runtime): bound the waits past stale data and in front of a Server Action - #444

Open
soleil-colza wants to merge 5 commits into
mainfrom
fix/runtime-bounded-waits
Open

soleil-colza wants to merge 5 commits into
mainfrom
fix/runtime-bounded-waits

Conversation

@soleil-colza

@soleil-colza soleil-colza commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Three bounded waits on the path to the first byte:

  • one deadline for everything asked past a stale or expired data value;
  • a Server Action's generation read runs while its route's module loads;
  • data memos are sized without stringifying their value.

Why

  • Stale data. Past a stale or expired data value, the runtime waits for a later one. It was documented as 200 ms at the most (500 ms past expired), but each step had its own budget, and the last step had none. In the worst case that came to about 800 ms past a stale value and about 1.4 s past an expired one, mid-render and before the first byte of pages without a shell.
  • Server Actions. A Server Action's postponed state needs a record read: up to 200 ms, plus up to 200 ms for a newer record. It ran only after the route's module had been evaluated, so the two waits added up.
  • Data memos. Keeping a data value measured its answer with JSON.stringify, which copied the whole inline base64: 0.6 ms at 512 KiB, 9.7 ms at 8 MiB.

Changes

  • laterIfAny sets one deadline (200 ms, or 500 ms past expired). The later-value read, its judgement, the probe and heldNow each get what is left. The probe is not started once the deadline has passed. Past it, heldNow still takes what the isolate has in hand — the write that overtook the later value, tags already brought up — where it settles within the turn, rather than answering a miss the render would compute again; what the host has to answer is not waited for. Late answers from the authority are still kept as the data center's copy.
  • serveWithBody starts the record lookup alongside module evaluation, under the route renderedBy will reach (read from the entry tables), for Node entries with a cache and an action handed state. It uses the early result only when the routes match, and calls it as before otherwise. Edge pages are unaffected.
  • dataBytes counts each short string (ids, handler, tags, artifact id) as JSON writes it, escapes included, and the inline base64 by its length alone, plus a fixed allowance, at two bytes a character, as before.

Tests

  • later-generations.test.ts: a 150 ms read plus a 100 ms judgement answers at 200 ms; no probe once the deadline has passed; a stalled heldNow is a miss at the deadline; past the deadline, the write that overtook the later value is still answered.
  • action-postponed.test.ts: the record read starts before module evaluation finishes; the same under a member's prerender route; no read for an edge page.
  • data-memo-bytes.test.ts: inline and artifact-reference values are sized with nothing written as JSON, and never below their JSON size.
  • Each new test was checked to fail without the behaviour it covers.
  • Locally: the touched runtime suites plus cache-reads, cache-mutations, the action-posting suites and the cache-data-copies workers suite; eslint, oxfmt, tsc (runtime), check:vocabulary.

Notes

Existing per-step data-wait tests, the memo budget tests and the data-copy workers suite all pass unchanged.

Review fixes

  • cubic: short strings are counted with their JSON escapes; the early route uses hasEntry; the expired-deadline comment spells out its 1.4 s; the artifact-id test fails where the id goes uncounted; a new test covers escape-heavy tags.

Not changed

  • cubic (held-reads.ts, the past-deadline read may start host work no render waits for): it happens only where a write overtook the later value after the whole wait was spent; what it finds lands in the isolate's data memo and tag view for the reads after it; a memory-only path would split both readData and the tag check for that one case.

A read that judges a data value stale asks the host for a later one
(`laterIfAny`), and was meant to wait 200 ms at the most for it, 500 ms
past an expired value. Each step had a budget of its own instead: the
read of the later value (200 or 500 ms), its judgement (200 ms), the
read past it where it stood no better than stale (200 or 500 ms) and
that one's judgement (200 ms), and then, where a write had overtaken the
value, a read of the key again and its judgement with no deadline at
all. That came to some 800 ms in front of the render past a stale value
and 1.4 s past an expired one, before the first byte of a page with no
shell; where the host keeps the later value far off, its read alone can
spend the whole 200 ms.

The deadline is now set once, as the read asks past the value it
judged, and each step is given what is left of it: the read of a later
value and its judgement; the read past it, which is not begun once the
deadline has gone; and the read of the key again and its judgement,
which answer a miss where they do not come in time. A later value the
host answers after the runtime stopped waiting is still kept as the
data center's copy: the host keeps that going behind the response, and
nothing the runtime gives up calls it off.

`later-generations.test.ts` covers a later value read in 150 ms whose
judgement takes 100 ms (the value read is answered at 200 ms), no read
past a later value once the deadline has gone, and a read of the key
again that does not come in time (a miss at 200 ms).
…oads

A Server Action posted to an App Router page is handed the postponed
state of the generation the page was answered from (`actionPostponedFor`):
a read of that generation's record, up to 200 ms and as long again for a
newer one (`currentGeneration`). It was asked for only once the route's
module had been evaluated (`renderedBy`, which evaluates it eagerly, on
purpose), though it needs nothing of the module — so the two came to
their sum in front of the action's answer.

The read is now asked for as the module is evaluated, under the route
`renderedBy` comes to, read off the entry tables as it reads them
(`routeRenderedBy`): the resolved route where an entrypoint of either
runtime has it, else the route its prerender names. Only for an action
that is handed state, of a Node.js entrypoint, with a cache; an edge
page's action is handed none and reads nothing. It is taken where it was
asked for under the route the module renders, and asked for then
otherwise; a failure it meets is caught as it is asked, since the
module's evaluation can fail first, and is the action's where it is
taken, as before. The read is read-only and kept for the requests after
it, so one not taken costs nothing more.

`action-postponed.test.ts` covers the record read begun before the module
is evaluated, the same under the route a member without a module of its
own renders under, and no read for an edge page's action.
What a value of the data cache an isolate keeps counts against its memo
was its bytes and its answer written out as JSON (`dataBytes`). An answer
that carries its value inline carries it as base64, so keeping a value
copied all of it into a string of its own only to measure it, on the
path of the read that keeps it: 0.6 ms for a value of 512 KiB, 9.7 ms
for one of 8 MiB.

The answer is now counted from the lengths of its strings — its ids, its
handler, each tag with its punctuation, and the value's base64 or the
artifact id that stands for it — beside an allowance of 512 characters
for its keys, punctuation, kinds and numbers (some 360 with every field
set). It counts in the same characters, two bytes each, so a value of
the same size takes the same room as before: an answer of ordinary
characters is never counted for less than its JSON.

`data-memo-bytes.test.ts` covers a value kept inline and one kept as an
artifact, each counted with nothing written as JSON.
…s over

Bounding everything asked past a stale or expired data value by one
deadline left what the isolate holds of the key, read again where a write
overtook the later value (`heldNow`), refused outright once the deadline
had gone by: a miss, which the render computes again — as a rule more
slowly than the write the isolate held in hand.

Past the deadline, that read and its judgement are now begun all the
same and taken where they settle within the turn, before a timer of none
fires: a write still out, tags already brought up. What the host has to
answer is still not waited for.
@arkor-deploy

arkor-deploy Bot commented Nov 3, 2026

Copy link
Copy Markdown

Arkor — Preview deployments for arkorlab-upwind

Use the checkbox below to choose whether this pull request gets preview deployments.

  • Build and deploy previews for this pull request

Checking this builds the latest commit and future pushes. Unchecking stops queued and future builds; a running build can finish and an existing preview stays available.

@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 31 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 3c3e8d2e-8c17-4d9e-9af3-8c50ef1b85b6
Commits

Reviewing files that changed from the base of the PR and between ead498e and 3f50fe3.

Files selected for processing: 7
  • packages/runtime/src/cache/data.ts
  • packages/runtime/src/cache/held-reads.ts
  • packages/runtime/src/cache/runtime.ts
  • packages/runtime/src/with-body.ts
  • packages/runtime/test/action-postponed.test.ts
  • packages/runtime/test/data-memo-bytes.test.ts
  • packages/runtime/test/later-generations.test.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-03T03:08:32.115321Z 3f50fe3 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@greptile-apps

greptile-apps Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no blocking issues were found.

Summary

The PR bounds waits after stale or expired data, starts a Server Action’s record read alongside module loading, and avoids copying inline base64 just to size a memo.

  • Stale and expired data reads share one deadline for later values.
  • Node Server Actions start their generation lookup while the route loads.
  • Data memos count inline values without copying their base64 text.

Diagram

sequenceDiagram
    participant R as Runtime
    participant C as Cache host
    participant M as Route module
    participant A as Server Action
    par Read saved page state
      R->>C: Read current generation
      C-->>R: Saved page state
    and Load route
      R->>M: Evaluate module
      M-->>R: Handler
    end
    R->>A: Invoke handler with saved state
Loading

Reviews (2) · Last reviewed commit: "fix(runtime): count what JSON escapes in..." · Reviewed by Greptile

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 7 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/runtime/src/cache/data.ts">

<violation number="1" location="packages/runtime/src/cache/data.ts:122">
P3: The figure "some 1.4 s" does not match the step arithmetic used for the stale case. Four steps at 500 ms each come to 2 s, so either the number or the step count in this comment should be corrected.</violation>
</file>

<file name="packages/runtime/src/cache/held-reads.ts">

<violation number="1" location="packages/runtime/src/cache/held-reads.ts:109">
P2: This bypass can start host-backed `reread` and tag-sync work after the deadline; skip those remote fallbacks and use only isolate-held data in the late path.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

async function heldNow(weighing: Weighing, until: number): Promise<Judged | undefined> {
try {
const now = await weighing.reread();
const now = await inTime(until, 'the key read again', () => weighing.reread(), true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This bypass can start host-backed reread and tag-sync work after the deadline; skip those remote fallbacks and use only isolate-held data in the late path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/runtime/src/cache/held-reads.ts, line 109:

<comment>This bypass can start host-backed `reread` and tag-sync work after the deadline; skip those remote fallbacks and use only isolate-held data in the late path.</comment>

<file context>
@@ -51,15 +98,18 @@ export interface Weighing {
+async function heldNow(weighing: Weighing, until: number): Promise<Judged | undefined> {
   try {
-    const now = await weighing.reread();
+    const now = await inTime(until, 'the key read again', () => weighing.reread(), true);
     return now === undefined
       ? undefined
</file context>

Comment thread packages/runtime/src/cache/runtime.ts Resolved
Comment thread packages/runtime/src/with-body.ts Resolved
* it reads from, and a read that crosses to it took 160–360 ms in deploy-tests runs, which the
* stale read's wait cut short.
* How long a read waits for a value later than the one it judged expired, past that read, all it
* asks there together, as `NEWER_DATA_DEADLINE_MS` is past a stale one: some 1.4 s given to each

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The figure "some 1.4 s" does not match the step arithmetic used for the stale case. Four steps at 500 ms each come to 2 s, so either the number or the step count in this comment should be corrected.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/runtime/src/cache/data.ts, line 122:

<comment>The figure "some 1.4 s" does not match the step arithmetic used for the stale case. Four steps at 500 ms each come to 2 s, so either the number or the step count in this comment should be corrected.</comment>

<file context>
@@ -107,28 +107,37 @@ export async function readData(runtime: CacheRuntime, request: DataReadRequest):
- * it reads from, and a read that crosses to it took 160–360 ms in deploy-tests runs, which the
- * stale read's wait cut short.
+ * How long a read waits for a value later than the one it judged expired, past that read, all it
+ * asks there together, as `NEWER_DATA_DEADLINE_MS` is past a stale one: some 1.4 s given to each
+ * step on its own. It computes the value again otherwise, in front of the render, which as a rule
+ * takes longer; and what it computes is written over the later value another isolate wrote, so that
</file context>
Suggested change
* asks there together, as `NEWER_DATA_DEADLINE_MS` is past a stale one: some 1.4 s given to each
* asks there together, as `NEWER_DATA_DEADLINE_MS` is past a stale one: some 2 s given to each

`dataBytes` counted a memo's ids, handler and tags by their lengths, so
tags full of quotes, backslashes or control characters — which JSON
writes escaped — could come to more than the allowance beside them and
leave the memo counted below its JSON (cubic). Each short string is now
counted as JSON writes it; the inline base64, which needs no escapes,
is still counted by its length alone, never written out.

Also from review (cubic): the early route of a Server Action asks the
entry tables through `hasEntry`, as `handle.ts` does, rather than a copy
of it; the comment on the expired-value deadline spells out the 1.4 s it
cites (two reads at 500 ms, two judgements at 200 ms); the artifact-id
test now fails where the id goes uncounted.

Not changed: past the deadline, the read of what the isolate holds may
still start a read on the host, or a tag check, that no render waits
for (cubic). It happens only where a write overtook the later value
after the whole wait was spent; what it finds lands in the isolate's
data memo and tag view for the reads after it; and a memory-only path
would split both `readData` and the tag check for that one case.

This branch has not been deployed

No deployments
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