Skip to content

Parallelize quadrant fetching and add smart 429 retry#114

Open
dougborg wants to merge 2 commits intoFoggedLens:mainfrom
dougborg:fix/109-parallelize-quadrant-fetching
Open

Parallelize quadrant fetching and add smart 429 retry#114
dougborg wants to merge 2 commits intoFoggedLens:mainfrom
dougborg:fix/109-parallelize-quadrant-fetching

Conversation

@dougborg
Copy link
Collaborator

@dougborg dougborg commented Feb 12, 2026

Closes #109

Summary

  • Parallelizes quadrant fetches with Future.wait() instead of sequential for-loop — at max split depth (3), up to 64 requests can overlap instead of running one-by-one
  • Adds dynamic concurrency limiter (_AsyncSemaphore) sized from Overpass /api/status slot count, so we never exceed the server's per-IP rate limit
  • Replaces "sleep 30s and give up" 429 behavior with smart retry: polls /api/status until a slot is available, retries up to 2 times, and resizes the semaphore with the latest observed slot count
  • Cancels stale fetch requests via a generation counter — when the user pans/zooms mid-fetch, queued sub-requests bail out instead of blocking the semaphore for the area the user actually wants to see

What changed

lib/services/overpass_service.dart

  • HTTP client injection via constructor (OverpassService({http.Client? client})) following the RoutingService pattern — enables mock-based testing
  • getSlotCount() — queries /api/status and parses Rate limit: N, falls back to 4
  • waitForSlot() — polls /api/status until "slots available now" appears, parsing "in N seconds" for smart delay, bounded by maxWait (default 2 min)

lib/services/node_data_manager.dart

  • DI constructorsNodeDataManager._({OverpassService?, NodeSpatialCache?}) + NodeDataManager.forTesting() factory
  • _AsyncSemaphore — resizable async semaphore; resize() wakes queued waiters when capacity increases; capacity clamped to >= 1 to prevent deadlock; uses Queue for O(1) dequeue
  • Lazy semaphore init_semaphoreInitFuture caches the init Future so concurrent calls get the same instance
  • fetchWithSplitting now wraps fetchNodes in semaphore.run() and handles RateLimitError by polling + retrying (max 2)
  • _fetchSplitAreas now uses Future.wait() for parallel quadrant fetching
  • splitBounds renamed from _splitBounds, made static + @visibleForTesting for direct unit testing
  • Generation counter for stale-fetch cancellation:
    • _fetchGeneration int incremented each getNodesFor() call
    • _isStale(int? generation) checked at 6 cooperative checkpoints throughout the fetch pipeline (before semaphore, inside lambda, before splitting, before/after waitForSlot, top of _fetchSplitAreas)
    • null generation (offline download, map_data_provider.dart) is never stale — zero regression risk
    • Stale requests that already completed in-flight still cache their data (valid if user pans back); short-circuited stale requests don't mark the area as fetched

lib/services/node_spatial_cache.dart

  • Added NodeSpatialCache.forTesting() constructor

test/services/node_data_manager_test.dart (new)

23 tests using mocktail + fake_async:

  • splitBounds — correct quadrant geometry, exact tiling (no gaps/overlaps)
  • getSlotCount — parses rate limit, falls back on HTTP/network failure
  • waitForSlot — immediate return, wait-and-repoll, unparseable fallback, slot count changes (all using FakeAsync)
  • fetchWithSplitting — happy path, NodeLimitError split, max depth, rate limit retry + cap
  • _fetchSplitAreas — partial failure (1/4 fails), total failure, recursive splitting
  • Stale fetch cancellation — skips fetch entirely, prevents HTTP call inside lambda, prevents recursive splitting, skips waitForSlot, null generation backward compat
  • Semaphore init — concurrent calls deduplicate

Design notes

The semaphore is safe without locks because Dart's event loop is single-threaded: microtasks from Completer.complete() drain sequentially before new events can enter run(). Each woken waiter re-checks while (_current >= _maxConcurrent) before proceeding.

/api/status is not polled on every request

