# Gap Checker and Backfiller Repair ## Purpose Continue the time-series gap checker and backfiller work from PRs/issues `#62`, `#71`, and `#75`, prioritizing data correctness and crash safety before annotation UX or rendering performance. Source baseline: - branch: `flake_update` - commit: `c6ec3d41` - gap handoff: `notes_to_self/claudy/gapsannotator_sesh.md` - primary code: `piker/tsp/_anal.py`, `piker/tsp/_history.py`, `piker/storage/nativedb.py`, and `piker/tsp/_annotate.py` ## Goals 1. Make gap detection operate on sorted, unique, validated OHLCV frames with fresh derived columns. 2. Make backfill completion, frame boundaries, and null repair deterministic under exhaustion and cancellation. 3. Prevent a bounded or transient SHM view from destructively replacing older persisted history. 4. Persist normalized history through crash-safe atomic file replacement. 5. Distinguish expected venue closures from suspicious missing data without production breakpoints. 6. Generate correct annotation specs before optimizing the remaining client-side Polars bottleneck. ## Confirmed Baseline Defects These are current-code findings to characterize in tests, not assumptions inferred from historical task state. ### Gap normalization - `dedupe()` computes `time_prev`, `s_diff`, and `dt_diff` before sorting/deduplication, then returns stale derived columns in the normalized frame (`piker/tsp/_anal.py:683-729`). - Negative/out-of-order deltas are treated as ordinary gaps because `detect_time_gaps()` filters on `abs(s_diff)` (`piker/tsp/_anal.py:582-638`). - `get_null_segs()` drops single zero rows and its Polars path does not define all state used by the shared path (`piker/tsp/_anal.py:227-471`). - `iter_null_segs()` ignores a supplied `null_segs` result and rescans the frame (`piker/tsp/_anal.py:474-546`). ### Backfill control and boundaries - `DataUnavailable` returns before `bf_done.set()`, while `tsdb_backfill()` later waits forever on that event (`piker/tsp/_history.py:516-536,1193-1212,1302-1308`). - Empty arrays are indexed before the nominal null-frame branch can handle them (`piker/tsp/_history.py:538-601`). - Provider frame seams have no explicit inclusive/exclusive contract, allowing duplicate adjacent timestamps in SHM. - Cancellation may occur after SHM mutation but before cursor and persistence bookkeeping (`piker/tsp/_history.py:661-789`). ### Null repair - Null query length is derived from an absolute endpoint, not the null-span width (`piker/tsp/_history.py:278-287`). - Fallback repair includes and mutates the valid leading boundary row (`piker/tsp/_history.py:345-367`). - Null repair and reverse backfill share one provider client concurrently without enforcing the backend's declared request limits. ### Persistence - Every iteration passes bounded `shm.array` to `write_ohlcv()`, while NativeDB replaces the complete parquet file. Older rows that do not fit in SHM can be lost (`piker/tsp/_history.py:722-789`, `piker/storage/nativedb.py:305-368`). - NativeDB writes directly to the final path and updates its cache before the write succeeds; interruption can leave disk and cache partial or inconsistent. - Intentional zero-filled SHM space may be persisted while null repair and reverse backfill run concurrently. ### Annotation contract - `markup_gaps()` performs a full Polars filter per gap and remains the measured client bottleneck (`piker/tsp/_annotate.py:139-217`). - Its missing-prior-row fallback selects the current row, and `copysign(1, 0)` makes the flat-price branch unreachable (`piker/tsp/_annotate.py:166-239`). - Remote/reposition timestamp lookups index `searchsorted()` results before excluding values equal to `len(array)`. ## Design Invariants All implementation phases preserve these rules: 1. Persisted provider rows have nonzero, finite timestamps. 2. Normalized timestamps are strictly increasing and unique. 3. Time-derived columns are computed only after final sorting and duplicate resolution. 4. A detected gap explicitly carries its left and right endpoints; consumers do not infer the left row from `index - 1`. 5. Provider frame metadata equals the normalized array's actual first and last timestamps. 6. Internal middleware uses one documented boundary convention; provider adapters translate into it. 7. Expected sparse venue closures are represented by timestamp distance, not persisted zero or synthetic bars. 8. Storage merges preserve all unaffected older rows. 9. A storage commit becomes visible atomically, and cache state changes only after disk commit succeeds. 10. Rendering or annotation failure cannot change the checker or persistence result. ## Phase 0: Deterministic Characterization Add synthetic OHLCV fixtures based on `piker.data._source.def_iohlcv_fields`; avoid brokers, networking, real actor trees, and Qt in the core suite. ### Analysis tests Create `tests/test_tsp_analysis.py` covering: - uniform 1-second and 60-second series; - one and multiple positive gaps; - duplicate plus out-of-order timestamps; - stale derived columns after sort/dedupe; - zero, one, and multiple null segments; - nulls at frame boundaries and nonzero absolute indexes; - NumPy and Polars inputs; - smart-dedupe conflict cases, including differing closes. ### Backfill tests Create `tests/test_history_backfill.py` with scripted `get_hist`, fake SHM, fake sampler stream, and recording storage implementations. Characterize: - `DataUnavailable` completion without a hang; - a genuinely empty frame; - exact adjacency, overlap, and a positive storage gap; - inclusive provider seam duplication; - one-row and two-row startup history; - cancellation after query, after SHM mutation, and before persistence; - one and multiple null rows with boundary preservation. ### Storage tests Create `tests/test_storage_nativedb.py` using `tmp_path`: - NumPy and Polars round trips; - preservation of history older than the SHM window; - duplicate conflict policy; - write failure leaves the prior parquet readable; - cache changes only after a successful commit; - separate FQME/timeframe series do not interfere. ### Annotation tests Create `tests/test_gap_annotations.py` around a fake `AnnotCtl`, without Qt initially: - left-close/right-open rectangle endpoints; - up, down, and flat price direction; - first-row and missing-prior-row behavior; - empty gaps and out-of-range timestamps; - deterministic batched rect/arrow/text payloads. Gate: every confirmed defect above has a focused failing test or a written reason it requires a later integration test. ## Phase 1: Canonical Frame and Gap Model Refactor `piker/tsp/_anal.py` around one normalization order: 1. validate required columns and finite timestamps; 2. sort by `time`; 3. resolve duplicate timestamps under an explicit policy; 4. recompute all datetime/delta columns with `with_dts()`; 5. reject/report non-positive ordering deltas; 6. detect positive sample-step gaps. Keep the first implementation small: a normalized Polars frame may remain the contract, but each gap row must carry explicit `time_prev`, `index_prev`, `close_prev`, current `time/index/open`, duration, and missing-sample count. Rewrite null grouping from extracted index/time arrays so the NumPy and Polars paths share one implementation. Treat a single null row as a normal one-row segment and honor a precomputed `null_segs` argument without rescanning. Gate: analysis tests pass and no deduped/sorted frame retains stale delta columns. ## Phase 2: Structured Backfill Completion Refactor `start_backfill()` and `tsdb_backfill()` before changing persistence semantics: - remove completion events that are awaited only after the owning nursery has already joined its children, or make completion a guaranteed `finally` outcome; - represent `completed`, `provider_exhausted`, `cancelled`, and `failed` as explicit results; - check empty arrays before indexing endpoints; - normalize each provider frame and recompute endpoint metadata from the normalized data; - define and enforce a disjoint seam between each new reverse frame and the already-published frame; - update cursor state immediately with the SHM mutation, before any optional notification checkpoint; - serialize reverse backfill and null repair unless a backend capability explicitly permits concurrent history requests; - enforce declared backend request/rate limits. Prefer structured task return values over actor-local events whose only consumer is the parent task. Gate: exhaustion and cancellation tests terminate within their timeout, and SHM timestamps remain strictly increasing across all frame seams. ## Phase 3: Non-Destructive Atomic NativeDB Writes Stop treating the bounded SHM view as the full database. Add a NativeDB merge/update operation that: - accepts only the normalized provider frame being committed; - loads or uses the cached persisted frame; - merges by timestamp while preserving unaffected rows; - uses the historical provider bar as authoritative at a closed-bar seam; - validates ordering, uniqueness, schema, and nonzero timestamps before writing; - writes to a same-directory temporary parquet; - reopens and validates the temporary file; - atomically replaces the final path; - updates `_dfs` only after replacement succeeds; - serializes writers per `(fqme, timeframe)`. Backfill commits the validated frame/delta, never a live `shm.array` snapshot containing mutable current bars or intentional zero space. Gate: a persisted series can only grow backward or replace explicitly overlapping timestamps; its earliest timestamp never moves forward during ordinary backfill. ## Phase 4: Exact Null Repair Use the canonical null spans from Phase 1: - query using explicit boundary timestamps; - trim provider responses to the actual missing interval; - write only null rows, never either valid boundary row; - validate repaired timestamps and OHLC fields before publication or persistence; - leave expected venue closures sparse; - do not persist synthetic flat bars without a durable quality marker and an explicit schema decision. If provider data is unavailable, retain and report the missing interval instead of silently converting it into apparently valid history. Gate: single and multi-row repairs preserve both boundary bars and either eliminate exactly the target nulls or return an unresolved-gap result. ## Phase 5: Venue and Provider Classification Apply gap classification after canonical detection rather than embedding it in individual warning paths. For IB: - correct expiry comparison and weekday constants; - classify internal positive gaps as well as trailing request-coverage gaps; - require closure interval alignment, not merely the presence of a weekend/holiday somewhere in the gap; - cover overnight sessions, weekends, holidays, DST, active/expired futures, and unknown calendars; - replace production `breakpoint()` calls with structured data-quality results/errors. Provider adapters must return normalized endpoint metadata and translate their native inclusive/exclusive APIs into the middleware boundary contract. Gate: every gap is classified as expected closure, suspicious missing data, provider exhaustion, or invalid ordering; no production path pauses interactively. ## Phase 6: Annotation Correctness and Performance Extract a synchronous, Qt-free spec builder from `markup_gaps()`: ```python build_gap_annotation_specs(frame, gaps) ``` It consumes the explicit gap endpoints from Phase 1 and: - extracts needed columns once; - replaces per-gap `wdts.filter()` with a vectorized join or indexed lookup; - handles equal prices explicitly; - preserves previous-close/current-open semantics; - styles expected closures separately from suspicious gaps; - emits deterministic rect/arrow/text batches. Then fix remote lookup/reposition bounds by masking `searchsorted()` results before indexing and enforce both FQME and timeframe ownership during redraw. Benchmark the same 1,285-gap workload from the Claude handoff. Treat these as local benchmark goals, not portable CI limits: - client spec build under 20 ms; - server preparation under 10 ms; - one composite gap graphics item; - no full-frame Polars filter per gap; - total local creation near or below 150 ms. Gate: annotation contract tests pass before performance numbers are accepted. ## Phase 7: Crash and Provider Qualification Run deterministic tests first, then the historical manual matrix from issue `#62` on disposable storage: - fresh full backfill; - append to existing history; - graceful Ctrl-C; - network interruption; - SIGTERM and SIGKILL during query and parquet commit; - restart and revalidate after every interruption. Start with Binance/Kraken/Kucoin, then run IB only with an available account and gateway. Record before/after earliest timestamp, latest timestamp, row count, unique count, null count, non-positive deltas, and parquet readability. ## Verification Commands Use an outer process timeout because actor teardown has a known second-runtime wedge: ```bash timeout -k 5 30 python -m pytest -q \ tests/test_tsp_analysis.py \ tests/test_storage_nativedb.py timeout -k 5 60 python -m pytest -q \ tests/test_history_backfill.py timeout -k 5 60 python -m pytest -q \ tests/test_gap_annotations.py CI=1 timeout -k 20 120 python -m pytest -q -rs \ tests/test_feeds.py ``` Run live/network tests separately from deterministic tests. Do not automate `piker store anal` or `piker store ldshm`: both may pause interactively or mutate attached data. ## Commit Boundaries Keep implementation reviewable in this order: 1. synthetic fixtures and analysis regressions; 2. canonical normalization and null grouping; 3. backfill completion and frame-boundary fixes; 4. NativeDB merge and atomic persistence; 5. exact null repair; 6. IB/provider gap classification; 7. annotation contract fixes and vectorization; 8. qualification notes and documentation. Each boundary must leave deterministic tests green. Do not mix renderer optimization into storage/backfill commits. ## Deferred The following remain follow-ups until the correctness gates above pass: - chart context-menu/manual piecewise refill UX; - automatic checker integration into every live chart; - compact columnar annotation IPC; - persistent synthetic-bar quality metadata/schema; - full Deribit history repair and broad provider cleanup; - Marketstore behavior changes; - long-term incremental parquet partitioning. ## Source References - `gitea/62.md`: workaround-era backfill and manual test history - `gitea/71.md`: IB venue closure behavior and pending account verification - `gitea/75.md`: gap annotation, storage races, and backfiller follow-ups - `notes_to_self/claudy/gapsannotator_sesh.md`: current annotation benchmark and client bottleneck - https://github.com/pikers/piker/pull/486 - https://github.com/pikers/piker/pull/446 - https://github.com/pikers/piker/issues/536