tidaldb/docs/ops/capacity-planning.md
jordan c97aaa8e5b fleet remediation: make the workspace gate runnable, then fix what it caught
`cargo test --workspace` could not run at all: dependency resolution failed with
"aws-types@1.3.16 requires rustc 1.91.1" on the 1.91.0 default toolchain, so the
gate the project documents was dead. Making it run exposed a compile break and
two wrong tests that had been invisible for months. Now green end to end:
143 suites, 3155 tests, exit 0.

Toolchain
- rust-toolchain.toml pins the DEV toolchain to 1.91.1. The published MSRV stays
  `rust-version = "1.91"` (the engine builds on 1.91.0); only tidalctl's AWS SDK
  chain needs the patch release, and it now declares that itself.

Consumer crates migrated to the current engine API (clean cutover)
- iknowyou-engine: `AgentPolicy` gained five m10 read/profile-override fields;
  the literal now spreads `..AgentPolicy::default()` as the engine's own doc
  example does, so future fields do not break it again.
- forage-engine: `RetrieveResult` gained p1 `reasons`. The app builds its own
  candidate pool, so it now tags what it knows: PreferenceMatch for the
  preference-vector blend, SemanticMatch (with the seed item) for
  similar-to-saved, ExplorationBudget for pinned discoveries.
- forage-engine: `url_to_item_id` folded into the u32 item universe. The engine
  narrows item IDs to a u32 slot in durable per-user state and rejects anything
  above u32::MAX rather than alias two items forever, so every add_item with a
  64-bit FNV hash failed. 9 of 28 smoke tests were failing on this alone.
- forage-engine: bridge items read the top-2 preference CLUSTERS via
  `query_vectors`, not the single centroid from `preference_vectors().get()`.
  Since m12 that accessor returns only the strongest cluster, so a tech+jazz user
  whose interests split into two clusters looked single-interest and never
  bridged. Falls back to top-2 dimensions when a user has one cluster.

Reconcile tests corrected to the shipped contract
- tidal/tests/m8p3_reconcile_production.rs asserted `3 + 5 == 8` for a windowed
  count after heal. `take_crdt_snapshot` deliberately keys signal contributions
  to ONE canonical contributor (ShardId::SINGLE) because signals are relayed from
  a single writer, so per-node attribution double-counted every replicated event
  on every reconcile. Merge is therefore LWW on (last_update_ns, score) plus
  PN-counter per-node max: nodes converge on the more complete accumulator. The
  old expectation was asserting the bug that fix removed.
- Rewrote to assert convergence, count survival (not 0), and no inflation, and
  added `repeated_reconcile_of_converged_nodes_does_not_creep` - the regression
  guard for the creep itself, which nothing covered.

Pre-commit hook unified
- hooks/pre-commit dropped `-D warnings`: each crate's `[lints]` table is the
  source of truth (`clippy::all`/`unwrap_used` deny, `pedantic` warn), and the
  flag promoted ~58 deliberate pedantic warnings in integration tests to errors,
  making every Rust commit impossible.
- It now lints all five tidal crates instead of path-matching `tidal/`, which
  silently skipped tidal-server, tidal-net, tidal-stress, tidalctl and
  applications/ - the rot above lived in exactly those crates. Ported the
  CODING_GUIDELINES file-length, println, and unsafe-SAFETY checks from the
  divergent untracked copy that this replaces.
- CONTRIBUTING.md now documents the real commands and the toolchain/MSRV split.

Fleet recovery and soak
- scripts/restore-fleet.sh: the fail-closed selective restore, promoted out of an
  ignored tmp/ directory into the repository. Preflights retained storage,
  digest-pinned images, parked state, and aggregate plus per-PV-node scheduler
  headroom before the first scale; writes a durable transcript under
  tmp/restore-logs/ with structured start/error/rollback/complete events.
- k8s manifests park the standalone store, the RF3 cluster, and the soak monitor
  at zero replicas with restore-fleet.sh as the only supported scale-up path.
- soak-eval/soak-watch and the nightly CronJob fail closed on stale or missing
  restart evidence instead of silently skipping the restart-aware half of the gate.
- docs/ops/capacity-planning.md corrects the RAM envelope to the real hot-tier
  formula and separates analytic totals from the measured process envelope.
2026-08-16 12:38:14 -06:00

