Skip to content

perf(db): stream the index-operation backlog at writer open - #1177

Merged
xav-db merged 15 commits into
mainfrom
bounded-writer-open-backlog-load
Oct 6, 2026
Merged

xav-db merged 15 commits into
mainfrom
bounded-writer-open-backlog-load

Conversation

@xav-db

@xav-db xav-db commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Fixes HEL-957.

Problem

At writer open, load_backlog fully decoded every queue of a scope (every payload, including 1536-dimension vectors) into one Vec before 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

before: for scope: decode every queue of scope -> Vec -> charge all
after:  one forward scan per keyspace (legacy, then tenants)
          row -> QueueDiscovery::push -> completed queue? -> validate owner -> charge -> drop
  • Streaming discovery. QueueStore::discover is replaced by QueueDiscovery, 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 at finish(). Open now holds one queue at a time plus the ledger.
  • Framing only. OperationFrame::decode_queue / decode_row read 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 ones OperationQueue::decode / QueueRow::decode reject (duplicate IDs, NaN, UTF-8, trailing bytes, unresolved values). A proptest checks this, and OperationQueue::decode and OperationFrame::decode_queue share one record walk (decode_unique), so their duplicate-ID check cannot drift.
  • Fewer scans.
    • One scan covers the legacy scope.
    • One forward-only iterator covers the tenant keyspace, with at most 2 seeks per tenant.
    • Index records are read only for scopes that have a queue, and are cached per scope.
  • Layout check folded in. discovery_range spans both layouts' adjacent record kinds (a const assert checks this). A queue of the other layout still fails closed with Config, during the same scan. The separate probes (require_scope_layout, require_layout, discover_scopes) are now compiled only for tests and the benchmark feature.
  • Ledger only. Open fills the admission ledger and nothing else: publication starts with no retained queue, schedule or held entity, and each target's first attempt reads and groups its own queue (StoredQueue).
  • Removed: QueueStore::discover, finish_rows and OperationQueue::from_rows. OperationQueue::operations() is now compiled only for tests and index-lifecycle-testing.
  • Unchanged:

Behaviour changes

  • A corrupt IndexRecord in 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.
  • No config or API changes.

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.

Phase base this PR
Backlog load only (no compactor) 15.1–15.3 s, peak 2.17–2.22 GB 4.63–4.86 s, peak 1.14 GB (flat)
Full writer open (compactor running) 24.4–24.8 s, peak 4.33–4.65 GB 4.83–5.01 s, peak 1.22–1.30 GB
  • Open is about 5× faster.
  • The base column was measured on main. linear-queue-publication-drain does not touch the open-time load path (recovery.rs, queue discovery), so it applies unchanged.
  • Without the compactor, this PR's peak scales with the largest queue: about 0.85 GB at 128 MiB queues, 1.14 GB at 256 MiB. The base's peak scales with the whole backlog.
  • Open peaks above the load-only peak come from SlateDB's background compactor running during open, not from the load.

Testing (EC2)

  • cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings and cargo check --locked --workspace --all-features are clean.
  • cargo test -p db --lib: 2,082 passed. cargo test -p db --doc: 40 passed.
  • Release production targets (queue publication, index-operation queue, text queue, text correctness regressions, internal, contracts, migration, index lifecycle), one test thread each: 186 passed.
  • cargo test -p server: 79 passed.
  • scripts/validate-cargo-target-references.py passes.
  • Product image, docker-image/test.sh and index_queue_contracts.py --scenario all pass. 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.
  • New recovery_tests compare every load against a reference loader that fully decodes each queue the old way. They cover:
    • legacy and tenants 0, 7, 8 and u128::MAX, with keys on both sides of each range;
    • the map layout across the compacted run, L0 and the memtable, with partial and full acks;
    • the row layout, with interleaved generations and the sequence resume;
    • the other layout failing closed in every scope;
    • corrupt payloads, duplicate IDs, foreign keys and invalid tenant envelopes failing closed;
    • misowned tenant queues after other scopes' records are cached (this test fails if the per-scope cache goes stale);
    • an SST read failure injected at every point during open, after which every reopen reloads the same ledger and writes nothing;
    • a full writer restart that reloads the ledger exactly, starts with no queue reads, retained bytes, schedules or blocked entities, and drains each reloaded target in exactly 2 reads.
  • Production contracts add a tenant scope-walk test and duplicate-ID / corrupt-payload cases.
  • open_measurement_tests is 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-drain merges and this PR is retargeted to main.

Comment on lines +128 to +129
discovery_range(DataScope::Tenant(TenantId::from_u128(0))).start
..Bytes::from_static(&[TENANT_KEY_PREFIX + 1]),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

xav-db added 13 commits October 6, 2026 14:33
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
xav-db force-pushed the bounded-writer-open-backlog-load branch from f7bc484 to 4ab7e27 Compare October 6, 2026 13:33
@xav-db
xav-db changed the base branch from linear-queue-publication-drain to main October 6, 2026 14:22
xav-db added 2 commits October 6, 2026 15:37
…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
xav-db merged commit d6b69cf into main Oct 6, 2026
23 checks passed
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 -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant