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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions src/Services/Controllers/DataStoresController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,19 @@ public async Task<ActionResult> 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();
}

Expand Down Expand Up @@ -292,8 +305,9 @@ public async Task<ActionResult> 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();
Expand Down Expand Up @@ -321,6 +335,11 @@ public async Task<ActionResult> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,20 @@ public static HttpClient CreateAuthenticatedClient(
this CustomWebApplicationFactory factory,
string? userId = null,
string? userName = null)
=> factory.CreateAuthenticatedFactory(userId, userName).CreateAuthenticatedClient();

/// <summary>
/// The host an authenticated client talks to. It is a separate host from
/// <paramref name="factory"/>, 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 <c>Services</c>.
/// </summary>
public static WebApplicationFactory<Program> CreateAuthenticatedFactory(
this CustomWebApplicationFactory factory,
string? userId = null,
string? userName = null)
{
var client = factory.WithWebHostBuilder(builder =>
return factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
Expand All @@ -37,7 +49,12 @@ public static HttpClient CreateAuthenticatedClient(
userId ?? DefaultUserId,
userName ?? DefaultUserName));
});
}).CreateClient(new WebApplicationFactoryClientOptions
});
}

public static HttpClient CreateAuthenticatedClient(this WebApplicationFactory<Program> authenticatedFactory)
{
var client = authenticatedFactory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
Expand Down
151 changes: 151 additions & 0 deletions tests/CoreSyncServer.Tests/SyncProviderCacheInvalidationTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public class SyncProviderCacheInvalidationTests : IClassFixture<CustomWebApplicationFactory>
{
// 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<ApplicationDbContext>();

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<Program> _host;

private ISyncProviderCache Cache => _host.Services.GetRequiredService<ISyncProviderCache>();

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();
}

/// <summary>Stands in for a real provider; the cache only stores the reference.</summary>
public class SentinelProvider : DispatchProxy
{
public static ISyncProvider Create() => Create<ISyncProvider, SentinelProvider>();

protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) =>
throw new NotSupportedException("Sentinel provider is never used for syncing.");
}
}
Loading