Skip to content

US-188: require allkeys-lru on the blob-cache Redis and warn at boot - #196

Merged
Hazzng merged 5 commits into
fix/187-poisoned-version-key-recoveryfrom
fix/188-redis-eviction-policy
Sep 19, 2026
Merged

Hazzng merged 5 commits into
fix/187-poisoned-version-key-recoveryfrom
fix/188-redis-eviction-policy

Conversation

@Hazzng

@Hazzng Hazzng commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Closes #188

Stacks on #195.


Summary by cubic

Requires the Redis backing the blob cache to run allkeys-lru or allkeys-lfu, and warns at boot when it doesn't. Redis's default noeviction turns a full instance into a permanent outage — every write is refused, blob entries carry a 24h TTL, and the load harness measured 97.6% 5xx with no recovery — while allkeys-lru makes the same memory pressure evict cold blobs instead. Closes #188.

Boot check

  • Runs CONFIG GET maxmemory-policy once at boot against the data Redis; it is never awaited, never fails startup, and is skipped when no Redis is configured or when the client carries no data-plane state (blob cache and path snapshot disabled).
  • Accepts only allkeys-lru / allkeys-lfu; anything else logs redis_eviction_policy_unsafe at critical severity with the fix command and a policy-specific reason.
  • Logs redis_eviction_policy_unknown with reason:"config_get_denied" when a provider forbids CONFIG GET, and config_get_failed for other failures.

Migration

  • Set and persist maxmemory-policy allkeys-lru (or allkeys-lfu) on the data Redis (REDIS_DATA_URL, defaulting to REDIS_URL) before deploying.
  • volatile-* is not sufficient: once the instance fills with non-TTL keys it behaves like noeviction, and allkeys-random can evict a live lease.
  • Providers that hide the setting log config_get_denied; confirm with them that the instance evicts.
  • CLAUDE.md and README.md document the requirement next to REDIS_URL / REDIS_DATA_URL.

The diff also carries the atomic poison-key repair and the startup race fix: version-key repair is one Lua script that never overwrites a concurrently healed key (the loser reloads and INCRs past the winner), and runStartupMigrations uses a referenced grace timer so a driver fault at boot exits 1 instead of exiting 0 before the verdict. The role-split changeset records the two-instance measurement: a paused data plane cost 0 of 12,584 requests, versus 94.8% when the control plane is paused.

Written for commit 22686e5. Summary will update on new commits.

Review in cubic

@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: a046c5d6-4f50-40b8-9c0e-ae579542b47b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

Comment @coderabbitai help to get the list of available commands.

@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.

All reported issues were addressed across 6 files

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

Re-trigger cubic

Comment thread CLAUDE.md Outdated
Comment thread src/redis/eviction-policy.ts Outdated
Comment thread README.md
Comment thread .changeset/fix-188-redis-eviction-policy.md
Comment thread src/redis/eviction-policy.ts
Comment thread src/api/server.ts Outdated
Comment thread .changeset/fix-188-redis-eviction-policy.md
@Hazzng

Hazzng commented Sep 19, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5b6ea6c471

ℹ️ 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".

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".

Comment thread src/redis/eviction-policy.ts Outdated
// shares an instance with the control role (`REDIS_DATA_URL` falls back to
// `REDIS_URL`), whose version counters and lock leases carry a TTL too — so
// it can reap a live lock lease to make room for a cached blob.
return { verdict: policy.startsWith("allkeys-") ? "safe" : "unsafe", policy };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not bless allkeys policies on a shared control Redis

When REDIS_DATA_URL is omitted, both clients target the same Redis server, so an allkeys-* policy also makes correctness-bearing exec/RW locks, version counters, and destroy tombstones eligible for eviction. In particular, the explicitly accepted allkeys-random can remove a live lease before its next renewal or remove the tombstone that prevents an absent version key from being interpreted as version 0, allowing overlapping work or stale ghost state under the exact memory pressure this change addresses. Only classify this policy as safe when the data plane is physically separated from the control Redis, or use a design that protects the control keys.

Useful? React with 👍 / 👎.

Comment thread src/redis/eviction-policy.ts
Comment thread src/api/server.ts Outdated
Hazzng and others added 2 commits September 19, 2026 15:14
Document maxmemory-policy allkeys-lru as a deployment requirement alongside
REDIS_URL / REDIS_DATA_URL in CLAUDE.md and README.md, and check it at boot
with CONFIG GET maxmemory-policy. A non-allkeys policy logs
redis_eviction_policy_unsafe at critical severity. The check is never awaited,
never fails startup, and recognises a provider that forbids CONFIG GET as its
own outcome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y Redis