The /api/status endpoint is only hit in two situations:

  1. Once per app lifetimegetSlotCount() is called during semaphore initialization, and the result is cached via _semaphoreInitFuture ??=. All subsequent requests reuse the cached semaphore instance with no network call.
  2. Only when rate-limitedwaitForSlot() is called inside the on RateLimitError handler, and only up to 2 times per request (capped by rateLimitRetries). This is where we poll until a slot opens up, then resize the semaphore with the fresh count.

Stale fetch cancellation avoids ~95% of wasted work

We can't cancel in-flight HTTP requests (the http package has no CancelToken), but the generation counter prevents queued/pending work from starting. Since only 4 requests run concurrently while up to 60 may be queued, this captures the vast majority of the benefit without changing the HTTP layer.

Test plan

  • flutter analyze — 0 issues
  • flutter test — 23/23 pass in node_data_manager_test
  • Manual: navigate to a surveillance-dense area (e.g. London, DC) → verify splitting is faster and debug logs show Overpass semaphore: N slots
  • Manual: trigger rate limit → verify Waiting N seconds for slot appears and request retries successfully
  • Manual: rapid panning in a dense area → stale requests should cancel, new area loads faster

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings February 12, 2026 21:32
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves Overpass-based node fetching performance and resilience by parallelizing quadrant fetches during splitting and adding adaptive rate-limit handling based on Overpass /api/status.

Changes:

  • Parallelize quadrant fetching via Future.wait() while limiting total concurrency with a resizable async semaphore.
  • Add /api/status support in OverpassService to derive slot count and to poll until a slot is available for smarter 429 retries.
  • Add DI hooks and new unit tests covering splitting, semaphore init, and rate-limit behavior.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
lib/services/overpass_service.dart Injects HTTP client and adds /api/status helpers (getSlotCount, waitForSlot) to support adaptive concurrency + 429 retry.
lib/services/node_data_manager.dart Introduces _AsyncSemaphore, rate-limit retry behavior, and parallel quadrant fetching in _fetchSplitAreas.
lib/services/node_spatial_cache.dart Adds a forTesting() constructor to support test setup.
test/services/node_data_manager_test.dart Adds extensive tests for splitting geometry, status parsing/polling, retries, and semaphore init deduplication.
COMMENT Adds an unrelated note/snippet that doesn’t appear connected to the PR’s purpose.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

dougborg pushed a commit to dougborg/deflock-app that referenced this pull request Feb 12, 2026
If Overpass /api/status ever returns Rate limit: 0 (or parsing yields
a non-positive value), the semaphore would block all run() calls
forever. Clamp both the constructor and resize() to a minimum of 1.

Addresses Copilot review feedback on PR FoggedLens#114.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dougborg dougborg requested a review from Copilot February 12, 2026 22:22
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@dougborg dougborg force-pushed the fix/109-parallelize-quadrant-fetching branch from 6b28f20 to 9a58036 Compare February 13, 2026 00:24
@dougborg dougborg requested a review from Copilot February 13, 2026 00:25
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

dougborg pushed a commit to dougborg/deflock-app that referenced this pull request Feb 25, 2026
Introduces a unified service policy system (ServiceType, ServicePolicy,
ServicePolicyResolver, ServiceRateLimiter) that resolves compliance rules
per-URL, covering OSMF official services and third-party tile providers.
Custom/self-hosted endpoints get permissive defaults.

Key changes:
- ServicePolicyResolver maps URLs to policies (OSM tile server, Nominatim,
  editing API, Overpass, Bing, Mapbox, custom)
- Nominatim: adds 1-req/sec rate limiting and client-side result caching
  with 5-minute TTL as required by Nominatim usage policy
- OSM tile server: blocks offline tile downloads (explicitly prohibited by
  tile usage policy) with user-facing dialog
- Attribution dialog: adds tappable license link to openstreetmap.org/copyright
  when using OSM-based tile providers (ODbL requirement)
- OSM editing API: enforces max 2 concurrent download threads via semaphore
- 23 unit tests covering policy resolution, URL template parsing, custom
  overrides, rate limiting, and all policy values

