Skip to content

Phase 38: Dual-Path Index Rebuild + Stale Detection - #41

Merged
Pascal-ZeGerman merged 11 commits into
mainfrom
gsd/phase-38-dual-path-index-rebuild-stale-detection
Aug 14, 2026
Merged

Phase 38: Dual-Path Index Rebuild + Stale Detection#41
Pascal-ZeGerman merged 11 commits into
mainfrom
gsd/phase-38-dual-path-index-rebuild-stale-detection

Conversation

@Pascal-ZeGerman

Copy link
Copy Markdown
Owner

Summary

Phase 38: Dual-Path Index Rebuild + Stale Detection
Goal: Users can choose between a fast prebuilt-index redownload (existing button) and a true from-source CSCL rebuild (new capability), and the integration auto-redownloads when the local index is stale (>60 days) so the index never silently rots.
Status: Verified ✓ (Nyquist-compliant, 96/96 automated tests green)

Adds a _sync_build_from_source() pipeline that rebuilds the full NYC spatial index directly from the live CSCL GeoJSON + SODA ASP-signs APIs (pure shapely/rtree/httpx/zstandard — no geopandas, no new manifest.json dependency), smart coordinator routing that picks between a fast prebuilt-release download and a slow from-source rebuild based on remote-asset age and press history, and a daily stale-index check that auto-triggers a redownload plus a persistent HA notification when the index passes 60 days old.

Changes

Plan 01 — From-source CSCL index rebuild (IDX-06)

Pure-shapely _sync_build_from_source(index_dir): CSCL fetch → filter/reproject → node lookup → cross streets → SODA fetch → BFS ASP propagation → R-tree + segments.jsongraph.json.zstbuild_info.json, writing only to <index_dir>_tmp (caller owns the atomic swap). Also patches the existing download path to stamp source: "github_release" provenance (D-04) and silently skip malformed build_info.json (D-05).

Key files: custom_components/asp_parking/index_io.py, custom_components/asp_parking/const.py, tests/test_index_io_build_from_source.py, tests/fixtures/cscl_geojson_sample.json, tests/fixtures/soda_asp_signs_sample.json

Plan 02 — Coordinator dual-path routing (IDX-05)

RebuildPath enum + _async_decide_rebuild_path(triggered_by): remote release <30 days old → download; ≥30 days stale, a double-press within 24h, or a GitHub API failure → from-source. triggered_by="stale_check" skips the 24h override. Path decision logged at INFO.

Key files: custom_components/asp_parking/coordinator.py, tests/test_coordinator_path_selection.py

Plan 03 — Stale detection (IDX-07)

Daily async_track_time_interval check against build_info.json's age; >60 days triggers an auto rebuild + persistent notification (asp_parking_index_stale), guarded against first-install (_last_rebuilt is None) and concurrent rebuilds (_is_rebuilding).

Key files: custom_components/asp_parking/coordinator.py, tests/test_coordinator_stale.py

Post-verification fixes (CR-01, WR-01–05)

Code-review and Nyquist-audit follow-up commits: fixed button-triggered rebuild always routing to FROM_SOURCE (CR-01), added a pagination cap to the SODA ASP-signs fetcher (WR-02), guarded it against a non-list JSON body (WR-03), dismissed the stale-index notification on rebuild failure (WR-04), added copy-based recovery before discarding the last index backup (WR-05), verified a newly-swapped-in index before trusting it (WR-01), fixed a stale-check UnboundLocalError, and closed 3 Nyquist coverage gaps (R-tree non-empty check, graph.json.zst round-trip via StreetGraph.load, and an evergreen guard against heavy GIS deps re-entering manifest.json).

Requirements Addressed

  • IDX-05 — User can trigger a full spatial index rebuild directly from the NYC Open Data CSCL API via a dedicated HA button entity; rebuild uses only existing stack deps (httpx, shapely, rtree — no geopandas, no new manifest.json requirements)
  • IDX-06 — From-source rebuild paginates the CSCL GeoJSON API, builds the R-tree index and adjacency graph from raw data, writes build_info.json, and atomically swaps the index directory using the Phase 33 _sync_atomic_swap + SpatialIndex.reset() sequence
  • IDX-07 — Integration detects when the local spatial index is >60 days old (via build_info.json) at coordinator startup and during daily checks; auto-triggers a re-download (fast path) and notifies the user via persistent notification

