Repository navigation
Demote local replicas under storage pressure, and report them in describe_table - #4107
Merged
Merged
Conversation
…ribe_table A non-owner keeps a replica of each remote record it reads until its deadline, regardless of free disk, because the table's reclamation handler only acted on caching tables. Replicas are pure cache, so they should go before the node runs out of space for the data it owns. Under priority > 1 a table with replicas now runs a pass that demotes replicas through the sweep's own version-guarded path, earliest deadline first, down to an effective lifetime of replicaExpiration / priority. The deadline store is keyed by id, so each batch is a bounded scan for the earliest N; a time budget per run bounds the wait on the path's other handlers. A thread that comes to own the sweep after loading the table registers the handler too. describe_table and describe_all report replica_count and replica_size_estimate. Closes #4101 Dispatch-Task: harper-4101-replica-storage-reclamation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xoBA8qucgMM6UHpQt3kZc
The time budget could end a run during its selection scan, so on a store too large to scan within it, no run ever demoted anything. The budget now only stops a run from starting another batch. The scan also rechecks cancellation after yielding rather than before. Dispatch-Task: harper-4101-replica-storage-reclamation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xoBA8qucgMM6UHpQt3kZc
… and report a stuck batch A deadline from a longer earlier lifetime was beyond the window even on a full volume, which the docs promised to demote. Table.clear() left every deadline entry behind, so describe_table kept reporting the cleared replicas. A batch that made no progress ended a run silently when its demotions were refused as busy or held, which log below warn. Dispatch-Task: harper-4101-replica-storage-reclamation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xoBA8qucgMM6UHpQt3kZc
A non-owner's own write is kept as a replica, and ordinary sharded writes do not wait for the owner, so a pass on a nearly full volume could demote a fresh write before the owner had it, and the next local read would see the older version. A replica with more than replicaExpiration - 30 s left is at most that old, so the pass's window now stops there. That floor rules out an infinite cutoff, so any deadline beyond the current lifetime, left from a longer earlier one, now counts as eligible at any pressure and goes last. Closes #4105 Dispatch-Task: harper-4101-replica-storage-reclamation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xoBA8qucgMM6UHpQt3kZc
… after each eviction The pressure flag could not change while a pass ran: a reclamation run awaits each handler and arms its next timer only after the whole run, so a report of 0 never overlaps a pass. The pass's own priority poll is what stops it. After an eviction's await, recheck the guards first, since a timed-out drain can close the store. Dispatch-Task: harper-4101-replica-storage-reclamation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xoBA8qucgMM6UHpQt3kZc
The sample averaged only rows still flagged REPLICA and multiplied by every deadline entry, so orphaned deadlines inflated the estimate. Average over every entry walked instead. The docs now say an orphan counts until its deadline passes, which is when the sweep drops it. Dispatch-Task: harper-4101-replica-storage-reclamation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xoBA8qucgMM6UHpQt3kZc
Dispatch-Task: harper-4101-replica-storage-reclamation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xoBA8qucgMM6UHpQt3kZc
The 30 second floor was derived from the deadline, assuming every deadline is admission plus replicaExpiration. A record with its own earlier expiry gets a capped deadline, so on a full volume its write replica could be demoted at age 0, before the owner had the write. The pass now also skips a row written less than the minimum age ago. Dispatch-Task: harper-4101-replica-storage-reclamation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xoBA8qucgMM6UHpQt3kZc
…eck read Fresh rows with capped deadlines sort ahead of older replicas, so a batch of them could end a run with nothing demoted. The pass now remembers them for the rest of the run and selects past them. It also hands the row it checked to the eviction, whose version guard then refuses a rewrite made since. Dispatch-Task: harper-4101-replica-storage-reclamation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xoBA8qucgMM6UHpQt3kZc
A Set matches decoded array and binary keys by reference, so each scan selected the same fresh rows again and the pass rescanned until its budget ran out. It now keys them by chainKeyForId. The eviction no longer falls back to rereading a missing row, and the age check applies only to rows flagged REPLICA, so a deadline left on a freshly written row is dropped as an orphan rather than skipped. Dispatch-Task: harper-4101-replica-storage-reclamation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xoBA8qucgMM6UHpQt3kZc
A skipped fresh row counted as progress, which hid refused demotions in the same batch. Skips and demotions are now tracked apart. Every key goes through chainKeyForId, so a string id cannot collide with a composite one. Dispatch-Task: harper-4101-replica-storage-reclamation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xoBA8qucgMM6UHpQt3kZc
Dispatch-Task: harper-4101-replica-storage-reclamation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xoBA8qucgMM6UHpQt3kZc
Contributor
There was a problem hiding this comment.
Code Review
This pull request implements a mechanism to demote local replicas back to stubs under storage pressure when free space falls below the reclamation threshold, and adds reporting for replica counts and size estimates. Feedback on the implementation includes a critical issue in runReplicaSweep where returning a promise without await can prematurely reset the sweep state, an incorrect calculation in getReplicaStats where the walked counter is incremented outside the replica check (underestimating replica size), and a recommendation to use optional chaining when calling query?.get in the test resources to prevent potential TypeErrors.
Stubbing Date.now ahead around writes moved the monotonic write clock forward for every test after it in the process, so later suites in the same CI shard saw their own writes as out of date. The test now waits out a real, short minimum age. Dispatch-Task: harper-4101-replica-storage-reclamation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xoBA8qucgMM6UHpQt3kZc
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
⊙ Problem
A non-owner on a sharded table keeps each replica it reads until its deadline, however full the disk gets. The reclamation handler only acted on caching tables, and nothing reported how many replicas a node held.
Closes #4101
Closes #4105
🤔 Is
L/pthe right window?The issue's window of
L/pfrom now shrinks as pressure rises and is empty on a full disk. I used a lifetime ofL/p, soremaining <= L - L/p.🤔 Why v5.4 and not a backport?
The evidence points at v5.3, but local replicas are new in 5.4.
💡 Solution
replicaExpiration / priority.replicaExpirationof 30 s or less is not shortened.describe_tablereportsreplica_countandreplica_size_estimate.🤔 Is a row's version a good enough age?
The floor uses version as write time. A fetched old record can be demoted sooner, costing only a refetch. A backdated write can too, tracked in harper#4106.
🤔 Is demotion enough without compaction?
Demotion frees no disk until compaction rewrites the old rows. Compacting after a pass rewrites owned rows too and needs temporary space, so it needs its own design. The docs say so.
Disk on a raw store: 200k rows of 2.7 KB, half overwritten with short values
compact()compact({ bottommost: true }), 2.5 sRows under 2 KB: 85 MB, then 85 MB after the overwrite, then 45 MB after
compact(). No runtime code callscompact()today, and caching-table eviction has the same limit.⚖️ Alternatives
🤔 Is oldest-first worth its scans?
Oldest-first picks what survives an early stop, costing about 25% of a pass at 1M replicas. Moving the sweep's cutoff is smaller, but has no order or budget.
🤔 Should a pass hold up the path's other handlers?
A pass delays audit cleanup on that path. A run starts no batch after 60 s, but the limit never cuts one short.
Options ruled out
[deadline, id]index — paid on every admission and renewal, with a backfill.Pass cost on a real table: 1M replicas of 1 KB, with owned-row writes on the same thread
bench-run(replica-reclaim-1m-20261106-121545), turbo off, Node 26.2, 20k batch, 60 s budget. Taken before the 30 s floor, which only changes which replicas are eligible.The maximum delays are garbage collection, inflated here by the benchmark's own array of 1M ids. A 500k run that released that array (
replica-reclaim-gc) saw GC pauses of at most 84 to 96 ms and delays of at most 92 to 105 ms.Selecting the earliest entries, on a raw store (
replica-scan-20261106-114728):🔧 Changes
resources/Table.ts—getRecordCounton any store;getReplicaStats;clear()clears deadlines.server/storageReclamation.ts— registration reports whether it registered, andgetStorageReclamationPriorityis shared with the pass.dataLayer/schemaDescribe.ts— the fields are linked in Solution, inside the existingskip_record_countgate.docs/reference/database/storage-tuning.md— the new section, including the 30 s floor and the compaction note.docs/reference/operations-api/operations.md,docs/reference/database/schema.md,docs/reference/configuration/options.mdand the release note describe the same behavior.resources/DESIGN.mdrecords why the window and budget bound a pass, andresources/MAP.mdpoints at it.What happens when disk runs low, and what an operator sees
Product and architecture tour
What happens when disk runs low
replicaExpiration / priority, oldest deadline first.One reclamation run on a table
lifetime = max(L / priority, min(L, 30 s)). The cutoffnow + L - lifetimestays fixed, so new admissions cannot extend the run.What an operator sees
describe_tableadds these fields, counted the same time-limited way asrecord_count. They are absent where a table never kept a replica, on LMDB, and underskip_record_count. The count comes from deadline entries, and the size is sampled from up to 100 rows, so both are estimates on a large table.✅ Verification
PR QA: Does a replica pressure pass stay correct while several HTTP workers read and write the same keys? — passed @ 8bcf674, qa-pr-harper-4107-3deb710
Full gates: CI on this head, and locally before the 30 s floor
CI on this head: every job passes, after reruns.
lmdbon Node 26 took two: firstttlCleanupInterval, which also fails on main's lmdb job, then the 9-minute step limit.resources-indexeson Node 24 took one (derivedIndexRuntimeRocks, passes alone 3 of 3). Main's lmdb jobs were red on its last 8 runs.test:unit:resourcesoriginClosedFloor, which passes alone 5 of 5 and also fails in other recent PR runstest:unit:resources:core:o-z, on this headtest:unit:maineffective installation destination protection, also on basetest:integration:allollama-backend, no local modelsDocs are in this PR. No Fabric follow-up: no new configuration.
🤖 Generated by Claude Opus 5.5; posted via @kriszyp.
🤖 Generated with Claude Code
https://claude.ai/code/session_017xoBA8qucgMM6UHpQt3kZc
Release target: v5.4 — local replicas are new in 5.4, and the issue is milestoned there
Related PRs: #2155 overlaps (eviction scheduling in Table.ts), #3504 overlaps (what a demoted row keeps of its history), 3 others independent
Complexity: complicated
Deferred findings
a7f4c4e1baa78f68minor correctness —resources/Table.ts— Repaired replicas get a fresh lifetime, so the following pass cannot demote them at finite priority — filed Known limits of the replica storage-pressure pass #4106631c3949331fd4cdminor correctness —resources/Table.ts— Under pressure a non-owner's fresh write replica can be demoted before its owner has the write — filed Known limits of the replica storage-pressure pass #41069c66c344117a55faminor correctness —resources/Table.ts— replica_count counts deadline entries, not REPLICA rows; orphans count, deadline-less replicas undercount — filed Known limits of the replica storage-pressure pass #410622df7f484d218377minor correctness —resources/Table.ts— Fresh-write floor uses record version; backdated context.timestamp on a TTL-capped write replica bypasses it — filed Known limits of the replica storage-pressure pass #410672ab9d5e5b7e6ea8nit reliability —resources/Table.ts— A batch with no successful demotion ends the run — filed Known limits of the replica storage-pressure pass #4106218edefe6cbda3f4nit maintainability —integrationTests/server/replica-storage-reclamation.test.ts— Added comments narrate code (integration header, schemaDescribe parenthetical) — filed Known limits of the replica storage-pressure pass #4106Origin — the dispatch brief this PR was written from
Reclaim sharded-table replicas under storage pressure
Dispatch: task
harper-4101-replica-storage-reclamation· queued by unknown · ran by claude/opus/xhigh · worker kzyp-xps-1Ready-For-Promotion: 8bcf674 urgency=normal late=folded
Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex,cursor-grok; late=cursor-grok:adjudicated; adjudicated=domain; declined=muse; rounds=14; full=5 @ 8bcf674
Review-Attention: deep ~35m (critical: schemaDescribe.ts, Table.ts +1; decisions: do-less-alternative, pressure-lifetime-divisor, fresh-write-floor-30s, sequential-awaited-pass, zero-expiration-exempt, describe-replica-fields, replica-count-from-deadlines, fixed-min-age, stats-gated-by-skip-record-count, release-target-vs-evidence) @ 8bcf674