feat(jetstream): add API leader forwarding and stream purge options (C7+C8)

C7: JetStreamApiRouter now checks leadership before mutating operations.
Non-leader nodes return error code 10003 with a leader_hint field.
JetStreamMetaGroup gains IsLeader() and Leader for cluster-aware routing.

C8: StreamApiHandlers.HandlePurge accepts PurgeRequest options (filter,
seq, keep). StreamManager.PurgeEx implements subject-filtered purge,
sequence-based purge, keep-last-N, and filter+keep combinations.
This commit is contained in:
Joseph Doherty
2026-02-24 15:22:22 -05:00
parent d259a2d03e
commit 7116988d03
8 changed files with 627 additions and 5 deletions

View File

@@ -103,6 +103,97 @@ public sealed class StreamManager
return true;
}
/// <summary>
/// Extended purge with optional subject filter, sequence cutoff, and keep-last-N.
/// Returns the number of messages purged, or -1 if the stream was not found.
/// Go reference: jetstream_api.go:1200-1350 — purge options: filter, seq, keep.
/// </summary>
public long PurgeEx(string name, string? filter, ulong? seq, ulong? keep)
{
if (!_streams.TryGetValue(name, out var stream))
return -1;
if (stream.Config.Sealed || stream.Config.DenyPurge)
return -1;
// No options — purge everything (backward-compatible with the original Purge).
if (filter is null && seq is null && keep is null)
{
var stateBefore = stream.Store.GetStateAsync(default).GetAwaiter().GetResult();
var count = stateBefore.Messages;
stream.Store.PurgeAsync(default).GetAwaiter().GetResult();
return (long)count;
}
var messages = stream.Store.ListAsync(default).GetAwaiter().GetResult();
long purged = 0;
// Filter + Keep: keep last N per matching subject.
if (filter is not null && keep is not null)
{
var matching = messages
.Where(m => SubjectMatch.MatchLiteral(m.Subject, filter))
.GroupBy(m => m.Subject, StringComparer.Ordinal);
foreach (var group in matching)
{
var ordered = group.OrderByDescending(m => m.Sequence).ToList();
foreach (var msg in ordered.Skip((int)keep.Value))
{
if (stream.Store.RemoveAsync(msg.Sequence, default).GetAwaiter().GetResult())
purged++;
}
}
return purged;
}
// Filter only: remove all messages matching the subject pattern.
if (filter is not null)
{
// If seq is also set, only purge matching messages below that sequence.
foreach (var msg in messages)
{
if (!SubjectMatch.MatchLiteral(msg.Subject, filter))
continue;
if (seq is not null && msg.Sequence >= seq.Value)
continue;
if (stream.Store.RemoveAsync(msg.Sequence, default).GetAwaiter().GetResult())
purged++;
}
return purged;
}
// Seq only: remove all messages with sequence < seq.
if (seq is not null)
{
foreach (var msg in messages)
{
if (msg.Sequence >= seq.Value)
continue;
if (stream.Store.RemoveAsync(msg.Sequence, default).GetAwaiter().GetResult())
purged++;
}
return purged;
}
// Keep only (no filter): keep the last N messages globally, delete the rest.
if (keep is not null)
{
var ordered = messages.OrderByDescending(m => m.Sequence).ToList();
foreach (var msg in ordered.Skip((int)keep.Value))
{
if (stream.Store.RemoveAsync(msg.Sequence, default).GetAwaiter().GetResult())
purged++;
}
return purged;
}
return purged;
}
public StoredMessage? GetMessage(string name, ulong sequence)
{
if (!_streams.TryGetValue(name, out var stream))