Repository navigation
perf(db): stream the index-operation backlog at writer open - #1177
Merged
Merged
Conversation
Comment on lines
+128
to
+129
| discovery_range(DataScope::Tenant(TenantId::from_u128(0))).start | ||
| ..Bytes::from_static(&[TENANT_KEY_PREFIX + 1]), |
Contributor
There was a problem hiding this comment.
Malformed tenant keys go undetected When a stored tenant key is an incomplete envelope that sorts before tenant zero’s queue prefix, such as the single byte
0xFD, this scan starts after it. The previous scope discovery rejected that key as catalog corruption; writer open now succeeds without detecting it, weakening the fail-closed corruption check. Start the tenant walk at the tenant-keyspace prefix and seek into queue ranges after validating the first key.
Writer open charged the backlog only after decoding every queue of a scope into one list, so peak memory grew with the whole backlog (up to the retained limit per index) and could crash-loop an open. Discovery now charges each queue as the scan yields it and drops it before the next, reading only operation framing. Open also made four serial scans per tenant scope (tenant seek, index records, layout probe, queue prefix). Both layouts' queue kinds are adjacent, so one range scan finds this layout's queues and fails closed on the other's without a probe; tenants share one forward-seeking iterator, so a tenant without queues costs at most one seek; and a scope's index records are read only once it yields a queue. The loaded ledger is unchanged: same charges, members, admission order, outcomes, and fail-closed errors. OperationFrame reads each queued operation's ID, entity, and exact retained bytes from a resolved queue value or row, validating payloads in place, so startup accounting allocates per operation instead of per payload byte. It accepts and rejects exactly what the full decoders do. The row-layout queue assembler loses its last caller and is removed, and the full queue's operation slice becomes test-only.
An ignored release test builds a multi-GiB backlog across legacy and tenant scopes on disk, then reopens it in fresh processes to report writer open time, backlog load time, and peak RSS.
Unit contracts pin each discovery range to exactly one scope's queue keys of both layouts and the push/finish grouping of map and row queues. A production contract reopens a writer over queues in the first and last tenant IDs and in adjacent tenants beside graph rows, and checks every queue is charged once and discarded once.
…records Owner validation of tenant queues ran only after the legacy scope, so a record cache reused across scopes passed every test. Misowned tenant queues (secondary owner, other family, other element kind) now fail the open after the legacy scope's and another tenant's records were cached, and a corrupt record fails the open only in a scope that yields a queue.
The load phase opened SlateDB with default settings, so compaction during the load swung its peak RSS between runs.
…rames Full decodes and startup frames share one unique-ID record walk, so both accept and reject the same framing.
The backlog load charges the ledger only. A reopened writer has no queue read, retained, scheduled, or held back; each reloaded queue is then read once, drains from what its commits retained, and is read again only to find it empty, leaving no schedule behind.
xav-db
force-pushed
the
bounded-writer-open-backlog-load
branch
from
October 6, 2026 13:33
f7bc484 to
4ab7e27
Compare
…r open Start the open-time tenant walk at the tenant keyspace prefix rather than tenant zero's queue range, so an incomplete envelope sorting before that range (a lone marker byte, or one byte short of tenant zero's envelope) fails the open closed as catalog corruption. Costs at most one extra seek per open.
…acklog-load # Conflicts: # crates/db/tests/TEST_TARGET_INVENTORY.md
xav-db
added a commit
that referenced
this pull request
Oct 6, 2026
Stacked on #1177 (HEL-957), which is stacked on #1175 (HEL-961). Merge those first, then retarget this PR to `main`. Fixes HEL-958. ## Problem Index-queue admission charged each operation only its encoded record size. A retained operation really costs much more memory than that, especially a small one, so the 1 GB per-index byte limit did not bound memory. Measured at glibc chunk sizes, the fixed cost of one retained operation is: - 370 B in the admission ledger; - 462–574 B plus the payload in a decoded queue (grouped by entity); - about 190–226 B plus the payload in a merge resolution. So a 24 B delete costs about 43× its charge. In a blocked-build probe, the base branch admitted 3.0M small updates without hitting the cap. RSS grew 393 B per op, so the cap would have allowed about 24M ops for one index, about 9.6 GB of RSS. ## Fix ```text charged_bytes(op) = encoded_len(op) + OPERATION_OVERHEAD_BYTES // 576 B: ledger + decoded copy + merge resolution admit: ledger.charge(index, charged_bytes) > max_retained_bytes -> 429 retained_bytes reload: open-time backlog load charges the same charged_bytes // restart == live admission release: ack / discard releases the same charged_bytes ``` Each operation is now charged `encoded + 576` bytes (`OPERATION_OVERHEAD_BYTES`). The ledger (`queue/backlog.rs`) adds the overhead itself, so every path charges the same way: - live admission, including blocker repairs admitted past the limits; - `load_durable` at open; - discovery during reconciliation. So a reopened writer charges exactly what live admission did. Publisher retained queues (`RetainedQueues`) budget in the same unit: `StoredQueue` sums `charged_bytes` and subtracts the same amount per acknowledged operation. Why 576: the payload-independent cost per op is at most about 1,050 B (the 4d tenant update), which is under 2 × 576. The decoded queue and the merge resolution each hold one copy of the payload. With those copies, the heap for ledger + one decoded copy + one resolution stays ≤ 2 × charge for every operation shape (measured 1.71× to 1.98×). The smallest overhead that meets this is 525 B; 576 is the next multiple of 64, about 10% headroom. ## Behaviour/config changes - These values are now in charged bytes, so benchmark sample consumers will see larger numbers: - `retained_bytes` in queue stats; - `requested`/`limit` in `index_backpressure`; - `observed`/`limit` in `IndexOperationBatchTooLarge(RetainedBytes)`. - The default 1 GB limit now holds at most about 1.7M operations per index (1e9 ÷ 597 B for the smallest operation). - Deployments with a small `max_retained_bytes` will throttle sooner. - `IndexOperationQueueTuning::with_max_retained_bytes` now rejects ceilings below `MIN_RETAINED_BYTES` with `RetainedBytesBelowOneOperation`. `MIN_RETAINED_BYTES` is 597 B, the charge of the smallest operation, so a lower ceiling would have rejected every queued write. - Unchanged: - the 250K member cap, which still binds first (250K × 618 B = 154.5 MB); - the 8 MiB operand limit, still in encoded bytes; - the blocked-build repair exemption; - `MAX_RETAINED_BYTES`; - the 429 contract. - Docs updated: search-consistency, limits, llms-full.txt. ## Measurements Calibration test: worst bytes per op across hash-table and Vec growth boundaries (8K–57K ops). The last two columns are heap ÷ charge, before and after: | Shape | Encoded | Ledger / decoded / resolution | Before | After | | --- | --- | --- | --- | --- | | text delete | 23 | 370 / 462 / 194 | 44.6× | 1.71× | | vector delete | 24 | 370 / 462 / 194 | 42.8× | 1.71× | | text 28 chars | 52 | 370 / 526 / 218 | 21.4× | 1.77× | | vector 4d | 42 | 370 / 510 / 208 | 25.9× | 1.76× | | vector 4d tenant update | 60 | 370 / 574 / 226 | 19.5× | 1.84× | | vector 128d | 540 | 370 / 1006 / 706 | 3.86× | 1.87× | | vector 1536d | 6172 | 370 / 6638 / 6338 | 2.16× | 1.98× | | text 4 KiB | 4131 | 370 / 4590 / 4297 | 2.24× | 1.97× | Product image, blocked build, 4d updates to 10K entities: - base: 3.0M ops admitted with no cap hit; RSS +1124 MiB. - branch: refused at 1,620,000 ops ("retained_bytes would reach 1,000,753,136, limit 1,000,000,000"); RSS +806 MiB, under the 1 GB cap. `member-limit` scenario (base → branch): - Both admit exactly 250,000 members, then return 429. - Charged backlog at the boundary: 10.5 MB → 154.5 MB. - Build activation: 263 s → 222 s. The retried batch was accepted 1.4 s → 2.5 s after activation. (Run back to back on a host shared with a cargo build, so these timings are noisy.) ## Testing (EC2) - fmt, clippy (`--workspace --all-targets -D warnings`) and `cargo check --locked --workspace --all-features` are clean. `validate-cargo-target-references.py` passes. - `cargo test -p db --lib`: 2,086 passed. `cargo test -p db --doc`: 40 passed. - Release production targets (`production-coverage,index-lifecycle-testing`, `--test-threads=1`): 187 passed. They cover queue publication, index-operation queue, text queue contracts, text correctness regressions, internal contracts, contracts, migration contracts, index lifecycle contracts and queue admission memory. - `index_lifecycle_contracts`, run serially as CI runs it: 8 passed. - `cargo test -p server`: 79 passed. - Product image `docker-image/test.sh` passes, and `index_queue_contracts.py --scenario all` passes: - correctness: 9,750 docs exact after restart and SIGKILL; - the same on S3 (10,550 docs); - member-limit: 429 at 250,000 members, retried batch accepted after activation. - Docs: `check-docs`, `check-llms` and `check-openapi` pass. - New coverage: - `production_queue_admission_memory` measures the real heap of each operation shape with a counting allocator. It fails if the 576 B overhead stops covering the fixed cost, or if any shape exceeds 2× its charge. - Ledger boundaries: - the exact cap is admitted and one byte over is refused; - a zero-byte op is still charged the overhead; - the cap admits exactly `limit / (E + 576)` tiny ops. - Every admission path charges `E + 576`, for both tiny and large ops: live, open, reconciliation and blocker repair. - A reopen after inserts, a 4 KiB document, updates and deletes charges exactly what live admission did, and refuses the next write at the same limit. - The minimum ceiling admits exactly one smallest operation. No stored format changes. **Expected CI follow-up:** the DB coverage fingerprint will change. Take the new count and sha from this PR's ARM64 "DB production thresholds" job. <!-- greptile_comment --> <!-- greptile_summary --> <p><a href="https://app.greptile.com/api/retrigger?id=75558378"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/RetriggerDark.svg?v=2"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=2"><img alt="Retrigger" src="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=2" align="right"></picture></a><br clear="all"></p> <!-- greptile-risk --> The PR appears safe to merge; the remaining calibration gap affects the benchmark-only row layout. <h3>Summary</h3> The PR adds a fixed 576-byte charge to each queued index operation, applies it across admission and recovery, aligns publisher retention accounting, and updates limits, documentation, and tests. - The remaining feedback concerns calibration coverage for the benchmark-only row layout. <details><summary>Diagram</summary> ```mermaid %%{init: {'theme': 'neutral'}}%% flowchart LR A[Queued operation] --> B[Encoded record size] B --> C[Add 576-byte overhead] C --> D[Per-index admission ledger] C --> E[Publisher retained-queue budget] D --> F[Backpressure and queue stats] ``` </details> <sub>Reviews (1) · Last reviewed commit: ["fix(db): reject retained-byte ceilings b..."](https://github.com/helixdb/helix-db/commit/d45ac728bb116935a23c53d664590fd745f50b1a)</sub> <!-- /greptile_comment -->
xav-db
added a commit
that referenced
this pull request
Oct 7, 2026
Release PR for **CLI 3.4.4** and **Docker image v0.0.11**, following the same shape as #1172. ```diff crates/cli/Cargo.toml version 3.4.3 → 3.4.4 (and Cargo.lock) crates/cli/src/config.rs DEFAULT_LOCAL_IMAGE_TAG v0.0.10 → v0.0.11 CLI tests, README, CLAUDE.md v0.0.10 → v0.0.11 docs default image, v0.0.11 release notes, llms-full.txt docker-image/README.md published release v0.0.10 → v0.0.11 CONTRIBUTORS.md CLI image v0.0.10 → v0.0.11 sdks/tests/parity/COVERAGE.md published-image parity example v0.0.10 → v0.0.11 ``` The local-server storage warning now reads "v0.0.10 or later", because v0.0.10 is the version that moved to index storage version 5. The v0.0.10 release notes are unchanged. ## Order 1. Merge this. The `Docker image` publish job requires `DEFAULT_LOCAL_IMAGE_TAG` on main to equal `release_version`. 2. Dispatch the `Docker image` workflow from main with `release_version=v0.0.11`. It reruns the full suite and publishes only if it passes. 3. Once v0.0.11 is published, run `cli.yml` from main to tag and publish v3.4.4. ## What's in v0.0.11 / 3.4.4 - **Strong text search keeps working during ingest** (#1176). It has its own bound, 512 MiB by default, configurable with `HELIX_STRONG_TEXT_SEARCH_MAX_ANALYSIS_BYTES`, and it reuses pending-text analyses across searches. 20K–40K-document bursts gave 0 rejections, against 106 and 234 before. - **Strong searches decode pending work once per request**, off the async workers, and a strong vector bound is added, set with `HELIX_STRONG_VECTOR_SEARCH_MAX_PENDING_BYTES` (#1174). 4 searches in one request went from 5.0 s to 2.2 s. - **Queue admission counts each operation's real memory**: encoded size + 576 B (#1178). - **Writer open streams the queue backlog**: 25 s / 4.5 GB → 5 s / 1.3 GB on a 2.26 GB backlog (#1177). - **Linear publication drains** (#1175). - No storage changes since v0.0.10. ## Validation of main (cfc5fca and 7372487) on EC2 c7i.8xlarge - **Checks:** fmt, clippy, `cargo check --all-features`, db lib and doc tests, server tests, and all release production suites (212 tests). - **Image:** `docker-image/test.sh` and every `index_queue_contracts` scenario (correctness on disk and S3, member limit, benchmark) pass. - **Upgrade from v0.0.9:** searches stay exact after the upgrade, and v0.0.9 then refuses the store with "Unsupported index storage version 5". - **Backfills** (100K docs, 100 writes/s), time until caught up, no 429s: | Index | Caught up | |---|---| | text | 215 s | | vec128 | 704 s | | vec768, disk | 1,907 s | | vec768, S3 | 1,852 s | - **Mixed workload** (100K docs, 768-d vector + text, 10 min), against v0.0.9 at the same write load: reads are unchanged (point read p99 7 ms, strong vector p99 69 vs 80 ms). Writes went from 406 / 708 ms with 16% transaction conflicts to 57 / 107 ms with none. - **This branch:** `cargo check --workspace --locked`, `cargo test -p helix-cli` (except `typescript_runtime`, which needs `npm ci` in the SDK), CLI clippy, rustfmt, and the docs checks (`check-docs`, `generate-llms(-full) --check`, `check-openapi`). <!-- greptile_comment --> <!-- greptile_summary --> <p><a href="https://app.greptile.com/api/retrigger?id=76201413"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/RetriggerDark.svg?v=2"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=2"><img alt="Retrigger" src="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=2" align="right"></picture></a><br clear="all"></p> <!-- greptile-risk --> The PR appears safe to merge with the stated Docker-first publication order. <h3>Summary</h3> Releases CLI `3.4.4` with Docker image `v0.0.11` as its default. - Updates image references and matching CLI tests. - Adds release notes for search and queue improvements. - Keeps saved project tags and storage version unchanged. - `xav-db` acknowledges the merge-before-publication order: publish Docker first, then CLI. The temporary pre-publication state is intentional. <sub>Reviews (1) · Last reviewed commit: ["Release CLI 3.4.4 and Docker v0.0.11"](7e15f74) · [Reviewed by Greptile](https://www.greptile.com/?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=helixdb/helix-db)</sub> <!-- /greptile_comment -->
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.
Fixes HEL-957.
Problem
At writer open,
load_backlogfully decoded every queue of a scope (every payload, including 1536-dimension vectors) into oneVecbefore charging any of it to the admission ledger. So open's peak memory and time grew with the whole durable backlog: about 25 s and 4.5 GB for 2.26 GB of queued work. Each scope also cost about 4 scans (scope seek, record scan, layout probe, queue scan).Fix
QueueStore::discoveris replaced byQueueDiscovery, which takes rows in key order and returns each queue as soon as it is complete. In the map layout each row is a whole queue. In the row layout a generation completes when the next generation's first row arrives, or atfinish(). Open now holds one queue at a time plus the ledger.OperationFrame::decode_queue/decode_rowread each operation's ID, entity and retained bytes. Payloads are validated in place (algebra::validate_body) and never decoded. The values and rows they reject are exactly the onesOperationQueue::decode/QueueRow::decodereject (duplicate IDs, NaN, UTF-8, trailing bytes, unresolved values). A proptest checks this, andOperationQueue::decodeandOperationFrame::decode_queueshare one record walk (decode_unique), so their duplicate-ID check cannot drift.discovery_rangespans both layouts' adjacent record kinds (a const assert checks this). A queue of the other layout still fails closed withConfig, during the same scan. The separate probes (require_scope_layout,require_layout,discover_scopes) are now compiled only for tests and the benchmark feature.StoredQueue).QueueStore::discover,finish_rowsandOperationQueue::from_rows.OperationQueue::operations()is now compiled only for tests andindex-lifecycle-testing.Behaviour changes
IndexRecordin a tenant that has no queue no longer fails writer open. That tenant's first catalog read fails instead (Encoding), and the other tenants keep working. A corrupt record in a scope that has a queue still fails the open.Measurements (EC2 c7i.8xlarge, release)
Fixture: 6 legacy vector queues of 256 MiB, 8 tenant queues of 64 MiB (dropped indexes), and 2000 tenants with rows but no queue. That is 14 queues, 366,930 ops and 2.26 GB retained, about 6 GB on disk. Each run starts from a fresh copy of the fixture, 4 runs each, and every run loaded the same totals.
linear-queue-publication-draindoes not touch the open-time load path (recovery.rs, queue discovery), so it applies unchanged.Testing (EC2)
cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warningsandcargo check --locked --workspace --all-featuresare clean.cargo test -p db --lib: 2,082 passed.cargo test -p db --doc: 40 passed.cargo test -p server: 79 passed.scripts/validate-cargo-target-references.pypasses.docker-image/test.shandindex_queue_contracts.py --scenario allpass. After SIGKILL and restart, 10,750 docs are exact on local disk and on S3. The member limit rejects at 250k and accepts again 1.4 s after the build activates.recovery_testscompare every load against a reference loader that fully decodes each queue the old way. They cover:u128::MAX, with keys on both sides of each range;open_measurement_testsis an ignored, manual release benchmark that produced the numbers above.No stored format changes.
Expected CI follow-up: the DB coverage fingerprint will change. Rows-layout-only lines are uncovered in production targets, and line numbers shift. Take the new count and sha from this PR's ARM64 "DB production thresholds" job, after
linear-queue-publication-drainmerges and this PR is retargeted to main.