Skip to content

Add Gatling load tests for overpass.deflock.org#4

Closed
dougborg wants to merge 25 commits intomainfrom
feat/load-tests
Closed

Add Gatling load tests for overpass.deflock.org#4
dougborg wants to merge 25 commits intomainfrom
feat/load-tests

Conversation

@dougborg
Copy link
Copy Markdown
Owner

@dougborg dougborg commented Mar 9, 2026

Summary

  • Adds a Gatling (Scala) load test project in load-tests/ to validate overpass.deflock.org performance before switching app users to it
  • Single-user zoom progression scenario walks from z15 (city blocks) to z10 (metro region) across 6 US cities, replaying the exact Overpass queries the app sends
  • Manual-trigger GitHub Actions workflow produces downloadable HTML reports with latency percentiles, error rates, and throughput metrics
  • Includes dev container, Gradle wrapper, and thorough documentation for contributors unfamiliar with the Gatling/Scala/JVM stack

Baseline results

All 6 requests pass with 0% error rate. Response times scale linearly with viewport size:

Zoom Area Latency
z15 A few blocks ~400-600ms
z14 Neighborhood ~500-700ms
z13 District ~600-800ms
z12 Mid-size city ~700-1000ms
z11 Large city ~900-1200ms
z10 Metro region ~1200-1600ms

What's next

This PR establishes the scaffolding. Follow-up work:

  • Concurrent user scenario (ramp up to find capacity limits)
  • Stress test scenario (push beyond capacity to find breaking points)

Test plan

  • cd load-tests && ./gradlew gatlingRun completes with BUILD SUCCESSFUL
  • Gatling HTML report generated in build/reports/gatling/
  • All 6 requests return HTTP 200 with valid Overpass JSON (elements array)
  • Report labels show zoom level and city (e.g., "Overpass z15 - Denver")
  • Assertions pass: p99 < 30s, error rate < 5%
  • GitHub Actions workflow runs successfully (trigger manually after merge)

🤖 Generated with Claude Code

dougborg and others added 25 commits February 25, 2026 09:45
…ints

Run `flutter pub upgrade` to pull in 42 dependency updates within
existing ^constraints. No pubspec.yaml changes needed.

Notable updates: flutter_map 8.2.1→8.2.2, flutter_svg 2.2.0→2.2.3,
http 1.5.0-beta.2→1.6.0, provider 6.1.5→6.1.5+1,
shared_preferences 2.5.3→2.5.4, uuid 4.5.1→4.5.2, xml 6.5.0→6.6.1,
flutter_native_splash 2.4.6→2.4.7, plus many transitive deps.

Closes FoggedLens#78

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Transitive AndroidX dependencies (browser:1.9.0, core-ktx:1.17.0,
core:1.17.0) pulled in by the pub upgrade now require AGP 8.9.1+.

- AGP: 8.7.3 → 8.9.1
- Java source/target compatibility: 11 → 17
- Gradle 8.12 already satisfies AGP 8.9.1's minimum of 8.11.1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…able releases

Move auth-critical packages from pre-release pins to stable:
- flutter_web_auth_2: 5.0.0-alpha.3 → ^5.0.1
- flutter_secure_storage: 10.0.0-beta.4 → ^10.0.0
- oauth2_client: 4.2.0 → 4.2.3 (auto-resolved, was blocked by pre-release pins)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
chore(deps): Upgrade minor/patch dependencies
chore(deps): Move auth packages to stable releases
…ersions

Upgrade packages:
- app_links: ^6.1.4 → ^7.0.0 (backward compatible with v6)
- package_info_plus: ^8.0.0 → ^9.0.0 (build tooling only, no Dart API changes)

Bump Android build tooling to latest Flutter 3.38-compatible versions:
- AGP: 8.9.1 → 8.11.1
- Gradle: 8.12 → 8.14
- Kotlin: 2.1.0 → 2.2.20

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… DSL deprecation

