Detect config drift in cached tile providers and replace stale instances

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>
This commit is contained in:
Doug Borg
2026-03-04 10:12:49 -07:00
parent f3f40f36ef
commit 91e5177056
7 changed files with 197 additions and 13 deletions

View File

@@ -52,6 +52,10 @@ class DeflockTileProvider extends NetworkTileProvider {
final models.TileType tileType;
final String? apiKey;
/// Opaque fingerprint of the config this provider was created with.
/// Used by [TileLayerManager] to detect config drift after edits.
final String configFingerprint;
/// Caching provider for the offline-first path. The same instance is passed
/// to super for the common path — we keep a reference here so we can also
/// use it in [DeflockOfflineTileImageProvider].
@@ -69,6 +73,7 @@ class DeflockTileProvider extends NetworkTileProvider {
this.apiKey,
MapCachingProvider? cachingProvider,
this.onNetworkSuccess,
this.configFingerprint = '',
}) : _sharedHttpClient = httpClient,
_cachingProvider = cachingProvider,
super(
@@ -87,6 +92,7 @@ class DeflockTileProvider extends NetworkTileProvider {
String? apiKey,
MapCachingProvider? cachingProvider,
VoidCallback? onNetworkSuccess,
String configFingerprint = '',
}) {
final client = UserAgentClient(RetryClient(Client()));
return DeflockTileProvider._(
@@ -96,6 +102,7 @@ class DeflockTileProvider extends NetworkTileProvider {
apiKey: apiKey,
cachingProvider: cachingProvider,
onNetworkSuccess: onNetworkSuccess,
configFingerprint: configFingerprint,
);
}

View File

@@ -37,8 +37,11 @@ class ProviderTileCacheManager {
required ServicePolicy policy,
int? maxCacheBytes,
}) {
assert(_baseCacheDir != null,
'ProviderTileCacheManager.init() must be called before getOrCreate()');
if (_baseCacheDir == null) {
throw StateError(
'ProviderTileCacheManager.init() must be called before getOrCreate()',
);
}
final key = '$providerId/$tileTypeId';
if (_stores.containsKey(key)) return _stores[key]!;

View File

@@ -47,7 +47,7 @@ class ProviderTileCacheStore implements MapCachingProvider {
@override
Future<CachedMapTile?> getTile(String url) async {
final key = _keyFor(url);
final key = keyFor(url);
final tileFile = File(p.join(cacheDirectory, '$key.tile'));
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
@@ -90,7 +90,7 @@ class ProviderTileCacheStore implements MapCachingProvider {
}) async {
await _ensureDirectory();
final key = _keyFor(url);
final key = keyFor(url);
final tileFile = File(p.join(cacheDirectory, '$key.tile'));
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
@@ -158,7 +158,8 @@ class ProviderTileCacheStore implements MapCachingProvider {
}
/// Generate a cache key from URL using UUID v5 (same as flutter_map built-in).
static String _keyFor(String url) => _uuid.v5(Namespace.url.value, url);
@visibleForTesting
static String keyFor(String url) => _uuid.v5(Namespace.url.value, url);
/// Estimate total cache size (lazy, first call scans directory).
Future<int> _getEstimatedSize() async {
@@ -301,6 +302,7 @@ class ProviderTileCacheStore implements MapCachingProvider {
}
_estimatedSize = null;
_directoryReady = null; // Allow lazy re-creation
_lastPruneCheck = null; // Reset throttle so next write can trigger eviction
}
/// Get the current estimated cache size in bytes.

View File

@@ -315,10 +315,12 @@ class ServiceRateLimiter {
static Future<void> acquire(ServiceType service) async {
final policy = ServicePolicyResolver.resolveByType(service);
// Concurrency: acquire semaphore slot first, so only one caller at a
// time proceeds to the rate-limit check. This prevents concurrent
// callers from bypassing the min interval when _lastRequestTime is
// still null or stale.
// Concurrency: acquire a semaphore slot first so that at most
// [policy.maxConcurrentRequests] callers proceed concurrently.
// The min-interval check below is only race-free when
// maxConcurrentRequests == 1 (currently only Nominatim). For services
// with higher concurrency the interval is approximate, which is
// acceptable — their policies don't specify a min interval.
_Semaphore? semaphore;
if (policy.maxConcurrentRequests > 0) {
semaphore = _semaphores.putIfAbsent(

View File

@@ -225,7 +225,24 @@ class TileLayerManager {
);
}
/// Build a config fingerprint for drift detection.
///
/// If any of these fields change (e.g. user edits the URL template or
/// rotates an API key) the cached [DeflockTileProvider] must be replaced.
static String _configFingerprint(
models.TileProvider provider,
models.TileType tileType,
) =>
'${provider.id}/${tileType.id}'
'|${tileType.urlTemplate}'
'|${tileType.maxZoom}'
'|${provider.apiKey ?? ''}';
/// Get or create a [DeflockTileProvider] for the given provider/type.
///
/// Providers are cached by `providerId/tileTypeId`. If the effective config
/// (URL template, max zoom, API key) has changed since the provider was
/// created, the stale instance is shut down and replaced.
DeflockTileProvider _getOrCreateProvider({
required models.TileProvider? selectedProvider,
required models.TileType? selectedTileType,
@@ -247,6 +264,19 @@ class TileLayerManager {
}
final key = '${selectedProvider.id}/${selectedTileType.id}';
final fingerprint = _configFingerprint(selectedProvider, selectedTileType);
// Check for config drift: if the provider exists but its config has
// changed, shut down the stale instance so a fresh one is created below.
final existing = _providers[key];
if (existing != null && existing.configFingerprint != fingerprint) {
debugPrint(
'[TileLayerManager] Config changed for $key — replacing provider',
);
existing.shutdown();
_providers.remove(key);
}
return _providers.putIfAbsent(key, () {
final cachingProvider = ProviderTileCacheManager.isInitialized
? ProviderTileCacheManager.getOrCreate(
@@ -267,6 +297,7 @@ class TileLayerManager {
apiKey: selectedProvider.apiKey,
cachingProvider: cachingProvider,
onNetworkSuccess: onTileLoadSuccess,
configFingerprint: fingerprint,
);
});
}