Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Areas/Public/Controllers/TilesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ public class TilesController : Controller

/// <summary>
/// Retry-After header value (in seconds) sent with HTTP 503 when the outbound budget is exhausted.
/// Set to 5s to align with <see cref="TileCacheService.OutboundBudget"/>: at 2 tokens/sec
/// (ReplenishIntervalMs=500) with BurstCapacity=10, a full burst refills in ~5 seconds.
/// Set to 6s to align with <see cref="TileCacheService.OutboundBudget"/>: at 2 tokens/sec
/// (ReplenishIntervalMs=500) with BurstCapacity=12, a full burst refills in ~6 seconds.
/// Also exposed to the client via <c>wayfarerTileConfig.retryAfterSeconds</c> so the
/// tile layer can derive its slow-retry interval without hardcoding the value.
/// </summary>
internal const int BudgetRetryAfterSeconds = 5;
internal const int BudgetRetryAfterSeconds = 6;

/// <summary>
/// Thread-safe dictionary for rate limiting anonymous tile requests by IP address.
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# CHANGELOG

## [1.2.26] - 2026-03-27

### Changed
- Outbound budget burst capacity raised from 10 to 12 — allows 2 more tiles through on initial burst before settling into sustained 2/sec rate, reducing 503s on cold-cache loads (#214)
- Outbound budget acquire timeout raised from 3.0s to 3.5s — extra 0.5s yields 1 more token from replenishment per wave, reducing false timeouts (#214)
- Client concurrency pool multiplier raised from 60% to 75% of burst capacity (pool size 6 → 9) — more tiles queue server-side instead of waiting client-side (#214)
- Budget retry-after interval updated from 5s to 6s to align with new burst refill time (12 tokens / 2 per sec = 6s) (#214)
- Client slow-retry interval auto-derives to 18s (was 15s) from updated retry-after × 3 (#214)

## [1.2.25] - 2026-03-26

### Fixed
Expand Down
15 changes: 11 additions & 4 deletions Services/TileCacheService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,11 @@ internal static class OutboundBudget
{
/// <summary>
/// Maximum burst capacity — how many outbound requests can proceed without waiting
/// for replenishment. Set to 10 to allow initial map loads to proceed quickly on
/// for replenishment. Set to 12 to allow initial map loads to proceed quickly on
/// a cold cache. OSM's 2-connection limit is enforced at the transport layer via
/// <c>SocketsHttpHandler.MaxConnectionsPerServer = 2</c> in Program.cs.
/// </summary>
internal const int BurstCapacity = 10;
internal const int BurstCapacity = 12;

/// <summary>
/// Replenishment interval — one token is released every this many milliseconds.
Expand All @@ -145,9 +145,10 @@ internal static class OutboundBudget
/// Maximum time to wait for a token before giving up. Callers that time out
/// serve stale cache or return 503 (graceful degradation).
/// Reduced from 10s to 3s to prevent thread pool starvation under sustained
/// cold-cache load (multiple users loading maps with many uncached tiles).
/// cold-cache load, then raised to 3.5s to allow one extra token per wave
/// during cold-cache bootstrap without exceeding thread pool pressure.
/// </summary>
internal static readonly TimeSpan AcquireTimeout = TimeSpan.FromSeconds(3);
internal static readonly TimeSpan AcquireTimeout = TimeSpan.FromSeconds(3.5);

/// <summary>
/// Semaphore representing available outbound tokens. Initialized to <see cref="BurstCapacity"/>.
Expand Down Expand Up @@ -742,6 +743,12 @@ private void WriteSidecarMetadata(string tileFilePath, TileSidecarMetadata metad

// ── Tile caching and retrieval ──────────────────────────────────────

// TODO #214-D: Investigate pre-warming adjacent zoom levels (z-1, z+1) after a successful
// fetch to avoid cold-cache penalties when users zoom in/out. Two approaches to evaluate:
// 1. Fire-and-forget from CacheTileAsync — simplest but competes equally for OutboundBudget tokens.
// 2. Background Channel<T> queue at lower priority — safer but requires a priority-aware token bucket.
// Deferred: OutboundBudget currently has no priority mechanism (SemaphoreSlim-based).

/// <summary>
/// Downloads a tile from the given URL and caches it on the file system.
/// Stores ETag, Last-Modified, and computed expiry from upstream response headers.
Expand Down
2 changes: 1 addition & 1 deletion tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ public async Task GetTile_Returns503WithRetryAfter_WhenBudgetExhausted()
var statusResult = Assert.IsType<ObjectResult>(result);
Assert.Equal(503, statusResult.StatusCode);
Assert.Equal("Tile server busy. Please retry shortly.", statusResult.Value);
Assert.Equal("5", controller.Response.Headers["Retry-After"].ToString());
Assert.Equal("6", controller.Response.Headers["Retry-After"].ToString());
}
finally
{
Expand Down
2 changes: 1 addition & 1 deletion tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -766,7 +766,7 @@ public async Task OutboundBudget_AcquireAsync_GrantsTokensUpToBurstCapacity()
{
TileCacheService.OutboundBudget.ResetForTesting();

// Burst capacity is 10 — first 10 should succeed immediately.
// Burst capacity is 12 — first 12 should succeed immediately.
for (int i = 0; i < TileCacheService.OutboundBudget.BurstCapacity; i++)
{
var acquired = await TileCacheService.OutboundBudget.AcquireAsync();
Expand Down
12 changes: 6 additions & 6 deletions wwwroot/js/retryTileLayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@
const _config = window.wayfarerTileConfig || {};

// ---------- Global concurrency pool ----------
// Pool size derived from server's outbound burst capacity: 60% of burst leaves headroom
// Pool size derived from server's outbound burst capacity: 75% of burst leaves headroom
// for other concurrent users while still allowing a cold-cache load to progress quickly.
// Falls back to 6 if config is unavailable (e.g., inline scripts outside _Layout).
const _poolSize = Math.ceil((_config.burstCapacity || 10) * 0.6);
// Falls back to 9 if config is unavailable (e.g., inline scripts outside _Layout).
const _poolSize = Math.ceil((_config.burstCapacity || 12) * 0.75);
let _inFlight = 0;
const _waiting = [];

Expand Down Expand Up @@ -79,8 +79,8 @@ const _releaseSlot = () => {
// ---------- Retry timing derived from server config ----------
// retryAfterSeconds is the Retry-After value the server sends on 503 (matches the budget
// replenishment cycle). Slow retry uses 3x that interval to give the per-IP sliding window
// time to decay between attempts. Falls back to 5s if config unavailable.
const _retryAfterSeconds = _config.retryAfterSeconds || 5;
// time to decay between attempts. Falls back to 6s if config unavailable.
const _retryAfterSeconds = _config.retryAfterSeconds || 6;
const _defaultSlowRetryDelayMs = _retryAfterSeconds * 3 * 1000;

const RetryTileLayer = L.TileLayer.extend({
Expand Down Expand Up @@ -225,7 +225,7 @@ const RetryTileLayer = L.TileLayer.extend({
* Two retry phases:
* - Fast: attempts 0..maxRetries with exponential backoff (seconds)
* - Slow: after fast retries exhaust on 503/network error, single-shot polls
* every ~15s (derived from retryAfterSeconds * 3) until the tile loads or
* every ~18s (derived from retryAfterSeconds * 3) until the tile loads or
* is removed — each poll is one fetch, not a full fast-retry cycle
* Acquires a concurrency slot before each fetch attempt to prevent overwhelming
* the server's budget. Respects AbortSignal so removed tiles stop immediately.
Expand Down
Loading