- Bump Dart SDK constraint from >=3.8.0 to >=3.10.3 to match resolved dependency floor
- Upgrade desugar_jdk_libs from 2.0.4 to 2.1.5 (adds Stream.toList(), better locale support)
- Migrate deprecated kotlinOptions { jvmTarget } to kotlin { compilerOptions { jvmTarget } }
- Remove stale comments and non-breaking space characters

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Apple requires all iOS/iPadOS apps to be built with the iOS 26 SDK
(Xcode 26+) starting April 28, 2026. Switch the build-ios and
upload-to-stores jobs from macos-latest (macOS 15 / Xcode 16) to
macos-26 (macOS 26 / Xcode 26).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ckageinfo

chore(deps): Update app_links and package_info_plus to latest majors
Build with iOS 26 SDK for App Store deadline
bump version
…ilds.sh

Don't require trailing new line in build.keys.conf
Preview/PR builds don't have access to GitHub Secrets, so the OAuth
client IDs are empty. Previously this caused a runtime crash from
keys.dart throwing on empty values. Now we detect missing secrets
and force simulate mode, which already fully supports fake auth
and uploads.

Also fixes a latent bug where forceLogin() would crash with
LateInitializationError in simulate mode since _helper is never
initialized when OAuth setup is skipped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…without-secrets

Force simulate mode when OAuth secrets are missing
- Add ServicePolicy framework with OSM-specific rate limiting and TTL
- Add per-provider disk tile cache (ProviderTileCacheStore) with O(1)
  lookup, oldest-modified eviction, and ETag/304 revalidation
- Rewrite DeflockTileProvider with two paths: common (NetworkTileProvider)
  and offline-first (disk cache -> local tiles -> network with caching)
- Add zoom-aware offline routing so tiles outside offline area zoom ranges
  use the efficient common path instead of the overhead-heavy offline path
- Fix HTTP client lifecycle: dispose() is now a no-op for flutter_map
  widget recycling; shutdown() handles permanent teardown
- Add TileLayerManager with exponential backoff retry (2s->60s cap),
  provider switch detection, and backoff reset
- Guard null provider/tileType in download dialog with localized error
- Fix Nominatim cache key to use normalized viewbox values
- Comprehensive test coverage (1800+ lines across 6 test files)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Allow offline area downloads for OSM tile server. Move the "downloads
not permitted" check from inside the download dialog to the download
button itself — the button is now disabled (greyed out) when the
current tile type doesn't support offline downloads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a user edits a tile type's URL template, max zoom, or API key
without changing IDs, the cached DeflockTileProvider would keep the old
frozen config. Now _getOrCreateProvider() computes a config fingerprint
and replaces the provider when drift is detected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
I think this finally has online, offline, proper caching, tile types, all working correctly.
Validate the performance of our self-hosted Overpass API endpoint
before switching app users to it. The simulation replays realistic
queries matching the app's actual request format, walking through
zoom levels 15 (city blocks) to 10 (metro region) across 6 US
cities with high surveillance camera density.

Includes:
- Gatling 3.15 / Scala 2.13 / Gradle 9.4 project in load-tests/
- Single-user zoom progression scenario with p99/error assertions
- Manual-trigger GitHub Actions workflow with HTML report artifact
- Dev container for JDK 21 + Scala development environment
- Thorough documentation for contributors unfamiliar with the stack

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 9, 2026 05:59
@dougborg dougborg closed this Mar 9, 2026
Copy link
Copy Markdown

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 is significantly larger than its title suggests. While it does add Gatling load tests for overpass.deflock.org, it also includes major app-level changes: per-provider tile caching with disk-based cache stores, a service policy system for OSMF compliance (rate limiting, caching, attribution), exponential backoff tile retry logic, a FOV bug fix for node profiles, profile reordering with drag-and-drop, build hardening for missing OAuth secrets, attribution dialog improvements with license links, and dependency/build tool updates.

Changes:

  • Adds Gatling load test project with a single-user zoom progression scenario, GitHub Actions workflow, dev container, and documentation
  • Implements per-provider tile caching (ProviderTileCacheStore, ProviderTileCacheManager), service policy enforcement (ServicePolicy, ServiceRateLimiter), and exponential backoff tile retries in TileLayerManager
  • Multiple app improvements: profile reordering, FOV bug fix for createExistingTagsProfile, build-without-secrets hardening, attribution dialog with license links, and Nominatim result caching

