Skip to content

Scale & Sharding

IndentiaDB's scale roadmap targets 250 million documents / billions of triples on commodity hardware. The offline memory-mapped RDF index already reads billions of triples at a flat ~2 GiB RAM footprint; the surrounding layers — live ingest, the Elasticsearch projection, vector index builds, cross-shard queries, and result serialization — are being raised to that same bar. This page describes what ships today and how to enable it.


Bounded Ingest (max_delta_quads)

Live writes accumulate in an in-memory delta overlay on top of the offline index. The overlay is hard-bounded so a runaway UPDATE stream degrades into clean backpressure instead of an out-of-memory crash:

[limits]
max_delta_quads      = 5000000   # hard cap (default); 0 disables
max_delta_quads_warn = 4000000   # WARN threshold (default)

[limits.compaction]
auto_compaction_enabled = false  # opt-in: fold the overlay into the offline index
max_delta_quads_compact = 2000000
memory_fraction_bp      = 2500   # trigger at 25% of the cgroup memory limit
min_interval_secs       = 300

An update that would exceed the cap is rejected with HTTP 413 (delta_overlay_limit_exceeded) — see the API Reference. The overlay size is observable as the ig_delta_estimated_bytes metric, and with auto-compaction enabled the server folds the overlay into the offline index before the ceiling is reached.

Auto-compaction is standalone only: in cluster mode the setting is ignored with a warning, because the fold would race the Raft apply path. Clusters reclaim overlay memory with the online garbage collection below and move bulk data into an offline index with HA base-index attach.

Online overlay garbage collection

Two growth components of the overlay can be reclaimed online and cluster-safely. Both run inside the replicated apply path and tick on the replicated log index, so identically configured nodes make identical changes at the same point in the log. Both are off by default.

Variable Default Effect
IG_VOCAB_EVICTION false Evicts local vocabulary terms that no overlay quad references anymore — for example the previous value of a literal that is rewritten over and over, including multi-kilobyte embedding strings.
IG_DELTA_TOMBSTONE_PURGE false Removes tombstones from the overlay's deleted set. Only meaningful, and only safe, without an on-disk base index: the server refuses to boot with this flag and a configured [index] path, and skips the purge while a base-index epoch is attached or journaling, because purging would resurrect deleted base quads.
IG_VOCAB_EVICTION_SCAN_INTERVAL 1000 Run the garbage-collection tick every N applied log entries.

Same settings on every node

Set these flags identically on all nodes of a cluster (same manifest). Vocabulary eviction and write pipelining are mutually exclusive: while eviction is active, every update takes the exclusive write path.


Streaming Projection Bootstrap

The Elasticsearch-compatible projection (:9200) is bootstrapped by streaming the corpus in bounded, hash-partitioned batches instead of materializing all quads in RAM — bootstrap memory is now independent of corpus size. Vector index cold builds (DiskIVF) are similarly data-parallel, with off-heap memory-mapped ID maps and keyset pagination, so building a 100M+ vector index no longer requires holding its ID maps on the heap.

On restart, both derived projections catch up incrementally from a persisted certified watermark instead of rebuilding — see Derived Projections.


Transparent Tenant Routing

With IG_TENANT_ROUTING=true, a tenant-scoped request on the normal /sparql and /update endpoints is routed to that tenant's shard — the same physical isolation as POST /cluster/tenant/{tenant}/update, without clients having to know the cluster API. The flag is off by default; with it off, request handling is unchanged.

A request is routed when all of the following hold: the node runs in cluster (multi-Raft) mode, the request's tenant resolves, and that tenant is mapped in the control plane. The tenant comes from the verified actor's tenant claim when authentication is enabled; the X-Indentia-Tenant header is honoured only when authentication is off. Routing is decided strictly after authentication and authorization.

  • Reads evaluate against the tenant's shard replica through a filter that only exposes named graphs under https://id.indentia.ai/sources/<tenant>/ (plus the tenant's urn:indentia:rag:<tenant> graph). The default graph and every other tenant's graphs are invisible. A node that does not host the shard redirects to one that does; strong reads use the shard group's read barrier.
  • Writes go through the shard leader, with WHERE evaluation restricted the same way and unconditional target-graph confinement: writes to the default graph, to foreign graphs, broad CLEAR/DROP, and LOAD are denied.
