Skip to content

feat(data_plane): durable disk-backed tier for warm SketchStore - #329

Merged
zzylol merged 1 commit into
mainfrom
feat/sketch-durable-tier
May 25, 2026
Merged

zzylol merged 1 commit into
mainfrom
feat/sketch-durable-tier

Conversation

@zzylol

@zzylol zzylol commented May 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Turns the warm SketchStore's half-wired persistence layer into a real durable tiered store: hot data in memory, older sealed data flushed to disk and queryable from disk, surviving restarts. Memory is now bounded by flush-then-evict under --persistence-enabled, instead of #327's age-based drop (which still bounds the in-memory-only default). Builds on #327 + #323#326 — does not revert any of them.

Tiered design

HOT current_epoch (mutable, in-mem)
  -> SEALED epochs (in-mem, pending flush)
     -> DISK parts (durable, via PartCache)

query_range unions all three across the requested range.

  • Sealing fires under persistence. SidStoreData gains a seal_window_count cadence (default 20 distinct windows ≈ 10 min of 30s panes — large enough to amortize per-part header/index overhead, small enough that data behind the flusher's hot_window is actually sealed and thus flushable). current_epoch rotates into sealed_epochs on cadence so the flusher has sealed epochs to persist. The max_epochs rotation-drop is disabled under persistence (the flusher owns sealed-epoch lifecycle: seal → durable part → evict). With persistence OFF, nothing seals → fix(data_plane): bound warm SketchStore memory with retention horizon #327 retention bounds memory (unchanged).
  • Query reads disk. union_disk_parts_into consults Manifest::live_parts_overlapping + PartCache for the evicted portion of the range. The fix(data_plane): canonicalize sketch metric name at OTLP ingest #323fix(data_plane): overlap-scan sketch reads for short/instant windows #326 read contract is preserved across the in-mem/on-disk boundary: same half-open overlap (end_ts > start && start_ts < end), and the delta-stitching carry-in fetches the most-recent Full base from disk when it has aged out of memory. The full label key→value map is rebuilt from the sid's group_by_keys (sorted) zipped against the stored values vector — no part-format change for keys. SketchEncoding (Full vs Delta) is round-tripped, via a repurposed v1 part-entry pad byte (legacy 0 decodes as Full, the safe carry-in default).
  • Retention vs flush coexist. When persistence is ON, enforce_retention is a no-op so retention never drops un-flushed sealed/current_epoch data out from under the flusher; the disk-tier TTL (delete_older_than_ms) bounds the durable copy. When OFF, fix(data_plane): bound warm SketchStore memory with retention horizon #327 retention stays the bound. Composition is explicit and tested both ways.
  • Restart durability. start_persistence runs recovery::recover, installs a PersistenceReadHandle (recovered manifest + cache) and the seal cadence, then starts the flusher — a reopened store on the same dir immediately serves recovered data.

Deploy flags (operator)

--persistence-enabled
--persistence-dir /var/lib/asap/sketchstore        # needs a PERSISTENT VOLUME mount
--persistence-memory-limit-mb 2048                  # sealed-epoch high-water; flusher evicts oldest-first above this
--persistence-hot-window-secs 3600                  # seal epochs older than 1h get flushed regardless of pressure
--persistence-delete-older-than-secs 604800         # disk-tier TTL (7d) — the durable retention horizon
--persistence-flush-interval-ms 1000
--persistence-part-cache-mb 256                     # Tier-2 mmap cache (default min(10% mem, 512))
--persistence-seal-window-count 20                  # NEW: seal cadence in distinct windows

Volume: mount a persistent volume at --persistence-dir (parts + manifest live under <dir>/sketch_index/; the series-resolver WAL under <dir>/series_resolver.wal).

Files changed

  • index/epoch_columnar.rsSidStoreData::{seal_window_count, persistence_enabled}; maybe_rotate_epoch (cadence + no-drop under persistence); enforce_retention (no-op under persistence).
  • index/mod.rsPersistenceReadHandle; SketchStore::{persistence_read, seal_window_count, fresh_sid_store, enable_persistence_mode, union_disk_parts_into, sid_group_by_keys, rebuild_label_map, list_sealed_epochs_len}; query_range disk union; encoding_to_tag/tag_to_encoding; snapshot_sealed_epoch records encoding.
  • persistence/{config,part,source}.rsseal_window_count config; per-entry encoding_tag round-trip; encoding_tag constants.
  • main.rs--persistence-seal-window-count plumbed into the config.

Test plan

Follow-ups (not in this PR)

  • Disk read-back is wired for the sketch path (query_range). The exact-agg / precompute disk read (query_exact_agg_range, query_precomputes_by_agg) is left in-memory-only — those payloads aren't part of the marquee sketch queries; extend with the same union_disk_parts_into pattern when needed.
  • The PR keeps the surgical unindented if let block in query_range to minimize diff (repo is not rustfmt-clean on main); a follow-up reindent is cosmetic.

🤖 Generated with Claude Code

Compose hot (current_epoch) -> sealed (in-mem, pending flush) -> disk
parts into a single tiered store so warm-sketch memory is bounded by
flush-then-evict rather than #327's age-based drop. Reads union all
three tiers across the requested range.

- Sealing now fires under persistence: SidStoreData gains a
  seal_window_count cadence (default 20 windows ~= 10 min of 30s panes)
  so current_epoch rotates into sealed_epochs for the flusher to
  persist. max_epochs drop is disabled under persistence (the flusher
  owns sealed-epoch lifecycle). In-memory-only deploys keep #327.
- query_range/union_disk_parts_into consult PartCache+Manifest for the
  evicted portion of the range, rebuilding the full label key->value
  map from the sid's group_by_keys and preserving the #323-#326 read
  contract (half-open overlap + delta-stitching carry-in) across the
  in-mem/on-disk boundary -- incl. a carry-in Full base that now lives
  on disk. Part format round-trips SketchEncoding via a repurposed v1
  pad byte (legacy 0 decodes as Full).
- enforce_retention is a no-op under persistence so retention never
  drops un-flushed sealed/current data; the disk-tier TTL bounds the
  durable copy. In-memory-only path is unchanged.
- start_persistence installs a read handle + seal cadence and recovers
  the manifest+parts so a restart immediately serves recovered data.
- New CLI flag --persistence-seal-window-count plumbs the cadence.

Tests: seal-fires, flush+evict bounds memory, query-from-disk incl.
disk carry-in base, restart recovery, and persistence-disabled
non-regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@zzylol
zzylol merged commit 67af771 into main May 25, 2026
@zzylol
zzylol deleted the feat/sketch-durable-tier branch May 25, 2026 14:27
zzylol added a commit that referenced this pull request May 25, 2026
…agg from disk, and report real memory (#330)

PR #329's durable tier passed its unit tests but the first live run
(--persistence-seal-window-count=4 --persistence-hot-window-secs=120)
left parts/ empty after 13 min of ingest, lost all data on docker
restart, dropped [5m]/HLL queries under persistence, and reported
~0 KB sealed bytes. Three root causes:

1. Flush never fired (most severe). The flusher only ever flushes
   SEALED epochs, and sealing only fires on the count cadence
   (seal_window_count distinct windows). A slow/stalled series never
   reaches the cadence, so its aged windows sit un-sealed in
   current_epoch forever — never made durable. Fix: a time-driven
   "phase 0" seal — the flusher now rolls every current_epoch window
   older than the hot window into a sealed epoch each tick
   (EpochSource::seal_aged_epochs / SidStoreData::seal_aged_windows /
   MutableEpoch::split_window_ends_before) so it becomes flushable
   regardless of cadence. Parts now commit during runtime and survive
   restart.

2. Exact-agg disk read-back missing. query_exact_agg_range and
   exact_agg_coverage_bounds read only in-memory epochs, so a
   `sum by (...)` / rate query returned "No result" once its windows
   were flushed-then-evicted. Fix: both now union the durable tier,
   reconstructing scalar accumulators (Sum/Increase/MinMax + Multiple*)
   from disk via reconstruct_exact_agg, keyed by the rebuilt label map.

3. approx_memory_bytes ignored current_epoch, so the MEMORY_DIAG
   under-reported and the flusher's memory-pressure trigger was blind
   to the bulk of memory (which under persistence lives un-sealed in
   current_epoch). Fix: count hot current_epoch + sealed; relabel the
   diagnostic.

Persistence-OFF default path is unchanged (seal_aged is a no-op when
persistence_enabled is false; the disk unions are no-ops without a read
handle). Reproducing tests fail on origin/main and pass here:
live_aged_unsealed_panes_flush_and_survive_restart (#1),
live_exact_agg_resolves_from_disk_after_evict (#2),
live_total_memory_accounts_for_current_epoch (#3), plus columnar/seal
and flusher-level unit tests.

Remaining follow-up: MultipleMinMaxAccumulator (needs an out-of-band
min/max sub_type) and the sketch-backed accumulator forms still have no
generic byte factory, so their evicted-to-disk exact-agg portion is
skipped; they remain served from memory.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
zzylol added a commit that referenced this pull request May 25, 2026
…yable (#332)

After a data-plane restart the durable warm-sketch tier recovered the
parts manifest + part cache (#329/#330) but registered NO sids in the
in-memory SketchStore `instances` map -- registration only ever happens
on the live ingest path when a fresh DataPoint arrives. With an empty
registry, `instances_matching` enumerated nothing for the recovered
metrics (engine returned "No result" before reading any window) and
`query_range`/`query_exact_agg_range`'s disk-union early-returned on the
missing `sid_group_by_keys`. The on-disk part format carries only label
VALUES + sketch_type_name -- not the metric name, group-by KEYS, or
structured `AggKind` the query path needs.

Fix: persist a compact per-sid metadata sidecar (`sid_metadata.json`)
that the flusher upserts whenever it makes a part durable, and replay it
on recovery to re-register each disk-resident sid as a queryable
instance. `capability`/`accuracy` are re-derived from the persisted
`agg_kind` exactly as the ingest path derives them. Idempotent: a sid a
live DataPoint already re-registered is not clobbered. Persistence-OFF
behavior is unchanged (the sidecar only exists under the flusher).

#330's restart tests passed despite this bug because they call
`idx2.register(...)` on the fresh store before querying ("here we
re-register to model that") -- masking the disk-only path. The two new
tests do a GENUINE fresh reopen with NO register() for both the KLL
quantile and Sum exact-agg shapes; both fail on origin/main and pass
with this fix.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant