10 min read 5 sections

Incremental Index Hydration from Change Streams

A snapshot is a photograph of a moving subject. Between the moment it was taken and the moment a node finishes loading it, fences have been created, edited and deleted, and the node’s index is wrong by exactly those edits. Closing that gap is the catch-up phase, and it is the step that decides whether snapshot-based warm start is actually faster than rebuilding: at a naive 1,400 edits/sec, a one-hour-old snapshot takes 103 seconds to catch up, which is longer than the 94-second rebuild it was supposed to replace. This page sits under spatial index persistence and warm start within Spatial Indexing for Real-Time Checks.

Concept and specification

Catch-up replays a change stream from the snapshot’s watermark to the current head. Three guarantees are required and they are not all free.

Per-fence ordering. Edits to one fence must be applied in the order they occurred, or a create-then-delete pair applied backwards leaves a deleted fence in the index forever. Ordering across fences is unnecessary, which is what allows the parallelism the throughput depends on — the same argument as the partition key choice makes for triggers, applied to state.

Idempotency. A change stream redelivers, so applying an edit twice must leave the same state as applying it once. An upsert keyed by fence id is naturally idempotent; a “translate this fence 5 m north” delta is not, and delta-encoded change streams are therefore unusable for hydration without a sequence check.

Completeness. The stream must be known to start at or before the watermark. A stream whose retention has expired past the watermark cannot close the gap at all, and the correct response is to abandon the snapshot and rebuild rather than to hydrate into a hole.

The crossover age is the snapshot age past which rebuilding is faster, and it is the number the snapshot cadence should be set from. Raising moves it out proportionally, which is why the throughput work below matters as much as the cadence.

Apply strategy Throughput Crossover age at 40 edits/s Ordering preserved
One edit at a time, immediate rebuild of affected node 340/s 13 min Yes
One at a time, deferred node rebalance 1,400/s 55 min Yes
Batched by 500, sorted by fence id 6,900/s 4 h 30 min Yes
Batched + parallel across fence-id ranges 11,000/s 7 h 10 min Yes
Crossover age at 40 edits/s — 4 options Each panel scales on its own, so Crossover age at 40 edits/s are compared across 4 options without sharing an axis they do not share a unit with. Crossover age at 40 edits/s — 4 options Apply strategy — the table above, drawn to scale One edit at a time, immediate rebuild… One at a time, deferred node rebalance Batched by 500, sorted by fence id Batched + parallel across fence-id ra… Crossover age at 40 edits/s 13 min 55 min
Crossover age at 40 edits/s for One edit at a time, immediate rebuild of affected node, One at a time, deferred node rebalance, Batched by 500, sorted by fence id and 1 more, drawn from the measurements in the table above. Each panel carries its own scale, so the shape of the gap is comparable even where the units are not.

Step-by-step implementation

1. Coalesce before applying. A fence edited fifteen times during the snapshot’s age needs its final state applied once, not fifteen times. Collapsing the batch by fence id, keeping the last edit per fence, is the single largest win available and it is pure bookkeeping.

python
from __future__ import annotations
from dataclasses import dataclass

@dataclass(slots=True, frozen=True)
class Edit:
    fence_id: str
    seq: int            # per-fence monotonic sequence from the source
    op: str             # "upsert" | "delete"
    payload: bytes | None

def coalesce(batch: list[Edit]) -> list[Edit]:
    """Keep only the highest-sequence edit per fence. A fence edited fifteen
    times during the snapshot's age needs its final state applied once."""
    latest: dict[str, Edit] = {}
    for e in batch:
        prev = latest.get(e.fence_id)
        if prev is None or e.seq > prev.seq:
            latest[e.fence_id] = e
    return list(latest.values())

2. Apply as a bulk mutation, not as individual inserts. An R-tree that rebalances after every insert does of structural work for a batch that could be applied as one bulk update. Defer the rebalance to the end of the batch, following the bulk-load discipline in optimizing R-tree bulk loads for real-time ingestion.

3. Parallelise across fence-id ranges, never within one. Hashing fence ids into $k$ ranges and applying each range on its own worker preserves per-fence order for free while giving near-linear speedup — the index’s per-range subtrees do not interact until the final merge.

4. Track the watermark as the minimum across workers. With parallel application the node is caught up only to the slowest range. Publishing the maximum instead is the bug that lets a node declare readiness while one range is still behind.

python
def catchup_watermark(per_range: dict[int, int]) -> int:
    """Caught up to the SLOWEST range, never the fastest."""
    return min(per_range.values()) if per_range else 0

5. Verify the sequence is contiguous per fence. A gap means the stream lost an edit — retention expiry, a producer bug, a topic misconfiguration — and hydrating past a gap produces an index that is wrong in a way no later check detects. Abandon the snapshot and rebuild.

