Repository navigation
fix(umbp): SSD staging no longer fails local loads; a local read holds its bytes until it is done - #762
Conversation
Eviction is decided in three places (master EvictKey, PageBackend's masterless round, PeerSsdManager's after-write round) and freed in two more ways than the medium's Evict (the page round's own copy of it, and the SPDK daemon's EvictLRU). The master's request also drops the tier it measured, so the peer frees by placement and can delete copies nobody asked about. The doc lays out the target: the master names (key, tier); PeerPool::Evict is the one routing entry point both the master and a background local eviction worker call; MediumBackend::Evict is the only code that frees a committed key; nothing evicts on the put path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rees that copy
The master picks victims per (node, tier) but shipped only keys, so the
peer freed by placement: on a tier without on_evict offload it deleted the
key from every backend, and with copy-mode promotion (key in DRAM and SSD)
SSD pressure freed the DRAM copy and left SSD as full as before.
- EvictionVictim{key, tier}: SelectVictims and EvictKeyDispatcher carry
the tier each victim was charged to.
- EvictKeyRequest gains `repeated TierType tiers = 2`, parallel to keys.
An older peer ignores it; an older master sends none and the peer frees
each key from the medium that currently holds it.
- PeerPool::Evict takes PoolEvictRequest{key, tier, backend_id} and frees
exactly one copy: demote it when it is the placement and its tier has a
downstream (any trigger -- on_evict vs watermark now only says whether
the pool also offloads proactively), otherwise drop that copy only. Drops
run outside the pool lock; lifecycle_mutex_ keeps ClearLocal out.
- A transition that lands on a parked (draining) copy promotes it to the
durable one, so a later eviction cannot delete it as still-draining.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rough PeerPool::Evict
Before this, a committed key could be freed four ways: the master's
EvictKey (through PeerPool and the medium's Evict), PageBackend's
masterless round (its own copy of Evict's body, inline on commit),
PeerSsdManager's round (inline after every SSD write, plus
evict-then-retry on a failed write), and tier transitions. The two
inline rounds bypassed PeerPool -- no on_evict demotion, stale placement,
invisible to InstrumentedBackend -- and the page round ran before the
pool's watermark check, so on a masterless node it deleted the cold keys
the watermark offload was about to move.
Now:
- MediumBackend::Evict is the only code that frees a committed key for
capacity. PageBackend::MaybeEvictToLowWatermark and
PeerSsdManager::EvictToLowWatermark (with its watermarks, after-write
checks and evict-and-retry) are gone; a write that does not fit fails.
- MediumBackend::EnableLocalEviction is replaced by ordering hooks that
free nothing: TrackEvictionOrder() and EvictionCandidates(max) ->
EvictionOffer{key, bytes}, coldest first, skipping leased/pinned keys.
- PeerPool::EnableLocalEviction(backend, high, low) starts one background
worker. Commits, NO_SPACE allocations and finished transitions only wake
it; it takes offers until their bytes cover the overage and calls
PeerPool::Evict({key, backend_id}, kReclaim) -- the call the master's
EvictKey makes -- so a tier with a downstream demotes instead of
deleting. Lowest tier first, 64 keys per call, with a fixed byte budget
as well as the live usage check.
- PoolClient enables it for every medium without a master (paged media at
local_evict_*, SSD at its own watermarks) and for SSD with a master, as
before.
- A demotion that loses a race to another transition no longer falls back
to deleting the source, which could abort that transition and lose the
key.
- mori_umbp_client_local_evict_total{event=round|key|no_candidate|stalled};
the SSD medium's eviction_round event is gone (rounds are the pool's,
keys and bytes are InstrumentedBackend's).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With ssd_backend=spdk, PeerSsdManager reaches the device through the spdk_proxy daemon, whose SpdkSsdTier retried a failed allocation by dropping its own oldest keys (EvictLRU). Nothing told PeerSsdManager: it kept listing those keys, no REMOVE reached the master, and the master kept routing reads to bytes that were gone. Fragmentation could trigger it well below the SSD watermark. A write that does not fit now fails, as it does on the file tier, and freeing space is the owner's local eviction, which reaches the tier through Evict(). PeerSsdManager is the daemon's only client. Not built by the default dev image (no SPDK); checked with a syntax-only compile against 3rdparty/spdk/include. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PR description carries the design; the two code comments that pointed at the doc already explain themselves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Same coverage, fewer lines: - the SSD- and DRAM-scoped evicts of a promoted key share one test, as do the stale-tier and backend-scoped evicts; - the read-recency check rides on the drain test, and the bad-config check on the unordered-medium one; - the mismatched-tier-count case rides on the unscoped EvictKey test; - the two "writes never evict" cases share one fill; - dropped VictimCarriesTheTierItWasChargedTo (KeyOverBudgetInTwoTiers checks the tier on both victims) and the master-chain SSD-pressure test (the parked-source chain test checks the SSD victim end to end, and TierScopedEvictFreesOnlyThatMediumsCopy what the pool does with it); - removed the now-unused Keys() and HasRemove() helpers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Folds #742 into this PR. - Master: a round frees each (node, tier)'s overage in bytes, optionally capped by UMBP_EVICT_MAX_BYTES_PER_ROUND, instead of 32 candidates per bucket -- which freed a few MB per round and took hours to drain a 512 GiB tier's 0.9 -> 0.7 band. The store returns every eligible row and the strategy trims to the budget. - Peer: PeerPool::Evict queues a demotion on the transition worker instead of running the copy inside the EvictKey RPC, so the call never waits on bytes. Each result carries an outcome (freed, demotion queued, in flight). The worker runs an eviction's demotion whatever the tier's own watermark says, and drops the copy if nothing downstream can take it. - Local eviction counts queued demotions toward its budget (a migration pin no longer hides a key from EvictionCandidates), so a pass never queues more than the overage while the copies run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… tests Comments: drop history and restated rationale (the commit message has it). Tests, same coverage with fewer of them: - The two GetEnvUint64 tests become one. - Drop LruMasterEvictStrategy.ABudgetLargerThanAnyRowCapIsMetInOneRound: the strategy never had a row cap (the manager did), and it is unchanged. - The three MasterEvictionBytes tests become one, which also reads the limit through UMBP_EVICT_MAX_BYTES_PER_ROUND. - EvictReturnsBeforeTheDemotionCopyRuns folds into OnEvictMigratesBytesToConfiguredBackend, and QueuedDemotionsCountTowardTheBudget into the local-eviction demotion test (now ATierWithADownstreamDemotesJustTheOverage). - GatedEngine's hold times out after 10 s, so it needs no ReleaseOnExit guard to keep a failed test from hanging teardown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment-only. Drops narrative and rationale that restates the code, and the same "the pool decides, the medium only supplies order" paragraph that was repeated across several headers; field and helper docs become trailing one-liners. Added comment lines across the PR: ~400 -> 195. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With a master, a node can evict at its own watermarks instead of waiting for the master's EvictKey rounds, while still taking its victims from the master's cluster-wide recency. - Master: new GetEvictionCandidates RPC returns a node's keys on one medium (optionally one logical tier), least recently accessed first, until their sizes cover the requested bytes; master-leased keys are skipped. RegisterClient and every Heartbeat carry peer_driven_eviction, and EvictionManager leaves such nodes out of its rounds (in memory only). ClientRegistryConfig::advertise_peer_driven_eviction gates both. - Peer: local eviction asks a candidate source first (at most once per 200 ms per backend), rounds its value sizes up to the backend's pages, evicts through PeerPool::Evict, and covers the rest from the medium's own LRU. In peer-driven mode PoolClient runs local eviction for every medium, at a tiered policy's per-tier watermarks when there is one. - Opt-in: PoolClientConfig::peer_driven_eviction or UMBP_PEER_DRIVEN_EVICTION=1, and only once the master confirms support; otherwise behaviour is unchanged. New metric event master_key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replaces the peer_driven_eviction switch with PoolClientConfig::eviction_mode (env UMBP_EVICTION_MODE), which says how a node with a master evicts: - local (default): every medium at the node's own watermarks, by its own LRU, without asking the master. - master_ranked: the same, with victims ranked by the master (GetEvictionCandidates), falling back to local LRU. - master: DRAM/HBM wait for the master's EvictKey rounds; SSD still evicts locally (the previous behaviour). A self-evicting node (local or master_ranked) asks the master to leave it out of its rounds; every mode still serves EvictKey, so the master can evict on demand. A node without a master evicts locally as before. Self-evicting nodes use a tiered policy's per-tier watermarks when there is one. Tests that fill tiny tiers on purpose (cross-node smoke, tier benchmark) now run in master mode, the behaviour they were written against. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two ways a local read of SSD-resident keys failed although every key was
on the device, both seen in the DRAM->SSD demotion e2e run (DSv4-Pro,
sglang direct linker, 300 GB DRAM + 1.5 TB SSD):
* A resolve batch needing more staging than the whole arena failed outright
("batch requires 77580 staging pages but arena has 65536; not retryable"),
however long the caller waited. The linker's layer-wise reads asked for
~2.6k objects per call, so every such call failed.
* Staging was only ever freed by the read lease's expiry (3 s): no local
reader released it, so the arena had to hold read_rate x 3 s -- ~27 GiB at
the 9 GiB/s the run reached.
Now:
* SsdBackend::BatchResolve stages at most half the arena per call and
returns the rest as ResolveOutcome::kDeferred -- held, readable, resolve it
in a further call. The first key is always taken, so a batch progresses.
PeerPool keeps deferred keys' placements; on the wire a deferral is a
failure (a remote reader cannot come back for the rest).
* MediumBackend::ReleaseResolved hands staging back as soon as the reader's
copy is done. Leases are refcounted per published entry and carry a token
(ResolvedEntry::lease_token), so a shared lease outlives its first reader
and a release arriving after expiry cannot free the successor's pages. A
BUSY resolve drops the holders it took, since the caller discards it.
Readers that never release (peers over the wire) still expire as before.
* PoolClient's local readers (BatchGetRanges, ServeLocalGets) run resolve ->
copy -> release in rounds until nothing is deferred; ExecuteLocalGet and
the two medium existence probes release what they staged.
* InstrumentedBackend counts a kFailed resolve as failed instead of a miss,
and does not count kBusy/kDeferred (the retry is counted).
* New SSD medium events: staging_early_release, resolve_deferred.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A resolve's read lease protected its bytes for a fixed window (SSD staging 3 s, DRAM/HBM pages 500 ms) whoever the reader was. For an in-process reader that is a use-after-free waiting to happen: a copy that outlives the window has its staging reclaimed (SSD) or its pages evicted (DRAM/HBM) and reused while it is still reading them. The timer exists for peers reading over the wire, which cannot report completion; a local reader can. BatchResolve now takes a ResolveReader (kRemote by default, the one that cannot leak). A kLocal reader always hands its entries back with ReleaseResolved, and until it does: * SsdBackend counts it in the lease's local_holders; the reaper skips such a lease past expiry (warning once at 30 s overdue). Remote holders keep the old expiry, so a lease a peer touched still waits for its timer after the local reader releases. * PageBackend counts it in the slot's local_readers, which fences Evict, eviction candidates and the local reclaimer like a live lease. Releases carry the slot generation as their token. PoolClient resolves its local reads as kLocal and releases on every path: the busy retry gives back a discarded response's holds, the read rounds release from a scope guard, and an existence-only ResolveLocalBatch caller releases at once. PeerPool forwards the reader and gains ReleaseResolved. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment-only, plus ProbeLocal for the two resolve-and-release probes that were written out twice. ResolveReader now sits above ResolveOutcome's comment instead of between it and its enum. The SSD backend tests fold from eight to five (one shared-lease test, one remote-expiry/stale-token test) and the PageBackend pair into one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
End-to-end validation of the eviction PR chain: passed. Branch
The log signatures of the 7 bugs this e2e originally found all stay at 0: whole-batch staging failures, Notes:
🤖 Generated with Claude Code |
The master ranks a node's keys by last_accessed_at, which only RouteGet updates. A local_first node serves its hits without the master, so under master_ranked (and master) the hottest keys looked the coldest and went first: in a 4 GB DRAM / 8 GB SSD smoke a hot set re-read throughout the fill survived 0/512 under master_ranked vs 512/512 under local, and the hot reads then served from SSD slowed demotion until DRAM sat at 100%. A node whose eviction follows the master's recency now ships the keys it reads locally (deduped, bounded) on its heartbeats, and the master records an access for each. Local mode sends nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…al-leases Picks up #741's local-read reports; ResolveLocalBatch keeps this branch's probe release and deferred keys alongside them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
master_ranked asked GetEvictionCandidates synchronously inside the eviction pass, at most every 200 ms per backend. The in-memory master answers by scanning its whole index; at e2e scale (500k DRAM + 2.7M SSD keys on one node) that is 0.66 s per DRAM query and 1.9 s per SSD query, so a DRAM pass stalled most of every second and every SSD query hit the 1 s deadline and was thrown away after the master had done the work. The pass now waits at most 20 ms for the query it sends (a small index still answers the same pass); a slower answer serves a later pass unless it is over 3 s old, and the medium's own order evicts meanwhile. One query per backend is in flight at a time. The deadline goes to 3 s to match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…al-leases Picks up #741's non-blocking ranked queries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One pass reclaims a backend's whole watermark band: in the e2e a single DRAM pass covered 108k keys, while one GetEvictionCandidates answer carries at most 4096 and arrives after the pass has moved on. The pass asked once, at its start, so master_ranked ranked 0 of those 108k victims (at most 4% even when it used to wait for the answer). The pass now takes the master's answer whenever it is ready, and asks again for what the budget still needs as each answer runs out (still at most one query per 200 ms per backend); the medium's own order evicts in between. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…al-leases Picks up #741's whole-pass master ranking. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflict with #758 in BatchGetRanges: #758 replaced the local_items vector with an incremental plan builder, and this branch splits the local phase into resolve -> copy -> release rounds. Each round now gets its own builder, since its copies must finish before its holds are released. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
staging_pages_in_use is sampled once per scrape, so a burst that fills the arena between two scrapes never shows. staging_pages_peak keeps the highest occupancy since start, which is what says whether the arena was ever too small. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BatchResolve now counts a held-but-unreadable key as status="failed" instead of a miss, but kProduces still marked resolve/failed as never produced, so SampleMetrics skipped the series. Those keys left "miss" and showed up nowhere: the backend panel read zero resolve failures and its hit ratio left them out of the denominator. Mark the pair produced, so it is published from the first tick like every other series. Tests: a scripted MockBackend checks that kFailed/kBusy/kDeferred/missing count as failed/nothing/nothing/miss, and the failed series exists at zero before its first event. Both fail with the table unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…local-leases # Conflicts: # src/umbp/distributed/pool_client.cpp
A local reader now protects what it resolved by keeping a shared_ptr hold (ResolvedEntry::hold). The bytes stay put while any hold lives and come back when the last one drops. This replaces the lease tokens, generations, ReleaseResolved and kDeferred that the first version of this PR added. - PageBackend: each committed slot owns a hold. Evict, EvictionCandidates and ClearLocal skip a slot whose hold is shared. A kLocal resolve takes no lease. - SsdBackend: a staging span is reclaimed once its lease has expired and its last hold is gone. Reclaim runs in the reaper and on demand when staging is full. A kLocal batch is staged up to half the arena at a time; the rest comes back kBusy, and the caller serves it in further parts. A kRemote batch keeps all-or-nothing staging. - PeerPool: a partial kLocal batch still touches and promotes what it served. - PoolClient: ServeLocalGets and BatchGetRanges drop each part's holds before resolving the next part. The probes use BatchContains, so they stage nothing. Also fixes test_peer_pool's FailingEngine to override CreatePlanBuilder (#774's interface). Before this, test_peer_pool did not build on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nt-staging Moves extent staging and the pipelined local get onto #762's holds: an extent stage keeps a hold and is reclaimed with the read leases once the last one drops; a pipeline part's holds live in its Stage, so the release paths (scope guards, drain-ahead) are gone. Brings in #775, with whose ShardedSsdTier capabilities the ranged_read check is combined. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ce-read-touch # Conflicts: # src/umbp/include/umbp/local/tiers/sharded_ssd_tier.h # src/umbp/include/umbp/local/tiers/ssd_tier.h # src/umbp/local/tiers/sharded_ssd_tier.cpp # src/umbp/local/tiers/ssd_tier.cpp # tests/cpp/umbp/local/test_ssd_segment_reclaim.cpp
…-ssd-direct-migration # Conflicts: # src/umbp/distributed/peer/backend/ssd_backend.cpp # src/umbp/distributed/pool/peer_pool.cpp # src/umbp/include/umbp/distributed/peer/backend/page_backend.h # src/umbp/include/umbp/distributed/peer/backend/ssd_backend.h
Two fixes found by the DRAM 300 GB → SSD 1.5 TB end-to-end run (InferenceY agentic, DeepSeek-V4-Pro, n06-21), where SSD-backed loads under the sglang UMBP linker crashed the scheduler.
1. A load batch larger than the SSD staging arena failed whole
Symptom:
UMBP get failed … success=78/2664, with 2,124staging arena busy; rolled back N reservationswarnings, then sglang's scheduler died (the linker raises on any failed get after a hit).Cause: the linker asks for ~2,000 keys per call. A resolve either fit the whole batch in staging or failed all of it. Local readers also held staging for the full 3 s read lease after their copy had finished, so the arena had to cover read rate × 3 s.
Fix:
SsdBackend::BatchResolvestages up to half the arena per call (the first key always fits). The rest comes backkBusy.PoolClientserves local gets and ranged gets in parts (resolve → copy → drop the part's holds) until every key is served.BatchContains, so they stage nothing.2. A local read's hold could expire mid-copy
In-process readers were protected only by time: the SSD staging lease (3 s) and the DRAM/HBM eviction fence (500 ms). A copy that outlived its lease could have its staging slot reused, or its DRAM page evicted, while still reading it.
Fix: a local read holds its bytes by refcount.
ResolvedEntry::holdis ashared_ptrthat the reader keeps for as long as it reads.PageBackendslots and SSD staging spans each own one, and neither evicts, frees nor reuses what is still held. There is nothing to release and nothing that expires, so no token, generation or overdue state is needed. Remote readers keep the timed lease (#749 is the remote half and is not fixed here).API change to review
MediumBackend::BatchResolvegains a 4th parameterResolveReader reader = kRemote, andResolvedEntrygainshold(kLocal only). The reader stays a parameter because three behaviours depend on it: no timed lease, partial staging (a remote reader retries the whole batch, so partial staging would livelock it), and #781's extents.Metrics
staging_expired_reclaim: staging spans freed after their lease and holds were gone.mori_umbp_backend_medium_state{tier="SSD",state="staging_pages_peak"}: the arena's highest occupancy since start.staging_pages_in_useis sampled per scrape and misses bursts.kBusykeys are not counted as resolve outcomes. A held-but-unreadable key isstatus="failed"instead of a miss.Also
test_peer_pool'sFailingEnginenow overridesCreatePlanBuilder(#774's interface). Before this,test_peer_pooldid not build on main.Testing
test_page_backend40,test_ssd_backend35,test_peer_pool41,test_component_metrics22,test_umbp_ssd_ranged_io7,test_umbp_local_first4,test_umbp_no_master9: all pass.🤖 Generated with Claude Code