Conversation
…y lock Wrap the whole migration run in one transaction and take pg_advisory_xact_lock as its first statement, replacing the session-scoped pg_advisory_lock that a transaction pooler silently defeats. Measured on the Neon pooler: a second booter acquired the old lock in 267-335ms while it was held; it now waits 1855-1906ms for the holder to commit. US-165: drop DATABASE_DIRECT_URL from the deployment, correct its doc row. No server code ever read it; the runner no longer needs a direct connection. drizzle-kit still reads it for pnpm db:generate, so the variable stays. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ion-class SQLSTATEs to 503 `app.onError` and the exec SSE error frame allowlisted the error message but emitted the raw `code`, leaking driver codes (ECONNRESET, CONNECTION_CLOSED) and Postgres SQLSTATEs to clients. Add `clientSafeErrorCode` next to `clientSafeErrorMessage` so the pair cannot be used asymmetrically, and map connection-class SQLSTATEs (08xxx, 53300, 53400, 57P03) to a retryable 503 reported as EUNAVAILABLE. Closes #174 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wire c.req.raw.signal into the /exec-sync abort controller, mirroring
/exec-sync-batch (already-aborted pre-check plus a { once: true }
listener), so an abandoned request stops holding the sandbox exec lock
for the rest of its timeout. Semantics stay abort-only — work the script
already committed stays committed, as on /exec, /exec-sync-batch and the
timeout path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ceeds the lease Move the REDIS_EXEC_LOCK_* env parsing into loadExecLockOptions(), lower the acquire-timeout default from 300s to lease + ~15s reap margin, and fail startup when the acquire window is not strictly greater than both the writer lease and the reader lease — a crashed holder is only reaped when its lease expires, so a shorter window turns recovery into a 503. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both read handlers went through withOwnedSessionOrRehydrate, so two concurrent GETs on one sandbox serialized as if each were a write. Switch the two read call sites to withOwnedSessionRead — the same path MCP file_read already uses for identical work. This does NOT let a GET overtake an in-flight writer: shared still excludes exclusive. It removes reader-reader serialization only (C=12 burst wall time 131ms -> 6ms on the load-test harness). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Data plane (blob cache, path snapshot) moves to its own ioredis connection so multi-MiB SETs can no longer head-of-line block the control plane's locks and version counter. RedisBlobCache.set gains an in-flight count/byte cap and drops rather than queues. The circuit breaker is keyed by role, and every transition now logs redis_circuit_open / redis_circuit_closed with the role — the original outage produced zero log lines. Closes #167 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add a `retryable` discriminator to every error body, give the poisoned (rolled-back) publish its own `ECOHERENCE_UNAPPLIED` code, correct the ECOHERENCE message that told clients to retry an already-committed write, and defer — rather than surface — a stranded INCR failure on a turn that mutated nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Root-anchored "comparison/**" missed the copy inside an in-repo git worktree, sweeping a duplicate src/ suite and deliberately-excluded comparison fixtures into every pnpm test:unit run. Also exclude .claude/** outright. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four test apps hand-rolled the leaky pre-#174 onError, so they no longer matched production and could not fail on a code-leak regression. Route them through one testErrorHandler built on clientSafeErrorCode, and guard both the shape and the absence of hand-rolled copies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`appendFile` captures its base from the in-memory pathCache before any lock and `getBlob` is content-addressed, so a writer whose lease lapsed rebuilds `stale-base + append` and silently erases the commit of the replica that took over. Add `sandboxes.version`, pin it when the pathCache is loaded, and make every composite write advance it conditionally inside its advisory-locked `ctx` CTE — a stale pin matches zero rows, every dependent CTE writes nothing and the transaction rolls back as ESTALEEPOCH (503, retryable). Closes #131. Closes #170. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
There was a problem hiding this comment.
6 issues found across 15 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/sql-fs/tests/integration/epoch-fence.integration.test.ts">
<violation number="1" location="src/sql-fs/tests/integration/epoch-fence.integration.test.ts:55">
P3: When `open()` fails before `a`/`b` are assigned (e.g., `createSandbox` throws on a transient DB error), the `finally` block calls `.disconnect()` on undefined instances and raises a TypeError that masks the real failure. Guard the disconnect calls with optional chaining.</violation>
</file>
<file name="src/api/errors.ts">
<violation number="1" location="src/api/errors.ts:32">
P3: When an epoch fence rejects an exec, this new classification returns `retryable: true`, but the served OpenAPI 503 contract still omits `ESTALEEPOCH` and says six codes share this status. Update the API documentation and related count so clients generated from the spec recognize the new safe retry case.</violation>
</file>
<file name="src/sql-fs/tests/unit/sql-fs.epoch-fence.test.ts">
<violation number="1" location="src/sql-fs/tests/unit/sql-fs.epoch-fence.test.ts:92">
P2: The harness records every lock-taking transaction as a script transaction, so its rollback/commit assertions can include unrelated transactions. Track only the transaction opened by `#openScriptTx` instead of classifying every `setSandboxContextWithLock` call.</violation>
<violation number="2" location="src/sql-fs/tests/unit/sql-fs.epoch-fence.test.ts:164">
P2: This assertion does not prove the epoch and path tree load share a transaction. A regression that reads the epoch in a separate transaction would still pass, so record both calls and assert the version read precedes `loadAllPaths` on the same transaction.</violation>
</file>
<file name="src/sql-fs/sql-fs.ts">
<violation number="1" location="src/sql-fs/sql-fs.ts:332">
P2: After a composite sets `#scopeFenced`, `readdirWithFileTypes` still serves the cache without checking the sticky verdict, so a script can observe writes that will be rolled back. Add `#assertScriptTxAlive()` at the start of every cache-served read, including `readdirWithFileTypes`.
(Based on your team's feedback about cache-served reads after transaction loss.) [439ef221-3655-4eff-93a7-698ae087780f]</violation>
<violation number="2" location="src/sql-fs/sql-fs.ts:604">
P0: When a Redis version publish is pending, this accepts an old path snapshot while pinning the current SQL epoch. The next composite can then pass the fence with stale cache data and overwrite the committed write. Store the SQL epoch with each snapshot and validate it, or fall back to `loadAllPaths` when it cannot be validated.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| }), | ||
| ); | ||
| return { entries: snap.entries, fromSnapshot: true }; | ||
| return { entries: snap.entries, fromSnapshot: true, epoch: snapshotEpoch }; |
There was a problem hiding this comment.
P0: When a Redis version publish is pending, this accepts an old path snapshot while pinning the current SQL epoch. The next composite can then pass the fence with stale cache data and overwrite the committed write. Store the SQL epoch with each snapshot and validate it, or fall back to loadAllPaths when it cannot be validated.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/sql-fs/sql-fs.ts, line 604:
<comment>When a Redis version publish is pending, this accepts an old path snapshot while pinning the current SQL epoch. The next composite can then pass the fence with stale cache data and overwrite the committed write. Store the SQL epoch with each snapshot and validate it, or fall back to `loadAllPaths` when it cannot be validated.</comment>
<file context>
@@ -542,7 +601,7 @@ export class SqlFs<Tx = unknown> implements ICoherentFs, IReadOnlyScopeFs {
}),
);
- return { entries: snap.entries, fromSnapshot: true };
+ return { entries: snap.entries, fromSnapshot: true, epoch: snapshotEpoch };
}
} catch (err) {
</file context>
| }), | ||
| setSandboxContext: vi.fn(), | ||
| setSandboxContextWithLock: vi.fn(async (tx: unknown) => { | ||
| scriptTxs.add(tx as object); |
There was a problem hiding this comment.
P2: The harness records every lock-taking transaction as a script transaction, so its rollback/commit assertions can include unrelated transactions. Track only the transaction opened by #openScriptTx instead of classifying every setSandboxContextWithLock call.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/sql-fs/tests/unit/sql-fs.epoch-fence.test.ts, line 92:
<comment>The harness records every lock-taking transaction as a script transaction, so its rollback/commit assertions can include unrelated transactions. Track only the transaction opened by `#openScriptTx` instead of classifying every `setSandboxContextWithLock` call.</comment>
<file context>
@@ -0,0 +1,288 @@
+ }),
+ setSandboxContext: vi.fn(),
+ setSandboxContextWithLock: vi.fn(async (tx: unknown) => {
+ scriptTxs.add(tx as object);
+ }),
+ getSandboxVersion,
</file context>
|
|
||
| it("reads the epoch in the same call that loads the pathCache", () => { | ||
| expect(h.getSandboxVersion).toHaveBeenCalledTimes(1); | ||
| expect(h.getSandboxVersion).toHaveBeenCalledWith(expect.anything(), "s1"); |
There was a problem hiding this comment.
P2: This assertion does not prove the epoch and path tree load share a transaction. A regression that reads the epoch in a separate transaction would still pass, so record both calls and assert the version read precedes loadAllPaths on the same transaction.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/sql-fs/tests/unit/sql-fs.epoch-fence.test.ts, line 164:
<comment>This assertion does not prove the epoch and path tree load share a transaction. A regression that reads the epoch in a separate transaction would still pass, so record both calls and assert the version read precedes `loadAllPaths` on the same transaction.</comment>
<file context>
@@ -0,0 +1,288 @@
+
+ it("reads the epoch in the same call that loads the pathCache", () => {
+ expect(h.getSandboxVersion).toHaveBeenCalledTimes(1);
+ expect(h.getSandboxVersion).toHaveBeenCalledWith(expect.anything(), "s1");
+ });
+
</file context>
| */ | ||
| #assertScriptTxAlive(): void { | ||
| if (this.#scriptScope && this.#scriptTxLost !== undefined) throw this.#scriptTxLost; | ||
| if (this.#scriptScope && this.#scopeFenced !== undefined) throw this.#scopeFenced; |
There was a problem hiding this comment.
P2: After a composite sets #scopeFenced, readdirWithFileTypes still serves the cache without checking the sticky verdict, so a script can observe writes that will be rolled back. Add #assertScriptTxAlive() at the start of every cache-served read, including readdirWithFileTypes.
(Based on your team's feedback about cache-served reads after transaction loss.) [439ef221-3655-4eff-93a7-698ae087780f]
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/sql-fs/sql-fs.ts, line 332:
<comment>After a composite sets `#scopeFenced`, `readdirWithFileTypes` still serves the cache without checking the sticky verdict, so a script can observe writes that will be rolled back. Add `#assertScriptTxAlive()` at the start of every cache-served read, including `readdirWithFileTypes`.
(Based on your team's feedback about cache-served reads after transaction loss.) [439ef221-3655-4eff-93a7-698ae087780f]</comment>
<file context>
@@ -306,6 +329,33 @@ export class SqlFs<Tx = unknown> implements ICoherentFs, IReadOnlyScopeFs {
*/
#assertScriptTxAlive(): void {
if (this.#scriptScope && this.#scriptTxLost !== undefined) throw this.#scriptTxLost;
+ if (this.#scriptScope && this.#scopeFenced !== undefined) throw this.#scopeFenced;
+ }
+
</file context>
| await a.disconnect(); | ||
| await b.disconnect(); |
There was a problem hiding this comment.
P3: When open() fails before a/b are assigned (e.g., createSandbox throws on a transient DB error), the finally block calls .disconnect() on undefined instances and raises a TypeError that masks the real failure. Guard the disconnect calls with optional chaining.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/sql-fs/tests/integration/epoch-fence.integration.test.ts, line 55:
<comment>When `open()` fails before `a`/`b` are assigned (e.g., `createSandbox` throws on a transient DB error), the `finally` block calls `.disconnect()` on undefined instances and raises a TypeError that masks the real failure. Guard the disconnect calls with optional chaining.</comment>
<file context>
@@ -0,0 +1,264 @@
+ try {
+ await admin.transaction((tx) => admin.deleteSandbox(tx, sandboxId));
+ } finally {
+ await a.disconnect();
+ await b.disconnect();
+ }
</file context>
| await a.disconnect(); | |
| await b.disconnect(); | |
| await a?.disconnect(); | |
| await b?.disconnect(); |
| "ELOCKLOST", | ||
| "ECOHERENCE", | ||
| "ECOHERENCE_UNAPPLIED", | ||
| "ESTALEEPOCH", |
There was a problem hiding this comment.
P3: When an epoch fence rejects an exec, this new classification returns retryable: true, but the served OpenAPI 503 contract still omits ESTALEEPOCH and says six codes share this status. Update the API documentation and related count so clients generated from the spec recognize the new safe retry case.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/api/errors.ts, line 32:
<comment>When an epoch fence rejects an exec, this new classification returns `retryable: true`, but the served OpenAPI 503 contract still omits `ESTALEEPOCH` and says six codes share this status. Update the API documentation and related count so clients generated from the spec recognize the new safe retry case.</comment>
<file context>
@@ -29,6 +29,7 @@ export const SAFE_FS_ERROR_CODES: ReadonlySet<string> = new Set([
"ELOCKLOST",
"ECOHERENCE",
"ECOHERENCE_UNAPPLIED",
+ "ESTALEEPOCH",
"ERUNTIME_BUSY",
"EREADONLY",
</file context>
|
@codex review |
|
Codex Review: Didn't find any major issues. 🎉 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
036a359 to
afd8054
Compare
|
Closing as superseded by #161, which merged to Both implement the #131 fence, and they collide directly: 25+ conflict markers across the core files, and two migrations numbered Having compared them, this PR is redundant and #161's is the better base:
The What does not carry over is #192 (PR #197): the fourteen non-composite mutation paths — Comparison written up in The rest of the hardening stack has been rebased onto the new |
Closes #131
Closes #170
Stacks on #190 (
fix/181-test-scaffolding-error-shape) — review/merge that first.The bug
appendFiletakes its base from the in-memory pathCache (existing.contentSha256,sql-fs.ts) before any lock is held, andgetBlobis content-addressed — it reads by hash, so it keeps serving that base long after another replica replaced the dirent. A writer A whose Redis writer key expired therefore rebuildsstale-base + appendand points the dirent at the result, erasing B's committed line.pg_advisory_xact_lockcannot stop it: it serializes the transactions, but the read it would need to fence was already taken, in memory, before the lock existed. The heartbeat cannot stop it either —markLost()only fires on the renew timer, so any request that starts and finishes inside one interval (20 s by default) commits without ever consulting a lease it no longer holds.The fix
0007:sandboxes.version BIGINT NOT NULL DEFAULT 0.SqlFs.writeFileComposite,mkdirComposite,rmComposite,mvComposite) carries that pin intoUPDATE sandboxes SET version = version + 1 WHERE id = $s AND version = $expectedinside its advisory-locked
ctxCTE, and every mutating CTE in the statement reads from that fence — directly, or throughnew_inode/old_dirent, which is cross-joined with it. Gating only the INSERTs would still rundeleted_old_inodeand drop the live writer's inode, cascading its dirent away; there is an integration test that commits a fenced statement on purpose to prove it does not.rm/mvgate the advance on the target existing, so an ENOENT cannot spend an epoch and desynchronise the pin.set -e— soendScriptScopewould have COMMITted and answered 200/exit 0 for a script whose writes were all dropped. The verdict is now sticky for the scope: every later operation fails andendScriptScoperolls back instead of committing.ESTALEEPOCH→ 503,retryable: true(the statement wrote nothing and the transaction rolled back).RLS,
SET LOCALand the defense-in-depthrunTrustedDbAsyncwrapping are unchanged — the fence rides inside the existing composite statements, which already setapp.sandbox_idinctxand already run under the trusted-async wrapper.Verification
replica-steal.mjs(two replicas, one Postgres, one Redis) — before:after (3/3 runs):
A's body is
{"error":"ESTALEEPOCH: another writer committed to sandbox '…' after this scope pinned its epoch; nothing was applied, retry","code":"ESTALEEPOCH","retryable":true}. Confirmed against theblobstable (the live dirent'scontent_sha256resolves tobase\nB-line\n) and on both replicas —readFileandcatagree on 8101 and 8102.concurrency.mjs: all 9 checks PASS, exit 0.pnpm typecheck/lint:fixclean; unit 1288 passed / 4 skipped (baseline 1271/4). Integration serial against a local non-superuser Postgres: 105 passed / 29 skipped, 0 failures (baseline 97/29). The fence + advisory-lock suites also pass against a Neon transaction-mode pooler.Every test was checked by reverting its source change in isolation — including each individual CTE gate — and confirming the failure.
Cost
One extra row UPDATE per composite write, in the same statement (no extra round trip), plus one
SELECT versionper cache load (cold start and reload, not per exec). Measured on the load harness at concurrency 1 over a 40/25/20/15 read/write/exec/edit mix: ~577 → ~478 ops/s, about 17%, narrowing to under 10% by concurrency 8. The structural cost is thatsandboxesnow has one hot row per sandbox — every write rewrites it, so it accumulates dead tuples at write rate and leans on autovacuum — and a long-running script transaction now holds a row lock onsandboxesfor its whole duration, so DDL against that table waits behind it where it previously did not.Rolling deploys
The column is backward compatible in both directions:
createSandboxinserts with an explicit column list and nothing selects*, so a replica that predates the migration runs unchanged against a migrated database — it simply never bumps the counter, and is neither fenced nor fencing. Protection is therefore complete only once every replica is on the new code; until then the mixed fleet behaves exactly as it does today.Not covered
Only the four composite writes are fenced.
bulkIngest,mkdir -p,rm -r,cp,link,symlink,chmod,utimesandtruncatestill mutate through the non-composite path — they neither advance the epoch nor are fenced by it. Sandbox delete-and-recreate reuses epoch0, so a scope pinned at0against a deleted sandbox would pass its replacement's fence; that is the tombstone half of #136 and is deliberately out of scope. ThelastSeenVersiondivergence defect (session-manager.ts:1127) that #170 also describes is untouched here.🤖 Generated with Claude Code
Summary by cubic
Fixes the lost-update bug where a writer whose Redis lease lapsed could silently erase another replica's committed write. Writers now pin a per-sandbox epoch that every composite write must advance conditionally; a stale pin makes the write fail with
ESTALEEPOCH(503, retryable) instead of committing stale data.sandboxes.version(migration0007), read in the same call that loads the pathCache and stamped intowriteFileComposite,mkdirComposite,rmComposite, andmvComposite.rm/mvadvance the epoch only when the target exists so an ENOENT cannot desynchronize the pin.ESTALEEPOCHmaps to 503 withretryable: true; a retry reloads the cache and re-runs against the live epoch.bulkIngest,mkdir -p,rm -r,cp,link,symlink,chmod,utimes,truncate) and sandbox delete-and-recreate are not fenced.sandboxesnow has one hot row per sandbox and long script transactions hold a row lock on it.Migration
Written for commit c0bb1a5. Summary will update on new commits.