6. Apply against a shadow, promote atomically. Hydrating the live index means queries see a partially applied batch. Build the updated structure alongside and swap it in with the copy-on-write promotion described in async index updates without locking, so readers move from one consistent state to the next.

Benchmark and verification

Measured hydrating a 60-second-old snapshot on a fleet taking 40 fence edits/sec, and a 60-minute-old one for comparison:

Strategy 60 s snapshot 60 min snapshot Edits coalesced away Peak memory over steady
Naive, one at a time 7.1 s 103.0 s 0% +4%
Coalesced 4.9 s 41.0 s 62% +5%
Coalesced + batched 2.2 s 11.0 s 62% +11%
Coalesced + batched + parallel 1.7 s 7.4 s 62% +19%
60 s snapshot, 60 min snapshot, Edits coalesced away — 4 options Each panel scales on its own, so 60 s snapshot, 60 min snapshot, Edits coalesced away are compared across 4 options without sharing an axis they do not share a unit with. 60 s snapshot, 60 min snapshot, Edits coalesced away — 4 options Strategy — the table above, drawn to scale Naive, one at a time Coalesced Coalesced + batched Coalesced + batched + parallel 60 s snapshot 7.1 s 4.9 s 2.2 s 1.7 s 60 min snapshot 103.0 s 41.0 s 11.0 s 7.4 s Edits coalesced away 0% 62% 62% 62%
60 s snapshot, 60 min snapshot and 1 more for Naive, one at a time, Coalesced, Coalesced + batched and 1 more, drawn from the measurements in the table above. Each panel carries its own scale, so the shape of the gap is comparable even where the units are not.

The coalescing column explains most of the improvement on the older snapshot: 62% of edits in an hour’s window are superseded by a later edit to the same fence, so applying them is pure waste. That figure is a property of how fences are edited — an operator adjusting a zone saves repeatedly — and it rises with snapshot age, which is why coalescing helps the stale case disproportionately.

The peak-memory column is the cost. Parallel hydration into a shadow structure holds the old index, the new one and the pending batches simultaneously, peaking 19% above steady state — well inside a typical container’s headroom, but worth sizing for, since being OOM-killed during hydration produces the crash loop described in the parent topic.

Verify with a three-way comparison: hydrate from a snapshot, rebuild from the source of truth, and assert the two indexes answer identically over a large sample of query points. That test catches ordering bugs, coalescing bugs and gap-handling bugs at once, and it is worth running in CI against a recorded change stream rather than only in staging.

Failure modes and edge cases

Failure mode Signature Mitigation
Delete applied before its create Deleted fence resident forever Order per fence by source sequence, not arrival
Watermark published as the maximum Node ready while one range lags Publish the minimum across workers
Sequence gap ignored Index silently missing an edit Verify contiguity; rebuild on a gap
Delta-encoded edits Redelivery applies the delta twice Require full-state upserts, or check the sequence
Hydrating the live index Queries see a half-applied batch Hydrate a shadow and promote atomically
Stream retention shorter than snapshot age Catch-up cannot start Compare the stream’s earliest offset against the watermark and rebuild
Failure mode at a glance: Signature, Mitigation A row per failure mode, a column per option, so a single axis can be compared across options in one sweep. Failure mode at a glance: Signature, Mitigation the same trade-offs, read across instead of down Signature Mitigation Delete applied before its create Deleted fence resident forever Order per fence by source sequence, not arrival Watermark published as the maximum Node ready while one range lags Publish the minimum across workers Sequence gap ignored Index silently missing an edit Verify contiguity; rebuild on a gap Delta-encoded edits Redelivery applies the delta twice Require full-state upserts, or check the sequence Hydrating the live index Queries see a half-applied batch Hydrate a shadow and promote atomically Stream retention shorter than snapshot age Catch-up cannot start Compare the stream's earliest offset against the watermark and rebuild
The failure mode table read across rather than down — each row is one axis of the decision, colour-keyed so a single trade-off can be followed across Signature, Mitigation.

The last row is the one that turns a warm start into a silent correctness failure if it is not checked. If the change stream’s retention is six hours and a node boots from a snapshot eight hours old, the stream simply has no records covering the first two hours, and a hydration loop that starts from “the earliest available offset” will happily replay what it can and declare success. Comparing the stream’s earliest available offset against the snapshot’s watermark before starting is two API calls and converts an undetectable wrong index into an ordinary rebuild.

One subtlety about coalescing is worth naming because it can be wrong in an audit context. Collapsing fifteen edits into one means the index never observes the intermediate states — which is correct for the index and wrong if anything downstream needs the history of a fence. Keep coalescing to the hydration path, where only the final state matters, and leave the change stream itself uncollapsed for whatever consumes it for audit purposes.