# Resource Limits and Back-pressure BenoStreamDB is a long-running process, so every resource it can exhaust has an explicit admission limit. This is the single reference for the knobs; each one is also documented at its implementation site. The design principle throughout: **throttle, then fail with a clear error** — never grow until the OS intervenes. Where a limit is hit the engine either blocks (resuming automatically) or returns an error, and in both cases emits telemetry. Every limit is **active by default**. There is no "off unless configured" switch: an unset environment variable means "use the memory-derived default", not "disable the guard". This is what lets the same binary run safely in a 4 GB container and scale up to a large host. ## File descriptors (`nofile`) The reader opens Parquet segment files on demand, and background compaction can rewrite segments while reads are saturated, so the transient open-file count is `read_concurrency × segment_files` plus compaction handles. The process default (`ulimit -n`, often 1024 in containers) is too low and surfaces as `Too many open files (os error 24)` — a failed query. - **Requirement:** raise the descriptor limit to **≥ 65536** for the process running BenoStreamDB (`LimitNOFILE=65536` in the systemd unit, `ulimit -n 65536` in a shell, or the container `nofile` ulimit). The benchmark runner sets this (`benchmarks/competitors/docker-compose.bench.yml`). - **Engine behaviour:** a transient EMFILE during a segment read is retried with a short exponential backoff (see `HybridReader::parquet_reader`) instead of failing the query outright — but the limit should still be raised; the backoff is a safety net, not a substitute for the ulimit. ## How the memory knobs interact All memory limits are derived from one number — the memory actually available to the process — and then layered so that *admission*, *reclamation*, and *concurrency* all scale together. ### The ingest back-pressure loop ```mermaid flowchart TD W["write_async(batch)"] --> G{"RSS ≥ 0.8·M ?
(BSDB_MAX_INGEST_RAM_GB)"} G -- "no" --> BUF["write buffer
memtable + WAL"] G -- "yes" --> PARK["writer parks
tokio::Notify + 250 ms poll"] PARK -. "memory_reclaimed" .-> G BUF -- "flush
(BSDB_CACHE_GB, default 1 GB)" --> SEG["segment write
data parquet + stats"] SEG -- "spawn one build per segment" --> GATE["index-build gate
clamp(M / 8 GiB, 1, nproc)
(BSDB_INDEX_BUILD_CONCURRENCY)"] GATE -- "permit (bounded concurrency)" --> BUILD["build HNSW / TQ4·TQ8 / BM25 / CSR"] BUILD -- "release permit" --> NOTIFY["notify(memory_reclaimed)"] NOTIFY -. "wakes parked writer" .-> PARK ``` `M = effective_memory_bytes()`; `RSS` is the process's own `VmRSS`. Admission and reclamation share the same default (`0.8·M`), so the moment a writer parks is also the moment the next unit boundary attempts reclamation — keeping the pause as short as the allocator allows. ### 1. Detection: `effective_memory_bytes()` `core::resources::effective_memory_bytes()` resolves, in order: 1. **cgroup v2** — `/sys/fs/cgroup//memory.max` (the process's own cgroup from `/proc/self/cgroup`, then the root path). `max` means "no limit". 2. **cgroup v1** — `/sys/fs/cgroup/memory//memory.limit_in_bytes`. The `0x7FFF_FFFF_FFFF_F000` sentinel means "no limit". 3. **Process `RLIMIT_AS`** — a hard address-space ceiling set by `ulimit -v` or a sandbox. This is the only per-process limit on macOS, which has no cgroups (it is usually unlimited there, in which case host RAM is used). 4. **Host *available* memory** — `/proc/meminfo` `MemAvailable` (Linux), falling back to `MemTotal` (Linux) or `sysctl hw.memsize` (macOS). Available, not total: on a shared machine `MemTotal` overstates what this process can allocate, so guards sized from it never trip before the OS OOM-killer does (the demo load was killed at ~74 GB RSS on a 121 GiB host because the derived high-water mark was ~102 GB). A cgroup limit or `RLIMIT_AS` ceiling is a hard cap and is used as-is; only the host-RAM fallback switches to available. 5. **Fallback** — `FALLBACK_MEMORY_BYTES` = **4 GiB**, so an environment where nothing can be detected is treated as a small container rather than an unbounded host. RSS is read from the same place on every platform — `/proc/self/status` `VmRSS` on Linux, `task_info(MACH_TASK_BASIC_INFO)` on macOS — so the back-pressure high-water mark and the heap-trim policy agree on what "over budget" means. This is the key fix for serverless: a 4 GB container on a 32 GB host reports **4 GB**, not 32 GB, so the guards trip at the right point instead of letting the container OOM. ### 2. Derivation: the memory knobs Let `M = effective_memory_bytes()` and `F = MEMORY_BUDGET_FRACTION = 0.8`. | Knob | Default | Role | | --- | --- | --- | | `BSDB_MAX_INGEST_RAM_GB` | `F × M` (decimal GB) | **Admission.** `write_async` blocks before accepting more batches while RSS ≥ this. | | `BSDB_INGEST_MEMORY_BUDGET_GB` | `F × M` (bytes) | **Reclamation.** At each committed ingest unit, if RSS ≥ this, the heap is trimmed and blocked writers are notified. | | `BSDB_INDEX_BUILD_CONCURRENCY` | `clamp(M / 8 GiB, 1, nproc)` | **Concurrency.** Caps how many segment index builds run at once, which caps peak RSS. | | `BSDB_DATAFUSION_MEMORY_GB` | `DATAFUSION_MEMORY_FRACTION × M` (0.5) | **Query.** Caps DataFusion's working set for SQL sorts/joins/aggregations, which then spill to disk. Applied to `Table.execute_sql` (the graph-UDF path) and to `Session`. | Each knob is independent: there is deliberately **no fixed percentage split** across the subsystems, so an operator can tune one to the actual workload without being forced to rebalance the others. They reference the same `M`, so they move together: - **Admission vs. reclamation.** The high-water mark and the trim budget share the same default (`0.8 × M`). The high-water mark is the *hard* stop — a writer parks until memory is freed. The trim budget is the *soft* trigger — it decides when a background task bothers to return freed pages and wake the parked writer. Because they coincide, the moment a writer parks is also the moment the next unit boundary attempts reclamation, so the pause is as short as the allocator allows. Setting the trim budget *below* the high-water mark makes reclamation more eager (more trims, shorter pauses); setting it *above* means the writer parks first and waits for the fallback poll. - **Concurrency vs. admission.** The build gate bounds how many builds are in flight, and therefore how fast RSS climbs toward the high-water mark. On a small container the gate is `1`, so a single build's working set is the whole budget; on a large host it fans out. Raising `BSDB_INDEX_BUILD_CONCURRENCY` without raising the high-water mark just makes writers park more often. - **Explicit overrides win.** Any of the three can be set independently; a non-positive value is ignored (the derived default applies) so a guard can never be accidentally disabled. ### 3. Worked examples | Environment | `M` | `BSDB_MAX_INGEST_RAM_GB` | `BSDB_INGEST_MEMORY_BUDGET_GB` | `BSDB_INDEX_BUILD_CONCURRENCY` | | --- | --- | --- | --- | --- | | 4 GB container | 4 GiB | ~3.4 GB | ~3.4 GB | 1 | | 16 GB container | 16 GiB | ~13.7 GB | ~13.7 GB | 2 | | 32 GB host | 32 GiB | ~27.5 GB | ~27.5 GB | 4 | | 128 GB host | 128 GiB | ~110 GB | ~110 GB | 16 (capped at `nproc`) | ## Memory | Knob | Default | Effect | | --- | --- | --- | | `BSDB_MAX_INGEST_RAM_GB` | `0.8 × effective memory` | Ingest RAM high-water mark. When RSS exceeds it, `write_async` blocks before accepting more batches and resumes the instant a background task frees memory (a segment index build finishing, or an ingest heap trim) — woken by `tokio::sync::Notify` with a 250 ms bounded fallback poll. | | `BSDB_INGEST_MEMORY_BUDGET_GB` | `0.8 × effective memory` | Heap-trim budget. Returning freed pages to the OS is an allocator concern, so see `core::memory` for the rationale. Also settable per ingest via `IngestOptions::memory_budget_bytes` / `bsdb table ingest --memory-budget-gb`. | | `BSDB_INDEX_BUILD_CONCURRENCY` | `clamp(effective memory / 8 GiB, 1, nproc)` | Maximum concurrent segment index builds. Each build holds its segment's vectors plus the HNSW/IVF/quantizer structures — several GB at a 1 GB flush size. Unbounded, the runtime fans out one build per worker thread, which is what OOM-killed the Wikipedia load at 105 GB RSS. Always bounded; a non-positive value is ignored. | | `BSDB_DATAFUSION_MEMORY_GB` | `0.5 × effective memory` | DataFusion query memory limit. SQL operators spill to disk at this limit instead of OOMing. Applied to `Table.execute_sql` — where the `subgraph`, `communities`, and BFS UDFs run — and to `Session` (which also accepts an explicit `limit_bytes`). Independent of the ingest budget; a non-positive value is ignored. | Observability: `benostreamdb_ingest_rss_bytes`, `benostreamdb_ingest_backpressure_pauses_total`, `benostreamdb_ingest_backpressure_pause_seconds`, `benostreamdb_index_build_gate_wait_seconds`. A rising gate-wait time means the build gate, not the CPU, is the throughput limit. ## Disk | Knob | Default | Effect | | --- | --- | --- | | `BSDB_MIN_FREE_DISK_GB` | `1` | Free-space admission threshold for local (`file://`) tables. A write is refused with a descriptive error when the target filesystem is below it, so the failure happens *before* any bytes are written rather than mid-segment. Fail-open: if the free space cannot be queried, the write proceeds. A non-positive value is ignored. | Observability: `benostreamdb_free_disk_bytes`, sampled at each flush. The check walks up to the nearest existing ancestor of the staging path, so a table directory that does not exist yet still resolves to its parent filesystem (`core::resources`). ## CPU / concurrency Parallelism is bounded wherever work is fanned out, rather than left to the runtime's worker count: | Site | Bound | | --- | --- | | Segment index builds | `BSDB_INDEX_BUILD_CONCURRENCY` (`Table::index_build_gate`) | | Parallel segment reads during a vector search | `auto_detect_parallel_readers` semaphore (`core::query`) | | Native bulk ingest | `IngestOptions::parallelism` (default 4) | | Vector aggregation / GPU dispatches | one block per row, fixed block size (no unbounded grid) | ## Index, manifest & maintenance tuning | Knob | Default | Effect | | --- | --- | --- | | `BSDB_HNSW_M` | `16` | HNSW per-node neighbour count (`M`) for index builds. | | `BSDB_HNSW_EF_CONSTRUCTION` | engine default | HNSW build beam width (`ef_construction`). | | `BSDB_HNSW_N_LISTS` | size-derived | IVF list count for HNSW-IVF builds (more lists = smaller, faster-to-build buckets). | | `BSDB_DRIFT_MEMORY_MB` | derived memory budget | Memory budget for drift search (`graph_view`). | | `BSDB_MANIFEST_MAX_FILES_BEFORE_CONSOLIDATION` | `32` | Append-only manifest files accumulated before a full-rewrite consolidation. Bounds read cost while amortizing small appends. | | `BSDB_VACUUM_MIN_FILE_AGE_SECS` | `60` | Grace period before vacuum may delete an unreferenced file; closes the GC-vs-writer race. `0` disables the grace period. | | `BSDB_NVRTC_PATH` | auto-discovered | Explicit path (file or directory) to the CUDA NVRTC library for GPU index builds. | ## Containers The engine reads the container's cgroup limit directly, so no configuration is needed to run in a 4 GB container — the defaults above are already sized for it. `docker-compose.production.yml` sets `SEARCH_MEMORY`/`FLIGHT_MEMORY` (default `4G`) and `SEARCH_CPUS` (default `2.0`); the engine's derived defaults follow those limits. To pin a value explicitly, set the corresponding `BSDB_*` variable in the service's `environment:` block. ## Validation Memory and disk behaviour under saturation is asserted by [`tests/stress/`](../tests/stress/README.md) — a low ingest high-water mark must pause and resume rather than OOM, and many commit cycles must stay durable. The long-running variants run on push via [`.github/workflows/soak.yml`](../.github/workflows/soak.yml).