Reviewed changes

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

Show a summary per file
File Description
load-tests/* New Gatling load test project (simulation, requests, test data, Gradle config, dev container, docs)
.github/workflows/load-test.yml Manual-trigger GitHub Actions workflow for load tests
lib/services/service_policy.dart New service policy system with per-service rate limiting and compliance rules
lib/services/provider_tile_cache_store.dart New per-provider disk-based tile cache with eviction
lib/services/provider_tile_cache_manager.dart Factory/registry for per-provider cache stores
lib/services/deflock_tile_provider.dart Refactored to frozen config, per-provider caching, structured error types, offline-first/online paths
lib/widgets/map/tile_layer_manager.dart Per-provider provider caching, exponential backoff retries, config drift detection
lib/widgets/map/map_overlays.dart Attribution dialog with license link, accessibility improvements
lib/widgets/map_view.dart Updated to pass provider ID to cache clearing
lib/models/tile_provider.dart Added servicePolicy and allowsOfflineDownload via ServicePolicyResolver
lib/models/node_profile.dart Fixed FOV bug: only assign FOV for explicit range notation in direction tags
lib/state/profile_state.dart Added profile reordering with drag-and-drop persistence
lib/state/settings_state.dart Build-without-secrets hardening, force simulate mode when kHasOsmSecrets is false
lib/keys.dart Added kHasOsmSecrets, removed throwing getters
lib/services/search_service.dart Added Nominatim result caching and rate limiting
lib/services/map_data_submodules/tiles_from_local.dart O(1) tileInBounds check, extracted normalizeBounds
lib/services/offline_area_service.dart Added hasOfflineAreasForProviderAtZoom, normalizeBounds on storage
lib/services/auth_service.dart Guard against null secrets in simulate mode
lib/screens/settings/sections/node_profiles_section.dart Drag-and-drop profile reordering UI
lib/screens/home_screen.dart Disable download button when offline download not permitted
lib/widgets/download_area_dialog.dart Guard against null provider/tile type before download
lib/main.dart Init ProviderTileCacheManager at startup
lib/migrations.dart Added v2.7.3 migration for profile ordering
pubspec.yaml Version bump, dependency updates, SDK constraint update
android/* Build tool updates (Gradle, Kotlin, AGP, desugar)
.github/workflows/workflow.yml Changed macOS runner to macos-26
lib/localizations/*.json Added new localization strings for attribution, offline download messages
test/* Comprehensive tests for new tile caching, service policy, tile layer manager, and more
assets/changelog.json Added changelog entries for 2.8.0, 2.8.1, 2.9.0

💡 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.

final int maxZoom; // Maximum zoom level for this tile type

const TileType({
TileType({
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

The TileType class was changed from const constructor to a non-const constructor (removing const keyword). This is a breaking change for any existing code that uses const TileType(...) expressions. The reason is the addition of late final field servicePolicy, which requires a non-const constructor. However, the test file deflock_tile_provider_test.dart had its const removed from the tile type declarations to match this, so the change appears intentional. Just be aware that any other callers using const TileType(...) will need updating.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Acknowledged — the const removal is intentional. TileType gains a late final servicePolicy that requires runtime initialization, so it can't be const. All existing callers (app code and tests) have been updated in this PR.

Comment on lines +169 to 178
void _pruneCache() {
_resultCache.removeWhere((_, cached) => cached.isExpired);
// Limit cache to 50 entries to prevent unbounded growth
if (_resultCache.length > 50) {
final sortedKeys = _resultCache.keys.toList()
..sort((a, b) => _resultCache[a]!.cachedAt.compareTo(_resultCache[b]!.cachedAt));
for (final key in sortedKeys.take(_resultCache.length - 50)) {
_resultCache.remove(key);
}
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

The _resultCache is a static Map but _pruneCache() is an instance method. Since _resultCache is shared across all SearchService instances, the pruning logic is fine, but _pruneCache should arguably also be static for consistency. This is a minor maintainability concern — a future developer might instantiate multiple SearchService objects, and the instance method accessing a static field could be confusing.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fair point — SearchService is used as a singleton via MapDataProvider, so multiple instances aren't a practical concern. The static cache + instance method pattern is consistent with how other services in the codebase work (e.g., ChangelogService). Not changing for now but noted.

Comment on lines +283 to +296
void _putCachedTile({
required Map<String, String> responseHeaders,
Uint8List? bytes,
}) {
if (cachingProvider == null || !cachingProvider!.isSupported) return;
try {
final metadata = CachedMapTileMetadata.fromHttpHeaders(responseHeaders);
cachingProvider!
.putTile(url: tileUrl, metadata: metadata, bytes: bytes)
.catchError((_) {});
} catch (_) {
// Best-effort: never fail the tile load due to cache write errors.
}
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

The _putCachedTile method uses .catchError((_) {}) which silently swallows all errors from the putTile future. While this is intentional (best-effort caching), the outer try/catch already catches synchronous errors. The .catchError here suppresses async errors from the fire-and-forget future. This is acceptable for best-effort caching, but consider at least logging the error in debug mode for diagnostics, similar to how other cache operations in this codebase use debugPrint.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Good call. The .catchError is intentional (fire-and-forget cache write shouldn't crash the tile pipeline), but adding a debugPrint for diagnostics is a reasonable improvement. Will address in a follow-up.

Comment on lines +107 to +121
void reorderProfiles(int oldIndex, int newIndex) {
final orderedProfiles = _getOrderedProfiles();

// Standard Flutter reordering logic
if (oldIndex < newIndex) {
newIndex -= 1;
}
final item = orderedProfiles.removeAt(oldIndex);
orderedProfiles.insert(newIndex, item);

// Update custom order with new sequence
_customOrder = orderedProfiles.map((p) => p.id).toList();
_saveCustomOrder();
notifyListeners();
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

The _getOrderedProfiles() method returns a new mutable List every time it's called. In reorderProfiles(), this mutable copy is modified (via removeAt and insert), but the underlying _profiles list is never reordered — only _customOrder is updated. This means the profiles getter (which calls _getOrderedProfiles()) will reflect the new order via _customOrder, but _profiles stays in its original order. This is correct behavior, but the reorderProfiles method creates and modifies a temporary list that is immediately discarded after extracting the IDs. This is fine for correctness but could be slightly confusing for future maintainers.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Correct — the temporary list in reorderProfiles() is only used to extract the new ID ordering, which is then persisted in _customOrder. The _profiles list stays in insertion order by design (it's the source of truth for profile data, not display order). The temporary list is discarded intentionally.

Comment on lines +1 to +20
// Gatling load test build configuration.
//
// Gatling (https://gatling.io) is a load testing framework that simulates
// virtual users sending HTTP requests and produces HTML performance reports.
//
// The `scala` plugin compiles our Scala simulation files.
// The `io.gatling.gradle` plugin adds the `gatlingRun` task and manages
// Gatling + Scala library dependencies automatically.
//
// Run tests: ./gradlew gatlingRun
// Reports: build/reports/gatling/

plugins {
scala
id("io.gatling.gradle") version "3.15.0"
}

repositories {
mavenCentral()
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

The PR description says "Add Gatling load tests for overpass.deflock.org" but the PR also contains substantial application code changes: per-provider tile caching, service policy enforcement, profile reordering, FOV bug fix, settings state changes for builds without OAuth secrets, attribution dialog improvements, and more. The PR title and description focus primarily on the load test scaffolding, but the majority of the code changes are app-level features and bug fixes that are not mentioned in the title. This makes the PR significantly larger and more complex than the description suggests.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This is a roll-up PR that includes the load test scaffolding plus the app changes that shipped in v2.8.0–2.9.0. The load tests were the last piece to land. The PR description has been focused on the load test aspect since the app changes were already reviewed and released incrementally. Happy to update the PR description to enumerate all the app changes if that would help reviewers.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 9, 2026

Debug builds

Download from the artifacts page:

  • debug-apk — install on Android device/emulator
  • ios-simulator — unzip and install with xcrun simctl install booted Runner.app

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.

3 participants