237 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Capacity Planning
This document provides RAM, disk, and startup time estimates for tidalDB deployments. Use these tables to provision hardware before going to production.
All estimates assume a single-node deployment with default configuration (30-second checkpoint interval, f16 vector quantization, DashMap-based hot tier).
---
## RAM Capacity
tidalDB is an in-memory-first database. USearch HNSW indexes, the signal ledger hot tier, and Tantivy reader segments all reside in RAM during operation. There is no swap tolerance for USearch -- if the process is swapped, ANN query latency degrades from microseconds to seconds.
| Items | Embedding Dims | USearch RAM | Signal Ledger RAM (10 active signals) | Tantivy RAM | Analytic Total |
|------:|---------------:|------------:|--------------------------------------:|------------:|---------------:|
| 100K | 128D | ~31 MB | ~1.09 GB | ~50 MB | ~1.17 GB |
| 100K | 768D | ~184 MB | ~1.09 GB | ~50 MB | ~1.32 GB |
| 100K | 1536D | ~369 MB | ~1.09 GB | ~50 MB | ~1.51 GB |
| 1M | 128D | ~307 MB | ~5.44 GB (hot-tier cap) | ~200 MB | ~5.95 GB |
| 1M | 768D | ~1.84 GB | ~5.44 GB (hot-tier cap) | ~200 MB | ~7.48 GB |
| 1M | 1536D | ~3.69 GB | ~5.44 GB (hot-tier cap) | ~200 MB | ~9.33 GB |
| 10M | 128D | ~3.07 GB | ~5.44 GB (hot-tier cap) | ~500 MB | ~9.01 GB |
| 10M | 768D | ~18.43 GB | ~5.44 GB (hot-tier cap) | ~500 MB | ~24.37 GB |
| 10M | 1536D | ~36.86 GB | ~5.44 GB (hot-tier cap) | ~500 MB | ~42.80 GB |
### Formulas
**USearch HNSW index:**
```
items * dims * 2 bytes (f16 quantization) * 1.2 (HNSW graph overhead)
```
The 20% graph overhead accounts for HNSW neighbor lists (M=16 default, two layers). Actual overhead varies with M and ef_construction parameters.
**Signal ledger hot tier:**
```
min(items * active_signal_types_per_item, DEFAULT_MAX_SIGNAL_ENTRIES)
* ~1,088 bytes/entry
```
Each `(entity_id, signal_type_id)` entry in the DashMap holds the running decay
score, windowed counters (BucketedCounter with minute and hour buckets),
velocity state, and the DashMap per-shard overhead. The 1,088 bytes/entry figure
was measured in the m7p3 scale benchmarks. The table uses ten active signal
types per item and the default 5M-entry hot-tier ceiling. Without trimming, the
signal column would be ~10.88 GB at 1M items and ~108.8 GB at 10M items.
The signal ledger has a memory budget of 5M entries
(`DEFAULT_MAX_SIGNAL_ENTRIES`, ~5.44 GB). When exceeded, the checkpoint thread
evicts cold entries (oldest `last_update` timestamp). If your workload has more
than 5M active `(entity, signal_type)` pairs, cold entries will be served from
fjall checkpoints (slower, but correct).
**Tantivy text index:**
Tantivy's RAM usage depends on the number of indexed documents, average document length, and the number of open reader segments. The estimates above assume short metadata fields (title + description, ~200 bytes average). Long-form content indexing will increase RAM proportionally.
### Notes
- Signal ledger RAM is for the in-memory hot tier only. The WAL and fjall checkpoints add disk usage, not RAM.
- The table assumes ten active signal types per item. Below the 5M-entry hot-tier
cap it scales linearly; beyond the cap, additional cold entries trade RAM for
checkpoint-backed lookup cost.
- USearch RAM is the dominant index cost at high dimensionality. If you use 1536D embeddings (e.g., OpenAI text-embedding-3-large), budget from the complete analytic total and measured process envelope, not the vector column alone.
- The analytic table excludes process/runtime overhead, replication and catch-up
buffers, allocator fragmentation, and concurrent query/write working memory.
It is a sizing input, not a container limit. Use the measured envelope below
for the multi-process cluster.
---
## Disk Capacity
Disk usage comes from three sources: fjall LSM-tree storage (metadata, relationships, signal checkpoints), WAL segments (append-only signal event log), and Tantivy/USearch index files.
| Items | Metadata Size | Signal Events/Day | Disk/Day (WAL) | Fjall (90 days) | Total (90 days) |
|------:|:----------------|------------------:|----------------:|----------------:|----------------:|
| 100K | small (256B avg) | 50K | ~2 MB | ~1 GB | ~1.2 GB |
| 1M | small | 500K | ~20 MB | ~10 GB | ~11.8 GB |
| 10M | small | 5M | ~200 MB | ~100 GB | ~118 GB |
### Formulas
**WAL daily growth:**
```
signal_events_per_day * ~40 bytes/event
```
Each WAL entry contains: 4-byte magic, 8-byte sequence, 1-byte event type, 8-byte entity ID, 2-byte signal type ID, 8-byte timestamp, 8-byte weight (f64), 32-byte BLAKE3 checksum. WAL segments are compacted after each successful checkpoint (every 30 seconds), so WAL disk usage represents only the uncompacted tail, not cumulative growth.
**Fjall storage:**
```
items * metadata_avg_bytes * 1.5 (LSM write amplification)
```
The 1.5x amplification factor accounts for LSM-tree space amplification (multiple sorted runs before compaction merges them). Actual amplification depends on the compaction strategy and write pattern. Signal checkpoints are also stored in fjall -- add ~100 bytes per active `(entity, signal_type)` pair for the serialized checkpoint data.
**Tantivy and USearch on disk:**
- Tantivy: roughly 1.5-2x the raw text size after indexing (inverted index + postings + term dictionary).
- USearch: saved index files are approximately the same size as the in-memory representation (items * dims * 2 bytes + graph metadata).
### WAL Compaction
WAL segments older than the last successful checkpoint are automatically deleted by the checkpoint thread (every 30 seconds). Under normal operation, WAL disk usage stays bounded at roughly `signal_rate * 40 bytes * 30 seconds`. Monitor `tidaldb_wal_lag_bytes` -- if it grows unbounded, checkpointing may be failing (check `tidaldb_checkpoint_failures_total`).
---
## Startup Time
Startup involves: opening fjall keyspaces, restoring the signal ledger from checkpoint, replaying WAL events since the last checkpoint, rebuilding in-memory indexes (bitmap, range, universe, creator-items, collections, suggestions), and loading USearch vector indexes.
| Items | Vectors | Typical Startup |
|------:|--------:|:----------------|
| 100K | 100K | ~2-5 sec |
| 1M | 1M | ~15-45 sec |
| 10M | 10M | ~3-8 min |
### Dominant Costs
1. **USearch index load** is the dominant startup cost at 1M+ vectors. USearch rebuilds the HNSW graph from its serialized format. Progress is logged every 10K vectors.
2. **Signal ledger restore** reads the checkpoint from fjall (a single prefix scan of `Tag::Sig` keys), then replays any WAL events with sequence numbers higher than the checkpoint's `wal_sequence`. Time is proportional to the number of active signal entries + unreplayed WAL events.
3. **Entity state rebuild** scans the items and users keyspaces to reconstruct creator-items bitmaps, relationship indexes (follows, blocks, hides), and interaction weights. Progress is logged every 10K items.
4. **Suggestion index rebuild** scans all item metadata for "title" fields and indexes terms for autocomplete. This is a sequential scan -- fast for 100K items, noticeable at 10M.
5. **Collection index rebuild** reconstructs collection membership bitmaps from fjall.
### Notes
- Startup time is I/O-bound, not CPU-bound. Fast NVMe storage reduces startup time significantly compared to spinning disk.
- WAL replay time depends on how many signals were written since the last checkpoint (at most ~30 seconds of writes under normal operation).
- Tantivy indexes are opened directly from disk (memory-mapped) and do not require a rebuild step.
---
## Recommended Provisioning
**General rule:** provision 2x the estimated RAM for headroom.
| Scale | Recommended RAM | Recommended Disk | CPU Cores |
|:---------|:----------------|:-----------------|:----------|
| 100K items, 128D | 512 MB | 5 GB SSD | 2 |
| 100K items, 768D | 1 GB | 5 GB SSD | 2 |
| 1M items, 128D | 4 GB | 25 GB SSD | 4 |
| 1M items, 768D | 8 GB | 25 GB SSD | 4 |
| 10M items, 128D | 32 GB | 250 GB NVMe | 8 |
| 10M items, 768D | 64 GB | 250 GB NVMe | 8 |
| 10M items, 1536D | 96 GB | 250 GB NVMe | 16 |
### Why 2x headroom?
- Signal ledger entries grow as new `(entity, signal_type)` pairs are written. The hot tier can hold up to 5M entries before trimming kicks in.
- Tantivy segment merges temporarily double the index size during merge operations.
- USearch does not support incremental resize -- if you approach capacity, you need enough free RAM to hold both the old and new index during a potential rebuild.
- The Rust allocator (jemalloc or system) has its own fragmentation overhead.
### Swap
Do not configure swap for production tidalDB instances. USearch HNSW traversal accesses memory in a random-access pattern that defeats page-level caching. A single swapped page in the HNSW graph can turn a 50-microsecond ANN query into a 50-millisecond disk seek.
### Disk Type
SSD is strongly recommended for all deployments. NVMe is recommended at 10M+ items. The WAL uses synchronous `fsync` on every segment rotation, and fjall's journal uses `persist(SyncAll)` during checkpoint. Spinning disk latency on these operations directly impacts signal write throughput.
---
## Cluster (Ref-A 3-node fleet) — measured capacity
The tables above are single-node, analytic estimates. This section is the **measured operating envelope** of the live cluster: **Ref-A = 3 nodes × 4 vCPU / 16 GiB**, full-placement RF3 (every pod replicates every shard group), 100k × 1536-D corpus. These are real `tidal-stress` numbers, not formulas. They are **Ref-A figures** — the enterprise **Ref-B** target is **≥5 × 8 vCPU / ≥16 GiB** nodes with **partitioned** placement (each pod hosts a subset of groups). See `docs/profiling/m12-cluster-deploy-findings.md` and `docs/profiling/m12p4-t5-sharded-throughput.md`.
### Read throughput
| metric | value | gate | verdict |
|--------|-------|------|---------|
| read p99 @ 100500 rps | **7.9711.47 ms** | ≤ 10 ms (G1) | **MET** @ 100k/1536-D |
| recall@10 | **0.9989** | ≥ 0.95 | **MET** |
| read ceiling (clean) | **~1000 read-ops/s** | — | CPU-bound |
| read ceiling (saturated) | **~1500 read-ops/s** | — | shed/error past here |
- Reads are CPU-bound: each read scatters **3 parallel HNSW searches** (one per shard group, full placement).
- Read throughput scales **~linearly with node count** on full placement — every node serves reads from its local replica of every group.
- Spread reads **round-robin across all 3 pods**; do not pin to the leader.
### Write throughput
| metric | value | note |
|--------|-------|------|
| write knee | **~250 rps in the clean sweep** | one benchmark, not a sustained-safe rate |
| nightly 200 rps soak | **23 PASS / 32 FAIL (42% green)** | not sustained with margin; gate parked |
| write scaling vs node count | **~1.0×** | does NOT scale at full-placement RF3 |
- Write tput does **NOT** scale with node count at full-placement RF3: every per-shard quorum spans all 3 nodes, so **every follower applies every 1536-D write**. Adding nodes adds replication work, not write capacity (~1.0×, not 2.5×).
- The old T5 **2.5× write-scaling** target needs **≥5 nodes + PARTITIONED placement**. T5 / G-S has been **re-scoped to read-throughput scaling**.
### Per-pod memory at 1536-D
| corpus / workload | observed per-pod working set | configured limit at observation | verdict |
|-------------------|------------------------------|---------------------------------|---------|
| 100k idle baseline | **~3.58 GiB** | 4 GiB | only ~420 MiB load/recovery headroom |
| 100k / 200 rps soak | **3.97-4.00 GiB before OOMKill** | 4 GiB | limit is insufficient |
| 1M × 1536-D estimate | **~7-8 GiB before runtime overhead** | — | requires Ref-B measurement |
- Full placement means each pod holds the whole corpus.
- Four independent historical windows show `OOMKilled` at 3.97-4.00 GiB, followed
by snapshot-required reseed self-restarts. The exact internal growth source is
not yet isolated; signal hot-entry count stayed flat, so do not label it a leak
without allocation/heap evidence.
- The corrected canary envelope is a 4 GiB request and 6 GiB limit (measured peak
plus 50% recovery/profiling headroom). Profile the exact 100k/1536-D/200-rps
trajectory before treating 6 GiB as a production ceiling.
### Startup / boot
- HNSW rebuild/load at 1536-D is **CPU-bound** (~5 min single-core at 100k); 1M scales up from there.
- `startupProbe` budget is **~20 min** (`failureThreshold` 240 × 5 s).
- Graceful shutdown **saves the graphs** (grace **600 s**), so a clean restart **skips the rebuild** (load, not rebuild). A SIGKILL/crash skips the save → next boot rebuilds.
### Pod resources (corrected canary manifest)
| resource | request | limit |
|----------|---------|-------|
| CPU | 2 | 3 |
| memory | 4 GiB | 6 GiB |
| PVC | — | 5 GiB/pod, `local-path` |
- PVC is **local NVMe** (`local-path`) — longhorn's fsync overhead was unacceptable for the WAL path.
### Ref-B target (enterprise)
These are Ref-A figures. The enterprise **Ref-B** target is **≥5 × 8 vCPU / ≥16 GiB** nodes with **partitioned** placement — the shape required to (a) scale writes past ~1.0×, and (b) hold a 1M × 1536-D corpus without OOM.