Not routed Response
Temporal SPARQL (and temporal write headers) 400
Tenants mapped as spread 409 (spread writes stay on /cluster/tenant/{tenant}/update)
The triple-level ACL wrapper is configured 503 — the two features are incompatible; disable one
Other surfaces (/sql, :9200, GraphQL, …) Whole-node path, as before

Enable only after the whole fleet is upgraded

Routed writes use a distinct mutation digest scope, so an older shard owner rejects them with 409. Turn IG_TENANT_ROUTING on only after every node runs a release that supports it.

Split-store caveat

Data written earlier through whole-node /update lives in the metadata dataset, not in the tenant's shard, and is not migrated automatically. Enabling the flag changes which store a tenant's requests see — plan a re-ingest before switching.


Distributed Queries

In a sharded cluster, POST /cluster/index/{index}/sparql (Admin) answers a SPARQL query over every shard of the index that the caller's tenant scope allows; POST /cluster/index/{index}/sparql-join always uses the materialize-and-join strategy. Nodes fan out over a dedicated Distributed Query Service (gRPC, dqs_port, default 7003, same mTLS as Raft).

Each query is answered by the first applicable strategy:

  1. Hash-shuffle join (opt-in, IG_DISTRIBUTED_SHUFFLE_JOIN) — for two-way joins: rows are hash-repartitioned on the join key across nodes, joined locally, and gathered. Response header X-IG-Join-Strategy: shuffle.
  2. Scatter-gather union (opt-in, IG_DISTRIBUTED_SCATTER_GATHER) — for union-safe queries: a SELECT whose only modifiers are a projection, optional DISTINCT/REDUCED, and optional OFFSET/LIMIT runs on every shard and is unioned centrally, with central de-duplication and limit application.
  3. Materialize-and-join (always available) — every multi-shard query that is not union-safe (joins, ORDER BY, GROUP BY, ASK, CONSTRUCT, …) and every shape the shuffle cannot run correctly. Each shard's triples are gathered — remote shards through budget-bounded subplans — and the query runs once centrally. Correct for arbitrary cross-shard joins and modifiers, but its cost grows with the data in scope. Blank-node identity is not reconciled across shards. Response header X-IG-Join-Strategy: materialize.

A query is never answered by a silent union that would drop cross-shard matches. Only lexical term text crosses node boundaries — never node-local term IDs — so no cross-node vocabulary coordination is needed.

Variable Effect
IG_DISTRIBUTED_SCATTER_GATHER Enable cross-node scatter-gather for union-safe queries
IG_DISTRIBUTED_SHUFFLE_JOIN Enable hash-shuffle joins for cross-shard join queries

Resource budgets

Remote subplans are bounded. When a request does not carry its own limits, these defaults apply; zero or unparsable values are ignored, and there is no "unlimited" setting:

Variable Default Effect
IG_DQS_DEFAULT_TIMEOUT_MS 30000 Timeout for a remote subplan
IG_DQS_MAX_MEMORY_BYTES 1073741824 (1 GiB) Memory budget per subplan
IG_DQS_MAX_RESULT_ROWS 250000 Row cap per subplan and per shuffle-join leg. On a breach the worker refuses — it never truncates.

Every stage receives the remaining budget of the query, and remote calls carry client-side deadlines, so a stalled peer surfaces as a typed timeout instead of hanging. A retryable failure (peer unavailable, timeout) tries the next copy of the shard while budget remains.

Fail-closed on incomplete or oversized results

Situation Response
A shard has no reachable copy 503 naming the missing shard, unless the caller sends X-IG-Allow-Partial: true — then the shard is skipped and X-IG-Partial: true is set
Row or memory limit exceeded 413 — narrow the query or raise the cap
Timeout budget exhausted 504
Any other remote failure, including malformed remote rows 502
X-IG-Allow-Partial: true

Production profile

