From 37b846a4d75b33f36fc0380154e4f0d7a5216692 Mon Sep 17 00:00:00 2001 From: Adolfo Marinucci Date: Tue, 29 Sep 2026 15:54:43 +0200 Subject: [PATCH] Evict cached sync providers when an endpoint is repointed or its data store edited Cached providers never expire and were only evicted by the publish toggle. Repointing an endpoint at another configuration is allowed while published, so the endpoint kept serving the previous configuration's tables until the next toggle or restart. Editing a data store's connection string, tracking mode or change retention had the same effect on every endpoint on it. Both actions now invalidate. The tests needed the host behind the authenticated client: CreateAuthenticatedClient runs a separate WebApplicationFactory with its own singletons, so CreateAuthenticatedFactory exposes it. --- .../Controllers/DataStoresController.cs | 23 ++- .../AuthenticatedHttpClientExtensions.cs | 21 ++- .../SyncProviderCacheInvalidationTests.cs | 151 ++++++++++++++++++ 3 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 tests/CoreSyncServer.Tests/SyncProviderCacheInvalidationTests.cs diff --git a/src/Services/Controllers/DataStoresController.cs b/src/Services/Controllers/DataStoresController.cs index d5395d2..15c3aac 100644 --- a/src/Services/Controllers/DataStoresController.cs +++ b/src/Services/Controllers/DataStoresController.cs @@ -231,6 +231,19 @@ public async Task Update(int id, [FromBody] UpdateDataStoreRequest await context.SaveChangesAsync(); + // Cached providers hold the connection string, tracking mode and change retention they were + // built with, and none of those require unpublishing to edit. Drop every endpoint on this + // data store so the next sync connects with the saved settings. + var endpointIds = await context.Endpoints + .Where(e => e.DataStoreConfiguration!.DataStoreId == id) + .Select(e => e.Id) + .ToListAsync(); + + foreach (var endpointId in endpointIds) + { + syncProviderCache.Invalidate(endpointId); + } + return NoContent(); } @@ -292,8 +305,9 @@ public async Task PublishEndpoint(int id, Guid endpointId, [FromBo endpoint.IsPublished = request.IsPublished; await context.SaveChangesAsync(); - // Drop any cached sync provider so the next sync rebuilds it from the current configuration - // (the table config can only change while unpublished, so this toggle is the safe seam). + // Drop any cached sync provider so the next sync rebuilds it from the current configuration. + // Table rules can only change while unpublished, so this toggle covers them; repointing the + // endpoint and editing the data store invalidate on their own. syncProviderCache.Invalidate(endpointId); return NoContent(); @@ -321,6 +335,11 @@ public async Task UpdateEndpointConfiguration(int id, Guid endpoin endpoint.DataStoreConfigurationId = request.DataStoreConfigurationId; await context.SaveChangesAsync(); + // The cached provider was built from the previous configuration's tables. Repointing is + // allowed while published (it is how a new configuration goes live), so without this the + // endpoint kept serving the old tables until the next publish toggle or restart. + syncProviderCache.Invalidate(endpointId); + return NoContent(); } } diff --git a/tests/CoreSyncServer.Tests/Infrastructure/AuthenticatedHttpClientExtensions.cs b/tests/CoreSyncServer.Tests/Infrastructure/AuthenticatedHttpClientExtensions.cs index 8d2580f..296e106 100644 --- a/tests/CoreSyncServer.Tests/Infrastructure/AuthenticatedHttpClientExtensions.cs +++ b/tests/CoreSyncServer.Tests/Infrastructure/AuthenticatedHttpClientExtensions.cs @@ -20,8 +20,20 @@ public static HttpClient CreateAuthenticatedClient( this CustomWebApplicationFactory factory, string? userId = null, string? userName = null) + => factory.CreateAuthenticatedFactory(userId, userName).CreateAuthenticatedClient(); + + /// + /// The host an authenticated client talks to. It is a separate host from + /// , with its own singletons, so a test that needs to see server + /// state (e.g. the sync provider cache) must resolve it from here rather than from the + /// fixture's Services. + /// + public static WebApplicationFactory CreateAuthenticatedFactory( + this CustomWebApplicationFactory factory, + string? userId = null, + string? userName = null) { - var client = factory.WithWebHostBuilder(builder => + return factory.WithWebHostBuilder(builder => { builder.ConfigureTestServices(services => { @@ -37,7 +49,12 @@ public static HttpClient CreateAuthenticatedClient( userId ?? DefaultUserId, userName ?? DefaultUserName)); }); - }).CreateClient(new WebApplicationFactoryClientOptions + }); + } + + public static HttpClient CreateAuthenticatedClient(this WebApplicationFactory authenticatedFactory) + { + var client = authenticatedFactory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); diff --git a/tests/CoreSyncServer.Tests/SyncProviderCacheInvalidationTests.cs b/tests/CoreSyncServer.Tests/SyncProviderCacheInvalidationTests.cs new file mode 100644 index 0000000..6c69f6d --- /dev/null +++ b/tests/CoreSyncServer.Tests/SyncProviderCacheInvalidationTests.cs @@ -0,0 +1,151 @@ +using System.Net; +using System.Net.Http.Json; +using System.Reflection; +using CoreSync; +using CoreSyncServer.Controllers; +using CoreSyncServer.Data; +using CoreSyncServer.Services; +using CoreSyncServer.Tests.Infrastructure; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace CoreSyncServer.Tests; + +/// +/// The sync provider cache never expires on its own, so every action that changes what a cached +/// provider was built from has to evict it. These cover the two that do not require unpublishing: +/// repointing an endpoint at another configuration, and editing the data store. +/// +public class SyncProviderCacheInvalidationTests : IClassFixture +{ + // SyncController keys an endpoint with no agent as "none". + private const string NoAgent = "none"; + + private readonly CustomWebApplicationFactory _factory; + + public SyncProviderCacheInvalidationTests(CustomWebApplicationFactory factory) + { + _factory = factory; + _host = factory.CreateAuthenticatedFactory(); + } + + private async Task<(int DataStoreId, int TargetConfigId, Guid EndpointId)> SeedPublishedEndpointAsync() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var project = new Project { Name = $"Cache Test {Guid.NewGuid():N}", CreatedDate = DateTime.UtcNow, IsEnabled = true }; + db.Projects.Add(project); + await db.SaveChangesAsync(); + + var dataStore = new SqliteDataStore + { + Name = "Cache Test DB", + FilePath = ":memory:", + ProjectId = project.Id, + Type = DataStoreType.SQLite + }; + db.DataStores.Add(dataStore); + await db.SaveChangesAsync(); + + var endpointId = Guid.NewGuid(); + var current = new DataStoreConfiguration + { + Name = "Current", + DataStoreId = dataStore.Id, + Endpoints = + { + new Endpoint + { + Id = endpointId, + Name = "Field devices", + IsPublished = true, + Authentication = new ApiKeyAuthentication { ApiKey = "key" } + } + } + }; + var next = new DataStoreConfiguration { Name = "Next", DataStoreId = dataStore.Id }; + db.DataStoreConfigurations.AddRange(current, next); + await db.SaveChangesAsync(); + + return (dataStore.Id, next.Id, endpointId); + } + + // The authenticated client runs against its own host, so the cache it invalidates is that + // host's singleton - not the one on the fixture. + private readonly WebApplicationFactory _host; + + private ISyncProviderCache Cache => _host.Services.GetRequiredService(); + + private void SeedCachedProvider(Guid endpointId) => + Cache.Set(endpointId, NoAgent, SentinelProvider.Create()); + + [Fact] + public async Task RepointingEndpoint_EvictsCachedProvider() + { + var (dataStoreId, targetConfigId, endpointId) = await SeedPublishedEndpointAsync(); + SeedCachedProvider(endpointId); + var client = _host.CreateAuthenticatedClient(); + + var response = await client.PutAsJsonAsync( + $"api/datastores/{dataStoreId}/endpoints/{endpointId}/configuration", + new DataStoresController.UpdateEndpointConfigurationRequest(targetConfigId)); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + Cache.TryGet(endpointId, NoAgent, out _).Should().BeFalse( + "the cached provider was built from the configuration the endpoint no longer points at"); + } + + [Fact] + public async Task UpdatingDataStore_EvictsCachedProvidersOfItsEndpoints() + { + var (dataStoreId, _, endpointId) = await SeedPublishedEndpointAsync(); + SeedCachedProvider(endpointId); + var client = _host.CreateAuthenticatedClient(); + + var response = await client.PutAsJsonAsync( + $"api/datastores/{dataStoreId}", + new DataStoresController.UpdateDataStoreRequest("Cache Test DB", null, "other.db", null, null, null, null)); + + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + Cache.TryGet(endpointId, NoAgent, out _).Should().BeFalse( + "the cached provider still holds the previous connection settings"); + } + + [Fact] + public async Task RepointingEndpoint_LeavesOtherEndpointsCached() + { + var (dataStoreId, targetConfigId, endpointId) = await SeedPublishedEndpointAsync(); + var (_, _, otherEndpointId) = await SeedPublishedEndpointAsync(); + SeedCachedProvider(endpointId); + SeedCachedProvider(otherEndpointId); + var client = _host.CreateAuthenticatedClient(); + + await client.PutAsJsonAsync( + $"api/datastores/{dataStoreId}/endpoints/{endpointId}/configuration", + new DataStoresController.UpdateEndpointConfigurationRequest(targetConfigId)); + + Cache.TryGet(otherEndpointId, NoAgent, out _).Should().BeTrue(); + } + + [Fact] + public void Invalidate_EvictsTheEndpointsEntry() + { + var endpointId = Guid.NewGuid(); + SeedCachedProvider(endpointId); + + Cache.Invalidate(endpointId); + + Cache.TryGet(endpointId, NoAgent, out _).Should().BeFalse(); + } + + /// Stands in for a real provider; the cache only stores the reference. + public class SentinelProvider : DispatchProxy + { + public static ISyncProvider Create() => Create(); + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) => + throw new NotSupportedException("Sentinel provider is never used for syncing."); + } +}