Skip to content

Commit

Permalink
feat(api): improve caching and place name matching (#451)
Browse files Browse the repository at this point in the history
  • Loading branch information
steveoh authored Dec 27, 2024
1 parent cab6218 commit b85e7d7
Show file tree
Hide file tree
Showing 12 changed files with 320 additions and 259 deletions.
7 changes: 0 additions & 7 deletions src/api/Cache/ICacheRepository.cs

This file was deleted.

103 changes: 0 additions & 103 deletions src/api/Cache/RedisCacheRepository.cs

This file was deleted.

68 changes: 58 additions & 10 deletions src/api/Extensions/WebApplicationBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Google.Cloud.Firestore;
using MediatR.Pipeline;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Caching.StackExchangeRedis;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
Expand All @@ -20,7 +21,6 @@
using Polly.Extensions.Http;
using Polly.Retry;
using Polly.Timeout;
using StackExchange.Redis;
using Swashbuckle.AspNetCore.SwaggerGen;
using ugrc.api.Cache;
using ugrc.api.Features.Converting;
Expand All @@ -33,6 +33,8 @@
using ugrc.api.Models.Configuration;
using ugrc.api.Models.ResponseContracts;
using ugrc.api.Services;
using ZiggyCreatures.Caching.Fusion;
using ZiggyCreatures.Caching.Fusion.Serialization.SystemTextJson;

namespace ugrc.api.Extensions;
public static class WebApplicationBuilderExtensions {
Expand Down Expand Up @@ -62,12 +64,12 @@ public static void ConfigureLogging(this WebApplicationBuilder builder) {
public static void ConfigureHealthChecks(this WebApplicationBuilder builder)
=> builder.Services.AddHealthChecks()
.AddCheck<StartupHealthCheck>("Startup", failureStatus: HealthStatus.Degraded, tags: ["startup"])
.AddCheck<CacheHealthCheck>("Cache", failureStatus: HealthStatus.Degraded, tags: ["health"])
.AddCheck<GeometryServiceHealthCheck>("ArcGIS:GeometryService", failureStatus: HealthStatus.Degraded, tags: ["health"])
.AddCheck<KeyStoreHealthCheck>("KeyStore", failureStatus: HealthStatus.Unhealthy, tags: ["health"])
.AddCheck<UdotServiceHealthCheck>("ArcGIS:RoadsAndHighwaysService", failureStatus: HealthStatus.Degraded, tags: ["health"])
.AddCheck<LocatorHealthCheck>("ArcGIS:LocatorServices", tags: ["health"])
.AddCheck<BigQueryHealthCheck>("Database", tags: ["health"]);
.AddCheck<BigQueryHealthCheck>("Database", tags: ["health"])
.AddCheck<GridMappingHealthCheck>("GridMapping", tags: ["health"]);
public static void ConfigureDependencyInjection(this WebApplicationBuilder builder) {
builder.Services.Configure<List<LocatorConfiguration>>(builder.Configuration.GetSection("webapi:locators"));
builder.Services.Configure<List<ReverseLocatorConfiguration>>(builder.Configuration.GetSection("webapi:locators"));
Expand All @@ -81,6 +83,59 @@ public static void ConfigureDependencyInjection(this WebApplicationBuilder build
_ => EmulatorDetection.EmulatorOnly,
};

// TODO! this might be a hack
var config = new DatabaseConfiguration {
Host = builder.Configuration.GetSection("webapi:redis").GetValue<string>("host") ?? string.Empty,
};
ArgumentNullException.ThrowIfNull(config);

builder.Services.AddMemoryCache();
builder.Services.AddFusionCache("places")
.WithOptions(options => {
options.DistributedCacheCircuitBreakerDuration = TimeSpan.FromSeconds(3);
})
.WithDefaultEntryOptions(new FusionCacheEntryOptions {
IsFailSafeEnabled = true,
Duration = TimeSpan.FromDays(7),

FailSafeMaxDuration = TimeSpan.FromHours(1),
FailSafeThrottleDuration = TimeSpan.FromSeconds(30),

FactorySoftTimeout = TimeSpan.FromSeconds(3),
FactoryHardTimeout = TimeSpan.FromSeconds(15),
})
.WithSerializer(
new FusionCacheSystemTextJsonSerializer(new JsonSerializerOptions {
IncludeFields = true,
})
)
.WithDistributedCache(new RedisCache(new RedisCacheOptions() { Configuration = config.ConnectionString })
);

builder.Services.AddFusionCache("firestore")
.WithOptions(options => {
options.DistributedCacheCircuitBreakerDuration = TimeSpan.FromSeconds(3);
})
.WithSerializer(
new FusionCacheSystemTextJsonSerializer()
)
.WithDefaultEntryOptions(new FusionCacheEntryOptions {
IsFailSafeEnabled = true,
Duration = TimeSpan.FromMinutes(5),

FailSafeMaxDuration = TimeSpan.FromHours(1),
FailSafeThrottleDuration = TimeSpan.FromSeconds(30),

FactorySoftTimeout = TimeSpan.FromSeconds(3),
FactoryHardTimeout = TimeSpan.FromSeconds(5),
})
.WithDistributedCache(new RedisCache(new RedisCacheOptions() { Configuration = config.ConnectionString })
);

})
.WithDistributedCache(new RedisCache(new RedisCacheOptions() { Configuration = config.ConnectionString })
);

// Singletons - same for every request
// This throws in dev but not prod if the database is not running
builder.Services.AddSingleton(new FirestoreDbBuilder {
Expand All @@ -91,19 +146,12 @@ public static void ConfigureDependencyInjection(this WebApplicationBuilder build
builder.Services.AddSingleton<IAbbreviations, Abbreviations>();
builder.Services.AddSingleton<IRegexCache, RegexCache>();
builder.Services.AddSingleton<IApiKeyRepository, FirestoreApiKeyRepository>();
builder.Services.AddSingleton<ICacheRepository, RedisCacheRepository>();
builder.Services.AddSingleton<IStaticCache, StaticCache>();
builder.Services.AddSingleton<IBrowserKeyProvider, BrowserKeyProvider>();
builder.Services.AddSingleton<IServerIpProvider, FirebaseClientIpProvider>();
builder.Services.AddSingleton<IDistanceStrategy, PythagoreanDistance>();
builder.Services.AddSingleton<ITableMapping, TableMapping>();
builder.Services.AddSingleton<StartupHealthCheck>();
builder.Services.AddSingleton((provider) => {
var options = provider.GetService<IOptions<DatabaseConfiguration>>();
ArgumentNullException.ThrowIfNull(options);

return new Lazy<IConnectionMultiplexer>(() => ConnectionMultiplexer.Connect(options.Value.ConnectionString));
});
builder.Services.AddSingleton((provider) => {
var options = provider.GetService<IOptions<SearchProviderConfiguration>>();
ArgumentNullException.ThrowIfNull(options);
Expand Down
109 changes: 95 additions & 14 deletions src/api/Features/Geocoding/AddressSystemFromPlace.cs
Original file line number Diff line number Diff line change
@@ -1,36 +1,117 @@
using ugrc.api.Cache;
using Fastenshtein;
using Microsoft.Extensions.Caching.Memory;
using ugrc.api.Infrastructure;
using ugrc.api.Models.Linkables;
using ugrc.api.Services;
using ZiggyCreatures.Caching.Fusion;

namespace ugrc.api.Features.Geocoding;
public class AddressSystemFromPlace {
public class Computation(string cityKey) : IComputation<IReadOnlyCollection<GridLinkable>> {
public readonly string _cityKey = cityKey.ToLowerInvariant();
}

public class Handler(ICacheRepository cache, ILogger log) : IComputationHandler<Computation, IReadOnlyCollection<GridLinkable>> {
public class Handler(IMemoryCache cache, IFusionCacheProvider provider, ILogger log) : IComputationHandler<Computation, IReadOnlyCollection<GridLinkable>> {
private readonly ILogger? _log = log?.ForContext<AddressSystemFromPlace>();
private readonly ICacheRepository _memoryCache = cache;
private readonly IMemoryCache _memoryCache = cache;
private readonly IFusionCache _fusionCache = provider.GetCache("places");

public async Task<IReadOnlyCollection<GridLinkable>> Handle(Computation request, CancellationToken cancellationToken) {
_log?.Debug("Getting address system from city {city}", request._cityKey);
public async Task<IReadOnlyCollection<GridLinkable>> Handle(Computation request, CancellationToken token) {
_log?.Debug("Getting address system for city {city}", request._cityKey);

if (string.IsNullOrEmpty(request._cityKey)) {
return [];
}

var result = await _memoryCache.FindGridsForPlaceAsync(request._cityKey);
// Check memory cache first
if (IsPlaceNameMatch(request._cityKey, out var result)) {
return result;
}

if (result.Count == 0) {
_log?.ForContext("place", request._cityKey)
.Information("Analytics:no-place");
} else {
_log?.ForContext("place", request._cityKey)
.ForContext("grids", result)
.Information("Analytics:place");
// Check fusion cache next
var (success, fuzzyResult) = await IsFuzzyMatchAsync(request._cityKey, token);
if (success) {
return fuzzyResult;
}

// levenshtein everything and check again
var (successful, newFuzzyResult) = await GetAndSetFuzzyMatchAsync(request._cityKey, token);
if (successful) {
return newFuzzyResult;
}

await LogMissAndSetCache(request._cityKey, "miss levenshtein", token);
return [];
}
private bool IsPlaceNameMatch(string key, out List<GridLinkable> result) {
result = _memoryCache.Get<List<GridLinkable>>($"mapping/place/{key}") ?? [];

if (result.Count > 0) {
LogCacheHit(key, "bigquery");

return true;
}

return false;
}
private async Task<(bool success, List<GridLinkable> result)> IsFuzzyMatchAsync(string key, CancellationToken token) {
var result = await _fusionCache.GetOrDefaultAsync<List<GridLinkable>>($"mapping/place/{key}", defaultValue: [], token: token);

if (result!.Count > 0) {
LogCacheHit(key, "fusion");

return (true, result);
}

return result;
return (false, result);
}
private async Task<(bool success, List<GridLinkable> fuzzyResult)> GetAndSetFuzzyMatchAsync(string key, CancellationToken token) {
// Try get all the keys to levenshtein
var places = _memoryCache.Get<List<string>>("mapping/places");
if (places is null || places.Count == 0) {
// This shouldn't happen but it could?
await LogMissAndSetCache(key, "empty mapping/places", token);

return (false, []);
}

var closestMatch = FindClosestMatch(key, places);
if (string.IsNullOrEmpty(closestMatch)) {
await LogMissAndSetCache(key, "empty levenshtein", token);

return (false, []);
}

var result = _memoryCache.Get<List<GridLinkable>>($"mapping/place/{closestMatch}");
if (result?.Count > 0) {
LogCacheHit(closestMatch, "levenshtein");
await _fusionCache.SetAsync($"mapping/place/{key}", result, token: token);

return (true, result);
}

return (false, []);
}
private static string? FindClosestMatch(string key, List<string> places) {
var lev = new Levenshtein(key);
var priorityQueue = new LowestDistance(1);

places.ForEach(place => priorityQueue.Add(new(lev.DistanceFrom(place), place)));

var closestMatch = priorityQueue.Get().FirstOrDefault(new Map(int.MaxValue, string.Empty));

return closestMatch.Difference > 2 ? null : closestMatch.Zone;
}
private void LogCacheHit(string place, string cacheType)
=> _log?.ForContext("place", place)
.ForContext("cache", cacheType)
.Information("Analytics:place");
private async Task LogMissAndSetCache(string place, string reason, CancellationToken token) {
_log?.ForContext("place", place)
.ForContext("reason", reason)
.Information("Analytics:no-place");

await _fusionCache.SetAsync<List<GridLinkable>>($"mapping/place/{place}", [], token: token);
}
}
}
Loading

0 comments on commit b85e7d7

Please sign in to comment.