From 71fa850a87fa9fbd32659e0462a99ce24d039848 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Mon, 13 Apr 2026 22:37:47 +0300 Subject: [PATCH 1/4] WIP: start issue 217 hot metadata cache (checkpoint) From 2aa00b89e5edc1cce40c377429595f5b1740900d Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Mon, 13 Apr 2026 22:52:13 +0300 Subject: [PATCH 2/4] WIP: implement tile metadata hot cache (checkpoint) --- Areas/Admin/Controllers/SettingsController.cs | 64 +- Areas/Admin/Views/Settings/Index.cshtml | 41 + ...ileMetadataHotCacheSizeSetting.Designer.cs | 1612 +++++++++++++++++ ...4552_AddTileMetadataHotCacheSizeSetting.cs | 29 + .../ApplicationDbContextModelSnapshot.cs | 3 + Models/ApplicationDbContextSeed.cs | 39 +- Models/ApplicationSettings.cs | 26 +- Program.cs | 1 + Services/TileCacheService.cs | 261 ++- Services/TileMetadataHotCache.cs | 252 +++ .../AdminSettingsControllerTests.cs | 27 +- .../Controllers/TilesControllerTests.cs | 4 +- .../Services/TileCacheServiceTests.cs | 142 +- wwwroot/js/Areas/Admin/Settings/Index.js | 25 + 14 files changed, 2432 insertions(+), 94 deletions(-) create mode 100644 Migrations/20260413194552_AddTileMetadataHotCacheSizeSetting.Designer.cs create mode 100644 Migrations/20260413194552_AddTileMetadataHotCacheSizeSetting.cs create mode 100644 Services/TileMetadataHotCache.cs diff --git a/Areas/Admin/Controllers/SettingsController.cs b/Areas/Admin/Controllers/SettingsController.cs index dd65c0da..84ab0966 100644 --- a/Areas/Admin/Controllers/SettingsController.cs +++ b/Areas/Admin/Controllers/SettingsController.cs @@ -62,13 +62,18 @@ public async Task Index() } // Fallbacks for unset values - if (settings.MaxCacheTileSizeInMB == 0) - { - settings.MaxCacheTileSizeInMB = ApplicationSettings.DefaultMaxCacheTileSizeInMB; - } - - if (settings.UploadSizeLimitMB == 0) - { + if (settings.MaxCacheTileSizeInMB == 0) + { + settings.MaxCacheTileSizeInMB = ApplicationSettings.DefaultMaxCacheTileSizeInMB; + } + + if (settings.TileMetadataHotCacheSizeMB == 0) + { + settings.TileMetadataHotCacheSizeMB = ApplicationSettings.DefaultTileMetadataHotCacheSizeMB; + } + + if (settings.UploadSizeLimitMB == 0) + { settings.UploadSizeLimitMB = ApplicationSettings.DefaultUploadSizeLimitMB; } @@ -143,14 +148,21 @@ public async Task Update(ApplicationSettings updatedSettings) } // Authenticated users should always have at least the same rate limit as anonymous users. - if (updatedSettings.TileRateLimitAuthenticatedPerMinute < updatedSettings.TileRateLimitPerMinute) - { - ModelState.AddModelError(nameof(updatedSettings.TileRateLimitAuthenticatedPerMinute), - "Authenticated rate limit must be equal to or greater than the anonymous rate limit."); - } - - if (currentSettings != null) - { + if (updatedSettings.TileRateLimitAuthenticatedPerMinute < updatedSettings.TileRateLimitPerMinute) + { + ModelState.AddModelError(nameof(updatedSettings.TileRateLimitAuthenticatedPerMinute), + "Authenticated rate limit must be equal to or greater than the anonymous rate limit."); + } + + if (updatedSettings.TileMetadataHotCacheSizeMB != -1 && + (updatedSettings.TileMetadataHotCacheSizeMB < 16 || updatedSettings.TileMetadataHotCacheSizeMB > 512)) + { + ModelState.AddModelError(nameof(updatedSettings.TileMetadataHotCacheSizeMB), + "Tile metadata hot cache size must be -1 (disable) or between 16 and 512 MB."); + } + + if (currentSettings != null) + { // Validate tile provider settings before model validation. NormalizeTileProviderSettings(currentSettings, updatedSettings); } @@ -190,11 +202,12 @@ void Track(string name, T oldVal, T newVal) changes.Add("TileProviderApiKey: [updated]"); } Track("TileRateLimitEnabled", currentSettings.TileRateLimitEnabled, updatedSettings.TileRateLimitEnabled); - Track("TileRateLimitPerMinute", currentSettings.TileRateLimitPerMinute, updatedSettings.TileRateLimitPerMinute); - Track("TileRateLimitAuthenticatedPerMinute", currentSettings.TileRateLimitAuthenticatedPerMinute, updatedSettings.TileRateLimitAuthenticatedPerMinute); - Track("TileOutboundBudgetPerIpPerMinute", currentSettings.TileOutboundBudgetPerIpPerMinute, updatedSettings.TileOutboundBudgetPerIpPerMinute); - Track("ProxyImageRateLimitEnabled", currentSettings.ProxyImageRateLimitEnabled, updatedSettings.ProxyImageRateLimitEnabled); - Track("ProxyImageRateLimitPerMinute", currentSettings.ProxyImageRateLimitPerMinute, updatedSettings.ProxyImageRateLimitPerMinute); + Track("TileRateLimitPerMinute", currentSettings.TileRateLimitPerMinute, updatedSettings.TileRateLimitPerMinute); + Track("TileRateLimitAuthenticatedPerMinute", currentSettings.TileRateLimitAuthenticatedPerMinute, updatedSettings.TileRateLimitAuthenticatedPerMinute); + Track("TileOutboundBudgetPerIpPerMinute", currentSettings.TileOutboundBudgetPerIpPerMinute, updatedSettings.TileOutboundBudgetPerIpPerMinute); + Track("TileMetadataHotCacheSizeMB", currentSettings.TileMetadataHotCacheSizeMB, updatedSettings.TileMetadataHotCacheSizeMB); + Track("ProxyImageRateLimitEnabled", currentSettings.ProxyImageRateLimitEnabled, updatedSettings.ProxyImageRateLimitEnabled); + Track("ProxyImageRateLimitPerMinute", currentSettings.ProxyImageRateLimitPerMinute, updatedSettings.ProxyImageRateLimitPerMinute); // Trip Place Auto-Visited settings Track("VisitedRequiredHits", currentSettings.VisitedRequiredHits, updatedSettings.VisitedRequiredHits); @@ -237,11 +250,12 @@ void Track(string name, T oldVal, T newVal) currentSettings.TileProviderAttribution = updatedSettings.TileProviderAttribution; currentSettings.TileProviderApiKey = updatedSettings.TileProviderApiKey; currentSettings.TileRateLimitEnabled = updatedSettings.TileRateLimitEnabled; - currentSettings.TileRateLimitPerMinute = updatedSettings.TileRateLimitPerMinute; - currentSettings.TileRateLimitAuthenticatedPerMinute = updatedSettings.TileRateLimitAuthenticatedPerMinute; - currentSettings.TileOutboundBudgetPerIpPerMinute = updatedSettings.TileOutboundBudgetPerIpPerMinute; - currentSettings.ProxyImageRateLimitEnabled = updatedSettings.ProxyImageRateLimitEnabled; - currentSettings.ProxyImageRateLimitPerMinute = updatedSettings.ProxyImageRateLimitPerMinute; + currentSettings.TileRateLimitPerMinute = updatedSettings.TileRateLimitPerMinute; + currentSettings.TileRateLimitAuthenticatedPerMinute = updatedSettings.TileRateLimitAuthenticatedPerMinute; + currentSettings.TileOutboundBudgetPerIpPerMinute = updatedSettings.TileOutboundBudgetPerIpPerMinute; + currentSettings.TileMetadataHotCacheSizeMB = updatedSettings.TileMetadataHotCacheSizeMB; + currentSettings.ProxyImageRateLimitEnabled = updatedSettings.ProxyImageRateLimitEnabled; + currentSettings.ProxyImageRateLimitPerMinute = updatedSettings.ProxyImageRateLimitPerMinute; // Trip Place Auto-Visited settings currentSettings.VisitedRequiredHits = updatedSettings.VisitedRequiredHits; diff --git a/Areas/Admin/Views/Settings/Index.cshtml b/Areas/Admin/Views/Settings/Index.cshtml index 7beaac42..609f922d 100644 --- a/Areas/Admin/Views/Settings/Index.cshtml +++ b/Areas/Admin/Views/Settings/Index.cshtml @@ -1,4 +1,7 @@ @model ApplicationSettings +@{ + var tileMetadataHotCacheEntries = Wayfarer.Services.TileMetadataHotCache.GetApproximateEntryLimit(Model.TileMetadataHotCacheSizeMB); +} @using Wayfarer.Util @{ var isRegistrationOpen = (ViewData["IsRegistrationOpen"] as bool?) ?? false; @@ -720,6 +723,44 @@ +
+ + +
+ + MB +
+ + + +
+ • Approximate RAM budget for the in-process tile metadata hot cache used only for zoom levels + ≥ 9.
+ • Stores tile metadata only. It does not store tile image bytes.
+ • Set to -1 to disable the feature.
+ • Allowed values are -1 or 16-512 MB.
+ • The derived entry count is approximate and does not represent exact process memory usage.
+
+ +
+ @if (Model.TileMetadataHotCacheSizeMB == -1) + { + Hot metadata cache disabled. + } + else + { + @Model.TileMetadataHotCacheSizeMB MB ≈ @tileMetadataHotCacheEntries metadata entries + } +
+
+
diff --git a/Migrations/20260413194552_AddTileMetadataHotCacheSizeSetting.Designer.cs b/Migrations/20260413194552_AddTileMetadataHotCacheSizeSetting.Designer.cs new file mode 100644 index 00000000..f0c69e25 --- /dev/null +++ b/Migrations/20260413194552_AddTileMetadataHotCacheSizeSetting.Designer.cs @@ -0,0 +1,1612 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Wayfarer.Models; + +#nullable disable + +namespace Wayfarer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260413194552_AddTileMetadataHotCacheSizeSetting")] + partial class AddTileMetadataHotCacheSizeSetting + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext"); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImageCacheExpiryDays") + .HasColumnType("integer"); + + b.Property("IsRegistrationOpen") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LocationAccuracyThresholdMeters") + .HasColumnType("integer"); + + b.Property("LocationDistanceThresholdMeters") + .HasColumnType("integer"); + + b.Property("LocationTimeThresholdMinutes") + .HasColumnType("integer"); + + b.Property("MaxCacheImageSizeInMB") + .HasColumnType("integer"); + + b.Property("MaxCacheTileSizeInMB") + .HasColumnType("integer"); + + b.Property("MaxProxyImageDownloadMB") + .HasColumnType("integer"); + + b.Property("ProxyImageRateLimitEnabled") + .HasColumnType("boolean"); + + b.Property("ProxyImageRateLimitPerMinute") + .HasColumnType("integer"); + + b.Property("TileMetadataHotCacheSizeMB") + .HasColumnType("integer"); + + b.Property("TileOutboundBudgetPerIpPerMinute") + .HasColumnType("integer"); + + b.Property("TileProviderApiKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TileProviderAttribution") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("TileProviderKey") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TileProviderUrlTemplate") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TileRateLimitAuthenticatedPerMinute") + .HasColumnType("integer"); + + b.Property("TileRateLimitEnabled") + .HasColumnType("boolean"); + + b.Property("TileRateLimitPerMinute") + .HasColumnType("integer"); + + b.Property("UploadSizeLimitMB") + .HasColumnType("integer"); + + b.Property("VisitNotificationCooldownHours") + .HasColumnType("integer"); + + b.Property("VisitedAccuracyMultiplier") + .HasColumnType("double precision"); + + b.Property("VisitedAccuracyRejectMeters") + .HasColumnType("integer"); + + b.Property("VisitedMaxRadiusMeters") + .HasColumnType("integer"); + + b.Property("VisitedMaxSearchRadiusMeters") + .HasColumnType("integer"); + + b.Property("VisitedMinRadiusMeters") + .HasColumnType("integer"); + + b.Property("VisitedPlaceNotesSnapshotMaxHtmlChars") + .HasColumnType("integer"); + + b.Property("VisitedRequiredHits") + .HasColumnType("integer"); + + b.Property("VisitedSuggestionMaxRadiusMultiplier") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ApplicationSettings"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TripTags", b => + { + b.Property("TripId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.HasKey("TripId", "TagId"); + + b.HasIndex("TagId"); + + b.HasIndex("TripId"); + + b.ToTable("TripTags", (string)null); + }); + + modelBuilder.Entity("Wayfarer.Models.ActivityType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("ActivityTypes"); + }); + + modelBuilder.Entity("Wayfarer.Models.ApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Token") + .HasColumnType("text"); + + b.Property("TokenHash") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Name", "UserId") + .IsUnique() + .HasDatabaseName("IX_ApiToken_Name_UserId"); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("Wayfarer.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsProtected") + .HasColumnType("boolean"); + + b.Property("IsTimelinePublic") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PublicTimelineTimeThreshold") + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Wayfarer.Models.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("FillHex") + .HasColumnType("text"); + + b.Property("Geometry") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RegionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RegionId"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("Wayfarer.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Wayfarer.Models.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GroupType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrgPeerVisibilityEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InviteeEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("InviteeUserId") + .HasColumnType("text"); + + b.Property("InviterUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("RespondedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("InviteeUserId"); + + b.HasIndex("InviterUserId"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("GroupId", "InviteeUserId") + .IsUnique() + .HasDatabaseName("IX_GroupInvitation_GroupId_InviteeUserId_Pending") + .HasFilter("\"Status\" = 'Pending' AND \"InviteeUserId\" IS NOT NULL"); + + b.ToTable("GroupInvitations"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LeftAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrgPeerVisibilityAccessDisabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("GroupId", "Status") + .HasDatabaseName("IX_GroupMember_GroupId_Status"); + + b.HasIndex("GroupId", "UserId") + .IsUnique(); + + b.ToTable("GroupMembers"); + }); + + modelBuilder.Entity("Wayfarer.Models.HiddenArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Area") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("HiddenAreas"); + }); + + modelBuilder.Entity("Wayfarer.Models.ImageCacheMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CacheKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastAccessed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CacheKey") + .IsUnique() + .HasDatabaseName("IX_ImageCacheMetadata_CacheKey"); + + b.ToTable("ImageCacheMetadata"); + }); + + modelBuilder.Entity("Wayfarer.Models.JobHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastRunTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("JobHistories"); + }); + + modelBuilder.Entity("Wayfarer.Models.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Accuracy") + .HasColumnType("double precision"); + + b.Property("ActivityTypeId") + .HasColumnType("integer"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("AddressNumber") + .HasColumnType("text"); + + b.Property("Altitude") + .HasColumnType("double precision"); + + b.Property("AppBuild") + .HasColumnType("text"); + + b.Property("AppVersion") + .HasColumnType("text"); + + b.Property("BatteryLevel") + .HasColumnType("integer"); + + b.Property("Bearing") + .HasColumnType("double precision"); + + b.Property("Coordinates") + .IsRequired() + .HasColumnType("geography(Point, 4326)"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("DeviceModel") + .HasColumnType("text"); + + b.Property("FullAddress") + .HasColumnType("text"); + + b.Property("IdempotencyKey") + .HasColumnType("uuid"); + + b.Property("IsCharging") + .HasColumnType("boolean"); + + b.Property("IsUserInvoked") + .HasColumnType("boolean"); + + b.Property("LocalTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("LocationType") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OsVersion") + .HasColumnType("text"); + + b.Property("Place") + .HasColumnType("text"); + + b.Property("PostCode") + .HasColumnType("text"); + + b.Property("Provider") + .HasColumnType("text"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("Source") + .HasColumnType("text"); + + b.Property("Speed") + .HasColumnType("double precision"); + + b.Property("StreetName") + .HasColumnType("text"); + + b.Property("TimeZoneId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ActivityTypeId"); + + b.HasIndex("Coordinates") + .HasDatabaseName("IX_Location_Coordinates"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Coordinates"), "GIST"); + + b.HasIndex("UserId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("IX_Location_UserId_IdempotencyKey"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationImport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileType") + .HasColumnType("integer"); + + b.Property("LastImportedRecord") + .HasColumnType("text"); + + b.Property("LastProcessedIndex") + .HasColumnType("integer"); + + b.Property("SkippedDuplicates") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalRecords") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("LocationImports"); + }); + + modelBuilder.Entity("Wayfarer.Models.Place", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IconName") + .HasColumnType("text"); + + b.Property("Location") + .HasColumnType("geography(Point,4326)"); + + b.Property("MarkerColor") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RegionId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RegionId"); + + b.ToTable("Places"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitCandidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsecutiveHits") + .HasColumnType("integer"); + + b.Property("FirstHitUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastHitUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PlaceId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("LastHitUtc") + .HasDatabaseName("IX_PlaceVisitCandidate_LastHitUtc"); + + b.HasIndex("PlaceId"); + + b.HasIndex("UserId", "PlaceId") + .IsUnique() + .HasDatabaseName("IX_PlaceVisitCandidate_UserId_PlaceId"); + + b.ToTable("PlaceVisitCandidates"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArrivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EndedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IconNameSnapshot") + .HasColumnType("text"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MarkerColorSnapshot") + .HasColumnType("text"); + + b.Property("NotesHtml") + .HasColumnType("text"); + + b.Property("PlaceId") + .HasColumnType("uuid"); + + b.Property("PlaceLocationSnapshot") + .HasColumnType("geography(Point,4326)"); + + b.Property("PlaceNameSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionNameSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("Source") + .HasColumnType("text"); + + b.Property("TripIdSnapshot") + .HasColumnType("uuid"); + + b.Property("TripNameSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ArrivedAtUtc") + .HasDatabaseName("IX_PlaceVisitEvent_ArrivedAtUtc"); + + b.HasIndex("PlaceId") + .HasDatabaseName("IX_PlaceVisitEvent_PlaceId"); + + b.HasIndex("UserId", "EndedAtUtc") + .HasDatabaseName("IX_PlaceVisitEvent_UserId_EndedAtUtc"); + + b.ToTable("PlaceVisitEvents"); + }); + + modelBuilder.Entity("Wayfarer.Models.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Center") + .HasColumnType("geography(Point,4326)"); + + b.Property("CoverImageUrl") + .HasColumnType("text"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("TripId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TripId"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Wayfarer.Models.Segment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("EstimatedDistanceKm") + .HasColumnType("double precision"); + + b.Property("EstimatedDuration") + .HasColumnType("interval"); + + b.Property("FromPlaceId") + .HasColumnType("uuid"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RouteGeometry") + .HasColumnType("geography(LineString,4326)"); + + b.Property("ToPlaceId") + .HasColumnType("uuid"); + + b.Property("TripId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("FromPlaceId"); + + b.HasIndex("ToPlaceId"); + + b.HasIndex("TripId"); + + b.ToTable("Segments"); + }); + + modelBuilder.Entity("Wayfarer.Models.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("citext"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Wayfarer.Models.TileCacheMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ETag") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAccessed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastModifiedUpstream") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("TileFilePath") + .HasColumnType("text"); + + b.Property("TileLocation") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("X") + .HasColumnType("integer"); + + b.Property("Y") + .HasColumnType("integer"); + + b.Property("Zoom") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TileLocation"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("TileLocation"), "GIST"); + + b.HasIndex("Zoom", "X", "Y") + .IsUnique(); + + b.ToTable("TileCacheMetadata"); + }); + + modelBuilder.Entity("Wayfarer.Models.Trip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CenterLat") + .HasColumnType("double precision"); + + b.Property("CenterLon") + .HasColumnType("double precision"); + + b.Property("CoverImageUrl") + .HasColumnType("text"); + + b.Property("IsPublic") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("ShareProgressEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Zoom") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Trips"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TripTags", b => + { + b.HasOne("Wayfarer.Models.Tag", null) + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.Trip", null) + .WithMany() + .HasForeignKey("TripId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.ApiToken", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("ApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Area", b => + { + b.HasOne("Wayfarer.Models.Region", "Region") + .WithMany("Areas") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Wayfarer.Models.Group", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "Owner") + .WithMany("GroupsOwned") + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupInvitation", b => + { + b.HasOne("Wayfarer.Models.Group", "Group") + .WithMany("Invitations") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", "Invitee") + .WithMany("GroupInvitationsReceived") + .HasForeignKey("InviteeUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Wayfarer.Models.ApplicationUser", "Inviter") + .WithMany("GroupInvitationsSent") + .HasForeignKey("InviterUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Invitee"); + + b.Navigation("Inviter"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupMember", b => + { + b.HasOne("Wayfarer.Models.Group", "Group") + .WithMany("Members") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("GroupMemberships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.HiddenArea", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("HiddenAreas") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Location", b => + { + b.HasOne("Wayfarer.Models.ActivityType", "ActivityType") + .WithMany() + .HasForeignKey("ActivityTypeId"); + + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany("Locations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ActivityType"); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationImport", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("LocationImports") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Place", b => + { + b.HasOne("Wayfarer.Models.Region", "Region") + .WithMany("Places") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitCandidate", b => + { + b.HasOne("Wayfarer.Models.Place", "Place") + .WithMany() + .HasForeignKey("PlaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Place"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitEvent", b => + { + b.HasOne("Wayfarer.Models.Place", "Place") + .WithMany() + .HasForeignKey("PlaceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Place"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Region", b => + { + b.HasOne("Wayfarer.Models.Trip", "Trip") + .WithMany("Regions") + .HasForeignKey("TripId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trip"); + }); + + modelBuilder.Entity("Wayfarer.Models.Segment", b => + { + b.HasOne("Wayfarer.Models.Place", "FromPlace") + .WithMany() + .HasForeignKey("FromPlaceId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Wayfarer.Models.Place", "ToPlace") + .WithMany() + .HasForeignKey("ToPlaceId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Wayfarer.Models.Trip", "Trip") + .WithMany("Segments") + .HasForeignKey("TripId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FromPlace"); + + b.Navigation("ToPlace"); + + b.Navigation("Trip"); + }); + + modelBuilder.Entity("Wayfarer.Models.Trip", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("Trips") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.ApplicationUser", b => + { + b.Navigation("ApiTokens"); + + b.Navigation("GroupInvitationsReceived"); + + b.Navigation("GroupInvitationsSent"); + + b.Navigation("GroupMemberships"); + + b.Navigation("GroupsOwned"); + + b.Navigation("HiddenAreas"); + + b.Navigation("LocationImports"); + + b.Navigation("Locations"); + + b.Navigation("Trips"); + }); + + modelBuilder.Entity("Wayfarer.Models.Group", b => + { + b.Navigation("Invitations"); + + b.Navigation("Members"); + }); + + modelBuilder.Entity("Wayfarer.Models.Region", b => + { + b.Navigation("Areas"); + + b.Navigation("Places"); + }); + + modelBuilder.Entity("Wayfarer.Models.Trip", b => + { + b.Navigation("Regions"); + + b.Navigation("Segments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260413194552_AddTileMetadataHotCacheSizeSetting.cs b/Migrations/20260413194552_AddTileMetadataHotCacheSizeSetting.cs new file mode 100644 index 00000000..006f8c93 --- /dev/null +++ b/Migrations/20260413194552_AddTileMetadataHotCacheSizeSetting.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Wayfarer.Migrations +{ + /// + public partial class AddTileMetadataHotCacheSizeSetting : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TileMetadataHotCacheSizeMB", + table: "ApplicationSettings", + type: "integer", + nullable: false, + defaultValue: 64); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "TileMetadataHotCacheSizeMB", + table: "ApplicationSettings"); + } + } +} diff --git a/Migrations/ApplicationDbContextModelSnapshot.cs b/Migrations/ApplicationDbContextModelSnapshot.cs index 46bc5873..a9dd9ed5 100644 --- a/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Migrations/ApplicationDbContextModelSnapshot.cs @@ -65,6 +65,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ProxyImageRateLimitPerMinute") .HasColumnType("integer"); + b.Property("TileMetadataHotCacheSizeMB") + .HasColumnType("integer"); + b.Property("TileOutboundBudgetPerIpPerMinute") .HasColumnType("integer"); diff --git a/Models/ApplicationDbContextSeed.cs b/Models/ApplicationDbContextSeed.cs index 25a8d3e0..8ded82e9 100644 --- a/Models/ApplicationDbContextSeed.cs +++ b/Models/ApplicationDbContextSeed.cs @@ -85,14 +85,15 @@ private static async Task SeedApplicationSettingsAsync(IServiceProvider serviceP if (settings == null) { - dbContext.ApplicationSettings.Add(new ApplicationSettings - { - LocationTimeThresholdMinutes = 5, - LocationDistanceThresholdMeters = 15, - MaxCacheTileSizeInMB = ApplicationSettings.DefaultMaxCacheTileSizeInMB, - UploadSizeLimitMB = ApplicationSettings.DefaultUploadSizeLimitMB, - IsRegistrationOpen = false - }); + dbContext.ApplicationSettings.Add(new ApplicationSettings + { + LocationTimeThresholdMinutes = 5, + LocationDistanceThresholdMeters = 15, + MaxCacheTileSizeInMB = ApplicationSettings.DefaultMaxCacheTileSizeInMB, + TileMetadataHotCacheSizeMB = ApplicationSettings.DefaultTileMetadataHotCacheSizeMB, + UploadSizeLimitMB = ApplicationSettings.DefaultUploadSizeLimitMB, + IsRegistrationOpen = false + }); } else { @@ -112,14 +113,20 @@ private static async Task SeedApplicationSettingsAsync(IServiceProvider serviceP } // Cache/Upload sizes (0 = not set, use fallback) - if (settings.MaxCacheTileSizeInMB == 0) - { - settings.MaxCacheTileSizeInMB = ApplicationSettings.DefaultMaxCacheTileSizeInMB; - changed = true; - } - - if (settings.UploadSizeLimitMB == 0) - { + if (settings.MaxCacheTileSizeInMB == 0) + { + settings.MaxCacheTileSizeInMB = ApplicationSettings.DefaultMaxCacheTileSizeInMB; + changed = true; + } + + if (settings.TileMetadataHotCacheSizeMB == 0) + { + settings.TileMetadataHotCacheSizeMB = ApplicationSettings.DefaultTileMetadataHotCacheSizeMB; + changed = true; + } + + if (settings.UploadSizeLimitMB == 0) + { settings.UploadSizeLimitMB = ApplicationSettings.DefaultUploadSizeLimitMB; changed = true; } diff --git a/Models/ApplicationSettings.cs b/Models/ApplicationSettings.cs index d146df16..5a4ac21e 100644 --- a/Models/ApplicationSettings.cs +++ b/Models/ApplicationSettings.cs @@ -14,11 +14,12 @@ public class ApplicationSettings public const string DefaultTileProviderKey = "osm"; public const string DefaultTileProviderUrlTemplate = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"; public const string DefaultTileProviderAttribution = "© OpenStreetMap contributors"; - public const int DefaultTileRateLimitPerMinute = 600; - public const int DefaultTileRateLimitAuthenticatedPerMinute = 2000; - public const int DefaultTileOutboundBudgetPerIpPerMinute = 80; - public const int DefaultProxyImageRateLimitPerMinute = 200; - public const int DefaultMaxProxyImageDownloadMB = 50; + public const int DefaultTileRateLimitPerMinute = 600; + public const int DefaultTileRateLimitAuthenticatedPerMinute = 2000; + public const int DefaultTileOutboundBudgetPerIpPerMinute = 80; + public const int DefaultTileMetadataHotCacheSizeMB = 64; + public const int DefaultProxyImageRateLimitPerMinute = 200; + public const int DefaultMaxProxyImageDownloadMB = 50; [Key] @@ -112,9 +113,18 @@ public class ApplicationSettings /// one full map load per minute while preventing sustained scraping attacks. /// Set to 0 to disable per-IP outbound budget tracking. /// - [Required] - [Range(0, 1000, ErrorMessage = "Per-IP outbound budget must be between 0 (disabled) and 1,000 per minute.")] - public int TileOutboundBudgetPerIpPerMinute { get; set; } = DefaultTileOutboundBudgetPerIpPerMinute; + [Required] + [Range(0, 1000, ErrorMessage = "Per-IP outbound budget must be between 0 (disabled) and 1,000 per minute.")] + public int TileOutboundBudgetPerIpPerMinute { get; set; } = DefaultTileOutboundBudgetPerIpPerMinute; + + /// + /// Approximate RAM budget in megabytes for the in-process tile metadata hot cache used + /// for zoom levels >= 9. This cache stores metadata only, never tile image bytes. + /// Set to -1 to disable the feature entirely. + /// + [Required] + [Range(-1, 512, ErrorMessage = "Tile metadata hot cache size must be -1 (disable) or between 16 and 512 MB.")] + public int TileMetadataHotCacheSizeMB { get; set; } = DefaultTileMetadataHotCacheSizeMB; /// /// Whether to rate limit anonymous proxy image requests to prevent abuse and origin flooding. diff --git a/Program.cs b/Program.cs index aebb7893..73be6cb2 100644 --- a/Program.cs +++ b/Program.cs @@ -469,6 +469,7 @@ static void ConfigureServices(WebApplicationBuilder builder) // Register memory cache for application services builder.Services.AddMemoryCache(); + builder.Services.AddSingleton(); // Register application services with DI container builder.Services.AddScoped(); diff --git a/Services/TileCacheService.cs b/Services/TileCacheService.cs index 47068797..66163c5a 100644 --- a/Services/TileCacheService.cs +++ b/Services/TileCacheService.cs @@ -30,6 +30,7 @@ public class TileCacheService private readonly IConfiguration _configuration; private readonly IApplicationSettingsService _applicationSettings; private readonly IHttpContextAccessor _httpContextAccessor; + private readonly TileMetadataHotCache _tileMetadataHotCache; /// /// Lock for serializing file write and delete operations across all service instances. @@ -361,7 +362,8 @@ internal static void ResetStaticStateForTesting() public TileCacheService(ILogger logger, IConfiguration configuration, HttpClient httpClient, ApplicationDbContext dbContext, IApplicationSettingsService applicationSettings, - IServiceScopeFactory serviceScopeFactory, IHttpContextAccessor httpContextAccessor) + IServiceScopeFactory serviceScopeFactory, IHttpContextAccessor httpContextAccessor, + TileMetadataHotCache tileMetadataHotCache) { _logger = logger; _dbContext = dbContext; @@ -370,6 +372,7 @@ public TileCacheService(ILogger logger, IConfiguration configu _applicationSettings = applicationSettings; _serviceScopeFactory = serviceScopeFactory; _httpContextAccessor = httpContextAccessor; + _tileMetadataHotCache = tileMetadataHotCache; _maxCacheSizeInMB = _applicationSettings.GetSettings().MaxCacheTileSizeInMB; if (_maxCacheSizeInMB == -1) @@ -922,6 +925,7 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string _dbContext.TileCacheMetadata.Add(tileMetadata); await _dbContext.SaveChangesAsync(); Interlocked.Add(ref _currentCacheSize, tileData.Length); + TrySetHotMetadataEntry(zoom, x, y, tileMetadata); _logger.LogInformation("Tile metadata stored in database."); } catch (DbUpdateException) @@ -932,6 +936,13 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string _logger.LogDebug( "Tile metadata insert skipped due to concurrent insert (non-critical) z={Zoom} x={X} y={Y}", zoom, x, y); + + var persistedMetadata = await _dbContext.TileCacheMetadata + .FirstOrDefaultAsync(t => t.Zoom == zoom && t.X == x && t.Y == y, cancellationToken); + if (persistedMetadata != null) + { + TrySetHotMetadataEntry(zoom, x, y, persistedMetadata); + } } } else @@ -993,6 +1004,7 @@ public async Task CacheTileAsync(string tileUrl, string zoomLevel, string // Adjust the in-memory cache size using the previously saved value. Interlocked.Add(ref _currentCacheSize, tileData.Length - oldSize); + TrySetHotMetadataEntry(zoom, x, y, existingMetadata); } } @@ -1045,33 +1057,68 @@ public async Task RetrieveTileAsync(string zoomLevel, strin var isExpired = false; string? etag = null; DateTime? lastModified = null; + var servedByFreshHotMetadata = false; if (zoomLvl >= DbMetadataZoomThreshold) { - // Single DB round-trip: load metadata + conditionally update LastAccessed - var meta = await LoadAndTouchMetadataAsync(zoomLvl, xVal, yVal); - if (meta != null) + if (TryGetHotMetadataEntry(zoomLvl, xVal, yVal, out var hotMetadata) && hotMetadata != null) { - if (meta.ExpiresAtUtc == null) + etag = hotMetadata.ETag; + lastModified = hotMetadata.LastModifiedUpstream; + + if (hotMetadata.ExpiresAtUtc == null) { - // Legacy tile (pre-migration): no expiry metadata yet. - // Assume fresh for 7 days to avoid re-downloading all tiles on deploy. - // The first re-validation after 7 days will populate ETag/expiry properly. - await SeedLegacyTileExpiryAsync(meta); - isExpired = false; + // Null expiry is legacy metadata; seed and continue via the authoritative DB path. + var seededMetadata = await LoadAndTouchMetadataAsync(zoomLvl, xVal, yVal); + if (seededMetadata != null) + { + if (seededMetadata.ExpiresAtUtc == null) + { + await SeedLegacyTileExpiryAsync(seededMetadata); + } + + isExpired = seededMetadata.ExpiresAtUtc <= DateTime.UtcNow; + etag = seededMetadata.ETag; + lastModified = seededMetadata.LastModifiedUpstream; + TrySetHotMetadataEntry(zoomLvl, xVal, yVal, seededMetadata); + } + else + { + isExpired = true; + } } else { - isExpired = meta.ExpiresAtUtc <= DateTime.UtcNow; + isExpired = hotMetadata.ExpiresAtUtc <= DateTime.UtcNow; + servedByFreshHotMetadata = !isExpired; } - - etag = meta.ETag; - lastModified = meta.LastModifiedUpstream; } else { - // No DB metadata — treat as expired to populate it - isExpired = true; + // Hot-cache miss: fall back to the authoritative DB path and seed the hot cache lazily. + var meta = await LoadAndTouchMetadataAsync(zoomLvl, xVal, yVal); + if (meta != null) + { + if (meta.ExpiresAtUtc == null) + { + await SeedLegacyTileExpiryAsync(meta); + TrySetHotMetadataEntry(zoomLvl, xVal, yVal, meta); + isExpired = false; + } + else + { + isExpired = meta.ExpiresAtUtc <= DateTime.UtcNow; + TrySetHotMetadataEntry(zoomLvl, xVal, yVal, meta); + } + + etag = meta.ETag; + lastModified = meta.LastModifiedUpstream; + } + else + { + // No DB metadata — treat as expired to populate it + isExpired = true; + } } } else @@ -1131,7 +1178,20 @@ public async Task RetrieveTileAsync(string zoomLevel, strin // File deleted by concurrent eviction/purge — treat as cache miss. } - if (cachedTileData != null) return TileRetrievalResult.Success(cachedTileData); + if (cachedTileData != null) + { + if (servedByFreshHotMetadata) + { + await TouchLastAccessedFromHotHitAsync(zoomLvl, xVal, yVal); + } + + return TileRetrievalResult.Success(cachedTileData); + } + + if (servedByFreshHotMetadata) + { + TryRemoveHotMetadataEntry(zoomLvl, xVal, yVal); + } } // Tile is expired — re-validate with upstream (if we have a URL) @@ -1360,6 +1420,7 @@ private async Task SeedLegacyTileExpiryAsync(TileCacheMetadata meta) try { await _dbContext.SaveChangesAsync(); + TrySetHotMetadataEntry(meta.Zoom, meta.X, meta.Y, meta); _logger.LogDebug("Seeded 7-day expiry for legacy tile z={Zoom} x={X} y={Y}", meta.Zoom, meta.X, meta.Y); } catch (DbUpdateConcurrencyException) @@ -1419,6 +1480,7 @@ private async Task UpdateTileExpiryScopedAsync(ApplicationDbContext dbContext, i try { await dbContext.SaveChangesAsync(); + TrySetHotMetadataEntry(zoom, x, y, meta); } catch (DbUpdateConcurrencyException) { @@ -1448,6 +1510,7 @@ private async Task UpdateTileAfterRevalidationScopedAsync(ApplicationDbContext d { await dbContext.SaveChangesAsync(); Interlocked.Add(ref _currentCacheSize, newSize - oldSize); + TrySetHotMetadataEntry(zoom, x, y, meta); } catch (DbUpdateConcurrencyException) { @@ -1482,7 +1545,13 @@ private async Task EvictDbTilesAsync() // If it succeeds but file deletion later fails, orphaned files are harmless // and self-correcting (next cache write for that tile overwrites them). var filePaths = tilesToEvict - .Select(t => Path.Combine(_cacheDirectory, $"{t.Zoom}_{t.X}_{t.Y}.png")) + .Select(t => new + { + t.Zoom, + t.X, + t.Y, + FilePath = Path.Combine(_cacheDirectory, $"{t.Zoom}_{t.X}_{t.Y}.png") + }) .ToList(); var tileIds = tilesToEvict.Select(t => t.Id).ToList(); @@ -1513,18 +1582,19 @@ private async Task EvictDbTilesAsync() await _cacheLock.WaitAsync(); try { - foreach (var tileFilePath in filePaths) + foreach (var tile in filePaths) { try { - if (File.Exists(tileFilePath)) + TryRemoveHotMetadataEntry(tile.Zoom, tile.X, tile.Y); + if (File.Exists(tile.FilePath)) { - File.Delete(tileFilePath); + File.Delete(tile.FilePath); } } catch (Exception ex) { - _logger.LogError(ex, "Failed to delete tile file: {TileFilePath}", tileFilePath); + _logger.LogError(ex, "Failed to delete tile file: {TileFilePath}", tile.FilePath); } } } @@ -1714,6 +1784,7 @@ await RetryOperationAsync(async () => // Clean up sidecar metadata files and temp files as a final sweep. CleanupSidecarFiles(); + TryClearHotMetadataCache(); } finally { @@ -1811,6 +1882,7 @@ await RetryOperationAsync(async () => if (metaId != null && actualSizes.TryGetValue(metaId.Value, out var actualSize)) { Interlocked.Add(ref _currentCacheSize, -actualSize); + TryRemoveHotMetadataEntryFromPath(filePath); } } } @@ -1941,15 +2013,17 @@ await RetryOperationAsync(async () => { try { - if (File.Exists(filePath)) + if (File.Exists(filePath)) + { + File.Delete(filePath); + if (actualSizes.TryGetValue(id, out var actualSize)) { - File.Delete(filePath); - if (actualSizes.TryGetValue(id, out var actualSize)) - { - Interlocked.Add(ref _currentCacheSize, -actualSize); - } + Interlocked.Add(ref _currentCacheSize, -actualSize); } + + TryRemoveHotMetadataEntryFromPath(filePath); } + } catch (Exception e) { _logger.LogError(e, "Error deleting LRU cache file {File}", filePath); @@ -1969,6 +2043,8 @@ await RetryOperationAsync(async () => await BroadcastPurgeProgressAsync(sseService, sseChannel, "progress", "lru", deletedFiles, totalFiles); } + + TryClearHotMetadataCache(); } finally { @@ -2019,6 +2095,7 @@ private async Task DeleteCacheFileAsync(string tileFilePath, long tileSize) { File.Delete(tileFilePath); Interlocked.Add(ref _currentCacheSize, -tileSize); + TryRemoveHotMetadataEntryFromPath(tileFilePath); } } finally @@ -2026,4 +2103,132 @@ private async Task DeleteCacheFileAsync(string tileFilePath, long tileSize) _cacheLock.Release(); } } + + /// + /// Updates LastAccessed at most once per throttle window for fresh hot-cache hits. + /// + private async Task TouchLastAccessedFromHotHitAsync(int zoom, int x, int y) + { + if (!_tileMetadataHotCache.ShouldPersistLastAccessed(zoom, x, y)) + { + return; + } + + var meta = await _dbContext.TileCacheMetadata + .FirstOrDefaultAsync(t => t.Zoom == zoom && t.X == x && t.Y == y); + if (meta == null) + { + TryRemoveHotMetadataEntry(zoom, x, y); + return; + } + + meta.LastAccessed = DateTime.UtcNow; + try + { + await _dbContext.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + _logger.LogDebug( + "LastAccessed update skipped due to concurrency after hot-cache hit (non-critical)"); + } + } + + /// + /// Best-effort hot metadata lookup that degrades to the DB path on cache failures. + /// + private bool TryGetHotMetadataEntry(int zoom, int x, int y, out HotTileMetadataCacheEntry? metadata) + { + try + { + return _tileMetadataHotCache.TryGet(GetTileMetadataHotCacheSizeMb(), zoom, x, y, out metadata); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Tile metadata hot-cache lookup failed for z={Zoom} x={X} y={Y}", zoom, x, y); + metadata = null; + return false; + } + } + + /// + /// Best-effort hot metadata insert/update after durable tile metadata changes succeed. + /// + private void TrySetHotMetadataEntry(int zoom, int x, int y, TileCacheMetadata metadata) + { + TrySetHotMetadataEntry(zoom, x, y, new HotTileMetadataCacheEntry + { + ExpiresAtUtc = metadata.ExpiresAtUtc, + ETag = metadata.ETag, + LastModifiedUpstream = metadata.LastModifiedUpstream + }); + } + + /// + /// Best-effort hot metadata insert/update after durable tile metadata changes succeed. + /// + private void TrySetHotMetadataEntry(int zoom, int x, int y, HotTileMetadataCacheEntry metadata) + { + try + { + _tileMetadataHotCache.Set(GetTileMetadataHotCacheSizeMb(), zoom, x, y, metadata); + _tileMetadataHotCache.MarkLastAccessedPersisted(zoom, x, y); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Tile metadata hot-cache update failed for z={Zoom} x={X} y={Y}", zoom, x, y); + } + } + + /// + /// Best-effort hot metadata invalidation for an explicit tile delete path. + /// + private void TryRemoveHotMetadataEntry(int zoom, int x, int y) + { + try + { + _tileMetadataHotCache.Remove(zoom, x, y); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Tile metadata hot-cache removal failed for z={Zoom} x={X} y={Y}", zoom, x, y); + } + } + + /// + /// Best-effort hot metadata invalidation for a cached file path with the standard z_x_y file name format. + /// + private void TryRemoveHotMetadataEntryFromPath(string tileFilePath) + { + var tileName = Path.GetFileNameWithoutExtension(tileFilePath)?.Split('_'); + if (tileName is not { Length: 3 } || + !int.TryParse(tileName[0], out var zoom) || + !int.TryParse(tileName[1], out var x) || + !int.TryParse(tileName[2], out var y)) + { + return; + } + + TryRemoveHotMetadataEntry(zoom, x, y); + } + + /// + /// Best-effort full hot metadata clear after purge/reset operations. + /// + private void TryClearHotMetadataCache() + { + try + { + _tileMetadataHotCache.Clear(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Tile metadata hot-cache clear failed after purge/reset."); + } + } + + /// + /// Reads the current admin-configured hot metadata cache budget. + /// + private int GetTileMetadataHotCacheSizeMb() => _applicationSettings.GetSettings().TileMetadataHotCacheSizeMB; } diff --git a/Services/TileMetadataHotCache.cs b/Services/TileMetadataHotCache.cs new file mode 100644 index 00000000..78120a28 --- /dev/null +++ b/Services/TileMetadataHotCache.cs @@ -0,0 +1,252 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace Wayfarer.Services; + +/// +/// In-process hot cache for zoom >= 9 tile metadata plus a throttling marker for LastAccessed writes. +/// Durable tile metadata in Postgres remains authoritative; this cache is an optimization hint only. +/// +public sealed class TileMetadataHotCache : IDisposable +{ + /// + /// Fixed pessimistic estimate used to convert the admin-facing MB budget into an approximate entry cap. + /// Each metadata entry stores ExpiresAtUtc, ETag, and LastModifiedUpstream only. + /// + public const int EstimatedBytesPerHotMetadataEntry = 768; + + private static readonly TimeSpan LastAccessedThrottleInterval = TimeSpan.FromMinutes(5); + + private readonly ILogger _logger; + private readonly object _syncLock = new(); + + private IMemoryCache _metadataCache = new MemoryCache(new MemoryCacheOptions()); + private readonly IMemoryCache _touchMarkerCache = new MemoryCache(new MemoryCacheOptions()); + private int _configuredSizeMb = int.MinValue; + private long _configuredEntryLimit = -1; + private bool _disposed; + + public TileMetadataHotCache(ILogger logger) + { + _logger = logger; + } + + /// + /// Returns true when the hot metadata cache is enabled and contains an entry for the tile. + /// Disabled mode bypasses the cache entirely. + /// + public bool TryGet(int hotCacheSizeMb, int zoom, int x, int y, out HotTileMetadataCacheEntry? metadata) + { + metadata = null; + if (!TryGetMetadataCache(hotCacheSizeMb, out var metadataCache)) + { + return false; + } + + return metadataCache.TryGetValue(BuildMetadataKey(zoom, x, y), out metadata); + } + + /// + /// Inserts or updates a hot metadata entry using the current deterministic size limit. + /// + public void Set(int hotCacheSizeMb, int zoom, int x, int y, HotTileMetadataCacheEntry metadata) + { + if (!TryGetMetadataCache(hotCacheSizeMb, out var metadataCache)) + { + return; + } + + metadataCache.Set( + BuildMetadataKey(zoom, x, y), + metadata, + new MemoryCacheEntryOptions + { + Size = 1 + }); + } + + /// + /// Removes the hot metadata and LastAccessed throttle marker for a tile. + /// + public void Remove(int zoom, int x, int y) + { + _metadataCache.Remove(BuildMetadataKey(zoom, x, y)); + _touchMarkerCache.Remove(BuildTouchMarkerKey(zoom, x, y)); + } + + /// + /// Clears all in-process metadata and throttle markers, used after purge/reset operations. + /// + public void Clear() + { + lock (_syncLock) + { + ThrowIfDisposed(); + _metadataCache.Dispose(); + _metadataCache = new MemoryCache(new MemoryCacheOptions()); + _configuredSizeMb = int.MinValue; + _configuredEntryLimit = -1; + } + + CompactTouchMarkers(); + } + + /// + /// Returns true once per tile per five-minute window so callers can throttle LastAccessed DB writes. + /// + public bool ShouldPersistLastAccessed(int zoom, int x, int y) + { + var key = BuildTouchMarkerKey(zoom, x, y); + if (_touchMarkerCache.TryGetValue(key, out _)) + { + return false; + } + + _touchMarkerCache.Set( + key, + true, + new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = LastAccessedThrottleInterval + }); + + return true; + } + + /// + /// Seeds or refreshes the LastAccessed throttle marker after a durable DB-backed metadata operation. + /// + public void MarkLastAccessedPersisted(int zoom, int x, int y) + { + _touchMarkerCache.Set( + BuildTouchMarkerKey(zoom, x, y), + true, + new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = LastAccessedThrottleInterval + }); + } + + /// + /// Computes the approximate maximum hot metadata entries for the current admin setting. + /// Returns null when the feature is disabled. + /// + public static long? GetApproximateEntryLimit(int hotCacheSizeMb) + { + if (hotCacheSizeMb == -1) + { + return null; + } + + return (long)Math.Floor(hotCacheSizeMb * 1024d * 1024d / EstimatedBytesPerHotMetadataEntry); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + lock (_syncLock) + { + if (_disposed) + { + return; + } + + _metadataCache.Dispose(); + _touchMarkerCache.Dispose(); + _disposed = true; + } + } + + private bool TryGetMetadataCache(int hotCacheSizeMb, out IMemoryCache metadataCache) + { + ThrowIfDisposed(); + + if (hotCacheSizeMb == -1) + { + lock (_syncLock) + { + if (_configuredSizeMb != -1) + { + _metadataCache.Dispose(); + _metadataCache = new MemoryCache(new MemoryCacheOptions()); + _configuredSizeMb = -1; + _configuredEntryLimit = -1; + } + } + + metadataCache = _metadataCache; + return false; + } + + var entryLimit = GetApproximateEntryLimit(hotCacheSizeMb) ?? 0; + lock (_syncLock) + { + if (_configuredSizeMb != hotCacheSizeMb || _configuredEntryLimit != entryLimit) + { + _metadataCache.Dispose(); + _metadataCache = new MemoryCache(new MemoryCacheOptions + { + SizeLimit = entryLimit + }); + _configuredSizeMb = hotCacheSizeMb; + _configuredEntryLimit = entryLimit; + _logger.LogDebug( + "Configured tile metadata hot cache for {HotCacheSizeMb} MB (~{EntryLimit} entries).", + hotCacheSizeMb, + entryLimit); + } + + metadataCache = _metadataCache; + } + + return true; + } + + private void CompactTouchMarkers() + { + try + { + if (_touchMarkerCache is MemoryCache touchMarkerCache) + { + touchMarkerCache.Compact(1.0); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to compact tile metadata touch markers."); + } + } + + private static string BuildMetadataKey(int zoom, int x, int y) => $"tile-meta:{zoom}:{x}:{y}"; + + private static string BuildTouchMarkerKey(int zoom, int x, int y) => $"tile-touch:{zoom}:{x}:{y}"; + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } +} + +/// +/// Metadata-only hot cache entry for zoom >= 9 tiles. +/// +public sealed class HotTileMetadataCacheEntry +{ + /// + /// Expiry timestamp used to decide whether the cached file can be served directly. + /// + public required DateTime? ExpiresAtUtc { get; init; } + + /// + /// ETag used for conditional revalidation after expiry. + /// + public string? ETag { get; init; } + + /// + /// Last-Modified value from the upstream provider used for conditional revalidation. + /// + public DateTime? LastModifiedUpstream { get; init; } +} diff --git a/tests/Wayfarer.Tests/Controllers/AdminSettingsControllerTests.cs b/tests/Wayfarer.Tests/Controllers/AdminSettingsControllerTests.cs index b1f6ff00..0da34958 100644 --- a/tests/Wayfarer.Tests/Controllers/AdminSettingsControllerTests.cs +++ b/tests/Wayfarer.Tests/Controllers/AdminSettingsControllerTests.cs @@ -53,7 +53,8 @@ public async Task Index_ReturnsView_WithSettings() db, settingsMock.Object, Mock.Of(), - new HttpContextAccessor()); + new HttpContextAccessor(), + new TileMetadataHotCache(NullLogger.Instance)); var scopeFactory = BuildScopeFactory(tileCache); var controller = new SettingsController(NullLogger.Instance, db, settingsMock.Object, tileCache, Mock.Of(), env.Object, scopeFactory, new SseService()); @@ -145,6 +146,27 @@ public async Task Update_DoesNotUpdate_WhenSettingsNotFound() settingsMock.Verify(s => s.RefreshSettings(), Times.Never); } + [Fact] + public async Task Update_ReturnsView_WhenTileMetadataHotCacheSizeIsOutOfRange() + { + var db = CreateDbContext(); + db.ApplicationSettings.Add(new ApplicationSettings { Id = 1 }); + await db.SaveChangesAsync(); + + var (controller, settingsMock, _) = BuildController(db); + + var result = await controller.Update(new ApplicationSettings + { + Id = 1, + TileMetadataHotCacheSizeMB = 8 + }); + + var view = Assert.IsType(result); + Assert.Equal("Index", view.ViewName); + Assert.False(controller.ModelState.IsValid); + settingsMock.Verify(s => s.RefreshSettings(), Times.Never); + } + [Fact] public async Task Update_TracksChanges_InAuditLog() { @@ -220,7 +242,8 @@ public void ClearMbtilesCache_RedirectsToIndex() db, settingsService ?? settingsMock!.Object, Mock.Of(), - new HttpContextAccessor()); + new HttpContextAccessor(), + new TileMetadataHotCache(NullLogger.Instance)); var scopeFactory = BuildScopeFactory(tileCache); var controller = new SettingsController( diff --git a/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs b/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs index 76f53a5d..cee051d4 100644 --- a/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs +++ b/tests/Wayfarer.Tests/Controllers/TilesControllerTests.cs @@ -14,6 +14,7 @@ using Wayfarer.Areas.Public.Controllers; using Wayfarer.Models; using Wayfarer.Parsers; +using Wayfarer.Services; using Wayfarer.Tests.Infrastructure; using Xunit; @@ -470,7 +471,8 @@ private TileCacheService CreateTileService(ApplicationDbContext dbContext, HttpM dbContext, settingsService, Mock.Of(), - new HttpContextAccessor()); + new HttpContextAccessor(), + new TileMetadataHotCache(NullLogger.Instance)); } private IApplicationSettingsService BuildSettingsService(bool rateLimitEnabled = true, int rateLimitPerMinute = 500, int rateLimitAuthenticatedPerMinute = 2000) diff --git a/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs b/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs index 12f8276f..9c6f4f23 100644 --- a/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs +++ b/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Wayfarer.Models; using Wayfarer.Parsers; +using Wayfarer.Services; using Wayfarer.Tests.Infrastructure; using Xunit; @@ -48,12 +49,14 @@ public async Task RetrieveTileAsync_UpdatesLastAccessed() { using var dir = new TempDir(); var db = CreateDbContext(); - var service = CreateService(db, dir.Path); + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + var service = CreateService(db, dir.Path, hotCache: hotCache); await service.CacheTileAsync("http://tiles/9/3/4.png", "9", "3", "4"); var meta = db.TileCacheMetadata.Single(); var old = DateTime.UtcNow.AddMinutes(-10); meta.LastAccessed = old; db.SaveChanges(); + hotCache.Remove(9, 3, 4); var result = await service.RetrieveTileAsync("9", "3", "4"); var bytes = result.TileData; @@ -67,16 +70,19 @@ public async Task PurgeAllCacheAsync_RemovesFilesAndMetadata() { using var dir = new TempDir(); var db = CreateDbContext(); - var service = CreateService(db, dir.Path); + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + var service = CreateService(db, dir.Path, hotCache: hotCache); await service.CacheTileAsync("http://tiles/9/5/6.png", "9", "5", "6"); await service.CacheTileAsync("http://tiles/9/7/8.png", "9", "7", "8"); Assert.True(Directory.GetFiles(dir.Path, "*.png").Length >= 2); Assert.Equal(2, db.TileCacheMetadata.Count()); + Assert.True(hotCache.TryGet(ApplicationSettings.DefaultTileMetadataHotCacheSizeMB, 9, 5, 6, out _)); await service.PurgeAllCacheAsync(); Assert.Empty(Directory.GetFiles(dir.Path)); Assert.Empty(db.TileCacheMetadata); + Assert.False(hotCache.TryGet(ApplicationSettings.DefaultTileMetadataHotCacheSizeMB, 9, 5, 6, out _)); } [Fact] @@ -85,7 +91,8 @@ public async Task CacheTileAsync_EvictsLru_WhenCacheOverLimit() using var dir = new TempDir(); var (db, dbName) = CreateNamedDbContext(); var handler = new SizedTileHandler(600_000); // ~0.57 MB tiles - var service = CreateService(db, dir.Path, handler, maxCacheMb: 1, dbName: dbName); + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + var service = CreateService(db, dir.Path, handler, maxCacheMb: 1, dbName: dbName, hotCache: hotCache); await service.CacheTileAsync("http://tiles/9/1/1.png", "9", "1", "1"); // fits await service.CacheTileAsync("http://tiles/9/1/2.png", "9", "1", "2"); // triggers eviction of oldest @@ -95,6 +102,8 @@ public async Task CacheTileAsync_EvictsLru_WhenCacheOverLimit() Assert.Single(remaining); Assert.Equal(1, remaining[0].X); Assert.Equal(2, remaining[0].Y); + Assert.False(hotCache.TryGet(ApplicationSettings.DefaultTileMetadataHotCacheSizeMB, 9, 1, 1, out _)); + Assert.True(hotCache.TryGet(ApplicationSettings.DefaultTileMetadataHotCacheSizeMB, 9, 1, 2, out _)); } [Fact] @@ -102,11 +111,13 @@ public async Task RetrieveTileAsync_UpdatesLastAccessed_ForExistingTile() { using var dir = new TempDir(); var db = CreateDbContext(); - var service = CreateService(db, dir.Path); + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + var service = CreateService(db, dir.Path, hotCache: hotCache); await service.CacheTileAsync("http://tiles/9/3/4.png", "9", "3", "4"); var meta = db.TileCacheMetadata.Single(); - meta.LastAccessed = DateTime.UtcNow.AddMinutes(-5); + meta.LastAccessed = DateTime.UtcNow.AddMinutes(-6); db.SaveChanges(); + hotCache.Remove(9, 3, 4); var old = meta.LastAccessed; await Task.Delay(5); @@ -115,6 +126,30 @@ public async Task RetrieveTileAsync_UpdatesLastAccessed_ForExistingTile() Assert.True(db.TileCacheMetadata.Single().LastAccessed > old); } + [Fact] + public async Task RetrieveTileAsync_HotHit_ThrottlesLastAccessedWritesToFiveMinutes() + { + using var dir = new TempDir(); + var db = CreateDbContext(); + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + var service = CreateService(db, dir.Path, hotCache: hotCache); + await service.CacheTileAsync("http://tiles/9/30/40.png", "9", "30", "40"); + + var meta = db.TileCacheMetadata.Single(); + meta.LastAccessed = DateTime.UtcNow.AddMinutes(-10); + await db.SaveChangesAsync(); + + await service.RetrieveTileAsync("9", "30", "40", "http://tiles/9/30/40.png"); + db.Entry(meta).Reload(); + var touchedAt = meta.LastAccessed; + + await Task.Delay(5); + await service.RetrieveTileAsync("9", "30", "40", "http://tiles/9/30/40.png"); + db.Entry(meta).Reload(); + + Assert.Equal(touchedAt, meta.LastAccessed); + } + [Fact] public async Task GetCacheFileSizeInMbAsync_ReturnsZeroWhenEmpty() { @@ -254,7 +289,8 @@ public async Task CacheTileAsync_StoresETagAndExpiry_ForZoomNineOrAbove() using var dir = new TempDir(); var db = CreateDbContext(); var handler = new ConditionalTileHandler(etag: "\"abc123\"", maxAgeSeconds: 3600); - var service = CreateService(db, dir.Path, handler); + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + var service = CreateService(db, dir.Path, handler, hotCache: hotCache); await service.CacheTileAsync("http://tiles/9/1/2.png", "9", "1", "2"); @@ -262,6 +298,8 @@ public async Task CacheTileAsync_StoresETagAndExpiry_ForZoomNineOrAbove() Assert.Equal("\"abc123\"", meta.ETag); Assert.NotNull(meta.ExpiresAtUtc); Assert.True(meta.ExpiresAtUtc > DateTime.UtcNow.AddMinutes(50)); // ~1 hour expiry + Assert.True(hotCache.TryGet(ApplicationSettings.DefaultTileMetadataHotCacheSizeMB, 9, 1, 2, out var entry)); + Assert.Equal("\"abc123\"", entry!.ETag); } [Fact] @@ -308,13 +346,72 @@ public async Task RetrieveTileAsync_ServesFromCache_WhenNotExpired() Assert.Equal(callCount, handler.CallCount); // No additional HTTP calls } + [Fact] + public async Task RetrieveTileAsync_FirstWarmHit_PopulatesHotMetadataCache() + { + using var dir = new TempDir(); + var db = CreateDbContext(); + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + var service = CreateService(db, dir.Path, hotCache: hotCache); + + await service.CacheTileAsync("http://tiles/9/11/12.png", "9", "11", "12"); + + hotCache.Remove(9, 11, 12); + var result = await service.RetrieveTileAsync("9", "11", "12", "http://tiles/9/11/12.png"); + + Assert.NotNull(result.TileData); + Assert.True(hotCache.TryGet(ApplicationSettings.DefaultTileMetadataHotCacheSizeMB, 9, 11, 12, out var entry)); + Assert.NotNull(entry); + Assert.NotNull(entry!.ExpiresAtUtc); + } + + [Fact] + public async Task RetrieveTileAsync_SecondWarmHit_ServesWithoutDbMetadataRead() + { + using var dir = new TempDir(); + var db = CreateDbContext(); + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + var service = CreateService(db, dir.Path, hotCache: hotCache); + + await service.CacheTileAsync("http://tiles/9/13/14.png", "9", "13", "14"); + await service.RetrieveTileAsync("9", "13", "14", "http://tiles/9/13/14.png"); + + db.TileCacheMetadata.RemoveRange(db.TileCacheMetadata.Where(t => t.Zoom == 9 && t.X == 13 && t.Y == 14)); + await db.SaveChangesAsync(); + + var result = await service.RetrieveTileAsync("9", "13", "14", "http://tiles/9/13/14.png"); + + Assert.NotNull(result.TileData); + } + + [Fact] + public async Task RetrieveTileAsync_FreshHotCacheHitWithMissingFile_RemovesEntryAndFallsBack() + { + using var dir = new TempDir(); + var db = CreateDbContext(); + var handler = new ConditionalTileHandler(etag: "\"fresh-missing\"", maxAgeSeconds: 3600); + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + var service = CreateService(db, dir.Path, handler, hotCache: hotCache); + + await service.CacheTileAsync("http://tiles/9/15/16.png", "9", "15", "16"); + await service.RetrieveTileAsync("9", "15", "16", "http://tiles/9/15/16.png"); + File.Delete(Path.Combine(dir.Path, "9_15_16.png")); + + var result = await service.RetrieveTileAsync("9", "15", "16", "http://tiles/9/15/16.png"); + + Assert.NotNull(result.TileData); + Assert.True(File.Exists(Path.Combine(dir.Path, "9_15_16.png"))); + Assert.True(hotCache.TryGet(ApplicationSettings.DefaultTileMetadataHotCacheSizeMB, 9, 15, 16, out _)); + } + [Fact] public async Task RetrieveTileAsync_SendsConditionalRequest_WhenExpired() { using var dir = new TempDir(); var db = CreateDbContext(); var handler = new ConditionalTileHandler(etag: "\"expired\"", maxAgeSeconds: 3600); - var service = CreateService(db, dir.Path, handler); + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + var service = CreateService(db, dir.Path, handler, hotCache: hotCache); // Cache the tile (1 HTTP call) await service.CacheTileAsync("http://tiles/9/1/2.png", "9", "1", "2"); @@ -324,6 +421,7 @@ public async Task RetrieveTileAsync_SendsConditionalRequest_WhenExpired() var meta = db.TileCacheMetadata.Single(); meta.ExpiresAtUtc = DateTime.UtcNow.AddHours(-1); await db.SaveChangesAsync(); + hotCache.Remove(9, 1, 2); // Retrieve should send conditional request because tile is expired var result = await service.RetrieveTileAsync("9", "1", "2", "http://tiles/9/1/2.png"); @@ -339,7 +437,8 @@ public async Task RetrieveTileAsync_HandlesNotModified304_WithoutRedownload() using var dir = new TempDir(); var db = CreateDbContext(); var handler = new ConditionalTileHandler(etag: "\"v1\"", maxAgeSeconds: 3600); - var service = CreateService(db, dir.Path, handler); + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + var service = CreateService(db, dir.Path, handler, hotCache: hotCache); // Cache the tile await service.CacheTileAsync("http://tiles/9/1/2.png", "9", "1", "2"); @@ -349,6 +448,7 @@ public async Task RetrieveTileAsync_HandlesNotModified304_WithoutRedownload() var meta = db.TileCacheMetadata.Single(); meta.ExpiresAtUtc = DateTime.UtcNow.AddHours(-1); await db.SaveChangesAsync(); + hotCache.Remove(9, 1, 2); // Retrieve: tile is expired, handler returns 304 when If-None-Match matches var result = await service.RetrieveTileAsync("9", "1", "2", "http://tiles/9/1/2.png"); @@ -361,6 +461,8 @@ public async Task RetrieveTileAsync_HandlesNotModified304_WithoutRedownload() db.Entry(meta).Reload(); Assert.NotNull(meta.ExpiresAtUtc); Assert.True(meta.ExpiresAtUtc > DateTime.UtcNow); + Assert.True(hotCache.TryGet(ApplicationSettings.DefaultTileMetadataHotCacheSizeMB, 9, 1, 2, out var entry)); + Assert.Equal(meta.ExpiresAtUtc, entry!.ExpiresAtUtc); } [Fact] @@ -370,7 +472,8 @@ public async Task RetrieveTileAsync_ReplacesFile_On200AfterExpiry() var db = CreateDbContext(); // First call returns etag "\"v1\"", revalidation returns new data with etag "\"v2\"" var handler = new ConditionalTileHandler(etag: "\"v1\"", maxAgeSeconds: 3600, newEtagOnRevalidation: "\"v2\""); - var service = CreateService(db, dir.Path, handler); + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + var service = CreateService(db, dir.Path, handler, hotCache: hotCache); // Cache the tile await service.CacheTileAsync("http://tiles/9/1/2.png", "9", "1", "2"); @@ -379,6 +482,7 @@ public async Task RetrieveTileAsync_ReplacesFile_On200AfterExpiry() var meta = db.TileCacheMetadata.Single(); meta.ExpiresAtUtc = DateTime.UtcNow.AddHours(-1); await db.SaveChangesAsync(); + hotCache.Remove(9, 1, 2); // Force the handler to return 200 on revalidation (different etag = new content) handler.ForceRevalidation200 = true; @@ -390,6 +494,8 @@ public async Task RetrieveTileAsync_ReplacesFile_On200AfterExpiry() // DB metadata should now have the new etag db.Entry(meta).Reload(); Assert.Equal("\"v2\"", meta.ETag); + Assert.True(hotCache.TryGet(ApplicationSettings.DefaultTileMetadataHotCacheSizeMB, 9, 1, 2, out var entry)); + Assert.Equal("\"v2\"", entry!.ETag); } [Fact] @@ -398,7 +504,8 @@ public async Task RetrieveTileAsync_ServesStaleCache_WhenRevalidationFails() using var dir = new TempDir(); var db = CreateDbContext(); var handler = new ConditionalTileHandler(etag: "\"stale\"", maxAgeSeconds: 3600); - var service = CreateService(db, dir.Path, handler); + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + var service = CreateService(db, dir.Path, handler, hotCache: hotCache); // Cache the tile await service.CacheTileAsync("http://tiles/9/1/2.png", "9", "1", "2"); @@ -407,6 +514,7 @@ public async Task RetrieveTileAsync_ServesStaleCache_WhenRevalidationFails() var meta = db.TileCacheMetadata.Single(); meta.ExpiresAtUtc = DateTime.UtcNow.AddHours(-1); await db.SaveChangesAsync(); + hotCache.Remove(9, 1, 2); // Make handler fail on next call handler.FailNextRequest = true; @@ -466,7 +574,8 @@ public async Task RetrieveTileAsync_CoalescesConcurrentRevalidations() using var dir = new TempDir(); var db = CreateDbContext(); var handler = new ConditionalTileHandler(etag: "\"coalesce\"", maxAgeSeconds: 3600); - var service = CreateService(db, dir.Path, handler); + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + var service = CreateService(db, dir.Path, handler, hotCache: hotCache); // Cache the tile first await service.CacheTileAsync("http://tiles/9/5/5.png", "9", "5", "5"); @@ -476,6 +585,7 @@ public async Task RetrieveTileAsync_CoalescesConcurrentRevalidations() var meta = db.TileCacheMetadata.Single(); meta.ExpiresAtUtc = DateTime.UtcNow.AddHours(-1); await db.SaveChangesAsync(); + hotCache.Remove(9, 5, 5); // Fire 5 concurrent retrieve requests for the same expired tile var tasks = Enumerable.Range(0, 5) @@ -568,7 +678,9 @@ public async Task RetrieveTileAsync_ReturnsThrottled_WhenBudgetExhausted() return (db, dbName); } - private TileCacheService CreateService(ApplicationDbContext db, string cacheDir, HttpMessageHandler? handler = null, int maxCacheMb = 10, IHttpContextAccessor? httpContextAccessor = null, string contactEmail = "test@example.com", string? dbName = null) + private TileCacheService CreateService(ApplicationDbContext db, string cacheDir, HttpMessageHandler? handler = null, + int maxCacheMb = 10, IHttpContextAccessor? httpContextAccessor = null, string contactEmail = "test@example.com", + string? dbName = null, TileMetadataHotCache? hotCache = null) { var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -610,7 +722,8 @@ private TileCacheService CreateService(ApplicationDbContext db, string cacheDir, db, appSettings, scopeFactory, - httpContextAccessor ?? new HttpContextAccessor()); + httpContextAccessor ?? new HttpContextAccessor(), + hotCache ?? new TileMetadataHotCache(NullLogger.Instance)); } /// @@ -891,7 +1004,8 @@ public async Task PurgeAllCacheAsync_RejectsSecondConcurrentPurge() .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) .Options, new ServiceCollection().BuildServiceProvider())), - new HttpContextAccessor()); + new HttpContextAccessor(), + new TileMetadataHotCache(NullLogger.Instance)); // Start the first purge. var firstPurge = service1.PurgeAllCacheAsync(); diff --git a/wwwroot/js/Areas/Admin/Settings/Index.js b/wwwroot/js/Areas/Admin/Settings/Index.js index e08fb350..90bc0adf 100644 --- a/wwwroot/js/Areas/Admin/Settings/Index.js +++ b/wwwroot/js/Areas/Admin/Settings/Index.js @@ -36,6 +36,31 @@ document.addEventListener('DOMContentLoaded', () => { timeThresholdSelect.addEventListener('change', updateWarningVisibility); } + const tileMetadataHotCacheInput = document.getElementById('TileMetadataHotCacheSizeMB'); + const tileMetadataHotCacheHint = document.getElementById('tileMetadataHotCacheHint'); + if (tileMetadataHotCacheInput && tileMetadataHotCacheHint) { + const estimatedBytesPerEntry = Number(tileMetadataHotCacheInput.dataset.entryBytes || '768'); + + const updateTileMetadataHotCacheHint = () => { + const sizeMb = Number(tileMetadataHotCacheInput.value); + if (Number.isNaN(sizeMb)) { + tileMetadataHotCacheHint.textContent = 'Enter a value of -1 or 16-512 MB.'; + return; + } + + if (sizeMb === -1) { + tileMetadataHotCacheHint.textContent = 'Hot metadata cache disabled.'; + return; + } + + const approximateEntries = Math.floor((sizeMb * 1024 * 1024) / estimatedBytesPerEntry); + tileMetadataHotCacheHint.textContent = `${sizeMb} MB ≈ ${approximateEntries} metadata entries`; + }; + + updateTileMetadataHotCacheHint(); + tileMetadataHotCacheInput.addEventListener('input', updateTileMetadataHotCacheHint); + } + // Tile provider UI: toggle preset details, custom inputs, and API key visibility. const tileProviderKey = document.getElementById('TileProviderKey'); const tileProviderTemplate = document.getElementById('TileProviderUrlTemplate'); From c5a21e4c9827497bebd9a9c80aabcfbfd79ecbbe Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Mon, 13 Apr 2026 23:04:21 +0300 Subject: [PATCH 3/4] fix(tiles): make hot-hit LastAccessed throttling atomic --- Services/TileCacheService.cs | 30 +++++++++------ Services/TileMetadataHotCache.cs | 37 ++++++++++++------- .../Services/TileCacheServiceTests.cs | 24 ++++++++++++ 3 files changed, 66 insertions(+), 25 deletions(-) diff --git a/Services/TileCacheService.cs b/Services/TileCacheService.cs index 66163c5a..d593f7d0 100644 --- a/Services/TileCacheService.cs +++ b/Services/TileCacheService.cs @@ -2109,29 +2109,37 @@ private async Task DeleteCacheFileAsync(string tileFilePath, long tileSize) /// private async Task TouchLastAccessedFromHotHitAsync(int zoom, int x, int y) { - if (!_tileMetadataHotCache.ShouldPersistLastAccessed(zoom, x, y)) + if (!_tileMetadataHotCache.TryBeginLastAccessedPersist(zoom, x, y)) { return; } - var meta = await _dbContext.TileCacheMetadata - .FirstOrDefaultAsync(t => t.Zoom == zoom && t.X == x && t.Y == y); - if (meta == null) - { - TryRemoveHotMetadataEntry(zoom, x, y); - return; - } - - meta.LastAccessed = DateTime.UtcNow; try { + var meta = await _dbContext.TileCacheMetadata + .FirstOrDefaultAsync(t => t.Zoom == zoom && t.X == x && t.Y == y); + if (meta == null) + { + _tileMetadataHotCache.AbortLastAccessedPersist(zoom, x, y); + TryRemoveHotMetadataEntry(zoom, x, y); + return; + } + + meta.LastAccessed = DateTime.UtcNow; await _dbContext.SaveChangesAsync(); + _tileMetadataHotCache.CompleteLastAccessedPersist(zoom, x, y); } catch (DbUpdateConcurrencyException) { + _tileMetadataHotCache.AbortLastAccessedPersist(zoom, x, y); _logger.LogDebug( "LastAccessed update skipped due to concurrency after hot-cache hit (non-critical)"); } + catch + { + _tileMetadataHotCache.AbortLastAccessedPersist(zoom, x, y); + throw; + } } /// @@ -2172,7 +2180,7 @@ private void TrySetHotMetadataEntry(int zoom, int x, int y, HotTileMetadataCache try { _tileMetadataHotCache.Set(GetTileMetadataHotCacheSizeMb(), zoom, x, y, metadata); - _tileMetadataHotCache.MarkLastAccessedPersisted(zoom, x, y); + _tileMetadataHotCache.CompleteLastAccessedPersist(zoom, x, y); } catch (Exception ex) { diff --git a/Services/TileMetadataHotCache.cs b/Services/TileMetadataHotCache.cs index 78120a28..4970dcfe 100644 --- a/Services/TileMetadataHotCache.cs +++ b/Services/TileMetadataHotCache.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Microsoft.Extensions.Caching.Memory; namespace Wayfarer.Services; @@ -18,6 +19,7 @@ public sealed class TileMetadataHotCache : IDisposable private readonly ILogger _logger; private readonly object _syncLock = new(); + private readonly ConcurrentDictionary _touchClaims = new(); private IMemoryCache _metadataCache = new MemoryCache(new MemoryCacheOptions()); private readonly IMemoryCache _touchMarkerCache = new MemoryCache(new MemoryCacheOptions()); @@ -69,8 +71,10 @@ public void Set(int hotCacheSizeMb, int zoom, int x, int y, HotTileMetadataCache /// public void Remove(int zoom, int x, int y) { + var touchKey = BuildTouchMarkerKey(zoom, x, y); _metadataCache.Remove(BuildMetadataKey(zoom, x, y)); - _touchMarkerCache.Remove(BuildTouchMarkerKey(zoom, x, y)); + _touchMarkerCache.Remove(touchKey); + _touchClaims.TryRemove(touchKey, out _); } /// @@ -87,13 +91,15 @@ public void Clear() _configuredEntryLimit = -1; } + _touchClaims.Clear(); CompactTouchMarkers(); } /// - /// Returns true once per tile per five-minute window so callers can throttle LastAccessed DB writes. + /// Atomically claims responsibility for persisting LastAccessed for a tile. + /// Returns true for at most one caller while there is no active five-minute cooldown. /// - public bool ShouldPersistLastAccessed(int zoom, int x, int y) + public bool TryBeginLastAccessedPersist(int zoom, int x, int y) { var key = BuildTouchMarkerKey(zoom, x, y); if (_touchMarkerCache.TryGetValue(key, out _)) @@ -101,6 +107,15 @@ public bool ShouldPersistLastAccessed(int zoom, int x, int y) return false; } + return _touchClaims.TryAdd(key, 0); + } + + /// + /// Starts the five-minute LastAccessed cooldown after a durable DB-backed metadata operation succeeds. + /// + public void CompleteLastAccessedPersist(int zoom, int x, int y) + { + var key = BuildTouchMarkerKey(zoom, x, y); _touchMarkerCache.Set( key, true, @@ -108,22 +123,16 @@ public bool ShouldPersistLastAccessed(int zoom, int x, int y) { AbsoluteExpirationRelativeToNow = LastAccessedThrottleInterval }); - - return true; + _touchClaims.TryRemove(key, out _); } /// - /// Seeds or refreshes the LastAccessed throttle marker after a durable DB-backed metadata operation. + /// Releases an in-flight LastAccessed claim without starting the cooldown. + /// Used when the DB update did not complete successfully so later requests can retry. /// - public void MarkLastAccessedPersisted(int zoom, int x, int y) + public void AbortLastAccessedPersist(int zoom, int x, int y) { - _touchMarkerCache.Set( - BuildTouchMarkerKey(zoom, x, y), - true, - new MemoryCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = LastAccessedThrottleInterval - }); + _touchClaims.TryRemove(BuildTouchMarkerKey(zoom, x, y), out _); } /// diff --git a/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs b/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs index 9c6f4f23..640c5df9 100644 --- a/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs +++ b/tests/Wayfarer.Tests/Services/TileCacheServiceTests.cs @@ -150,6 +150,30 @@ public async Task RetrieveTileAsync_HotHit_ThrottlesLastAccessedWritesToFiveMinu Assert.Equal(touchedAt, meta.LastAccessed); } + [Fact] + public void TileMetadataHotCache_TryBeginLastAccessedPersist_IsAtomicPerTile() + { + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + + var results = Enumerable.Range(0, 10) + .AsParallel() + .Select(_ => hotCache.TryBeginLastAccessedPersist(9, 77, 88)) + .ToList(); + + Assert.Equal(1, results.Count(r => r)); + } + + [Fact] + public void TileMetadataHotCache_AbortLastAccessedPersist_AllowsImmediateRetry() + { + var hotCache = new TileMetadataHotCache(NullLogger.Instance); + + Assert.True(hotCache.TryBeginLastAccessedPersist(9, 90, 91)); + hotCache.AbortLastAccessedPersist(9, 90, 91); + + Assert.True(hotCache.TryBeginLastAccessedPersist(9, 90, 91)); + } + [Fact] public async Task GetCacheFileSizeInMbAsync_ReturnsZeroWhenEmpty() { From 4d5c1e74ebf7bda2819cf455118f287046d2f347 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Mon, 13 Apr 2026 23:10:38 +0300 Subject: [PATCH 4/4] chore: update changelog for 1.2.28 --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d319e32..55fe9b65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # CHANGELOG +## [1.2.28] - 2026-04-13 + +### Added +- `TileMetadataHotCacheSizeMB` application setting with Admin Settings UI support and client-side derived entry-count hint for the zoom `>= 9` tile metadata hot cache (#217) + +### Changed +- Warm zoom `>= 9` tile hits now use an in-process metadata hot cache to avoid the per-hit Postgres metadata read on the common fresh-cache path (#217) +- Hot metadata cache invalidation now participates in purge, eviction, and tile-delete paths while preserving DB/file durability ordering and existing revalidation behavior (#217) + +### Fixed +- Fresh hot-hit `LastAccessed` throttling is now atomic per tile and retries immediately after failed DB persists instead of suppressing writes for the full cooldown window (#217) + ## [1.2.27] - 2026-03-27 ### Fixed