Under IG_PRODUCTION, the /cluster/tenant/* and /cluster/index/* data endpoints return 503 legacy_cluster_data_lane_unsupported; production traffic uses the canonical Raft lane.


Shard Replication and Failover

Each shard runs its own Raft group. The following [cluster] settings tune how shard copies are promoted, published as in-sync, and repaired. All defaults are behaviour-neutral.

[cluster] key / environment Default Effect
promote_max_lag / IG_SHARD_PROMOTE_MAX_LAG 50 Replication lag (log entries) at or below which a shard learner counts as caught up for voter promotion. The same threshold is used by strict StartShard.
per_copy_promotion / IG_SHARD_PER_COPY_PROMOTION false Promote each learner as soon as it catches up (add-only membership changes) instead of waiting for the full desired set.
strict_shard_start false (always on under IG_PRODUCTION) Corroborate every StartShard publication against live Raft state: a copy is marked in-sync only when it is a replicated voter or a caught-up learner, otherwise 409 shard_copy_not_caught_up (the publisher retries). Outside production this requires IG_CLUSTER_INTERNAL_BEARER_TOKEN; the server refuses to boot without it.
dead_node_timeout_secs unset (off) Heartbeat-based dead-node detection. The metadata leader treats nodes silent for longer than this as dead: allocation and rebalancing avoid them and lost copies are refilled, independent of auto_rebalance. Reported as ig_cluster_dead_nodes. Enable only after the whole fleet runs a release with node heartbeats.
publish_in_sync_replace false Publish shard health by replacing the in-sync set with the led shard's live voters, so the in-sync set can also shrink.
shard_snapshot_entries / IG_RAFT_SHARD_SNAPSHOT_ENTRIES Inherits the metadata cadence Snapshot cadence for shard groups — see Snapshots and Restart Time.

Failed shard membership reconciliations back off exponentially (starting at 2 s, capped at 60 s); ig_shard_membership_sync_stuck reports 1 while a group has failed ten or more times in a row. A snapshot can be triggered on one shard group with POST /cluster/shard/{group_no}/snapshot, which returns 202 Accepted.


Control-Plane Durability

When the control-plane state (indices, tenants, shard map) is persisted to a meta WAL (IG_META_DATA_DIR), that WAL is now checkpointed and compacted: full segments are sealed as generations, a covering meta.checkpoint is written atomically with a checksum (sealed when encryption at rest is configured), and covered generations are removed. Boot loads the checkpoint and replays only the tail, so startup cost is proportional to the state plus the tail rather than the whole history. A crash in the middle of a checkpoint keeps the previous checkpoint and generations intact; a failed checkpoint is retried on the next control-plane write. An existing un-checkpointed meta WAL is adopted transparently.

Variable Default Effect
IG_META_CHECKPOINT true Master switch for creating checkpoints. An existing checkpoint is always honoured at boot.
IG_META_CHECKPOINT_RECORDS 1024 Appended records that trigger a checkpoint.
IG_META_CHECKPOINT_WAL_BYTES 8388608 (8 MiB) Appended bytes that trigger a checkpoint.
IG_META_CHECKPOINT_RECOVERY strict ignore boots despite a corrupt meta.checkpoint, starting from the surviving WAL generations and losing the covered control-plane history (tenant-to-shard routing). Refused under IG_PRODUCTION unless acknowledged with IG_PRODUCTION_RECOVERY_ACK=IG_META_CHECKPOINT_RECOVERY.

The legacy standalone per-shard WAL data lane (shard-N.wal) is retired: a node refuses to boot over record-bearing legacy shard WALs until they are exported with indentiagraph shard-wal export — see Cluster Boot Checks.


HA Base-Index Attach

The delta overlay is the wrong home for bulk data; the offline memory-mapped index is the right one. In cluster mode, the replicated base-index lifecycle (command format v8) swaps in an offline-built index without a write freeze: every replica stages a byte-identical artifact in advance, all replicas switch at the same log index, and the writes that landed while the index was being built are replayed on top. Writes keep flowing; expect only a short latency spike during publication.

All lifecycle endpoints require Admin.

  1. Begin. POST /cluster/base-index/begin {"epoch": E} — E must be greater than the attached epoch. From this log index on, every applied RDF mutation is also recorded in a replicated write journal.
  2. Export. GET /admin/export/nquads?min_snapshot_index=N streams an N-Quads export of the whole dataset (all graphs, unfiltered). Record the X-IG-Export-Snapshot-Index response header. The export may be fuzzy — the journal replay makes the result correct — and a node that has not yet applied the begin entry refuses with 409.
  3. Build the index offline, for example indentiagraph import --mode bulk-index --rdf-format nquads --file export.nq --index-output <dir>/base.
  4. Publish the built index as an indentiagraph backup whose payload contains exactly one index, and push it to object storage with indentiagraph backup push. Its manifest_sha256 is the SHA-256 of the backup's manifest.json.
  5. Prepare. POST /cluster/base-index/prepare with {"epoch": E, "uri": "s3://<bucket>/<prefix>/<backup-id>", "manifest_sha256": "…", "export_min_snapshot_index": <header value>, "total_bytes": <size>}. Every node pulls the artifact from its configured IG_S3_* object store, verifies the digest, dry-opens the index under <epoch_root>/epochs/E/, and reports back.
  6. Attach. Poll GET /cluster/base-index until every voter reports a matching verified copy, then POST /cluster/base-index/attach {"epoch": E}. Attach is refused with 409 while a voter has not prepared the artifact or the leader has not staged it. POST /cluster/base-index/abort {"epoch": E} discards a failed cycle.

Operational limits:

  • Bounded journal. The write journal holds at most 1,000,000 quads. At 95% of that, /update returns a retryable 503 until the cycle is attached or aborted; a journal that overflows anyway makes attach impossible and the cycle must be restarted.
  • Epoch storage. Staged epochs live under [index] epoch_root (default: the directory of [index] path, otherwise <data>/index).
  • Fetch failures degrade, not crash. A replica that cannot fetch or open a committed epoch keeps serving its pre-attach state and retries with backoff; the degraded status is visible in authenticated /health/details (base_index_fetch) and in GET /cluster/base-index, and does not affect readiness. Set IG_BASE_INDEX_FETCH_FATAL_AFTER_SECS to make the process exit after retrying that long instead, so the boot path re-fetches. A node that joins later and installs a post-attach snapshot fetches the epoch during the install.
  • One-way. Once a BaseIndex command has committed, the data directory requires a release with command format v8 or later — see Upgrades and Command-Format Activation.

Tenant-Spread Sharding (Experimental)

By default a tenant's data lives on one shard (routed by tenant). Spread routing partitions a single large tenant across shards by named-graph IRI — one document stays whole on one shard; only cross-document operations go cross-shard.

Experimental — not for production

Spread tenants are gated behind IG_D5_SPREAD_EXPERIMENTAL. Without the flag, mapping a tenant with spread=true is rejected (HTTP 400), and the flag itself is refused under IG_PRODUCTION=true. The write path (per-shard scatter with fail-closed routing guards) is complete; distributed read/WHERE evaluation across a spread tenant's shards is still in development.


What Ships When

Workstream Status
Bounded ingest + auto-compaction (D1) ✅ Enforced on /update; auto-compaction opt-in, standalone only
Online overlay garbage collection (vocabulary eviction, tombstone purge) ✅ Shipped, opt-in, cluster-safe
Streaming ES projection bootstrap (D3) ✅ Default behavior
DiskIVF parallel cold build, off-heap ID maps (D2) ✅ Shipped; DiskIVF recommended above ~500K vectors
Lazy result streaming (D7) ✅ Opt-in — see Streaming Results
Cross-node scatter-gather + shuffle joins (D6) ✅ Shipped, opt-in via env flags
Correct cross-shard joins via materialize-and-join, bounded subplans ✅ Default behavior for non-union-safe multi-shard queries
Transparent tenant routing on /sparql and /update ✅ Shipped, opt-in (IG_TENANT_ROUTING)
Per-shard replication parity (snapshot cadence and trigger, strict StartShard, dead-node detection) ✅ Shipped; knobs opt-in
Meta-WAL checkpoints; legacy shard-WAL lane retired ✅ Default behavior
HA base-index attach without write freeze ✅ Shipped (command format v8)
Tenant-spread sharding (D5) ⚠️ Experimental, gated
Persisted incremental full-text (Tantivy) at scale (D4) 🔜 Roadmap

See Also