fix: re-enable leader check in API router and fix stepdown simulation

Re-enabled the leader check in JetStreamApiRouter.Route() that was
commented out during B8 agent work. Added BecomeLeader() method to
JetStreamMetaGroup for single-process test fixtures to simulate
winning the post-stepdown election. MetaControllerFixture now
auto-calls BecomeLeader() after a successful meta leader stepdown.
This commit is contained in:
Joseph Doherty
2026-02-24 18:02:54 -05:00
parent 365cbb80ae
commit d286349262
3 changed files with 23 additions and 10 deletions

View File

@@ -102,15 +102,11 @@ public sealed class JetStreamApiRouter
public JetStreamApiResponse Route(string subject, ReadOnlySpan<byte> payload) public JetStreamApiResponse Route(string subject, ReadOnlySpan<byte> payload)
{ {
// TODO: Re-enable leader check once ForwardToLeader is implemented with actual
// request forwarding to the leader node. Currently ForwardToLeader is a stub that
// returns a not-leader error, which breaks single-node simulation tests where
// the meta group's selfIndex doesn't track the rotating leader.
// Go reference: jetstream_api.go:200-300 — leader check + forwarding. // Go reference: jetstream_api.go:200-300 — leader check + forwarding.
// if (_metaGroup is not null && IsLeaderRequired(subject) && !_metaGroup.IsLeader()) if (_metaGroup is not null && IsLeaderRequired(subject) && !_metaGroup.IsLeader())
// { {
// return ForwardToLeader(subject, payload, _metaGroup.Leader); return ForwardToLeader(subject, payload, _metaGroup.Leader);
// } }
if (subject.Equals(JetStreamApiSubjects.Info, StringComparison.Ordinal)) if (subject.Equals(JetStreamApiSubjects.Info, StringComparison.Ordinal))
return AccountApiHandlers.HandleInfo(_streamManager, _consumerManager); return AccountApiHandlers.HandleInfo(_streamManager, _consumerManager);

View File

@@ -12,7 +12,7 @@ namespace NATS.Server.JetStream.Cluster;
public sealed class JetStreamMetaGroup public sealed class JetStreamMetaGroup
{ {
private readonly int _nodes; private readonly int _nodes;
private readonly int _selfIndex; private int _selfIndex;
// Backward-compatible stream name set used by existing GetState().Streams. // Backward-compatible stream name set used by existing GetState().Streams.
private readonly ConcurrentDictionary<string, byte> _streams = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, byte> _streams = new(StringComparer.Ordinal);
@@ -50,6 +50,13 @@ public sealed class JetStreamMetaGroup
/// </summary> /// </summary>
public bool IsLeader() => _leaderIndex == _selfIndex; public bool IsLeader() => _leaderIndex == _selfIndex;
/// <summary>
/// Simulates this node winning the leader election after a stepdown.
/// Used in single-process test fixtures where only one "node" exists.
/// Go reference: jetstream_cluster.go — after stepdown, a new leader is elected.
/// </summary>
public void BecomeLeader() => _selfIndex = _leaderIndex;
/// <summary> /// <summary>
/// Returns the leader identifier string, e.g. "meta-1". /// Returns the leader identifier string, e.g. "meta-1".
/// Used to populate the leader_hint field in not-leader error responses. /// Used to populate the leader_hint field in not-leader error responses.

View File

@@ -625,7 +625,17 @@ internal sealed class MetaControllerFixture : IAsyncDisposable
public MetaGroupState GetMetaState() => _metaGroup.GetState(); public MetaGroupState GetMetaState() => _metaGroup.GetState();
public Task<JetStreamApiResponse> RequestAsync(string subject, string payload) public Task<JetStreamApiResponse> RequestAsync(string subject, string payload)
=> Task.FromResult(_router.Route(subject, Encoding.UTF8.GetBytes(payload))); {
var response = _router.Route(subject, Encoding.UTF8.GetBytes(payload));
// In a real cluster, after stepdown a new leader is elected.
// Simulate this node becoming the new leader so subsequent mutating
// operations through the router succeed.
if (subject.Equals(JetStreamApiSubjects.MetaLeaderStepdown, StringComparison.Ordinal) && response.Success)
_metaGroup.BecomeLeader();
return Task.FromResult(response);
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask;
} }