Skip to content

Rate-limit retries for unloaded map tiles - #114

Merged
pfeiferj merged 2 commits into
pfeiferj:mainfrom
FrogAi:codex/debounce-offline-tile-reloads
Aug 9, 2026
Merged

Rate-limit retries for unloaded map tiles#114
pfeiferj merged 2 commits into
pfeiferj:mainfrom
FrogAi:codex/debounce-offline-tile-reloads

Conversation

@FrogAi

@FrogAi FrogAi commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Treat Offline.Loaded as the tile-load result instead of using a non-empty way list as a proxy.
  • Cache valid zero-way tiles while the position remains inside their bounds.
  • Retry an unloaded tile once per second, while preserving immediate attempts on startup and after leaving the cached bounds.
  • Keep unloaded data out of current-way and lookahead processing between retry attempts.

Motivation

The main loop currently reloads the offline tile whenever len(state.Data.Ways()) == 0. That condition does not distinguish a failed load from a valid tile containing no roads.

As a result, both states are retried on every successful GPS update:

  • A valid zero-way tile is repeatedly reopened and decoded even though it loaded successfully.
  • A missing tile or packed-unmarshal failure is repeatedly searched for and read until it becomes available.

Offline.Loaded already owns that distinction. A successfully decoded empty tile has Loaded=true; a missing tile or packed-unmarshal failure has Loaded=false.

Retry policy

The retry state remains local to the main loop and uses elapsed time between attempts:

State Base behavior This change
Startup Load immediately Unchanged
Loaded populated tile, position inside bounds Reuse tile Unchanged
Loaded zero-way tile, position inside bounds Reload on every GPS update Reuse tile
Unloaded tile, position inside bounds Retry on every GPS update Retry once per second
Position leaves cached bounds Load immediately Unchanged
Tile appears after a failed load Load on next GPS update Load on the first GPS update at or after the retry deadline

There is no retry limit or increasing backoff. A one-second fixed interval preserves the nominal retry cadence of the 1 Hz internal GPS source while bounding retries from the 10 Hz external source and the 20 Hz main-loop ceiling.

When an unloaded tile is waiting for its next retry, the loop does not run current-way, next-way, or curvature processing against that unloaded data.

Attempt reduction

The deterministic policy oracle counted attempts over ten seconds, including the initial attempt at t=0:

Update cadence Base This change Reduction
20 Hz loop ceiling 201 11 94.5%
10 Hz external GPS 101 11 89.1%
1 Hz internal GPS 11 11 0%

The 10 Hz and 1 Hz rates come from the matching FrogPilot service table. Actual attempts remain gated by successful GPS updates.

Loader cost context

An external Linux probe called the real missing-tile loader 20 times from a fresh directory:

Source stack Elapsed Total allocation Allocation per call
Base 68813e05 251.026 ms 1,170,985,672 bytes 55.84 MiB
PR #97 + PR #112 composition 97597b8 0.653 ms 15,624 bytes 781 bytes

This change owns the attempt-frequency correction only. The large base allocation is the separate area-grid defect addressed by PR #97; it is not counted as an allocation improvement from this patch. PR #112 makes intentionally omitted empty cells more common, so retaining a bounded missing-tile retry remains useful after that allocation fix.

Validation

External overlay validation exercised the production retry state and tile loader:

  • Initial unloaded state attempts immediately.
  • deadline - 1 ns does not retry; the exact one-second deadline does.
  • A backward clock value does not bypass the delay.
  • Leaving the cached bounds attempts immediately, even inside the prior retry interval.
  • A valid zero-way tile is loaded once and then cached.
  • A missing tile retries twice across 21 simulated 50 ms updates from t=0 through t=1s.
  • A missing tile that becomes valid is loaded on the next due attempt and then stops retrying.
  • Missing, corrupt packed, and valid zero-way tile files produce the expected Loaded states through FindWaysAroundPosition.
  • go test ./..., go vet ./..., and go build ./... passed on Linux/amd64 with Go 1.25.1.
  • FrogAi Actions Build #43 ran make build successfully for exact commit 4ed9b1a.

Compatibility

  • No offline file-format, schema, filename, settings, CLI, or dependency changes.
  • No timer goroutine or background retry worker; attempts remain driven by GPS updates in the existing main loop.
  • Loaded populated-tile behavior and immediate tile-exit loading are unchanged.
  • Missing tiles and packed-unmarshal failures remain retryable indefinitely.
  • A valid empty tile no longer hot-reloads while stationary. This matches the existing cache behavior for loaded populated tiles; explicit post-download invalidation remains a separate concern.
  • Tile selection and boundary resolution are unchanged by this patch. PR Avoid allocating global offline area grid #97 remains the owner of the direct coordinate-to-area lookup and exact-boundary consistency fix.

Audit follow-up (head 84cf709)

An independent multi-agent audit of 4ed9b1a found a regression in the bundled if !state.Data.Loaded { continue }, which this head removes.

A tile file that exists but fails to decode makes FindWaysAroundPosition return Loaded=false with a nil error. On base that nil error let control reach GetCurrentWay, which exhausts its fallbacks and returns SelectionType: fail, resetting the current way so mapdOut publishes wayId 0 and speedLimit 0. The early continue made that reset unreachable, so CurrentWay, NextWays, Curvatures, and TargetVelocities retained values from the last good tile indefinitely — mapd kept publishing the previous cell's limit with waySelectionType=possible while SuggestedSpeed() clamped cruise to it. Measured with the real loader: after crossing into a corrupt neighbour, base reported id=0 selType=fail while the PR reported id=1 name="Stale Street" maxspeed=11.176 selType=possible, never reassigned.

This is reachable because tile extraction on base writes tar entries straight to the final path with no temp file, no atomic rename, and no O_TRUNC, so an interrupted download leaves exactly this corrupt-but-present tile. The missing-tile case was never affected — both branches continue on the non-nil error — which is what made the divergence easy to miss. Rate limiting never depended on the skip: the gate is the retry condition, so removing it leaves every attempt-frequency claim above intact.

The same head also collapses the single-use mapLoadRetryState struct and its two methods into a local timestamp plus the inline condition, cutting the change from +24/-2 to roughly +7/-2. The retry gate was proven logically identical to the replaced version across the full matrix of loaded/inside/elapsed combinations, including the zero-value first-attempt case. FrogAi Build #53 passed for exact head 84cf709.

Merge order with #106. These two conflict in main.go, but only in the both-added declaration block near the top, where the union is the only resolution that compiles. The semantically important hunk auto-merges silently about 115 lines below, in a different function, where a maintainer reviewing the conflict would never look. A trial merge confirms the retry bookkeeping lands above #106's early exits, so the backoff survives; the remaining concern is two stacked "map unusable" exits with different bookkeeping. Land #106 first and fold this PR's gate into #106's state.MapValid = state.Data.Loaded; if !state.MapValid { state.ClearRoute(); continue } rather than leaving both in place.

@pfeiferj
pfeiferj merged commit 5b9c091 into pfeiferj:main Aug 9, 2026
1 check passed
@FrogAi
FrogAi deleted the codex/debounce-offline-tile-reloads branch August 10, 2026 04:19
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