fix(cubestore): stop main-node OOM from concurrent metastore scans#11082
Conversation
|
Claude finished @waralexrom's task in 2m 2s —— View job Code ReviewVerdict: LGTM with minor notes. 0 high, 0 medium, 4 low. DetailsSummaryThe PR adds two cache + single-flight layers and a streaming-table gate to bound metastore-scan memory on the router. Logic is clean and the public surface is unchanged. I read the full diff and traced the hot paths; correctness looks preserved. What's done well
Low-severity notes (non-blocking)
Things I checked and didn't flag
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #11082 +/- ##
=======================================
Coverage 58.50% 58.50%
=======================================
Files 216 216
Lines 17270 17270
Branches 3524 3524
=======================================
Hits 10103 10103
Misses 6652 6652
Partials 515 515
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Claude finished @waralexrom's task in 2m 28s —— View job Code Review (update — new commits)Verdict: LGTM. New commits since the prior review look correct. 0 high, 0 medium, 4 low (3 new, plus the prior metastore notes still stand). DetailsScope of this updateThree new commits on top of the previously-reviewed metastore-scan fixes:
Plus The earlier metastore changes ( What's done well in the new commits
Low‑severity notes (new commits, non‑blocking)
Things I checked and didn't flag
|
0c0b1dc to
6742a90
Compare
…und router memory The disk-space limit check (`get_used_disk_space_out_of_queue`, hit per persisted chunk write during CSV/location imports and on CREATE TABLE) materialized the entire partition AND chunk tables into Vecs via `get_all_partitions_and_chunks_out_of_queue`. Because it runs out-of-queue (ungated `spawn_blocking`) and the cache has no single-flight, a burst of concurrent chunk writes on a cold cache makes N callers each hold a full copy of the tenant's metastore at once — peak memory grows ~linearly with concurrency, producing the multi-GB spikes and OOM-kills on main/router nodes (observed ~20-24GB on prod tenants with millions of chunks). Rewrite the cold path to stream via `scan_all_rows()` inside a single read_operation_out_of_queue closure, accumulating only the per-partition size map (O(partitions)) and folding chunks one at a time. Fold semantics are preserved exactly (same overwrite + workers-seed behavior; scan order is identical since all_rows is scan_all_rows collected). Add a reproduction harness: `benches/disk_space_stampede.rs` (peak-memory scaling vs concurrency via TrackingAllocator), peak/current getters on the tracking allocator, and `run-stampede-docker.sh` for a faithful Linux/cgroup run. Bench @200k chunks/K=16: peak 1181 MB -> 0.4 MB, flat in K. Note: this bounds memory only; the redundant CPU of K concurrent full chunk scans remains and should be addressed with a single-flight disk_space_cache.
…etastore scans The disk-space limit check (`get_used_disk_space_out_of_queue`) cache had no single-flight: a burst of concurrent cold-cache callers (many partition writes during an import/repartition) each ran its own full metastore scan. Memory was already bounded by the streaming rewrite, but the redundant CPU/scan work scaled linearly with concurrency. Add a compute lock with double-checked caching: on a cache miss the first caller scans and populates the cache while the rest wait and reuse the fresh result, so a burst collapses to one scan per cache-TTL window instead of N. Bench (200k chunks): with cache_secs>0 total allocations stay flat across K (one scan shared); with cache_secs=0 (cache always stale) total scales ~linearly with K (126MB * K), while peak stays flat in both — confirming the dedup affects work, not the already-bounded memory. Adds a total_allocated() getter and a "total alloc" column to the repro bench.
… scan The in-memory compaction job (NodeInMemoryChunksCompaction → compact_node_in_memory_chunks) calls get_partitions_for_in_memory_compaction, which scanned the entire chunks table to find partitions with active in-memory chunks. This runs for every node on each scheduling tick (~5s), so a fan-out of concurrent jobs each scanned the whole table on the metastore node — the same N-concurrent-full-scans pattern that drives main-node CPU/memory pressure. Memoize the discovery: a short-lived (TTL = compaction_in_memory_chunks_schedule_period_secs) single-flight cache holds only the per-node partition ids (no chunk data), so concurrent jobs in a tick share one scan instead of each running it. The chunks are re-read fresh per partition (indexed by partition id) inside the same method, so compaction always acts on current data and the public signature is unchanged (no RPC/trait change). Cached ids are re-validated against a fresh snapshot (partition still active / not multi-partition, chunks non-empty) so stale ids are harmless. The per-partition chunk predicate is kept identical to the original (active && in_memory).
…g tables In-memory chunks are only produced by streaming ingestion — tables with in_memory_ingest() (a seq column, set when a unique key is defined). A regular INSERT/import writes persisted chunks (in_memory=false). So on a cluster with no such table the in-memory compaction discovery scan can never find anything, yet it re-scanned the whole chunks table on every node every scheduling tick. Short-circuit in_memory_compaction_partition_ids: if no ready table has in_memory_ingest(), return empty immediately and skip the full chunks scan. The check reads the in-RAM table cache (get_tables_with_path(false), kept fresh on table create/ready/drop), so it costs ~nothing in the common no-streaming case. With a streaming table present, behavior is unchanged (cached + single-flight discovery, fresh per-partition chunk re-read). Adds a metastore regression test (regular table => empty discovery; unique-key/streaming table => partition found). Existing in_memory_compaction test is unaffected (per-partition path).
…ness These were investigation-only (peak-memory / single-flight repro). The actual fixes live in metastore/mod.rs; the bench, its Cargo entry, the docker runner, and the tracking-allocator getters added for it are not worth keeping permanently.
…nicking partition_rows_for_index sliced `columns[0..sort_key_size]` unguarded. When a chunk's stored column count is below the index's sort key (schema/version skew), this panicked the RepartitionChunk job and crash-looped it, flooding the metastore event bus. Guard it: if sort_key_size > columns.len(), deactivate the table via the corrupt-data mechanism and return CorruptData — mirroring the existing corrupt-data handling already in this function.
…are lost The metastore broadcast channel can drop completion events under load (Lagged), and a busy router can otherwise miss/never-deliver an event, leaving CREATE TABLE finalize blocked in wait_for_job_results forever (table stuck "processing"). - wait_for_job_results: on Lagged, re-derive pending job outcomes authoritatively from the metastore instead of erroring; add an optional wall-clock safety poll (independent tokio::time::interval + biased select, so it fires on schedule regardless of unrelated channel traffic — a per-iteration recv timeout would be reset by every event and never fire on a busy node) that re-checks pending jobs. - Handle Orphaned symmetrically in the event and poll paths (a channel-delivered Orphaned was previously dropped, so the job was never removed from the wait set). - finalize_external_table opts into a 30s poll for the import job wait. - Raise the metastore event channel capacity 8192 -> 32768 to reduce Lagged.
"Error during processing <query>: <error>" buried the error after a potentially huge query. Print the error first, then the query on its own "Query:" line.
53b17ea to
3db1965
Compare
|
Claude finished @waralexrom's task in 1m 21s —— View job Code Review (sync update)Verdict: LGTM. No new substantive issues since the previous two passes. PR is already approved by @paveltiunov. DetailsScope of this updateI re-checked the current tip against the prior reviews. The 8 commits on the branch match the changes already covered:
No code changes since the last pass — same diff content, same file SHAs as the second review covered. Carry-over notes from prior passes (still applicable, all low / non-blocking)
None of these block merging. |
Summary
Main/router (
-main-) nodes periodically spiked to 20-24GB and OOM-killed under load. Root cause: several O(all-chunks) metastore operations run out-of-queue (concurrent, ungated) with no single-flight, so a burst of callers (imports / per-node compaction jobs) each materialize the whole tenant's chunk set at once → peak memory ~ N × scan. This PR bounds and deduplicates those scans.Based on
cubestore-early-split. The 4 commits are independent and meant to be reviewed/merged one at a time.Changes
get_used_disk_space_out_of_queue, hit per persisted chunk write on import + on CREATE TABLE):scan_all_rows()instead of materializing full Vecs → per-call memory O(partitions), not O(chunks);get_partitions_for_in_memory_compaction):compaction_in_memory_chunks_schedule_period_secs; chunks re-read fresh per partition (indexed) so compaction acts on current data; public signature unchanged (no RPC/trait change);in_memory_ingest()= seq column), via the in-RAM table cache.benches/disk_space_stampede.rs+run-stampede-docker.sh+tracking_allocatorgetters.Testing
cargo check -p cubestore --all-targetsgreen on top ofcubestore-early-split.in_memory_compaction_discovery_streaming_gatepasses; existingin_memory_compactiontest unaffected.1181 MB → 0.4 MBat K=16 and flat in K (streaming); with cache enabled total allocations flat in K (single-flight dedup), vs ~linear without.active && in_memory).🤖 Generated with Claude Code