Global (value-sharded) secondary indexes — internals
A regular secondary index is local per node: each node indexes only its
own shard, so a non-PK indexed read scatters IndexScan to every member,
unions candidate keys, and quorum re-reads them. A global index instead
places its entries on the ring by indexed value, so an equality probe
routes to the one replica set owning that value — a single replica-set
round-trip, like a PK point read, regardless of cluster size.
For the user-facing guide (syntax, when to choose global over local, query shapes) see INDEXING.md. This document covers the mechanism.
Measured on a 3-member RF<members cluster (2,000 probe pairs, exact correctness parity with the scatter path): routed probes win reads ~16% median / ~12% p95 over the local-index scatter; writes pay ~20–27% batch latency for the companion entry writes. At 2 members the two are at parity (the candidate re-read dominates); the routing win grows with member count, since scatter cost scales with members and the probe does not. On a full-copy cluster (RF ≥ members) a local index already answers without fan-out and a global index buys nothing.
Data model: the index is a table
A global index is an internal row table whose rows are the index entries:
key = u16 BE prefix_len ‖ values_prefix ‖ row_key
value = (empty)
The PK tail makes entries unique per row and lets a probe enumerate
candidate PKs by scanning the [values...] prefix range. Because it is a
table, it inherits every existing mechanism unchanged: ring placement +
replication + hints (replicas_for on the entry key), LWW via HLC,
anti-entropy repair (including the digest gate), backup, resharding. No
separate storage or repair machinery exists for it.
- Entry keys are self-describing for placement: every ring lookup with
table context (writes, repair ownership, reshard/joiner motion, reads)
places
__gidx__keys by the embedded VALUES prefix, so one value's entries live on one replica set — the routed-probe contract. - The entry table is named
<db>␟__gidx__<bare-index-name>. It is hidden fromSHOW TABLESand from schema replay (the index DDL implies it; replay emits theWITH (global = true)clause). - Catalog:
IndexDef.global(serde-default false, so old catalogs deserialize).SHOW INDEXESreports kindglobalwith local healthok— entries are replicated rows, so there is no per-node index state to be missing. - Readiness is part of the index's schema: schema replay emits
WITH (global = true, ready = true)once backfill completes.readyis an internal DDL option.
Write path
On a row put/delete the coordinator computes old→new entry deltas and issues companion writes to the entry table, routed by the entry keys — potentially to different nodes than the row:
- Companion writes ship at the row write's consistency, after the row write, before the ack — read-your-writes parity with local indexes.
- The old row version is fetched with one quorum point read (only on
tables that declare a global index); multi-row INSERTs on such tables
take the per-row path.
DELETEreuses the already-matched row — no extra read. - Unchanged entries produce no writes (old/new entry-key set difference,
global_entry_delta). - Single-node (Session) writes maintain entries directly in the local entry table — the local shard is the whole ring there. The replica APPLY path never touches entries.
There is no cross-key atomicity (no distributed transaction). A crash between row ack and entry write leaves a missing or orphan entry; both are self-healing:
- orphan entry → the probe's candidate re-read finds the row absent or non-matching and drops it (the standard residual re-check);
- missing entry → invisible to index reads until the repair pass regenerates it from the row (the row table is the source of truth); bounded by the anti-entropy interval.
Read path
Full-tuple equality only (plan_global_probe), consulted by the
coordinator after PK and local-index plans decline:
start/end = prefix range of encode_key([values...])in the entry table.- Route to
replicas_for(prefix)— the one replica set owning that value. - Read the entry range from that set at the statement's consistency
(
Request::EntryRange), LWW-merge per entry key, extract candidate row keys. - Resolve candidates through the standard quorum point-read + residual filter (orphan entries drop out there).
IN-list probes: every index column pinned by=/literalINexpands to one probe range per value tuple (cross product, capped at 100 ranges — past that the scatter paths win), each routed to its own value's replica set, candidates unioned into one resolve. A pin to an empty set answers empty without touching the ring.- Fallbacks to the scatter paths (still correct, just wider): an
entry-set quorum miss, a peer lacking the probe verb (mixed-version
rolling upgrade), a hot value past
GIDX_PROBE_MAX(10 000 candidates), or the index stillbuilding. - Value ranges and partial prefixes of a composite never route (hash placement) and keep the scatter paths.
- Multikey (
[]) columns produce one entry per array element (the same expansion as the local multikey index), equality-pinned probes only. EXPLAIN: accessglobal-index probe via '<name>' (routed …); cluster.fan_outglobal-index probe routed to the value's replica set.
Backfill and readiness
The DDL-coordinating node drives backfill in the background: it pages every
member's shard (ScanPage), writes entries for rows the member primarily
owns (exactly-once across members) through the normal routed write path at
QUORUM, then broadcasts GidxReady; every node flips building off and
starts routing probes. Single-node databases backfill inline before the DDL
returns.
- Convergence: readiness advances the index's schema stamp, so a node
that missed the
GidxReadybroadcast (down at the time, or freshly bootstrapped) converges on its next schema sync instead of never routing probes. Until then it keepsbuilding— routes no probes; safe, just slower. - Resumability: a repair pass that finds a global index still
buildingre-queues the backfill drive on that node; duplicate drives are idempotent LWW upserts and readiness is stamped, so whichever drive finishes first wins.
Repair
gidx_repair, part of every repair pass, is a paged two-direction
verification of entries against rows:
- heals missing entries — a missing entry silently hides its row from probes (the correctness direction);
- GCs orphans — harmless, pure waste.
On full-copy clusters everything is local point reads. At RF < members it
is a batched cross-node exchange driven by each shard's PRIMARY owner:
row-primaries derive their rows' entries and ask entry owners which exist
(KeysPresent; absentees are re-put to the entry's replica set), and
entry-primaries ask row-owners which entries are still produced
(GidxProduced — recomputed on the node that has the row) and tombstone
the rest. An unreachable owner skips the batch: silence is never treated as
absence.
Limits
- Equality/
INprobes on the full value tuple only; composite indexes require every column pinned (leftmost prefixes do not route under hash placement). Ranges and partial prefixes stay on the scatter paths. - No index-only answers: entries carry no row data, and counts go through the candidate-resolve path (an entry-only count would double-count unrepaired divergence).
INcross products cap at 100 probe ranges; values hotter than 10 000 candidate rows fall back to scatter.