Verification

  • Automated verification: passed (.planning/phases/38-.../38-VERIFICATION.md)
  • Nyquist validation: compliant — 96/96 tests green across test_coordinator_path_selection.py, test_coordinator_stale.py, test_index_io_build_from_source.py, test_index_io.py
  • Manual-only (documented, not automated — require live HA + real GitHub API): button press routes correctly in live HA; stale notification appears in HA UI when index >60 days

Key Decisions

  • GITHUB_INDEX_RELEASE_TAG = "index-v1" instead of /releases/latest — a research probe found /latest returns a release with zero assets while index.zip lives on tag index-v1.
  • Reimplemented the offline scripts/build_index.py pipeline in pure shapely rather than importing it — geopandas pulls in GDAL (~500MB), which would violate the manifest.json "no new external deps" constraint.
  • Degenerate geometries (all projected points identical) are skipped during from-source build rather than inserted with a zero-area bbox.
  • D-05 silent-skip covers three malformed build_info.json cases (absent, unparseable, non-dict) without failing the download path.

TDD Audit

Test commit Impl commit gate_status
9daf1e4 fix(38): WR-05 attempt copy-based recovery before discarding last index backup missing
e1097e7 fix(38): WR-03 guard SODA ASP-signs fetcher against non-list JSON body missing
2044c0f fix(38): WR-02 add pagination cap to SODA ASP-signs fetcher missing
56536df fix(38): WR-04 dismiss stale-index notification on rebuild failure missing
fd3db64 fix(38): WR-01 verify newly-swapped-in index before trusting it missing
7a24238 fix(38): CR-01 fix button-triggered rebuild always routing to FROM_SOURCE missing
10c13d3 fix(38-03): stale-check error handler crashed with UnboundLocalError missing
0a2e557 test(phase-38): add Nyquist validation tests for from-source index build invariants missing