M9: `policy.startsWith("allkeys-")` blessed allkeys-random, which samples
uniformly and can reap a live exec-lock lease as readily as a cold blob — the
very hazard the changeset used to reject volatile-* for. That argument never
distinguished the accepted policies from the rejected ones. Safe is now
allkeys-lru / allkeys-lfu only, on the argument that does hold (recency and
frequency make a lease renewed every 20s and a hot version counter effectively
immune), and volatile-* is rejected on its real defect: it evicts only
TTL-bearing keys, so an instance that fills with keys that carry none degrades
to noeviction. The unsafe log line now names the reason for the policy found.
Changeset, CLAUDE.md and README reconciled.

M8: the boot check fired unconditionally against the data client. With
REDIS_BLOB_CACHE_ENABLED=false and no path snapshot, REDIS_DATA_URL's fallback
makes that the CONTROL instance, so a correct control-only deployment got a
critical page whose remediation would make its leases and tombstones
evictable. Gated on blobCacheEnabled || pathSnapshotEnabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Hazzng
Hazzng force-pushed the fix/188-redis-eviction-policy branch from 1f3c47e to 473d8c7 Compare September 19, 2026 05:45
@Hazzng
Hazzng removed this pull request from stack #199 September 19, 2026 05:56
@Hazzng
Hazzng added this pull request to stack #212 September 19, 2026 05:57
Hazzng and others added 3 commits September 19, 2026 17:46
…writes (#195)

* US-187: recover from a poisoned Redis version key instead of wedging writes

Distinguish a structurally invalid version key (WRONGTYPE, "ERR value is not
an integer") from a transport failure. A structurally invalid key is repaired
in place with an epoch-stamped SET so the write publishes normally; every
other INCR failure keeps the #186 deferral. The F7 destroy tombstone is exempt
so a repair cannot resurrect a destroyed sandbox.

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

* US-167: record what a physically split data-plane Redis actually buys (#211)

Replaces the "not verified: a real two-Redis deployment" line with the
measurement. Control plane and data plane on separate instances, pausing
each in turn: a data-plane stall costs 0 of 12,584 requests, a control-plane
stall costs 94.8% of 26,643. The ~54% on the single-instance harness is a
property of sharing one Redis, not a ceiling on the split.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* US-180: make vitest excludes path-independent (#210)

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>

* US-181: share production's error handler with the test apps (#190)

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>

* US-168: tighten bulk-write caps and make event-loop stalls visible (#201)

* US-168: tighten bulk-write caps and make event-loop stalls visible

Default MAX_BULK_WRITE_BYTES to MAX_FILE_WRITE_BYTES instead of 128 MiB,
add the streaming body cap POST /writeFiles never had, expose the lag
histogram on /readyz, and surface a lone stall via p99.9 and a new
event_loop_stall line thresholded at the Redis commandTimeout.

Refs #168 — the structural exec cap / worker-thread fix stays open.

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

* US-168: put the create route's initial files under the real bulk caps

M10: POST /v1/sandboxes re-derived MAX_INITIAL_FILES / MAX_INITIAL_FILE_BYTES
from MAX_BULK_WRITE_FILES / MAX_BULK_WRITE_BYTES but with its own 128 MiB
default, so with the knobs unset /writeFiles capped a batch at 50 MiB while
create still accepted 128 MiB of identical synchronous work. It also used a
bare Number(), so a non-numeric override became NaN and removed the cap, and
it enforced no per-entry limit at all.

All three limits now come from lib/env.ts (MAX_BULK_WRITE_FILES moved there
and gained positiveIntEnv), and the route enforces the per-file cap per entry.
That is what makes CLAUDE.md's "any write surface" true; README, CLAUDE.md and
the changeset now say the caps cover both batch surfaces.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* US-169: survive postgres.js throwing from its own socket-write path (#194)

* US-169: survive postgres.js throwing from its own socket-write path

A backend reaped mid-transaction leaves the driver flushing a buffered write
to a nulled socket from a bare setImmediate, which is a fatal uncaught
exception that kills the replica and every other in-flight request on it.
Recognise exactly that stack frame, log it, and fail the DB awaits it stranded
with EDRIVERFAULT instead of letting them hang; everything else keeps Node's
default crash.

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

* US-169: teach the driver-fault fake dialect getSandboxEpoch

main's epoch fence (#161) calls getSandboxEpoch on every script-scope
transaction, so a fake dialect without it throws before the test reaches
the fault path it is asserting.

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

* US-169: fail a condemned script scope closed, and race the boot migrations

M3: endScriptScope never checked #scriptTxLost. After a driver fault every
later fs op throws via #assertScriptTxAlive, but just-bash swallows those into
a nonzero exit rather than rejecting, so the exec path still reached
endScriptScope and it COMMITTED the part of the script that had landed and
reported success — the opposite of the invariant the #scriptTxLost doc states.
It now delegates to the existing abort path (reject endPromise -> ROLLBACK ->
reload) and rethrows the fault.

M4: writeFile/appendFile awaited commitBlob — a root-`sql` statement — before
#withBareTx reached the liveness assert, so a write in a condemned scope still
put a statement on the wire; it was also unraced, so a fault during the blob
write never settled. Assert first, route it through #db, and do the same for
loadAllPaths and #refreshKnownEpoch. The sticky test now asserts the commitBlob
call count too, which is what its comment already claimed.

M5: runMigrations was unraced behind the new crash guard, so a driver fault at
boot hung forever with no listen instead of exiting 1. Extracted
runStartupMigrations() and raced it.

Also fixed the script-tx-lost fake, whose `void fn({})` left the abort path's
callback rejection unhandled.

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

* US-167: record what a physically split data-plane Redis actually buys (#211)

Replaces the "not verified: a real two-Redis deployment" line with the
measurement. Control plane and data plane on separate instances, pausing
each in turn: a data-plane stall costs 0 of 12,584 requests, a control-plane
stall costs 94.8% of 26,643. The ~54% on the single-instance harness is a
property of sharing one Redis, not a ceiling on the split.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* US-180: make vitest excludes path-independent (#210)

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>

* US-181: share production's error handler with the test apps (#190)

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>

* US-168: tighten bulk-write caps and make event-loop stalls visible (#201)

* US-168: tighten bulk-write caps and make event-loop stalls visible

Default MAX_BULK_WRITE_BYTES to MAX_FILE_WRITE_BYTES instead of 128 MiB,
add the streaming body cap POST /writeFiles never had, expose the lag
histogram on /readyz, and surface a lone stall via p99.9 and a new
event_loop_stall line thresholded at the Redis commandTimeout.

Refs #168 — the structural exec cap / worker-thread fix stays open.

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

* US-168: put the create route's initial files under the real bulk caps

M10: POST /v1/sandboxes re-derived MAX_INITIAL_FILES / MAX_INITIAL_FILE_BYTES
from MAX_BULK_WRITE_FILES / MAX_BULK_WRITE_BYTES but with its own 128 MiB
default, so with the knobs unset /writeFiles capped a batch at 50 MiB while
create still accepted 128 MiB of identical synchronous work. It also used a
bare Number(), so a non-numeric override became NaN and removed the cap, and
it enforced no per-entry limit at all.

All three limits now come from lib/env.ts (MAX_BULK_WRITE_FILES moved there
and gained positiveIntEnv), and the route enforces the per-file cap per entry.
That is what makes CLAUDE.md's "any write surface" true; README, CLAUDE.md and
the changeset now say the caps cover both batch surfaces.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* US-169: hold the boot race on a referenced grace timer so startup fails loudly

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* US-187: make poisoned version-key repair atomic via Lua

Replace the split GET-then-SET with one EVAL that only swaps a
still-poisoned key, never a concurrently healed counter or tombstone.
The repair loser reloads from Postgres then INCRs past the winner.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Keep main's US-169 refTimer follow-up and US-187 Lua poison repair; retain this branch's eviction-policy boot check and docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
…-eviction-policy

Co-authored-by: Cursor <cursoragent@cursor.com>
@Hazzng
Hazzng merged commit c2e7741 into main Sep 19, 2026
5 checks passed
An error occurred while trying to automatically change base from fix/187-poisoned-version-key-recovery to main September 19, 2026 08:23
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.

Redis blob cache assumes allkeys-lru; under noeviction a memory-full outage is permanent, not transient

1 participant