e92cdc6d3a
Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
413 lines
17 KiB
C#
413 lines
17 KiB
C#
using Grpc.Core;
|
|
using Grpc.Core.Interceptors;
|
|
using Grpc.Net.Client;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.TestHost;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using ZB.MOM.WW.LocalDb.Contracts;
|
|
using ZB.MOM.WW.LocalDb.Replication;
|
|
|
|
namespace ZB.MOM.WW.LocalDb.Tests;
|
|
|
|
public sealed class GrpcAdapterTests : IAsyncLifetime
|
|
{
|
|
private static readonly TimeSpan RunTimeout = TimeSpan.FromSeconds(30);
|
|
|
|
private readonly List<string> _paths = [];
|
|
private readonly List<IHost> _hosts = [];
|
|
private readonly List<ServiceProvider> _providers = [];
|
|
|
|
public Task InitializeAsync() => Task.CompletedTask;
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
foreach (var provider in _providers)
|
|
await provider.DisposeAsync();
|
|
foreach (var host in _hosts)
|
|
{
|
|
try { await host.StopAsync(TimeSpan.FromSeconds(5)); } catch { /* teardown */ }
|
|
host.Dispose();
|
|
}
|
|
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
|
foreach (var p in _paths)
|
|
{
|
|
if (File.Exists(p)) File.Delete(p);
|
|
if (File.Exists(p + "-wal")) File.Delete(p + "-wal");
|
|
if (File.Exists(p + "-shm")) File.Delete(p + "-shm");
|
|
}
|
|
}
|
|
|
|
// ---- end-to-end wire tests -------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task EndToEnd_TwoDbs_InsertOnA_AppearsOnB()
|
|
{
|
|
var serverHost = await BuildServerAsync(NewDbPath(), apiKey: null, sink: null);
|
|
var serverDb = serverHost.Services.GetRequiredService<ILocalDb>();
|
|
await serverDb.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (2, 'FROM_SERVER', 20)");
|
|
var handler = serverHost.GetTestServer().CreateHandler();
|
|
|
|
var (clientProvider, bg) = BuildClient(NewDbPath(), "http://localhost", apiKey: null, () => ChannelOver(handler));
|
|
var clientDb = clientProvider.GetRequiredService<ILocalDb>();
|
|
await clientDb.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'FROM_CLIENT', 10)");
|
|
|
|
using var cts = new CancellationTokenSource(RunTimeout);
|
|
await bg.StartAsync(cts.Token);
|
|
|
|
await WaitForAsync(
|
|
async () => (await ReadOrders(clientDb)).Count == 2 && (await ReadOrders(serverDb)).Count == 2,
|
|
RunTimeout);
|
|
|
|
var clientRows = await ReadOrders(clientDb);
|
|
var serverRows = await ReadOrders(serverDb);
|
|
Assert.Equal(clientRows, serverRows);
|
|
Assert.Equal([(1L, "FROM_CLIENT", (long?)10), (2L, "FROM_SERVER", (long?)20)], clientRows);
|
|
|
|
await bg.StopAsync(CancellationToken.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SecondConcurrentStream_Rejected()
|
|
{
|
|
var serverHost = await BuildServerAsync(NewDbPath(), apiKey: null, sink: null);
|
|
var serverDb = serverHost.Services.GetRequiredService<ILocalDb>();
|
|
var handler = serverHost.GetTestServer().CreateHandler();
|
|
|
|
var (clientProvider, bg) = BuildClient(NewDbPath(), "http://localhost", apiKey: null, () => ChannelOver(handler));
|
|
var clientDb = clientProvider.GetRequiredService<ILocalDb>();
|
|
await clientDb.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'C', 10)");
|
|
|
|
using var cts = new CancellationTokenSource(RunTimeout);
|
|
await bg.StartAsync(cts.Token);
|
|
|
|
// Convergence proves the first stream's server handler is active and holding the single-stream guard.
|
|
await WaitForAsync(async () => (await ReadOrders(serverDb)).Count == 1, RunTimeout);
|
|
|
|
var client = new LocalDbSync.LocalDbSyncClient(ChannelOver(handler));
|
|
using var call2 = client.Sync(cancellationToken: cts.Token);
|
|
var ex = await Assert.ThrowsAsync<RpcException>(() => call2.ResponseStream.MoveNext(cts.Token));
|
|
Assert.Equal(StatusCode.AlreadyExists, ex.StatusCode);
|
|
|
|
await bg.StopAsync(CancellationToken.None);
|
|
}
|
|
|
|
// Shipped variant: the channel factory throws on its first call, then returns a live channel.
|
|
// This deterministically drives the backoff/retry loop (no flaky server restart in TestHost)
|
|
// and proves convergence after recovery.
|
|
[Fact]
|
|
public async Task ClientReconnects_AfterTransientFault()
|
|
{
|
|
var serverHost = await BuildServerAsync(NewDbPath(), apiKey: null, sink: null);
|
|
var serverDb = serverHost.Services.GetRequiredService<ILocalDb>();
|
|
await serverDb.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (2, 'S', 20)");
|
|
var handler = serverHost.GetTestServer().CreateHandler();
|
|
|
|
var calls = 0;
|
|
GrpcChannel Factory()
|
|
{
|
|
if (Interlocked.Increment(ref calls) == 1)
|
|
throw new InvalidOperationException("transient dial failure");
|
|
return ChannelOver(handler);
|
|
}
|
|
|
|
var (clientProvider, bg) = BuildClient(NewDbPath(), "http://localhost", apiKey: null, Factory);
|
|
var clientDb = clientProvider.GetRequiredService<ILocalDb>();
|
|
await clientDb.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'C', 10)");
|
|
|
|
using var cts = new CancellationTokenSource(RunTimeout);
|
|
await bg.StartAsync(cts.Token);
|
|
|
|
await WaitForAsync(
|
|
async () => (await ReadOrders(clientDb)).Count == 2 && (await ReadOrders(serverDb)).Count == 2,
|
|
RunTimeout);
|
|
Assert.True(bg.ConnectionAttempts >= 2, $"expected >= 2 connection attempts, got {bg.ConnectionAttempts}");
|
|
|
|
await bg.StopAsync(CancellationToken.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApiKeyHeader_SentWhenConfigured()
|
|
{
|
|
var sink = new AuthCaptureSink();
|
|
var serverHost = await BuildServerAsync(NewDbPath(), apiKey: "test-key", sink: sink);
|
|
var handler = serverHost.GetTestServer().CreateHandler();
|
|
|
|
var (clientProvider, bg) = BuildClient(NewDbPath(), "http://localhost", apiKey: "test-key", () => ChannelOver(handler));
|
|
var clientDb = clientProvider.GetRequiredService<ILocalDb>();
|
|
await clientDb.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'C', 10)");
|
|
|
|
using var cts = new CancellationTokenSource(RunTimeout);
|
|
await bg.StartAsync(cts.Token);
|
|
|
|
await WaitForAsync(() => Task.FromResult(sink.Authorization is not null), RunTimeout);
|
|
Assert.Equal("Bearer test-key", sink.Authorization);
|
|
|
|
await bg.StopAsync(CancellationToken.None);
|
|
}
|
|
|
|
// Shipped variant: in-proc against GrpcSyncDuplex with a flow-control-stalled fake writer
|
|
// (WriteAsync never completes until its token fires) — pins requirement 2 (Send honors its
|
|
// CancellationToken even when transport-blocked) deterministically; a true stalled-gRPC-peer
|
|
// rig in TestHost cannot force flow-control blockage reliably.
|
|
[Fact]
|
|
public async Task Send_StalledOnFlowControl_CancelUnblocksPromptly()
|
|
{
|
|
using var cts = new CancellationTokenSource();
|
|
var (duplex, readerTask) = Replication.Internal.GrpcSyncDuplex.Create(
|
|
new HangingReader(), new HangingWriter(), cts.Token);
|
|
|
|
var send = duplex.Send(new SyncMessage(), cts.Token);
|
|
Assert.False(send.IsCompleted);
|
|
|
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
cts.Cancel();
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => send);
|
|
// The reader pump must also tear down: its fault travels into the inbox completion.
|
|
await readerTask.WaitAsync(TimeSpan.FromSeconds(5));
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
|
async () => await duplex.Inbox.Completion);
|
|
sw.Stop();
|
|
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(3), $"teardown took {sw.Elapsed}");
|
|
}
|
|
|
|
// ---- validator -------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void Validator_InvalidPeerAddress_Fails()
|
|
{
|
|
var result = new ReplicationOptionsValidator().Validate(
|
|
null, new ReplicationOptions { PeerAddress = "not-a-uri" });
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains(result.Failures, f => f.Contains("PeerAddress", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void Validator_NonPositiveIntervals_Fail()
|
|
{
|
|
var result = new ReplicationOptionsValidator().Validate(null, new ReplicationOptions
|
|
{
|
|
FlushInterval = TimeSpan.Zero,
|
|
MaxBatchSize = 0,
|
|
MaxOplogRows = 0,
|
|
MaxOplogAge = TimeSpan.Zero,
|
|
TombstoneRetention = TimeSpan.Zero,
|
|
ReconnectBackoffMax = TimeSpan.Zero,
|
|
MaxHlcDriftAhead = TimeSpan.Zero,
|
|
});
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains(result.Failures, f => f.Contains("FlushInterval", StringComparison.Ordinal));
|
|
Assert.Contains(result.Failures, f => f.Contains("MaxBatchSize", StringComparison.Ordinal));
|
|
Assert.Contains(result.Failures, f => f.Contains("MaxOplogRows", StringComparison.Ordinal));
|
|
Assert.Contains(result.Failures, f => f.Contains("MaxOplogAge", StringComparison.Ordinal));
|
|
Assert.Contains(result.Failures, f => f.Contains("TombstoneRetention", StringComparison.Ordinal));
|
|
Assert.Contains(result.Failures, f => f.Contains("ReconnectBackoffMax", StringComparison.Ordinal));
|
|
Assert.Contains(result.Failures, f => f.Contains("MaxHlcDriftAhead", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void Validator_EmptyPeerAddress_Allowed()
|
|
{
|
|
// A passive node leaves PeerAddress empty; the validator can't know the role, so it only
|
|
// checks format when present. All other defaults are valid.
|
|
var result = new ReplicationOptionsValidator().Validate(null, new ReplicationOptions { PeerAddress = "" });
|
|
|
|
Assert.True(result.Succeeded);
|
|
}
|
|
|
|
[Fact]
|
|
public void Validator_ReconnectBackoffMax_CappedAtOneDay()
|
|
{
|
|
var atCeiling = new ReplicationOptionsValidator().Validate(
|
|
null, new ReplicationOptions { ReconnectBackoffMax = TimeSpan.FromDays(1) });
|
|
Assert.True(atCeiling.Succeeded);
|
|
|
|
var overCeiling = new ReplicationOptionsValidator().Validate(
|
|
null, new ReplicationOptions { ReconnectBackoffMax = TimeSpan.FromDays(1) + TimeSpan.FromTicks(1) });
|
|
Assert.True(overCeiling.Failed);
|
|
Assert.Contains(overCeiling.Failures, f => f.Contains("ReconnectBackoffMax", StringComparison.Ordinal));
|
|
}
|
|
|
|
// ---- DI --------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task AddZbLocalDbReplication_PassiveConfig_InitiatorIdles()
|
|
{
|
|
var passive = new ServiceCollection();
|
|
passive.AddLogging();
|
|
var passiveCfg = ConfigFor(NewDbPath(), peerAddress: null, apiKey: null);
|
|
passive.AddZbLocalDb(passiveCfg, OnReady);
|
|
passive.AddZbLocalDbReplication(passiveCfg);
|
|
var p1 = passive.BuildServiceProvider();
|
|
_providers.Add(p1);
|
|
|
|
Assert.NotNull(p1.GetService<LocalDbSyncService>());
|
|
|
|
// The hosted service is always registered; with no PeerAddress it starts, idles, and
|
|
// completes without dialing anything.
|
|
var bg = p1.GetServices<IHostedService>().OfType<SyncBackgroundService>().Single();
|
|
await bg.StartAsync(CancellationToken.None);
|
|
await bg.ExecuteTask!.WaitAsync(TimeSpan.FromSeconds(5));
|
|
Assert.True(bg.ExecuteTask.IsCompletedSuccessfully);
|
|
Assert.Equal(0, bg.ConnectionAttempts);
|
|
await bg.StopAsync(CancellationToken.None);
|
|
|
|
var initiator = new ServiceCollection();
|
|
initiator.AddLogging();
|
|
var initiatorCfg = ConfigFor(NewDbPath(), peerAddress: "http://localhost", apiKey: null);
|
|
initiator.AddZbLocalDb(initiatorCfg, OnReady);
|
|
initiator.AddZbLocalDbReplication(initiatorCfg);
|
|
var p2 = initiator.BuildServiceProvider();
|
|
_providers.Add(p2);
|
|
|
|
Assert.Contains(p2.GetServices<IHostedService>(), s => s is SyncBackgroundService);
|
|
}
|
|
|
|
// ---- harness ---------------------------------------------------------------------------
|
|
|
|
private string NewDbPath()
|
|
{
|
|
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db");
|
|
_paths.Add(path);
|
|
return path;
|
|
}
|
|
|
|
private static void OnReady(ILocalDb db)
|
|
{
|
|
using var conn = db.CreateConnection();
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "CREATE TABLE orders (id INTEGER PRIMARY KEY, sku TEXT, qty INTEGER)";
|
|
cmd.ExecuteNonQuery();
|
|
db.RegisterReplicated("orders");
|
|
}
|
|
|
|
private static IConfiguration ConfigFor(string path, string? peerAddress, string? apiKey)
|
|
{
|
|
var dict = new Dictionary<string, string?>
|
|
{
|
|
["LocalDb:Path"] = path,
|
|
["LocalDb:Replication:FlushInterval"] = "00:00:00.020",
|
|
};
|
|
if (peerAddress is not null) dict["LocalDb:Replication:PeerAddress"] = peerAddress;
|
|
if (apiKey is not null) dict["LocalDb:Replication:ApiKey"] = apiKey;
|
|
return new ConfigurationBuilder().AddInMemoryCollection(dict).Build();
|
|
}
|
|
|
|
private static GrpcChannel ChannelOver(HttpMessageHandler handler) =>
|
|
GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions { HttpHandler = handler });
|
|
|
|
private async Task<IHost> BuildServerAsync(string path, string? apiKey, AuthCaptureSink? sink)
|
|
{
|
|
var config = ConfigFor(path, peerAddress: null, apiKey: apiKey);
|
|
var host = await new HostBuilder()
|
|
.ConfigureWebHost(web =>
|
|
{
|
|
web.UseTestServer();
|
|
web.ConfigureServices(services =>
|
|
{
|
|
services.AddRouting();
|
|
if (sink is not null)
|
|
{
|
|
services.AddSingleton(sink);
|
|
services.AddGrpc(o => o.Interceptors.Add<AuthCaptureInterceptor>());
|
|
}
|
|
else
|
|
{
|
|
services.AddGrpc();
|
|
}
|
|
|
|
services.AddZbLocalDb(config, OnReady);
|
|
services.AddZbLocalDbReplication(config);
|
|
});
|
|
web.Configure(app =>
|
|
{
|
|
app.UseRouting();
|
|
app.UseEndpoints(e => e.MapZbLocalDbSync());
|
|
});
|
|
})
|
|
.StartAsync();
|
|
_hosts.Add(host);
|
|
return host;
|
|
}
|
|
|
|
private (ServiceProvider Provider, SyncBackgroundService Bg) BuildClient(
|
|
string path, string peerAddress, string? apiKey, Func<GrpcChannel> channelFactory)
|
|
{
|
|
var config = ConfigFor(path, peerAddress, apiKey);
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddZbLocalDb(config, OnReady);
|
|
services.AddZbLocalDbReplication(config);
|
|
var provider = services.BuildServiceProvider();
|
|
_providers.Add(provider);
|
|
|
|
var bg = provider.GetServices<IHostedService>().OfType<SyncBackgroundService>().Single();
|
|
bg.ChannelFactory = channelFactory;
|
|
return (provider, bg);
|
|
}
|
|
|
|
private static async Task<List<(long Id, string? Sku, long? Qty)>> ReadOrders(ILocalDb db)
|
|
{
|
|
var rows = await db.QueryAsync(
|
|
"SELECT id, sku, qty FROM orders ORDER BY id",
|
|
x => (x.GetInt64(0), x.IsDBNull(1) ? null : x.GetString(1), (long?)(x.IsDBNull(2) ? null : x.GetInt64(2))));
|
|
return rows.ToList();
|
|
}
|
|
|
|
private static async Task WaitForAsync(Func<Task<bool>> predicate, TimeSpan timeout)
|
|
{
|
|
var deadline = DateTime.UtcNow + timeout;
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
if (await predicate()) return;
|
|
await Task.Delay(20);
|
|
}
|
|
throw new TimeoutException("Condition not reached within " + timeout);
|
|
}
|
|
|
|
private sealed class HangingReader : IAsyncStreamReader<SyncMessage>
|
|
{
|
|
public SyncMessage Current => throw new InvalidOperationException("no message");
|
|
|
|
public async Task<bool> MoveNext(CancellationToken cancellationToken)
|
|
{
|
|
await Task.Delay(Timeout.Infinite, cancellationToken);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private sealed class HangingWriter : IAsyncStreamWriter<SyncMessage>
|
|
{
|
|
public WriteOptions? WriteOptions { get; set; }
|
|
|
|
public Task WriteAsync(SyncMessage message) => Task.Delay(Timeout.Infinite);
|
|
|
|
public Task WriteAsync(SyncMessage message, CancellationToken cancellationToken) =>
|
|
Task.Delay(Timeout.Infinite, cancellationToken);
|
|
}
|
|
|
|
private sealed class AuthCaptureSink
|
|
{
|
|
public volatile string? Authorization;
|
|
}
|
|
|
|
private sealed class AuthCaptureInterceptor(AuthCaptureSink sink) : Interceptor
|
|
{
|
|
public override Task DuplexStreamingServerHandler<TRequest, TResponse>(
|
|
IAsyncStreamReader<TRequest> requestStream,
|
|
IServerStreamWriter<TResponse> responseStream,
|
|
ServerCallContext context,
|
|
DuplexStreamingServerMethod<TRequest, TResponse> continuation)
|
|
{
|
|
var auth = context.RequestHeaders.GetValue("authorization");
|
|
if (auth is not null)
|
|
sink.Authorization = auth;
|
|
return continuation(requestStream, responseStream, context);
|
|
}
|
|
}
|
|
}
|