Aggregate: 0 skill, 0 fallback, 0 exempt — 8 missing. (No commit in this range carries a gate_status: trailer; the underlying phase-38 execution/review-fix commits predate this repo's trailer convention, and this audit trail is informational only — it does not block the ship.)

gate_status: skill=0, fallback=0, exempt=0, missing=8

Pascal-ZeGerman and others added 10 commits August 13, 2026 17:18
The lazy `from ... import async_create as pn_create` inside
_async_check_stale_and_rebuild's try block makes `pn_create` a
function-local name for the whole method scope. Any exception raised
before that import ran left the name unbound, so the `except` handler
died with UnboundLocalError instead of posting its error notification.

- Re-import pn_create inside the except handler (additive; keeps the
  sys.modules patchability the tests rely on)
- Add regression test reproducing the path via a tz-naive _last_rebuilt

Verified RED before the fix (UnboundLocalError at coordinator.py:1107),
GREEN after. Full offline suite 818 passed.
…URCE

async_request_rebuild() overwrote self._last_button_press with "now"
before _async_decide_rebuild_path() read it, making the 24h double-press
check a self-comparison that was always true. Every button press
(including the first) was misrouted to the slow FROM_SOURCE path,
defeating the dual-path routing feature. Fix threads the previous press
snapshot through _async_do_rebuild -> _async_decide_rebuild_path as an
explicit argument instead of re-reading the mutated instance attribute.
Adds a real async_request_rebuild() end-to-end regression test (fired
twice) that the prior isolated-stub tests could not catch.
_async_do_rebuild went straight from _sync_atomic_swap to posting a
success notification without ever calling the existing
_sync_verify_index() / IndexIntegrityError integrity check (already used
by the first-time-setup path). A partial/corrupt write (disk full
mid-write, SD-card corruption, truncated extraction) would be silently
promoted to the live index with a false "success" message. Adds the
verify call right after the swap so a raised IndexIntegrityError is
caught by the existing except block and reported as a normal rebuild
failure instead of an opaque rtree crash later. Updates test spy helpers
in the affected suites to stub the new call.
_async_check_stale_and_rebuild posts the "index is stale, auto-rebuilding"
notification then awaits async_request_rebuild(triggered_by="stale_check").
If the rebuild failed, _async_do_rebuild's except block dismissed only
"asp_parking_index_rebuild" and posted "asp_parking_index_rebuild_error" --
it never dismissed "asp_parking_index_stale". Users ended up with a stale,
now-inaccurate "auto-rebuilding" banner sitting alongside the new
"Rebuild Failed" notification until the next 24h stale-check cycle.
Dismissing it is idempotent (safe even when the notification was never
posted, e.g. button-triggered rebuilds).
_sync_fetch_asp_signs paginated the SODA parking-signs endpoint with the
same while-True/offset-increment shape as _sync_fetch_cscl_features but
had no equivalent MAX_CSCL_PAGES-style cap. A misbehaving or compromised
SODA endpoint that kept returning exactly SIGNS_BATCH_SIZE records would
loop indefinitely in the executor thread. Adds MAX_SIGNS_PAGES (mirroring
MAX_CSCL_PAGES) and breaks with a warning + partial results once the cap
is hit, preserving the fetcher's existing fail-soft contract (unlike CSCL's
fail-hard RuntimeError).
_sync_fetch_cscl_features guards its response shape
(features = body.get(...) if isinstance(body, dict) else []) but
_sync_fetch_asp_signs did not: `records = resp.json()` followed by
`if not records: break` then `for record in records:`. A truthy
non-list JSON body (e.g. {"error": "..."} on an HTTP-200 soft error)
made `not records` False, so the loop iterated the dict's string KEYS
and `record.get(...)` raised AttributeError -- an exception type not in
the caught tuple, turning the documented "SODA outages must NOT block a
CSCL rebuild" fail-soft contract (T-38-01-02) into a hard failure of the
entire from-source rebuild. Adds an isinstance(records, list) guard that
treats a non-list body as "no more data", mirroring the CSCL fetcher.
…ex backup

_sync_cleanup_stale's crash-recovery path treats _bak as the LAST viable
copy of the index when index_dir is absent. If os.replace(bak, index_dir)
raised OSError (e.g. EXDEV when _bak and the parent dir unexpectedly end
up on different filesystems, or a transient EBUSY), the handler
unconditionally rmtree'd _bak -- permanently discarding a state that was
often still recoverable via a plain copy. Adds a shutil.copytree fallback
before giving up; _bak is wiped either way once the fallback is resolved
(redundant on copy success, unusable on copy failure). Splits the old
combined regression test into a copy-succeeds case (index recovered) and
a both-fallbacks-fail case (bak wiped, index absent, matching the
previous behavior only as a true last resort).
…ild invariants

Retroactive Nyquist audit found the existing tests only checked file
existence, not the invariants the threat model actually cares about:
R-tree population (guards rtree bug #159's silent-empty-index failure
mode), graph.json.zst round-tripping through StreetGraph, and manifest.json
never picking up a heavy GIS dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# -- First press: no prior press exists ------------------------------
await request_rebuild(triggered_by="button")
assert len(captured_coros) == 1
await captured_coros[0] # run _async_do_rebuild -> _async_decide_rebuild_path
# -- Second press: within the 24h double-press window ----------------
await request_rebuild(triggered_by="button")
assert len(captured_coros) == 2
await captured_coros[1]
…est_index_io.py

CI's ruff format check flagged these two pre-existing files as unformatted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Pascal-ZeGerman
Pascal-ZeGerman merged commit 81cf2c0 into main Aug 14, 2026
17 checks passed
@Pascal-ZeGerman
Pascal-ZeGerman deleted the gsd/phase-38-dual-path-index-rebuild-stale-detection branch August 14, 2026 03:33
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.

2 participants