Smart Overpass fetching, SQLite node cache, and rate-limit handling#133
Smart Overpass fetching, SQLite node cache, and rate-limit handling#133dougborg wants to merge 3 commits intoFoggedLens:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Enables progressive map updates while fetching Overpass quadrants in parallel by propagating mid-fetch NodeDataManager cache updates through NodeProviderWithCache, and adds unit tests for the per-quadrant notification behavior.
Changes:
- Emit
NodeDataManager.notifyListeners()as each non-empty quadrant result is cached, enabling progressive rendering. - Have
NodeProviderWithCachesubscribe toNodeDataManagerupdates (and remove the manual post-fetch provider notification). - Add tests (and
fake_async) to verify per-quadrant notification behavior and Overpass status polling behavior.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
lib/services/node_data_manager.dart |
Adds per-quadrant notifyListeners() after caching and threads stale-generation + semaphore logic through split fetches. |
lib/widgets/node_provider_with_cache.dart |
Subscribes provider to NodeDataManager updates; removes redundant provider-side notify after fetch. |
lib/services/overpass_service.dart |
Adds /api/status slot-count parsing and polling wait helper for rate limiting. |
lib/services/node_spatial_cache.dart |
Adds a forTesting constructor to allow non-singleton cache instances in tests. |
lib/services/map_data_provider.dart |
Documents that offline downloads pass null generation (not stale-cancelled) and may occupy semaphore slots. |
test/services/node_data_manager_test.dart |
Adds extensive tests including progressive rendering notification counts. |
pubspec.yaml |
Adds fake_async as a direct dev dependency. |
pubspec.lock |
Updates dependency metadata for fake_async to direct dev. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
This one could merge in place of #114 - it has all those commits too. |
a63e21d to
e1b5c0a
Compare
642bdec to
a63a87a
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
0341e97 to
1be4b82
Compare
43b359b to
ce2c422
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 22 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
43a3646 to
e31d361
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 32 out of 33 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
e31d361 to
3157e12
Compare
Replace fire-and-forget Overpass requests with a managed pipeline: - Semaphore limits concurrent requests to the Overpass slot count (2), with priority queueing so user-initiated requests jump ahead of background work - Generation counter cancels stale requests at 6 checkpoints when the user pans away, without aborting in-flight HTTP calls - Automatic area splitting on NodeLimitError (up to 4^3 = 64 sub-areas) with progressive rendering as each quadrant completes - Pre-flight /api/status check avoids burning time on requests that will be rejected; semaphore cooldown pauses all requests during rate limits with server-reported wait times - Reconciliation timer auto-retries failed viewport fetches after cooldown expires - SQLite write-through persistence (7-day TTL) so the node cache survives app restarts; expired areas pruned on startup - Background refresh of stale cached areas (>1hr) targeting original bounds; ring-based prefetching of surrounding areas at higher zooms - Defer fetches until map settles (no fingers down, fling done) to avoid burning Overpass slots on immediately-stale requests - Skip redundant fetches when in-flight request covers the viewport Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
3157e12 to
004a1e3
Compare
004a1e3 to
6307995
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 33 out of 34 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
- Coverage overlay ("fog of war") shows which map areas have loaded
data, with age-colored debug tinting in dev mode (green/yellow/orange)
- Rate-limit countdown circle in the network status indicator shows
server-reported wait time draining in real time; proportional progress
updates prevent timer jumps on repeated rate-limit reports
- Prioritize closest nodes to viewport center when render limit is
active, preventing visible gaps that shift as you pan
- Add coverage overlay toggle in advanced settings
- Cancel constrained-node snap-back timer in dispose
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… math Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
6307995 to
94c56f6
Compare
| // Apply rendering limit to prevent UI lag | ||
| // Apply rendering limit to prevent UI lag. | ||
| // Sort by distance from viewport center so the most visible nodes | ||
| // always make the cut, preventing gaps that shift as you pan. | ||
| if (validNodes.length > maxNodes) { | ||
| final centerLat = (mapBounds!.north + mapBounds.south) / 2; | ||
| final centerLng = (mapBounds.east + mapBounds.west) / 2; | ||
| validNodes.sort((a, b) { | ||
| final distA = (a.coord.latitude - centerLat) * (a.coord.latitude - centerLat) + | ||
| (a.coord.longitude - centerLng) * (a.coord.longitude - centerLng); | ||
| final distB = (b.coord.latitude - centerLat) * (b.coord.latitude - centerLat) + | ||
| (b.coord.longitude - centerLng) * (b.coord.longitude - centerLng); | ||
| return distA.compareTo(distB); | ||
| }); |
There was a problem hiding this comment.
This is a small improvement that massively improves the UX imo. I think we should do this no matter what else happens with the other changes in the PR.
|
Can you rebase this? I want to play with a build on top of latest |
Summary
/api/statusbefore expensive POST requests; semaphore cooldown pauses all requests for the server-reported wait time, with single-slot recovery to avoid burning both slots simultaneouslyHow cancellation works
_fetchGenerationincrements when the user pans to an uncached area. All in-flight work snapshots this value and bails at checkpoints when stale:Additionally, stale-but-completed HTTP responses are still cached (the
nodes.isNotEmptyguard ensures real data from a stale generation isn't thrown away). Post-fetch result handling ingetNodesForalso checks staleness to skip UI updates for superseded requests.Once an HTTP call is in flight, it can't be cancelled (Dart limitation). What generation checks prevent is new work from starting: queued requests, split sub-requests, and rate-limit retries.
_prefetchGenerationincrements when the user pans to a cached area (triggering a new prefetch) or when a rate limit sets cooldown. This cancels the previous prefetch loop without affecting user-initiated fetches.Rate limiting
Pre-flight
/api/statuscheck → if no slot available, throwRateLimitErrorwith server-reported wait time (+2s buffer) → semaphore enters cooldown (all requests pause) → reduce to 1 slot to prevent two concurrent 7s queries from burning both slots → reconciliation timer fires after cooldown + 1s buffer → retry viewport fetch → on success, restore to 2 slots.Background refreshes and prefetches do not retry on 429 — they bail immediately so slots serve the user.
Other design decisions
NodeLimitErrorhandlers run outside the semaphore (slot already released), competing fairly in the FIFO queuemarkAreaAsFetchedprunes in-memory entries subsumed by the new area;insertCachedAreadoes the same in SQLite;deleteExpiredDatausesNOT EXISTSsubquery to avoid SQLite's 999 bind-variable limitrefreshArea(manual retry) invalidates only overlapping cached areas instead of clearing the entire cacheFiles changed
lib/services/node_data_manager.dartlib/services/node_cache_database.dartlib/services/node_spatial_cache.dartinitPersistence(),hasFreshDataFor(),staleAreaFor(),invalidateArea(), write-through, area compaction, cachedfetchedAreasviewlib/services/overpass_service.dart/api/statuscheck viasecondsUntilSlotAvailable(),RateLimitErrorwithwaitSeconds, shared_parseWaitSecondshelperlib/services/network_status.dartsetRateLimited(waitSeconds:), dynamic auto-reset timer using actual wait timelib/widgets/network_status_indicator.dartStatefulWidgetwith rate-limit countdown circle and proportional progress updateslib/widgets/map/coverage_overlay.dartlib/widgets/map/map_data_manager.dartlib/widgets/map_view.dartlib/widgets/map/node_refresh_controller.dartlib/widgets/node_provider_with_cache.dartNodeDataManagerfor progressive updates, dispose cleanuplib/app_state.dartinitPersistence()beforepreloadOfflineNodes(), coverage overlay settinglib/state/settings_state.dartlib/screens/advanced_settings_screen.dartlib/services/map_data_provider.dartpubspec.yaml>=3.10.0, addsqflite_common_ffidev deptest/services/node_data_manager_test.darttest/services/node_cache_database_test.darttest/widgets/coverage_overlay_test.dartTest plan
flutter test— all tests pass, no regressionsflutter analyze— no issues🤖 Generated with Claude Code