Multi-host prefix cache: startup row thinning of a recovered disk store overflows the 64-frame record backlog and stops the server
メンテナーはふだん 1 日以内に返信
まだ誰も着手していません。
評価
- 難易度
- 3/5
- 見積もり時間
- 1〜2日
- 初心者へのやさしさ
- 42/100
- issue の種類
- バグ
- 明瞭さ
- 明確に書かれている
- 活発さ
- 活発
- 技術スタック
- rust
- 領域
- backend, performance
調査の方向性
Start with thin_rows and maintenance scheduling in selene/src/disk_store.rs, then inspect the backlog handling in selene/src/shard.rs. Reproduce with the supplied recovered-chain test and compare the short and long cases. Done when startup thinning completes without overflowing the peer record backlog and the regression test passes.
索引モデルが issue の本文から書いたものです。
説明
Symptom. A TP3 server (one rank per host) whose ranks reuse an existing --prefix-cache-disk directory joins every rank ("4890 of 4890 records on every rank") and starts the HTTP server, then stops immediately:
- rank 0:
native replay did not drain: rank 2's prefix record connection failed while jobs were outstanding: prefix record connection backlog is full; the peer is not keeping up - peer:
host prefix record service did not drain: prefix record connection closed
The same package with an empty directory starts and serves normally.
Root cause.
DiskStore::newseedsthinningwith the parent of every scanned record (selene/src/disk_store.rs:1561).- The first
pollcallsthin_rows(disk_store.rs:2075, fromdisk_store.rs:2617/composite.rs:1324), which compacts every redundant row in one pass with no bound. Each compaction is atruncate(disk_store.rs:2299). - With
Sharded, each truncate is queued to every peer throughLink::enqueue(selene/src/shard.rs:587-591,897-903), whichtry_sends into async_channel(BACKLOG = 64)(shard.rs:29,202,239-266). A full queue marks the link broken andabandonpoisons the transport (shard.rs:756). - The 64-frame bound is on the channel to the link's writer thread, so a single
pollthat queues more than about 64 frames for a peer can overflow before the writer thread drains it, even when the peer reads promptly over loopback. - A directory written before eager thinning (#168) can hold thousands of thinnable interior rows. Ours had 3485 per rank.
Conditions. A multi-host package with --prefix-cache-disk and a recovered directory holding more than about 64 interior records whose rows are redundant (each has one ready child). Not verified: any other path that queues more than 64 frames for a peer within one poll would fail the same way, for example make_room evicting many small leaves for one large store. Max-age expiry is capped at 64 per sweep (shared/index.rs:406), so it could only contribute together with other jobs. A peer that briefly stops reading makes all of these more likely.
Reproduction (CPU only). In the disk_store.rs tests, build a DiskStore over a scanned linear chain of N records, with the files present in both directories, and give it a Sharded transport with one Link to a crate::shard::serve(Peer { .. }) thread over a loopback TcpStream; then call store.poll() in a loop. With cargo test --release, N = 3500 fails 5 out of 5 runs after 65-80 truncates with the two errors above; N = 40-70 passes, and N = 100-400 fails intermittently.
Test (append to the mod tests of selene/src/disk_store.rs)
/// Repro: a store recovered over a linear chain thins every interior row in
/// one `poll`, and over the multi-host transport each truncate becomes one
/// queued frame per peer. With a local peer that reads promptly, a chain
/// longer than `shard::BACKLOG` still poisons the transport.
fn thin_recovered_chain_over_sharded(records: u64) -> std::result::Result<(), String> {
use crate::shard::{Link, Peer, Sharded};
let geometry = geometry();
let full = history(records * PAGE_TOKENS);
let zero_dir = std::env::temp_dir().join(format!("flare-repro-zero-{records}-{}", std::process::id()));
let peer_dir = std::env::temp_dir().join(format!("flare-repro-peer-{records}-{}", std::process::id()));
for d in [&zero_dir, &peer_dir] {
let _ = fs::remove_dir_all(d);
fs::create_dir_all(d).unwrap();
}
let mut scanned_records = Vec::new();
for i in 1..=records {
let parent = (i > 1).then(|| (i - 1) * PAGE_TOKENS);
let header = header(&geometry, &full, i * PAGE_TOKENS, parent);
let length = header.length_with_row();
for d in [&zero_dir, &peer_dir] {
let file = fs::File::create(d.join(record::file_name(&header.key))).unwrap();
file.set_len(length).unwrap();
}
scanned_records.push(scanned(&zero_dir, header, i, true));
}
let staged = || {
Staged::new(
None,
StoppedCopier::shared(&geometry).unwrap(),
DiskThread::start("flare-repro").unwrap(),
)
};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap();
let server = listener.accept().unwrap().0;
let peer = Peer {
staged: staged(),
directory: peer_dir.clone(),
fingerprint: FINGERPRINT,
geometry: geometry.clone(),
link: Link::new(server).unwrap(),
_lock: crate::testing::directory_lock(&peer_dir).unwrap(),
};
let service = std::thread::spawn(move || crate::shard::serve(peer));
let transport = Sharded::new(staged(), vec![Link::new(client).unwrap()]).unwrap();
let mut store = DiskStore::new(
crate::testing::directory_lock(&zero_dir).unwrap(),
IDENTITY,
FINGERPRINT,
1 << 40,
geometry.clone(),
scanned_records,
Box::new(transport),
)
.unwrap();
let mut outcome = Ok(());
let deadline = Instant::now() + Duration::from_secs(20);
while Instant::now() < deadline {
if let Err(error) = store.poll(&mut Vec::new()) {
outcome = Err(error.to_string());
break;
}
if store.stats().rows_thinned[1] == records - 2 && store.maintenance.is_empty() {
break;
}
std::thread::sleep(Duration::from_millis(1));
}
let thinned = store.stats().rows_thinned[1];
let pending = store.maintenance.len();
let closed = store.close();
drop(store);
let served = service.join().unwrap();
eprintln!(
"records={records} thinned={thinned} pending_maintenance={pending} outcome={outcome:?} close={closed:?} peer={served:?}"
);
if outcome.is_ok() && pending != 0 {
outcome = Err(format!("{pending} truncates did not complete"));
}
let _ = fs::remove_dir_all(&zero_dir);
let _ = fs::remove_dir_all(&peer_dir);
outcome
}
#[test]
fn repro_short_recovered_chain_thins_over_sharded_transport() {
// 40 records: 38 interior rows to thin, under the 64-frame backlog.
thin_recovered_chain_over_sharded(std::env::var("REPRO_SHORT").ok().and_then(|v| v.parse().ok()).unwrap_or(40)).unwrap();
}
#[test]
fn repro_long_recovered_chain_thinning_overflows_the_record_backlog() {
// 3500 records (as in the failing directory): 3498 truncates queued for the peer in one poll.
let error = thin_recovered_chain_over_sharded(3500).unwrap_err();
assert!(error.contains("backlog is full"), "{error}");
}
Suggested fix, one or more of:
- Bound
thin_rows(and other maintenance loops) per poll: stop issuing truncates whilemaintenance.len()is at or above a burst limit belowBACKLOG, and keep the remaining keys inthinning. Locally, a limit of 32 makes N = 3500 (and 100, 400) pass in about 0.5 s and keeps the 38 existingdisk_storetests passing. - In
Sharded, keep a per-peer overflow queue and flush it into the link on eachpollinstead of failing onTrySendError::Full, so the bounded channel limits memory rather than correctness; real stalls are still caught byJOB_TIMEOUTand the writer'sREPLY_TIMEOUT. - If failing stays the policy, have the error say that rank 0 queued more than
BACKLOGjobs in one burst rather than "the peer is not keeping up".
Found on main at cc696a6b with DeepSeek V4.1 Flash at TP3 on three DGX Spark (GB10) hosts, after upgrading from a build without eager thinning and keeping its prefix-cache directory.
I have the fix in option 1 with the test above ready as a PR, if that approach works for you; happy to do option 2 instead if you prefer to keep thinning unbounded and make the link absorb bursts.
- 主要言語
- Rust
- スター
- 800
- フォーク
- 106
- 平均マージ
- 1日 3時間
- マージ済み PR(30日)
- 100
環境構築
- Dockerfile または Docker Compose ファイルあり
- プルリクエストのテンプレートなし
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
NVIDIA/hyperflare-inference のほかの issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
NVIDIA/hyperflare-inference#268 ·
メンテナーはふだん 1 日以内に返信
-
area:caching area:engram enhancement performance triage:design-review
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
NVIDIA/hyperflare-inference#230 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
難易度 5/5 1週間以上 初心者へのやさしさ 15/100
NVIDIA/hyperflare-inference#278 ·
メンテナーはふだん 1 日以内に返信
-
難易度 4/5 3〜5日 初心者へのやさしさ 25/100
NVIDIA/hyperflare-inference#276 ·
メンテナーはふだん 1 日以内に返信
-
難易度 4/5 3〜5日 初心者へのやさしさ 35/100
NVIDIA/hyperflare-inference#271 ·
メンテナーはふだん 1 日以内に返信
NVIDIA/hyperflare-inference の issue をすべて見る
似ている issue
-
難易度 1/5 1時間未満 初心者へのやさしさ 62/100
-
bug UI
難易度 2/5 1〜3時間 初心者へのやさしさ 70/100
wardian-app/Wardian#1746 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 82/100
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 85/100
メンテナーはふだん 2 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 82/100
jlcodes99/cockpit-tools#3125 ·
メンテナーはふだん 2 日以内に返信