Builds on top of PR FoggedLens#123 (UserAgentClient) and PR FoggedLens#127 (NetworkTileProvider).
Compatible with PR FoggedLens#114 (Overpass parallelization) — Overpass policy defers
to NodeDataManager's _AsyncSemaphore.

https://claude.ai/code/session_01XyRTrax1tmtjcuT7CMoJhD
@dougborg dougborg force-pushed the fix/109-parallelize-quadrant-fetching branch from 9a58036 to 48e19ec Compare February 25, 2026 17:15
dougborg pushed a commit to dougborg/deflock-app that referenced this pull request Feb 25, 2026
Introduces a unified service policy system (ServiceType, ServicePolicy,
ServicePolicyResolver, ServiceRateLimiter) that resolves compliance rules
per-URL, covering OSMF official services and third-party tile providers.
Custom/self-hosted endpoints get permissive defaults.

Key changes:
- ServicePolicyResolver maps URLs to policies (OSM tile server, Nominatim,
  editing API, Overpass, Bing, Mapbox, custom)
- Nominatim: adds 1-req/sec rate limiting and client-side result caching
  with 5-minute TTL as required by Nominatim usage policy
- OSM tile server: blocks offline tile downloads (explicitly prohibited by
  tile usage policy) with user-facing dialog
- Attribution dialog: adds tappable license link to openstreetmap.org/copyright
  when using OSM-based tile providers (ODbL requirement)
- OSM editing API: enforces max 2 concurrent download threads via semaphore
- 23 unit tests covering policy resolution, URL template parsing, custom
  overrides, rate limiting, and all policy values

Builds on top of PR FoggedLens#123 (UserAgentClient) and PR FoggedLens#127 (NetworkTileProvider).
Compatible with PR FoggedLens#114 (Overpass parallelization) — Overpass policy defers
to NodeDataManager's _AsyncSemaphore.

https://claude.ai/code/session_01XyRTrax1tmtjcuT7CMoJhD
@dougborg dougborg force-pushed the fix/109-parallelize-quadrant-fetching branch from 48e19ec to c901c8c Compare February 26, 2026 00:57
@dougborg dougborg requested a review from Copilot February 26, 2026 01:02
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Doug Borg and others added 2 commits February 25, 2026 18:19
When Overpass queries hit the 50k node limit, _fetchSplitAreas() splits
the area into 4 quadrants. Previously these were fetched sequentially —
at max split depth (3) that's up to 64 serial HTTP requests, which is
the primary cause of the slow "splitting" network status users report.

This change:

- Parallelizes quadrant fetches with Future.wait() instead of a for-loop
- Adds a dynamic concurrency limiter (_AsyncSemaphore) sized from the
  Overpass /api/status slot count, so we never exceed the server's
  per-IP rate limit
- Replaces the "sleep 30s and give up on 429" behavior with smart retry:
  polls /api/status until a slot is available (modeled after OSMnx and
  OSMPythonTools), then retries up to 2 times
- Resizes the semaphore on retry with the latest observed slot count,
  adapting to changing server conditions

Refactors for testability:
- HTTP client injection in OverpassService (following RoutingService pattern)
- DI constructors + forTesting() factories on NodeDataManager and
  NodeSpatialCache
- splitBounds made static + @VisibleForTesting for direct unit testing

18 new tests covering: splitBounds geometry, getSlotCount/waitForSlot
parsing, fetchWithSplitting (happy path, split, max depth, rate limit
retry + cap), partial/total quadrant failure, recursive splitting, and
semaphore init deduplication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When the user pans/zooms mid-fetch, queued sub-requests now bail out
instead of blocking the semaphore. Each getNodesFor() call increments
_fetchGeneration; fetchWithSplitting checks _isStale(generation) at
6 cooperative checkpoints (before semaphore, inside lambda, before
splitting, before/after waitForSlot, top of _fetchSplitAreas).

Null generation (offline download, existing tests) is never stale,
so there is zero regression risk for non-production-path callers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dougborg dougborg force-pushed the fix/109-parallelize-quadrant-fetching branch from c901c8c to 9bebba6 Compare February 26, 2026 01: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.

perf: Parallelize quadrant fetching during area splitting

2 participants