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). - Every entry write carries the row version's stamp, so an entry key's last-writer-wins order is its rows': a retraction sent late by a write that lost to another coordinator's later write of the same value can no longer land over that write's put (stamped by the coordinator's clock when sent, it could, and hid the row until the next verify pass).
- Single-node (Session) writes maintain entries directly in the local
entry table — the local shard is the whole ring there. The replaced
version is read as stored, TTL-expired or not (expiry removes no
entry). New entries go in before the row, the replaced version's come
out only after the row append succeeded; a refused append (WAL error,
full disk) takes the new entries back out, so the entries stay exactly
as they were. A
DELETEremoves the row's entries after its tombstone is appended. 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.
Both directions, and CHECK INDEX, judge a row as stored: a row the
table's TTL has expired but storage still holds keeps its entries until
it is reclaimed (a probe's candidate re-read drops it meanwhile).
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.
CHECK INDEX <name> audits a global index on demand, on a node that holds
every row of its table and every entry (a single node, a full-copy
cluster): both directions from that node's copy, with counts and sample
keys, and REPAIR fixes its copy of the entry table (anti-entropy carries
the fix to the other replicas). Entries arrive as their own replicated
writes, so on a cluster member a difference whose row or entry is under
30 seconds old counts as in_flight, not drift. Where no node holds the
whole table (RF < members) it is listed skipped: sharded; the repair
pass's exchange above is the verification there.
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.