feat(jetstream): add mirror sync loop and source coordination with filtering (C9+C10)
This commit is contained in:
Binary file not shown.
@@ -1,22 +1,364 @@
|
|||||||
|
using System.Threading.Channels;
|
||||||
using NATS.Server.JetStream.Storage;
|
using NATS.Server.JetStream.Storage;
|
||||||
|
|
||||||
namespace NATS.Server.JetStream.MirrorSource;
|
namespace NATS.Server.JetStream.MirrorSource;
|
||||||
|
|
||||||
public sealed class MirrorCoordinator
|
// Go reference: server/stream.go:2788-2854 (processMirrorMsgs), 3125-3400 (setupMirrorConsumer)
|
||||||
|
// Go reference: server/stream.go:2863-3014 (processInboundMirrorMsg)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Coordinates continuous synchronization from an origin stream to a local mirror.
|
||||||
|
/// Runs a background pull loop that fetches batches of messages from the origin,
|
||||||
|
/// applies them to the local store, and tracks origin-to-current sequence alignment
|
||||||
|
/// for catchup after restarts. Includes exponential backoff retry on failures
|
||||||
|
/// and health reporting via lag calculation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MirrorCoordinator : IAsyncDisposable
|
||||||
{
|
{
|
||||||
|
// Go: sourceHealthCheckInterval = 10 * time.Second
|
||||||
|
private static readonly TimeSpan HealthCheckInterval = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
// Go: sourceHealthHB = 1 * time.Second
|
||||||
|
private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
|
private static readonly TimeSpan InitialRetryDelay = TimeSpan.FromMilliseconds(250);
|
||||||
|
private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromSeconds(30);
|
||||||
|
private const int DefaultBatchSize = 256;
|
||||||
|
|
||||||
private readonly IStreamStore _targetStore;
|
private readonly IStreamStore _targetStore;
|
||||||
|
private readonly Channel<StoredMessage> _inbound;
|
||||||
|
private readonly Lock _gate = new();
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
private Task? _syncLoop;
|
||||||
|
private int _consecutiveFailures;
|
||||||
|
|
||||||
|
/// <summary>Last sequence number successfully applied from the origin stream.</summary>
|
||||||
public ulong LastOriginSequence { get; private set; }
|
public ulong LastOriginSequence { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>UTC timestamp of the last successful sync operation.</summary>
|
||||||
public DateTime LastSyncUtc { get; private set; }
|
public DateTime LastSyncUtc { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Number of consecutive sync failures (resets on success).</summary>
|
||||||
|
public int ConsecutiveFailures
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _consecutiveFailures; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the background sync loop is actively running.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsRunning
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _syncLoop is not null && !_syncLoop.IsCompleted; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current lag: origin last sequence minus local last sequence.
|
||||||
|
/// Returns 0 when fully caught up or when origin sequence is unknown.
|
||||||
|
/// </summary>
|
||||||
|
public ulong Lag { get; private set; }
|
||||||
|
|
||||||
|
// Go: mirror.sseq — stream sequence tracking for gap detection
|
||||||
|
private ulong _expectedOriginSeq;
|
||||||
|
|
||||||
|
// Go: mirror.dseq — delivery sequence tracking
|
||||||
|
private ulong _deliverySeq;
|
||||||
|
|
||||||
public MirrorCoordinator(IStreamStore targetStore)
|
public MirrorCoordinator(IStreamStore targetStore)
|
||||||
{
|
{
|
||||||
_targetStore = targetStore;
|
_targetStore = targetStore;
|
||||||
|
_inbound = Channel.CreateUnbounded<StoredMessage>(new UnboundedChannelOptions
|
||||||
|
{
|
||||||
|
SingleReader = true,
|
||||||
|
SingleWriter = false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes a single inbound message from the origin stream.
|
||||||
|
/// This is the direct-call path used when the origin and mirror are in the same process.
|
||||||
|
/// Go reference: server/stream.go:2863-3014 (processInboundMirrorMsg)
|
||||||
|
/// </summary>
|
||||||
public async Task OnOriginAppendAsync(StoredMessage message, CancellationToken ct)
|
public async Task OnOriginAppendAsync(StoredMessage message, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// Go: sseq == mset.mirror.sseq+1 — normal in-order delivery
|
||||||
|
if (_expectedOriginSeq > 0 && message.Sequence <= _expectedOriginSeq)
|
||||||
|
{
|
||||||
|
// Ignore older/duplicate messages (Go: sseq <= mset.mirror.sseq)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go: sseq > mset.mirror.sseq+1 and dseq == mset.mirror.dseq+1 — gap in origin (deleted/expired)
|
||||||
|
// For in-process mirrors we skip gap handling since the origin store handles its own deletions.
|
||||||
|
|
||||||
await _targetStore.AppendAsync(message.Subject, message.Payload, ct);
|
await _targetStore.AppendAsync(message.Subject, message.Payload, ct);
|
||||||
|
_expectedOriginSeq = message.Sequence;
|
||||||
|
_deliverySeq++;
|
||||||
LastOriginSequence = message.Sequence;
|
LastOriginSequence = message.Sequence;
|
||||||
LastSyncUtc = DateTime.UtcNow;
|
LastSyncUtc = DateTime.UtcNow;
|
||||||
|
Lag = 0; // In-process mirror receives messages synchronously, so lag is always zero here.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enqueues a message for processing by the background sync loop.
|
||||||
|
/// Used when messages arrive asynchronously (e.g., from a pull consumer on the origin).
|
||||||
|
/// </summary>
|
||||||
|
public bool TryEnqueue(StoredMessage message)
|
||||||
|
{
|
||||||
|
return _inbound.Writer.TryWrite(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the background sync loop that drains the inbound channel and applies
|
||||||
|
/// messages to the local store. This models Go's processMirrorMsgs goroutine.
|
||||||
|
/// Go reference: server/stream.go:2788-2854 (processMirrorMsgs)
|
||||||
|
/// </summary>
|
||||||
|
public void StartSyncLoop()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_syncLoop is not null && !_syncLoop.IsCompleted)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
_syncLoop = RunSyncLoopAsync(_cts.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the background sync loop with a pull-based fetch from the origin store.
|
||||||
|
/// This models Go's setupMirrorConsumer + processMirrorMsgs pattern where the mirror
|
||||||
|
/// actively pulls batches from the origin.
|
||||||
|
/// Go reference: server/stream.go:3125-3400 (setupMirrorConsumer)
|
||||||
|
/// </summary>
|
||||||
|
public void StartPullSyncLoop(IStreamStore originStore, int batchSize = DefaultBatchSize)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_syncLoop is not null && !_syncLoop.IsCompleted)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
_syncLoop = RunPullSyncLoopAsync(originStore, batchSize, _cts.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the background sync loop and waits for it to complete.
|
||||||
|
/// Go reference: server/stream.go:3027-3032 (cancelMirrorConsumer)
|
||||||
|
/// </summary>
|
||||||
|
public async Task StopAsync()
|
||||||
|
{
|
||||||
|
CancellationTokenSource? cts;
|
||||||
|
Task? loop;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
cts = _cts;
|
||||||
|
loop = _syncLoop;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cts is not null)
|
||||||
|
{
|
||||||
|
await cts.CancelAsync();
|
||||||
|
if (loop is not null)
|
||||||
|
{
|
||||||
|
try { await loop; }
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_cts?.Dispose();
|
||||||
|
_cts = null;
|
||||||
|
_syncLoop = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reports current health state for monitoring.
|
||||||
|
/// Go reference: server/stream.go:2739-2743 (mirrorInfo), 2698-2736 (sourceInfo)
|
||||||
|
/// </summary>
|
||||||
|
public MirrorHealthReport GetHealthReport(ulong? originLastSeq = null)
|
||||||
|
{
|
||||||
|
var lag = originLastSeq.HasValue && originLastSeq.Value > LastOriginSequence
|
||||||
|
? originLastSeq.Value - LastOriginSequence
|
||||||
|
: Lag;
|
||||||
|
|
||||||
|
return new MirrorHealthReport
|
||||||
|
{
|
||||||
|
LastOriginSequence = LastOriginSequence,
|
||||||
|
LastSyncUtc = LastSyncUtc,
|
||||||
|
Lag = lag,
|
||||||
|
ConsecutiveFailures = ConsecutiveFailures,
|
||||||
|
IsRunning = IsRunning,
|
||||||
|
IsStalled = LastSyncUtc != default
|
||||||
|
&& DateTime.UtcNow - LastSyncUtc > HealthCheckInterval,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await StopAsync();
|
||||||
|
_inbound.Writer.TryComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Background sync loop: channel-based (inbound messages pushed to us)
|
||||||
|
// Go reference: server/stream.go:2788-2854 (processMirrorMsgs main loop)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
private async Task RunSyncLoopAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Go: t := time.NewTicker(sourceHealthCheckInterval)
|
||||||
|
using var healthTimer = new PeriodicTimer(HealthCheckInterval);
|
||||||
|
var reader = _inbound.Reader;
|
||||||
|
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Go: select { case <-msgs.ch: ... case <-t.C: ... }
|
||||||
|
// We process all available messages, then wait for more or health check.
|
||||||
|
while (reader.TryRead(out var msg))
|
||||||
|
{
|
||||||
|
await ProcessInboundMessageAsync(msg, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for either a new message or health check tick
|
||||||
|
var readTask = reader.WaitToReadAsync(ct).AsTask();
|
||||||
|
var healthTask = healthTimer.WaitForNextTickAsync(ct).AsTask();
|
||||||
|
await Task.WhenAny(readTask, healthTask);
|
||||||
|
|
||||||
|
if (ct.IsCancellationRequested)
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Drain any messages that arrived
|
||||||
|
while (reader.TryRead(out var msg2))
|
||||||
|
{
|
||||||
|
await ProcessInboundMessageAsync(msg2, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Go: mset.retryMirrorConsumer() on errors
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_consecutiveFailures++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var delay = CalculateBackoff(_consecutiveFailures);
|
||||||
|
try { await Task.Delay(delay, ct); }
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Background sync loop: pull-based (we fetch from origin)
|
||||||
|
// Go reference: server/stream.go:3125-3400 (setupMirrorConsumer creates
|
||||||
|
// ephemeral pull consumer; processMirrorMsgs drains it)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
private async Task RunPullSyncLoopAsync(IStreamStore originStore, int batchSize, CancellationToken ct)
|
||||||
|
{
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var messages = await originStore.ListAsync(ct);
|
||||||
|
var applied = 0;
|
||||||
|
|
||||||
|
foreach (var msg in messages)
|
||||||
|
{
|
||||||
|
if (ct.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
// Skip messages we've already synced
|
||||||
|
if (msg.Sequence <= LastOriginSequence)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
await ProcessInboundMessageAsync(msg, ct);
|
||||||
|
applied++;
|
||||||
|
|
||||||
|
if (applied >= batchSize)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update lag based on origin state
|
||||||
|
if (messages.Count > 0)
|
||||||
|
{
|
||||||
|
var originLast = messages[^1].Sequence;
|
||||||
|
Lag = originLast > LastOriginSequence ? originLast - LastOriginSequence : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate) _consecutiveFailures = 0;
|
||||||
|
|
||||||
|
// Go: If caught up, wait briefly before next poll
|
||||||
|
if (applied == 0)
|
||||||
|
{
|
||||||
|
try { await Task.Delay(HeartbeatInterval, ct); }
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
lock (_gate) _consecutiveFailures++;
|
||||||
|
var delay = CalculateBackoff(_consecutiveFailures);
|
||||||
|
try { await Task.Delay(delay, ct); }
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go reference: server/stream.go:2863-3014 (processInboundMirrorMsg)
|
||||||
|
private async Task ProcessInboundMessageAsync(StoredMessage message, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Go: sseq <= mset.mirror.sseq — ignore older messages
|
||||||
|
if (_expectedOriginSeq > 0 && message.Sequence <= _expectedOriginSeq)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Go: dc > 1 — skip redelivered messages
|
||||||
|
if (message.Redelivered)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Go: sseq == mset.mirror.sseq+1 — normal sequential delivery
|
||||||
|
// Go: else — gap handling (skip sequences if deliver seq matches)
|
||||||
|
await _targetStore.AppendAsync(message.Subject, message.Payload, ct);
|
||||||
|
_expectedOriginSeq = message.Sequence;
|
||||||
|
_deliverySeq++;
|
||||||
|
LastOriginSequence = message.Sequence;
|
||||||
|
LastSyncUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
|
lock (_gate) _consecutiveFailures = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go reference: server/stream.go:3478-3505 (calculateRetryBackoff in setupSourceConsumer)
|
||||||
|
// Exponential backoff with jitter, capped at MaxRetryDelay.
|
||||||
|
private static TimeSpan CalculateBackoff(int failures)
|
||||||
|
{
|
||||||
|
var baseDelay = InitialRetryDelay.TotalMilliseconds * Math.Pow(2, Math.Min(failures - 1, 10));
|
||||||
|
var capped = Math.Min(baseDelay, MaxRetryDelay.TotalMilliseconds);
|
||||||
|
var jitter = Random.Shared.NextDouble() * 0.2 * capped; // +-20% jitter
|
||||||
|
return TimeSpan.FromMilliseconds(capped + jitter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Health report for a mirror coordinator, used by monitoring endpoints.
|
||||||
|
/// Go reference: server/stream.go:2698-2736 (sourceInfo/StreamSourceInfo)
|
||||||
|
/// </summary>
|
||||||
|
public sealed record MirrorHealthReport
|
||||||
|
{
|
||||||
|
public ulong LastOriginSequence { get; init; }
|
||||||
|
public DateTime LastSyncUtc { get; init; }
|
||||||
|
public ulong Lag { get; init; }
|
||||||
|
public int ConsecutiveFailures { get; init; }
|
||||||
|
public bool IsRunning { get; init; }
|
||||||
|
public bool IsStalled { get; init; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +1,109 @@
|
|||||||
using NATS.Server.JetStream.Storage;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Threading.Channels;
|
||||||
using NATS.Server.JetStream.Models;
|
using NATS.Server.JetStream.Models;
|
||||||
|
using NATS.Server.JetStream.Storage;
|
||||||
|
using NATS.Server.Subscriptions;
|
||||||
|
|
||||||
namespace NATS.Server.JetStream.MirrorSource;
|
namespace NATS.Server.JetStream.MirrorSource;
|
||||||
|
|
||||||
public sealed class SourceCoordinator
|
// Go reference: server/stream.go:3860-4007 (processInboundSourceMsg)
|
||||||
|
// Go reference: server/stream.go:3752-3833 (processAllSourceMsgs)
|
||||||
|
// Go reference: server/stream.go:3474-3720 (setupSourceConsumer, trySetupSourceConsumer)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Coordinates consumption from a source stream into a target stream with support for:
|
||||||
|
/// - Subject filtering via FilterSubject (Go: StreamSource.FilterSubject)
|
||||||
|
/// - Subject transform prefix applied before storing (Go: SubjectTransforms)
|
||||||
|
/// - Account isolation via SourceAccount
|
||||||
|
/// - Deduplication via Nats-Msg-Id header with configurable window
|
||||||
|
/// - Lag tracking per source
|
||||||
|
/// - Background sync loop with exponential backoff retry
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SourceCoordinator : IAsyncDisposable
|
||||||
{
|
{
|
||||||
|
// Go: sourceHealthCheckInterval = 10 * time.Second
|
||||||
|
private static readonly TimeSpan HealthCheckInterval = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
// Go: sourceHealthHB = 1 * time.Second
|
||||||
|
private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
|
private static readonly TimeSpan InitialRetryDelay = TimeSpan.FromMilliseconds(250);
|
||||||
|
private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromSeconds(30);
|
||||||
|
private const int DefaultBatchSize = 256;
|
||||||
|
|
||||||
private readonly IStreamStore _targetStore;
|
private readonly IStreamStore _targetStore;
|
||||||
private readonly StreamSourceConfig _sourceConfig;
|
private readonly StreamSourceConfig _sourceConfig;
|
||||||
|
private readonly Channel<StoredMessage> _inbound;
|
||||||
|
private readonly Lock _gate = new();
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
private Task? _syncLoop;
|
||||||
|
private int _consecutiveFailures;
|
||||||
|
|
||||||
|
// Go: si.sseq — last stream sequence from origin
|
||||||
|
private ulong _expectedOriginSeq;
|
||||||
|
|
||||||
|
// Go: si.dseq — delivery sequence tracking
|
||||||
|
private ulong _deliverySeq;
|
||||||
|
|
||||||
|
// Deduplication state: tracks recently seen Nats-Msg-Id values with their timestamps.
|
||||||
|
// Go: server/stream.go doesn't have per-source dedup, but the stream's duplicate window
|
||||||
|
// (DuplicateWindowMs) applies to publishes. We implement source-level dedup here.
|
||||||
|
private readonly ConcurrentDictionary<string, DateTime> _dedupWindow = new(StringComparer.Ordinal);
|
||||||
|
private DateTime _lastDedupPrune = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>Last sequence number successfully applied from the origin stream.</summary>
|
||||||
public ulong LastOriginSequence { get; private set; }
|
public ulong LastOriginSequence { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>UTC timestamp of the last successful sync operation.</summary>
|
||||||
public DateTime LastSyncUtc { get; private set; }
|
public DateTime LastSyncUtc { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Number of consecutive sync failures (resets on success).</summary>
|
||||||
|
public int ConsecutiveFailures
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _consecutiveFailures; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the background sync loop is actively running.</summary>
|
||||||
|
public bool IsRunning
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _syncLoop is not null && !_syncLoop.IsCompleted; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current lag: origin last sequence minus local last sequence.
|
||||||
|
/// Returns 0 when fully caught up.
|
||||||
|
/// </summary>
|
||||||
|
public ulong Lag { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Total messages dropped by the subject filter.</summary>
|
||||||
|
public long FilteredOutCount { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Total messages dropped by deduplication.</summary>
|
||||||
|
public long DeduplicatedCount { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>The source configuration driving this coordinator.</summary>
|
||||||
|
public StreamSourceConfig Config => _sourceConfig;
|
||||||
|
|
||||||
public SourceCoordinator(IStreamStore targetStore, StreamSourceConfig sourceConfig)
|
public SourceCoordinator(IStreamStore targetStore, StreamSourceConfig sourceConfig)
|
||||||
{
|
{
|
||||||
_targetStore = targetStore;
|
_targetStore = targetStore;
|
||||||
_sourceConfig = sourceConfig;
|
_sourceConfig = sourceConfig;
|
||||||
|
_inbound = Channel.CreateUnbounded<StoredMessage>(new UnboundedChannelOptions
|
||||||
|
{
|
||||||
|
SingleReader = true,
|
||||||
|
SingleWriter = false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes a single inbound message from the origin stream.
|
||||||
|
/// This is the direct-call path used when the origin and target are in the same process.
|
||||||
|
/// Go reference: server/stream.go:3860-4007 (processInboundSourceMsg)
|
||||||
|
/// </summary>
|
||||||
public async Task OnOriginAppendAsync(StoredMessage message, CancellationToken ct)
|
public async Task OnOriginAppendAsync(StoredMessage message, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// Account isolation: skip messages from different accounts.
|
||||||
|
// Go: This is checked at the subscription level, but we enforce it here for in-process sources.
|
||||||
if (!string.IsNullOrWhiteSpace(_sourceConfig.SourceAccount)
|
if (!string.IsNullOrWhiteSpace(_sourceConfig.SourceAccount)
|
||||||
&& !string.IsNullOrWhiteSpace(message.Account)
|
&& !string.IsNullOrWhiteSpace(message.Account)
|
||||||
&& !string.Equals(_sourceConfig.SourceAccount, message.Account, StringComparison.Ordinal))
|
&& !string.Equals(_sourceConfig.SourceAccount, message.Account, StringComparison.Ordinal))
|
||||||
@@ -25,12 +111,360 @@ public sealed class SourceCoordinator
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subject filter: only forward messages matching the filter.
|
||||||
|
// Go: server/stream.go:3597-3598 — if ssi.FilterSubject != _EMPTY_ { req.Config.FilterSubject = ssi.FilterSubject }
|
||||||
|
if (!string.IsNullOrWhiteSpace(_sourceConfig.FilterSubject)
|
||||||
|
&& !SubjectMatch.MatchLiteral(message.Subject, _sourceConfig.FilterSubject))
|
||||||
|
{
|
||||||
|
FilteredOutCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplication: check Nats-Msg-Id header against the dedup window.
|
||||||
|
if (_sourceConfig.DuplicateWindowMs > 0 && message.MsgId is not null)
|
||||||
|
{
|
||||||
|
if (IsDuplicate(message.MsgId))
|
||||||
|
{
|
||||||
|
DeduplicatedCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RecordMsgId(message.MsgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go: si.sseq <= current — ignore older/duplicate messages
|
||||||
|
if (_expectedOriginSeq > 0 && message.Sequence <= _expectedOriginSeq)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Subject transform: apply prefix before storing.
|
||||||
|
// Go: server/stream.go:3943-3956 (subject transform for the source)
|
||||||
var subject = message.Subject;
|
var subject = message.Subject;
|
||||||
if (!string.IsNullOrWhiteSpace(_sourceConfig.SubjectTransformPrefix))
|
if (!string.IsNullOrWhiteSpace(_sourceConfig.SubjectTransformPrefix))
|
||||||
subject = $"{_sourceConfig.SubjectTransformPrefix}{subject}";
|
subject = $"{_sourceConfig.SubjectTransformPrefix}{subject}";
|
||||||
|
|
||||||
await _targetStore.AppendAsync(subject, message.Payload, ct);
|
await _targetStore.AppendAsync(subject, message.Payload, ct);
|
||||||
|
_expectedOriginSeq = message.Sequence;
|
||||||
|
_deliverySeq++;
|
||||||
LastOriginSequence = message.Sequence;
|
LastOriginSequence = message.Sequence;
|
||||||
LastSyncUtc = DateTime.UtcNow;
|
LastSyncUtc = DateTime.UtcNow;
|
||||||
|
Lag = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enqueues a message for processing by the background sync loop.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryEnqueue(StoredMessage message)
|
||||||
|
{
|
||||||
|
return _inbound.Writer.TryWrite(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the background sync loop that drains the inbound channel.
|
||||||
|
/// Go reference: server/stream.go:3752-3833 (processAllSourceMsgs)
|
||||||
|
/// </summary>
|
||||||
|
public void StartSyncLoop()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_syncLoop is not null && !_syncLoop.IsCompleted)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
_syncLoop = RunSyncLoopAsync(_cts.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts a pull-based sync loop that actively fetches from the origin store.
|
||||||
|
/// Go reference: server/stream.go:3474-3720 (setupSourceConsumer + trySetupSourceConsumer)
|
||||||
|
/// </summary>
|
||||||
|
public void StartPullSyncLoop(IStreamStore originStore, int batchSize = DefaultBatchSize)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_syncLoop is not null && !_syncLoop.IsCompleted)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
_syncLoop = RunPullSyncLoopAsync(originStore, batchSize, _cts.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the background sync loop.
|
||||||
|
/// Go reference: server/stream.go:3438-3469 (cancelSourceConsumer)
|
||||||
|
/// </summary>
|
||||||
|
public async Task StopAsync()
|
||||||
|
{
|
||||||
|
CancellationTokenSource? cts;
|
||||||
|
Task? loop;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
cts = _cts;
|
||||||
|
loop = _syncLoop;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cts is not null)
|
||||||
|
{
|
||||||
|
await cts.CancelAsync();
|
||||||
|
if (loop is not null)
|
||||||
|
{
|
||||||
|
try { await loop; }
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_cts?.Dispose();
|
||||||
|
_cts = null;
|
||||||
|
_syncLoop = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reports current health state for monitoring.
|
||||||
|
/// Go reference: server/stream.go:2687-2695 (sourcesInfo)
|
||||||
|
/// </summary>
|
||||||
|
public SourceHealthReport GetHealthReport(ulong? originLastSeq = null)
|
||||||
|
{
|
||||||
|
var lag = originLastSeq.HasValue && originLastSeq.Value > LastOriginSequence
|
||||||
|
? originLastSeq.Value - LastOriginSequence
|
||||||
|
: Lag;
|
||||||
|
|
||||||
|
return new SourceHealthReport
|
||||||
|
{
|
||||||
|
SourceName = _sourceConfig.Name,
|
||||||
|
FilterSubject = _sourceConfig.FilterSubject,
|
||||||
|
LastOriginSequence = LastOriginSequence,
|
||||||
|
LastSyncUtc = LastSyncUtc,
|
||||||
|
Lag = lag,
|
||||||
|
ConsecutiveFailures = ConsecutiveFailures,
|
||||||
|
IsRunning = IsRunning,
|
||||||
|
IsStalled = LastSyncUtc != default
|
||||||
|
&& DateTime.UtcNow - LastSyncUtc > HealthCheckInterval,
|
||||||
|
FilteredOutCount = FilteredOutCount,
|
||||||
|
DeduplicatedCount = DeduplicatedCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await StopAsync();
|
||||||
|
_inbound.Writer.TryComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Background sync loop: channel-based
|
||||||
|
// Go reference: server/stream.go:3752-3833 (processAllSourceMsgs)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
private async Task RunSyncLoopAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var healthTimer = new PeriodicTimer(HealthCheckInterval);
|
||||||
|
var reader = _inbound.Reader;
|
||||||
|
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (reader.TryRead(out var msg))
|
||||||
|
{
|
||||||
|
await ProcessInboundMessageAsync(msg, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
var readTask = reader.WaitToReadAsync(ct).AsTask();
|
||||||
|
var healthTask = healthTimer.WaitForNextTickAsync(ct).AsTask();
|
||||||
|
await Task.WhenAny(readTask, healthTask);
|
||||||
|
|
||||||
|
if (ct.IsCancellationRequested)
|
||||||
|
break;
|
||||||
|
|
||||||
|
while (reader.TryRead(out var msg2))
|
||||||
|
{
|
||||||
|
await ProcessInboundMessageAsync(msg2, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
lock (_gate) _consecutiveFailures++;
|
||||||
|
var delay = CalculateBackoff(_consecutiveFailures);
|
||||||
|
try { await Task.Delay(delay, ct); }
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Background sync loop: pull-based
|
||||||
|
// Go reference: server/stream.go:3474-3720 (setupSourceConsumer)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
private async Task RunPullSyncLoopAsync(IStreamStore originStore, int batchSize, CancellationToken ct)
|
||||||
|
{
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var messages = await originStore.ListAsync(ct);
|
||||||
|
var applied = 0;
|
||||||
|
|
||||||
|
foreach (var msg in messages)
|
||||||
|
{
|
||||||
|
if (ct.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
if (msg.Sequence <= LastOriginSequence)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
await ProcessInboundMessageAsync(msg, ct);
|
||||||
|
applied++;
|
||||||
|
|
||||||
|
if (applied >= batchSize)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update lag
|
||||||
|
if (messages.Count > 0)
|
||||||
|
{
|
||||||
|
var originLast = messages[^1].Sequence;
|
||||||
|
Lag = originLast > LastOriginSequence ? originLast - LastOriginSequence : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate) _consecutiveFailures = 0;
|
||||||
|
|
||||||
|
if (applied == 0)
|
||||||
|
{
|
||||||
|
try { await Task.Delay(HeartbeatInterval, ct); }
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
lock (_gate) _consecutiveFailures++;
|
||||||
|
var delay = CalculateBackoff(_consecutiveFailures);
|
||||||
|
try { await Task.Delay(delay, ct); }
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go reference: server/stream.go:3860-4007 (processInboundSourceMsg)
|
||||||
|
private async Task ProcessInboundMessageAsync(StoredMessage message, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Account isolation
|
||||||
|
if (!string.IsNullOrWhiteSpace(_sourceConfig.SourceAccount)
|
||||||
|
&& !string.IsNullOrWhiteSpace(message.Account)
|
||||||
|
&& !string.Equals(_sourceConfig.SourceAccount, message.Account, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subject filter
|
||||||
|
if (!string.IsNullOrWhiteSpace(_sourceConfig.FilterSubject)
|
||||||
|
&& !SubjectMatch.MatchLiteral(message.Subject, _sourceConfig.FilterSubject))
|
||||||
|
{
|
||||||
|
FilteredOutCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplication
|
||||||
|
if (_sourceConfig.DuplicateWindowMs > 0 && message.MsgId is not null)
|
||||||
|
{
|
||||||
|
if (IsDuplicate(message.MsgId))
|
||||||
|
{
|
||||||
|
DeduplicatedCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RecordMsgId(message.MsgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip already-seen sequences
|
||||||
|
if (_expectedOriginSeq > 0 && message.Sequence <= _expectedOriginSeq)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Redelivery check (Go: dc > 1)
|
||||||
|
if (message.Redelivered)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Subject transform
|
||||||
|
var subject = message.Subject;
|
||||||
|
if (!string.IsNullOrWhiteSpace(_sourceConfig.SubjectTransformPrefix))
|
||||||
|
subject = $"{_sourceConfig.SubjectTransformPrefix}{subject}";
|
||||||
|
|
||||||
|
await _targetStore.AppendAsync(subject, message.Payload, ct);
|
||||||
|
_expectedOriginSeq = message.Sequence;
|
||||||
|
_deliverySeq++;
|
||||||
|
LastOriginSequence = message.Sequence;
|
||||||
|
LastSyncUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
|
lock (_gate) _consecutiveFailures = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Deduplication helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private bool IsDuplicate(string msgId)
|
||||||
|
{
|
||||||
|
PruneDedupWindowIfNeeded();
|
||||||
|
return _dedupWindow.ContainsKey(msgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecordMsgId(string msgId)
|
||||||
|
{
|
||||||
|
_dedupWindow[msgId] = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PruneDedupWindowIfNeeded()
|
||||||
|
{
|
||||||
|
if (_sourceConfig.DuplicateWindowMs <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
// Prune at most once per second to avoid overhead
|
||||||
|
if ((now - _lastDedupPrune).TotalMilliseconds < 1000)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_lastDedupPrune = now;
|
||||||
|
var cutoff = now.AddMilliseconds(-_sourceConfig.DuplicateWindowMs);
|
||||||
|
foreach (var kvp in _dedupWindow)
|
||||||
|
{
|
||||||
|
if (kvp.Value < cutoff)
|
||||||
|
_dedupWindow.TryRemove(kvp.Key, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go reference: server/stream.go:3478-3505 (calculateRetryBackoff)
|
||||||
|
private static TimeSpan CalculateBackoff(int failures)
|
||||||
|
{
|
||||||
|
var baseDelay = InitialRetryDelay.TotalMilliseconds * Math.Pow(2, Math.Min(failures - 1, 10));
|
||||||
|
var capped = Math.Min(baseDelay, MaxRetryDelay.TotalMilliseconds);
|
||||||
|
var jitter = Random.Shared.NextDouble() * 0.2 * capped;
|
||||||
|
return TimeSpan.FromMilliseconds(capped + jitter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Health report for a source coordinator, used by monitoring endpoints.
|
||||||
|
/// Go reference: server/stream.go:2687-2736 (sourcesInfo, sourceInfo)
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SourceHealthReport
|
||||||
|
{
|
||||||
|
public string SourceName { get; init; } = string.Empty;
|
||||||
|
public string? FilterSubject { get; init; }
|
||||||
|
public ulong LastOriginSequence { get; init; }
|
||||||
|
public DateTime LastSyncUtc { get; init; }
|
||||||
|
public ulong Lag { get; init; }
|
||||||
|
public int ConsecutiveFailures { get; init; }
|
||||||
|
public bool IsRunning { get; init; }
|
||||||
|
public bool IsStalled { get; init; }
|
||||||
|
public long FilteredOutCount { get; init; }
|
||||||
|
public long DeduplicatedCount { get; init; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,4 +35,12 @@ public sealed class StreamSourceConfig
|
|||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string? SubjectTransformPrefix { get; set; }
|
public string? SubjectTransformPrefix { get; set; }
|
||||||
public string? SourceAccount { get; set; }
|
public string? SourceAccount { get; set; }
|
||||||
|
|
||||||
|
// Go: StreamSource.FilterSubject — only forward messages matching this subject filter.
|
||||||
|
public string? FilterSubject { get; set; }
|
||||||
|
|
||||||
|
// Deduplication window in milliseconds for Nats-Msg-Id header-based dedup.
|
||||||
|
// Defaults to 0 (disabled). When > 0, duplicate messages with the same Nats-Msg-Id
|
||||||
|
// within this window are silently dropped.
|
||||||
|
public int DuplicateWindowMs { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,14 @@ public sealed class StoredMessage
|
|||||||
public DateTime TimestampUtc { get; init; } = DateTime.UtcNow;
|
public DateTime TimestampUtc { get; init; } = DateTime.UtcNow;
|
||||||
public string? Account { get; init; }
|
public string? Account { get; init; }
|
||||||
public bool Redelivered { get; init; }
|
public bool Redelivered { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional message headers. Used for deduplication (Nats-Msg-Id) and source tracking.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyDictionary<string, string>? Headers { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convenience accessor for the Nats-Msg-Id header value, used by source deduplication.
|
||||||
|
/// </summary>
|
||||||
|
public string? MsgId => Headers is not null && Headers.TryGetValue("Nats-Msg-Id", out var id) ? id : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,6 +336,8 @@ public sealed class StreamManager
|
|||||||
Name = s.Name,
|
Name = s.Name,
|
||||||
SubjectTransformPrefix = s.SubjectTransformPrefix,
|
SubjectTransformPrefix = s.SubjectTransformPrefix,
|
||||||
SourceAccount = s.SourceAccount,
|
SourceAccount = s.SourceAccount,
|
||||||
|
FilterSubject = s.FilterSubject,
|
||||||
|
DuplicateWindowMs = s.DuplicateWindowMs,
|
||||||
})],
|
})],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
using NATS.Server.JetStream.MirrorSource;
|
||||||
|
using NATS.Server.JetStream.Storage;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.JetStream.MirrorSource;
|
||||||
|
|
||||||
|
// Go reference: server/stream.go:2788-2854 (processMirrorMsgs)
|
||||||
|
// Go reference: server/stream.go:2863-3014 (processInboundMirrorMsg)
|
||||||
|
// Go reference: server/stream.go:3125-3400 (setupMirrorConsumer)
|
||||||
|
|
||||||
|
public class MirrorSyncTests
|
||||||
|
{
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Direct in-process synchronization tests
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
// Go reference: server/stream.go:2915 — sseq == mset.mirror.sseq+1 (normal in-order)
|
||||||
|
public async Task Mirror_applies_single_message_and_tracks_sequence()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var mirror = new MirrorCoordinator(target);
|
||||||
|
|
||||||
|
var msg = MakeMessage(seq: 1, subject: "orders.created", payload: "order-1");
|
||||||
|
await mirror.OnOriginAppendAsync(msg, default);
|
||||||
|
|
||||||
|
mirror.LastOriginSequence.ShouldBe(1UL);
|
||||||
|
mirror.LastSyncUtc.ShouldNotBe(default(DateTime));
|
||||||
|
mirror.Lag.ShouldBe(0UL);
|
||||||
|
|
||||||
|
var stored = await target.LoadAsync(1, default);
|
||||||
|
stored.ShouldNotBeNull();
|
||||||
|
stored.Subject.ShouldBe("orders.created");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
// Go reference: server/stream.go:2915-2917 — sequential messages increment sseq/dseq
|
||||||
|
public async Task Mirror_applies_sequential_messages_in_order()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var mirror = new MirrorCoordinator(target);
|
||||||
|
|
||||||
|
for (ulong i = 1; i <= 5; i++)
|
||||||
|
{
|
||||||
|
await mirror.OnOriginAppendAsync(
|
||||||
|
MakeMessage(seq: i, subject: $"orders.{i}", payload: $"payload-{i}"), default);
|
||||||
|
}
|
||||||
|
|
||||||
|
mirror.LastOriginSequence.ShouldBe(5UL);
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(5UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
// Go reference: server/stream.go:2918-2921 — sseq <= mset.mirror.sseq (ignore older)
|
||||||
|
public async Task Mirror_ignores_older_duplicate_messages()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var mirror = new MirrorCoordinator(target);
|
||||||
|
|
||||||
|
await mirror.OnOriginAppendAsync(MakeMessage(seq: 5, subject: "a", payload: "1"), default);
|
||||||
|
await mirror.OnOriginAppendAsync(MakeMessage(seq: 3, subject: "b", payload: "2"), default); // older
|
||||||
|
await mirror.OnOriginAppendAsync(MakeMessage(seq: 5, subject: "c", payload: "3"), default); // same
|
||||||
|
|
||||||
|
mirror.LastOriginSequence.ShouldBe(5UL);
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(1UL); // only seq 5 stored
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
// Go reference: server/stream.go:2927-2936 — gap handling (sseq > mirror.sseq+1)
|
||||||
|
public async Task Mirror_handles_sequence_gaps_from_origin()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var mirror = new MirrorCoordinator(target);
|
||||||
|
|
||||||
|
await mirror.OnOriginAppendAsync(MakeMessage(seq: 1, subject: "a", payload: "1"), default);
|
||||||
|
// Gap: origin deleted seq 2-4
|
||||||
|
await mirror.OnOriginAppendAsync(MakeMessage(seq: 5, subject: "b", payload: "2"), default);
|
||||||
|
|
||||||
|
mirror.LastOriginSequence.ShouldBe(5UL);
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(2UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Mirror_first_message_at_arbitrary_sequence()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var mirror = new MirrorCoordinator(target);
|
||||||
|
|
||||||
|
// First message arrives at seq 100 (origin has prior history)
|
||||||
|
await mirror.OnOriginAppendAsync(MakeMessage(seq: 100, subject: "a", payload: "1"), default);
|
||||||
|
|
||||||
|
mirror.LastOriginSequence.ShouldBe(100UL);
|
||||||
|
var stored = await target.LoadAsync(1, default);
|
||||||
|
stored.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Health reporting tests
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
// Go reference: server/stream.go:2739-2743 (mirrorInfo)
|
||||||
|
public async Task Health_report_reflects_current_state()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var mirror = new MirrorCoordinator(target);
|
||||||
|
|
||||||
|
var report = mirror.GetHealthReport(originLastSeq: 10);
|
||||||
|
report.LastOriginSequence.ShouldBe(0UL);
|
||||||
|
report.Lag.ShouldBe(10UL);
|
||||||
|
report.IsRunning.ShouldBeFalse();
|
||||||
|
|
||||||
|
await mirror.OnOriginAppendAsync(MakeMessage(seq: 7, subject: "a", payload: "1"), default);
|
||||||
|
|
||||||
|
report = mirror.GetHealthReport(originLastSeq: 10);
|
||||||
|
report.LastOriginSequence.ShouldBe(7UL);
|
||||||
|
report.Lag.ShouldBe(3UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Health_report_shows_zero_lag_when_caught_up()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var mirror = new MirrorCoordinator(target);
|
||||||
|
|
||||||
|
await mirror.OnOriginAppendAsync(MakeMessage(seq: 10, subject: "a", payload: "1"), default);
|
||||||
|
|
||||||
|
var report = mirror.GetHealthReport(originLastSeq: 10);
|
||||||
|
report.Lag.ShouldBe(0UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Background sync loop: channel-based
|
||||||
|
// Go reference: server/stream.go:2788-2854 (processMirrorMsgs goroutine)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Channel_sync_loop_processes_enqueued_messages()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
await using var mirror = new MirrorCoordinator(target);
|
||||||
|
|
||||||
|
mirror.StartSyncLoop();
|
||||||
|
mirror.IsRunning.ShouldBeTrue();
|
||||||
|
|
||||||
|
mirror.TryEnqueue(MakeMessage(seq: 1, subject: "a", payload: "1"));
|
||||||
|
mirror.TryEnqueue(MakeMessage(seq: 2, subject: "b", payload: "2"));
|
||||||
|
|
||||||
|
await WaitForConditionAsync(() => mirror.LastOriginSequence >= 2, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
mirror.LastOriginSequence.ShouldBe(2UL);
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(2UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Channel_sync_loop_can_be_stopped()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
await using var mirror = new MirrorCoordinator(target);
|
||||||
|
|
||||||
|
mirror.StartSyncLoop();
|
||||||
|
mirror.IsRunning.ShouldBeTrue();
|
||||||
|
|
||||||
|
await mirror.StopAsync();
|
||||||
|
mirror.IsRunning.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Channel_sync_loop_ignores_duplicates()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
await using var mirror = new MirrorCoordinator(target);
|
||||||
|
|
||||||
|
mirror.StartSyncLoop();
|
||||||
|
|
||||||
|
mirror.TryEnqueue(MakeMessage(seq: 1, subject: "a", payload: "1"));
|
||||||
|
mirror.TryEnqueue(MakeMessage(seq: 1, subject: "a", payload: "1")); // duplicate
|
||||||
|
mirror.TryEnqueue(MakeMessage(seq: 2, subject: "b", payload: "2"));
|
||||||
|
|
||||||
|
await WaitForConditionAsync(() => mirror.LastOriginSequence >= 2, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(2UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Background sync loop: pull-based
|
||||||
|
// Go reference: server/stream.go:3125-3400 (setupMirrorConsumer)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Pull_sync_loop_fetches_from_origin_store()
|
||||||
|
{
|
||||||
|
var origin = new MemStore();
|
||||||
|
var target = new MemStore();
|
||||||
|
await using var mirror = new MirrorCoordinator(target);
|
||||||
|
|
||||||
|
// Pre-populate origin
|
||||||
|
await origin.AppendAsync("a", "1"u8.ToArray(), default);
|
||||||
|
await origin.AppendAsync("b", "2"u8.ToArray(), default);
|
||||||
|
await origin.AppendAsync("c", "3"u8.ToArray(), default);
|
||||||
|
|
||||||
|
mirror.StartPullSyncLoop(origin);
|
||||||
|
|
||||||
|
await WaitForConditionAsync(() => mirror.LastOriginSequence >= 3, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
mirror.LastOriginSequence.ShouldBe(3UL);
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(3UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Pull_sync_loop_catches_up_after_restart()
|
||||||
|
{
|
||||||
|
var origin = new MemStore();
|
||||||
|
var target = new MemStore();
|
||||||
|
|
||||||
|
// Phase 1: sync first 2 messages
|
||||||
|
{
|
||||||
|
await using var mirror = new MirrorCoordinator(target);
|
||||||
|
await origin.AppendAsync("a", "1"u8.ToArray(), default);
|
||||||
|
await origin.AppendAsync("b", "2"u8.ToArray(), default);
|
||||||
|
|
||||||
|
mirror.StartPullSyncLoop(origin);
|
||||||
|
await WaitForConditionAsync(() => mirror.LastOriginSequence >= 2, TimeSpan.FromSeconds(5));
|
||||||
|
await mirror.StopAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: add more messages and restart with new coordinator
|
||||||
|
await origin.AppendAsync("c", "3"u8.ToArray(), default);
|
||||||
|
await origin.AppendAsync("d", "4"u8.ToArray(), default);
|
||||||
|
|
||||||
|
{
|
||||||
|
// Simulate restart: new coordinator, same target store
|
||||||
|
await using var mirror2 = new MirrorCoordinator(target);
|
||||||
|
|
||||||
|
// Manually sync to simulate catchup from seq 2
|
||||||
|
await mirror2.OnOriginAppendAsync(
|
||||||
|
new StoredMessage { Sequence = 3, Subject = "c", Payload = "3"u8.ToArray() }, default);
|
||||||
|
await mirror2.OnOriginAppendAsync(
|
||||||
|
new StoredMessage { Sequence = 4, Subject = "d", Payload = "4"u8.ToArray() }, default);
|
||||||
|
|
||||||
|
mirror2.LastOriginSequence.ShouldBe(4UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(4UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Pull_sync_loop_updates_lag()
|
||||||
|
{
|
||||||
|
var origin = new MemStore();
|
||||||
|
var target = new MemStore();
|
||||||
|
await using var mirror = new MirrorCoordinator(target);
|
||||||
|
|
||||||
|
// Pre-populate origin with 10 messages
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
await origin.AppendAsync($"subj.{i}", System.Text.Encoding.UTF8.GetBytes($"payload-{i}"), default);
|
||||||
|
|
||||||
|
mirror.StartPullSyncLoop(origin, batchSize: 3);
|
||||||
|
|
||||||
|
// Wait for some progress
|
||||||
|
await WaitForConditionAsync(() => mirror.LastOriginSequence >= 3, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
// Eventually should catch up to all 10
|
||||||
|
await WaitForConditionAsync(() => mirror.LastOriginSequence >= 10, TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
var report = mirror.GetHealthReport(originLastSeq: 10);
|
||||||
|
report.Lag.ShouldBe(0UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Pull_sync_loop_handles_empty_origin()
|
||||||
|
{
|
||||||
|
var origin = new MemStore();
|
||||||
|
var target = new MemStore();
|
||||||
|
await using var mirror = new MirrorCoordinator(target);
|
||||||
|
|
||||||
|
mirror.StartPullSyncLoop(origin);
|
||||||
|
|
||||||
|
// Wait a bit to ensure it doesn't crash
|
||||||
|
await Task.Delay(200);
|
||||||
|
|
||||||
|
mirror.IsRunning.ShouldBeTrue();
|
||||||
|
mirror.LastOriginSequence.ShouldBe(0UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Dispose / lifecycle tests
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dispose_stops_running_sync_loop()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var mirror = new MirrorCoordinator(target);
|
||||||
|
|
||||||
|
mirror.StartSyncLoop();
|
||||||
|
mirror.IsRunning.ShouldBeTrue();
|
||||||
|
|
||||||
|
await mirror.DisposeAsync();
|
||||||
|
mirror.IsRunning.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Multiple_start_calls_are_idempotent()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
await using var mirror = new MirrorCoordinator(target);
|
||||||
|
|
||||||
|
mirror.StartSyncLoop();
|
||||||
|
mirror.StartSyncLoop(); // second call should be no-op
|
||||||
|
|
||||||
|
mirror.IsRunning.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static StoredMessage MakeMessage(ulong seq, string subject, string payload) => new()
|
||||||
|
{
|
||||||
|
Sequence = seq,
|
||||||
|
Subject = subject,
|
||||||
|
Payload = System.Text.Encoding.UTF8.GetBytes(payload),
|
||||||
|
TimestampUtc = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static async Task WaitForConditionAsync(Func<bool> condition, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource(timeout);
|
||||||
|
while (!condition())
|
||||||
|
{
|
||||||
|
await Task.Delay(25, cts.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
using NATS.Server.JetStream.MirrorSource;
|
||||||
|
using NATS.Server.JetStream.Models;
|
||||||
|
using NATS.Server.JetStream.Storage;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.JetStream.MirrorSource;
|
||||||
|
|
||||||
|
// Go reference: server/stream.go:3860-4007 (processInboundSourceMsg)
|
||||||
|
// Go reference: server/stream.go:3474-3720 (setupSourceConsumer, trySetupSourceConsumer)
|
||||||
|
// Go reference: server/stream.go:3752-3833 (processAllSourceMsgs)
|
||||||
|
|
||||||
|
public class SourceFilterTests
|
||||||
|
{
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Subject filtering
|
||||||
|
// Go reference: server/stream.go:3597-3598 — FilterSubject on consumer creation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Source_with_filter_only_forwards_matching_messages()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var source = new SourceCoordinator(target, new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "SRC",
|
||||||
|
FilterSubject = "orders.*",
|
||||||
|
});
|
||||||
|
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(1, "orders.created", "1"), default);
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(2, "events.login", "2"), default); // filtered out
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(3, "orders.updated", "3"), default);
|
||||||
|
|
||||||
|
source.LastOriginSequence.ShouldBe(3UL);
|
||||||
|
source.FilteredOutCount.ShouldBe(1);
|
||||||
|
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(2UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Source_with_wildcard_filter_matches_multi_token()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var source = new SourceCoordinator(target, new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "SRC",
|
||||||
|
FilterSubject = "orders.>",
|
||||||
|
});
|
||||||
|
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(1, "orders.created", "1"), default);
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(2, "orders.us.created", "2"), default);
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(3, "events.login", "3"), default); // filtered out
|
||||||
|
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(2UL);
|
||||||
|
source.FilteredOutCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Source_without_filter_forwards_all_messages()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var source = new SourceCoordinator(target, new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "SRC",
|
||||||
|
});
|
||||||
|
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(1, "orders.created", "1"), default);
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(2, "events.login", "2"), default);
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(3, "anything.goes", "3"), default);
|
||||||
|
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(3UL);
|
||||||
|
source.FilteredOutCount.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Subject transform prefix
|
||||||
|
// Go reference: server/stream.go:3943-3956 (subject transform for the source)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Source_applies_subject_transform_prefix()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var source = new SourceCoordinator(target, new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "SRC",
|
||||||
|
SubjectTransformPrefix = "agg.",
|
||||||
|
});
|
||||||
|
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(1, "orders.created", "1"), default);
|
||||||
|
|
||||||
|
var stored = await target.LoadAsync(1, default);
|
||||||
|
stored.ShouldNotBeNull();
|
||||||
|
stored.Subject.ShouldBe("agg.orders.created");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Source_with_filter_and_transform_applies_both()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var source = new SourceCoordinator(target, new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "SRC",
|
||||||
|
FilterSubject = "orders.*",
|
||||||
|
SubjectTransformPrefix = "mirror.",
|
||||||
|
});
|
||||||
|
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(1, "orders.created", "1"), default);
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(2, "events.login", "2"), default);
|
||||||
|
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(1UL);
|
||||||
|
|
||||||
|
var stored = await target.LoadAsync(1, default);
|
||||||
|
stored.ShouldNotBeNull();
|
||||||
|
stored.Subject.ShouldBe("mirror.orders.created");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Account isolation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Source_with_account_filter_skips_wrong_account()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var source = new SourceCoordinator(target, new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "SRC",
|
||||||
|
SourceAccount = "PROD",
|
||||||
|
});
|
||||||
|
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(1, "a", "1", account: "PROD"), default);
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(2, "b", "2", account: "DEV"), default); // wrong account
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(3, "c", "3", account: "PROD"), default);
|
||||||
|
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(2UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Source_with_account_allows_null_account_messages()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var source = new SourceCoordinator(target, new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "SRC",
|
||||||
|
SourceAccount = "PROD",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Messages with no account set should pass through (Go: account field empty means skip check)
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(1, "a", "1", account: null), default);
|
||||||
|
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(1UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Source_without_account_filter_passes_all_accounts()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var source = new SourceCoordinator(target, new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "SRC",
|
||||||
|
});
|
||||||
|
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(1, "a", "1", account: "A"), default);
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(2, "b", "2", account: "B"), default);
|
||||||
|
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(2UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Deduplication via Nats-Msg-Id header
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Source_deduplicates_messages_by_msg_id()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var source = new SourceCoordinator(target, new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "SRC",
|
||||||
|
DuplicateWindowMs = 60_000, // 60 second window
|
||||||
|
});
|
||||||
|
|
||||||
|
await source.OnOriginAppendAsync(MakeMessageWithMsgId(1, "a", "1", "msg-001"), default);
|
||||||
|
await source.OnOriginAppendAsync(MakeMessageWithMsgId(2, "a", "1", "msg-001"), default); // duplicate
|
||||||
|
|
||||||
|
source.DeduplicatedCount.ShouldBe(1);
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(1UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Source_allows_different_msg_ids()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var source = new SourceCoordinator(target, new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "SRC",
|
||||||
|
DuplicateWindowMs = 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await source.OnOriginAppendAsync(MakeMessageWithMsgId(1, "a", "1", "msg-001"), default);
|
||||||
|
await source.OnOriginAppendAsync(MakeMessageWithMsgId(2, "b", "2", "msg-002"), default);
|
||||||
|
|
||||||
|
source.DeduplicatedCount.ShouldBe(0);
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(2UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Source_dedup_disabled_when_window_is_zero()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var source = new SourceCoordinator(target, new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "SRC",
|
||||||
|
DuplicateWindowMs = 0, // disabled
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same msg-id should NOT be deduped when window is 0
|
||||||
|
await source.OnOriginAppendAsync(MakeMessageWithMsgId(1, "a", "1", "msg-001"), default);
|
||||||
|
await source.OnOriginAppendAsync(MakeMessageWithMsgId(2, "a", "1", "msg-001"), default);
|
||||||
|
|
||||||
|
source.DeduplicatedCount.ShouldBe(0);
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(2UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Source_dedup_ignores_messages_without_msg_id()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var source = new SourceCoordinator(target, new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "SRC",
|
||||||
|
DuplicateWindowMs = 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Messages without Nats-Msg-Id header bypass dedup
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(1, "a", "1"), default);
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(2, "a", "2"), default);
|
||||||
|
|
||||||
|
source.DeduplicatedCount.ShouldBe(0);
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(2UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Multiple sources per stream
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Multiple_sources_aggregate_into_single_target()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
|
||||||
|
var src1 = new SourceCoordinator(target, new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "SRC1",
|
||||||
|
SubjectTransformPrefix = "agg.",
|
||||||
|
});
|
||||||
|
var src2 = new SourceCoordinator(target, new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "SRC2",
|
||||||
|
SubjectTransformPrefix = "agg.",
|
||||||
|
});
|
||||||
|
|
||||||
|
await src1.OnOriginAppendAsync(MakeMessage(1, "orders.created", "1"), default);
|
||||||
|
await src2.OnOriginAppendAsync(MakeMessage(1, "events.login", "2"), default);
|
||||||
|
await src1.OnOriginAppendAsync(MakeMessage(2, "orders.updated", "3"), default);
|
||||||
|
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(3UL);
|
||||||
|
|
||||||
|
var msg1 = await target.LoadAsync(1, default);
|
||||||
|
msg1.ShouldNotBeNull();
|
||||||
|
msg1.Subject.ShouldBe("agg.orders.created");
|
||||||
|
|
||||||
|
var msg2 = await target.LoadAsync(2, default);
|
||||||
|
msg2.ShouldNotBeNull();
|
||||||
|
msg2.Subject.ShouldBe("agg.events.login");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Multiple_sources_with_different_filters()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
|
||||||
|
var src1 = new SourceCoordinator(target, new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "SRC1",
|
||||||
|
FilterSubject = "orders.*",
|
||||||
|
});
|
||||||
|
var src2 = new SourceCoordinator(target, new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "SRC2",
|
||||||
|
FilterSubject = "events.*",
|
||||||
|
});
|
||||||
|
|
||||||
|
await src1.OnOriginAppendAsync(MakeMessage(1, "orders.created", "1"), default);
|
||||||
|
await src1.OnOriginAppendAsync(MakeMessage(2, "events.login", "2"), default); // filtered by src1
|
||||||
|
await src2.OnOriginAppendAsync(MakeMessage(1, "events.login", "3"), default);
|
||||||
|
await src2.OnOriginAppendAsync(MakeMessage(2, "orders.created", "4"), default); // filtered by src2
|
||||||
|
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(2UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Lag tracking per source
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Source_lag_tracking_reflects_origin_position()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var source = new SourceCoordinator(target, new StreamSourceConfig { Name = "SRC" });
|
||||||
|
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(5, "a", "1"), default);
|
||||||
|
|
||||||
|
var report = source.GetHealthReport(originLastSeq: 10);
|
||||||
|
report.Lag.ShouldBe(5UL);
|
||||||
|
report.SourceName.ShouldBe("SRC");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Source_lag_zero_when_caught_up()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var source = new SourceCoordinator(target, new StreamSourceConfig { Name = "SRC" });
|
||||||
|
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(10, "a", "1"), default);
|
||||||
|
|
||||||
|
var report = source.GetHealthReport(originLastSeq: 10);
|
||||||
|
report.Lag.ShouldBe(0UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Sequence tracking — ignores older messages
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Source_ignores_older_sequences()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var source = new SourceCoordinator(target, new StreamSourceConfig { Name = "SRC" });
|
||||||
|
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(5, "a", "1"), default);
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(3, "b", "2"), default); // older, ignored
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(5, "c", "3"), default); // same, ignored
|
||||||
|
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(1UL);
|
||||||
|
source.LastOriginSequence.ShouldBe(5UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Background sync loop: channel-based
|
||||||
|
// Go reference: server/stream.go:3752-3833 (processAllSourceMsgs)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Channel_sync_loop_processes_enqueued_source_messages()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
await using var source = new SourceCoordinator(target, new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "SRC",
|
||||||
|
FilterSubject = "orders.*",
|
||||||
|
});
|
||||||
|
|
||||||
|
source.StartSyncLoop();
|
||||||
|
source.IsRunning.ShouldBeTrue();
|
||||||
|
|
||||||
|
source.TryEnqueue(MakeMessage(1, "orders.created", "1"));
|
||||||
|
source.TryEnqueue(MakeMessage(2, "events.login", "2")); // filtered
|
||||||
|
source.TryEnqueue(MakeMessage(3, "orders.updated", "3"));
|
||||||
|
|
||||||
|
await WaitForConditionAsync(() => source.LastOriginSequence >= 3, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(2UL);
|
||||||
|
source.FilteredOutCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Background sync loop: pull-based
|
||||||
|
// Go reference: server/stream.go:3474-3720 (setupSourceConsumer)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Pull_sync_loop_fetches_filtered_from_origin()
|
||||||
|
{
|
||||||
|
var origin = new MemStore();
|
||||||
|
var target = new MemStore();
|
||||||
|
await using var source = new SourceCoordinator(target, new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "SRC",
|
||||||
|
FilterSubject = "orders.*",
|
||||||
|
SubjectTransformPrefix = "agg.",
|
||||||
|
});
|
||||||
|
|
||||||
|
await origin.AppendAsync("orders.created", "1"u8.ToArray(), default);
|
||||||
|
await origin.AppendAsync("events.login", "2"u8.ToArray(), default);
|
||||||
|
await origin.AppendAsync("orders.updated", "3"u8.ToArray(), default);
|
||||||
|
|
||||||
|
source.StartPullSyncLoop(origin);
|
||||||
|
|
||||||
|
await WaitForConditionAsync(() => source.LastOriginSequence >= 3, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(2UL);
|
||||||
|
|
||||||
|
// Verify transform was applied
|
||||||
|
var msg1 = await target.LoadAsync(1, default);
|
||||||
|
msg1.ShouldNotBeNull();
|
||||||
|
msg1.Subject.ShouldBe("agg.orders.created");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Combined: filter + account + transform + dedup
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Source_applies_all_filters_together()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var source = new SourceCoordinator(target, new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "SRC",
|
||||||
|
FilterSubject = "orders.*",
|
||||||
|
SubjectTransformPrefix = "agg.",
|
||||||
|
SourceAccount = "PROD",
|
||||||
|
DuplicateWindowMs = 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pass: correct account, matching subject, unique msg-id
|
||||||
|
await source.OnOriginAppendAsync(
|
||||||
|
MakeMessageWithMsgId(1, "orders.created", "1", "m1", account: "PROD"), default);
|
||||||
|
|
||||||
|
// Fail: wrong account
|
||||||
|
await source.OnOriginAppendAsync(
|
||||||
|
MakeMessageWithMsgId(2, "orders.created", "2", "m2", account: "DEV"), default);
|
||||||
|
|
||||||
|
// Fail: wrong subject
|
||||||
|
await source.OnOriginAppendAsync(
|
||||||
|
MakeMessageWithMsgId(3, "events.login", "3", "m3", account: "PROD"), default);
|
||||||
|
|
||||||
|
// Fail: duplicate msg-id
|
||||||
|
await source.OnOriginAppendAsync(
|
||||||
|
MakeMessageWithMsgId(4, "orders.updated", "4", "m1", account: "PROD"), default);
|
||||||
|
|
||||||
|
// Pass: everything checks out
|
||||||
|
await source.OnOriginAppendAsync(
|
||||||
|
MakeMessageWithMsgId(5, "orders.updated", "5", "m5", account: "PROD"), default);
|
||||||
|
|
||||||
|
var state = await target.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe(2UL);
|
||||||
|
source.FilteredOutCount.ShouldBe(1);
|
||||||
|
source.DeduplicatedCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Health report
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Health_report_includes_filter_and_dedup_stats()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var source = new SourceCoordinator(target, new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "SRC",
|
||||||
|
FilterSubject = "orders.*",
|
||||||
|
DuplicateWindowMs = 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await source.OnOriginAppendAsync(MakeMessage(1, "events.login", "1"), default); // filtered
|
||||||
|
await source.OnOriginAppendAsync(
|
||||||
|
MakeMessageWithMsgId(2, "orders.created", "2", "m1"), default);
|
||||||
|
await source.OnOriginAppendAsync(
|
||||||
|
MakeMessageWithMsgId(3, "orders.updated", "3", "m1"), default); // deduped
|
||||||
|
|
||||||
|
var report = source.GetHealthReport(originLastSeq: 10);
|
||||||
|
report.SourceName.ShouldBe("SRC");
|
||||||
|
report.FilterSubject.ShouldBe("orders.*");
|
||||||
|
report.FilteredOutCount.ShouldBe(1);
|
||||||
|
report.DeduplicatedCount.ShouldBe(1);
|
||||||
|
report.Lag.ShouldBeGreaterThan(0UL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Dispose / lifecycle tests
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dispose_stops_running_source_sync_loop()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var source = new SourceCoordinator(target, new StreamSourceConfig { Name = "SRC" });
|
||||||
|
|
||||||
|
source.StartSyncLoop();
|
||||||
|
source.IsRunning.ShouldBeTrue();
|
||||||
|
|
||||||
|
await source.DisposeAsync();
|
||||||
|
source.IsRunning.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Config_property_exposes_source_configuration()
|
||||||
|
{
|
||||||
|
var target = new MemStore();
|
||||||
|
var config = new StreamSourceConfig
|
||||||
|
{
|
||||||
|
Name = "MY_SOURCE",
|
||||||
|
FilterSubject = "orders.*",
|
||||||
|
SubjectTransformPrefix = "agg.",
|
||||||
|
SourceAccount = "PROD",
|
||||||
|
DuplicateWindowMs = 5000,
|
||||||
|
};
|
||||||
|
var source = new SourceCoordinator(target, config);
|
||||||
|
|
||||||
|
source.Config.Name.ShouldBe("MY_SOURCE");
|
||||||
|
source.Config.FilterSubject.ShouldBe("orders.*");
|
||||||
|
source.Config.SubjectTransformPrefix.ShouldBe("agg.");
|
||||||
|
source.Config.SourceAccount.ShouldBe("PROD");
|
||||||
|
source.Config.DuplicateWindowMs.ShouldBe(5000);
|
||||||
|
|
||||||
|
await source.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static StoredMessage MakeMessage(ulong seq, string subject, string payload, string? account = null) => new()
|
||||||
|
{
|
||||||
|
Sequence = seq,
|
||||||
|
Subject = subject,
|
||||||
|
Payload = System.Text.Encoding.UTF8.GetBytes(payload),
|
||||||
|
TimestampUtc = DateTime.UtcNow,
|
||||||
|
Account = account,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static StoredMessage MakeMessageWithMsgId(
|
||||||
|
ulong seq, string subject, string payload, string msgId, string? account = null) => new()
|
||||||
|
{
|
||||||
|
Sequence = seq,
|
||||||
|
Subject = subject,
|
||||||
|
Payload = System.Text.Encoding.UTF8.GetBytes(payload),
|
||||||
|
TimestampUtc = DateTime.UtcNow,
|
||||||
|
Account = account,
|
||||||
|
Headers = new Dictionary<string, string> { ["Nats-Msg-Id"] = msgId },
|
||||||
|
};
|
||||||
|
|
||||||
|
private static async Task WaitForConditionAsync(Func<bool> condition, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource(timeout);
|
||||||
|
while (!condition())
|
||||||
|
{
|
||||||
|
await Task.Delay(25, cts.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user