refactor: extract NATS.Server.JetStream.Tests project

Move 225 JetStream-related test files from NATS.Server.Tests into a
dedicated NATS.Server.JetStream.Tests project. This includes root-level
JetStream*.cs files, storage test files (FileStore, MemStore,
StreamStoreContract), and the full JetStream/ subfolder tree (Api,
Cluster, Consumers, MirrorSource, Snapshots, Storage, Streams).

Updated all namespaces, added InternalsVisibleTo, registered in the
solution file, and added the JETSTREAM_INTEGRATION_MATRIX define.
This commit is contained in:
Joseph Doherty
2026-03-12 15:58:10 -04:00
parent 36b9dfa654
commit 78b4bc2486
228 changed files with 253 additions and 227 deletions

View File

@@ -0,0 +1,303 @@
// Go reference: jetstream_cluster.go:7620-7701 — jsClusteredStreamRequest lifecycle:
// propose to meta RAFT → wait for result → deliver or time out.
// ClusteredRequestProcessor tracks pending requests and delivers results when RAFT entries
// are applied, matching the Go server's callback-based completion mechanism.
using NATS.Server.JetStream.Api;
namespace NATS.Server.JetStream.Tests.JetStream.Api;
public class ClusteredRequestTests
{
// ---------------------------------------------------------------
// RegisterPending
// ---------------------------------------------------------------
/// <summary>
/// Each call to RegisterPending returns a distinct, non-empty string identifier.
/// Go reference: jetstream_cluster.go:7620 — each clustered request gets a unique ID
/// used to correlate the RAFT apply callback with the waiting caller.
/// </summary>
[Fact]
public void RegisterPending_returns_unique_id()
{
var processor = new ClusteredRequestProcessor();
var id1 = processor.RegisterPending();
var id2 = processor.RegisterPending();
id1.ShouldNotBeNullOrWhiteSpace();
id2.ShouldNotBeNullOrWhiteSpace();
id1.ShouldNotBe(id2);
}
// ---------------------------------------------------------------
// WaitForResult
// ---------------------------------------------------------------
/// <summary>
/// When a result is delivered for a pending request, WaitForResultAsync returns that response.
/// Go reference: jetstream_cluster.go:7620 — the waiting goroutine receives the result
/// via channel once the RAFT leader applies the entry.
/// </summary>
[Fact]
public async Task WaitForResult_returns_delivered_response()
{
var processor = new ClusteredRequestProcessor(timeout: TimeSpan.FromSeconds(5));
var requestId = processor.RegisterPending();
var expected = JetStreamApiResponse.SuccessResponse();
// Use a semaphore so the wait starts before delivery occurs — no timing dependency.
var waitStarted = new SemaphoreSlim(0, 1);
var deliverTask = Task.Run(async () =>
{
// Wait until WaitForResultAsync has been entered before delivering.
await waitStarted.WaitAsync();
processor.DeliverResult(requestId, expected);
});
// Signal the deliver task once we begin waiting.
waitStarted.Release();
var result = await processor.WaitForResultAsync(requestId);
await deliverTask;
result.ShouldBeSameAs(expected);
}
/// <summary>
/// When no result is delivered within the timeout, WaitForResultAsync returns a 408 error.
/// Go reference: jetstream_cluster.go:7620 — if the RAFT group does not respond in time,
/// the request is considered timed out and an error is returned to the client.
/// </summary>
[Fact]
public async Task WaitForResult_times_out_after_timeout()
{
var processor = new ClusteredRequestProcessor(timeout: TimeSpan.FromMilliseconds(50));
var requestId = processor.RegisterPending();
var result = await processor.WaitForResultAsync(requestId);
result.Error.ShouldNotBeNull();
result.Error!.Code.ShouldBe(408);
result.Error.Description.ShouldContain("timeout");
}
/// <summary>
/// WaitForResultAsync returns a 500 error for an ID that was never registered.
/// Go reference: jetstream_cluster.go — requesting a result for an unknown request ID
/// is a programming error; return an internal server error.
/// </summary>
[Fact]
public async Task WaitForResult_returns_error_for_unknown_id()
{
var processor = new ClusteredRequestProcessor();
var result = await processor.WaitForResultAsync("nonexistent-id");
result.Error.ShouldNotBeNull();
result.Error!.Code.ShouldBe(500);
result.Error.Description.ShouldContain("not found");
}
/// <summary>
/// When the caller's CancellationToken is triggered, WaitForResultAsync returns a timeout error.
/// Go reference: jetstream_cluster.go:7620 — callers can cancel waiting for a RAFT result
/// if their own request context is cancelled.
/// </summary>
[Fact]
public async Task WaitForResult_respects_cancellation_token()
{
var processor = new ClusteredRequestProcessor(timeout: TimeSpan.FromSeconds(30));
var requestId = processor.RegisterPending();
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
var result = await processor.WaitForResultAsync(requestId, cts.Token);
result.Error.ShouldNotBeNull();
result.Error!.Code.ShouldBe(408);
}
// ---------------------------------------------------------------
// DeliverResult
// ---------------------------------------------------------------
/// <summary>
/// DeliverResult returns true when the request ID is known and pending.
/// Go reference: jetstream_cluster.go:7620 — the RAFT apply callback signals success
/// by resolving the pending request.
/// </summary>
[Fact]
public void DeliverResult_returns_true_for_pending_request()
{
var processor = new ClusteredRequestProcessor();
var requestId = processor.RegisterPending();
var delivered = processor.DeliverResult(requestId, JetStreamApiResponse.SuccessResponse());
delivered.ShouldBeTrue();
}
/// <summary>
/// DeliverResult returns false when the request ID is not found.
/// Go reference: jetstream_cluster.go — delivering a result for an unknown or already-completed
/// request is a no-op; return false so the caller knows the result was not consumed.
/// </summary>
[Fact]
public void DeliverResult_returns_false_for_unknown_request()
{
var processor = new ClusteredRequestProcessor();
var delivered = processor.DeliverResult("unknown-id", JetStreamApiResponse.SuccessResponse());
delivered.ShouldBeFalse();
}
// ---------------------------------------------------------------
// PendingCount
// ---------------------------------------------------------------
/// <summary>
/// PendingCount increases with each RegisterPending call and decreases when a result is
/// delivered or the request times out.
/// Go reference: jetstream_cluster.go — the server tracks pending RAFT proposals for
/// observability and to detect stuck requests.
/// </summary>
[Fact]
public async Task PendingCount_tracks_active_requests()
{
var processor = new ClusteredRequestProcessor(timeout: TimeSpan.FromMilliseconds(50));
processor.PendingCount.ShouldBe(0);
var id1 = processor.RegisterPending();
processor.PendingCount.ShouldBe(1);
var id2 = processor.RegisterPending();
processor.PendingCount.ShouldBe(2);
// Deliver one request.
processor.DeliverResult(id1, JetStreamApiResponse.SuccessResponse());
processor.PendingCount.ShouldBe(1);
// Let id2 time out.
await processor.WaitForResultAsync(id2);
processor.PendingCount.ShouldBe(0);
}
// ---------------------------------------------------------------
// CancelAll
// ---------------------------------------------------------------
/// <summary>
/// CancelAll completes all pending requests with a 503 error response.
/// Go reference: jetstream_cluster.go — when this node loses RAFT leadership, all
/// in-flight proposals must be failed so callers do not hang indefinitely.
/// </summary>
[Fact]
public async Task CancelAll_completes_all_pending_with_error()
{
var processor = new ClusteredRequestProcessor(timeout: TimeSpan.FromSeconds(30));
var id1 = processor.RegisterPending();
var id2 = processor.RegisterPending();
var task1 = processor.WaitForResultAsync(id1);
var task2 = processor.WaitForResultAsync(id2);
processor.CancelAll("leadership changed");
var result1 = await task1;
var result2 = await task2;
result1.Error.ShouldNotBeNull();
result1.Error!.Code.ShouldBe(503);
result1.Error.Description.ShouldContain("leadership changed");
result2.Error.ShouldNotBeNull();
result2.Error!.Code.ShouldBe(503);
result2.Error.Description.ShouldContain("leadership changed");
}
/// <summary>
/// After CancelAll, PendingCount drops to zero.
/// Go reference: jetstream_cluster.go — a leadership change clears all pending state.
/// </summary>
[Fact]
public void CancelAll_clears_pending_count()
{
var processor = new ClusteredRequestProcessor(timeout: TimeSpan.FromSeconds(30));
processor.RegisterPending();
processor.RegisterPending();
processor.RegisterPending();
processor.PendingCount.ShouldBe(3);
processor.CancelAll();
processor.PendingCount.ShouldBe(0);
}
/// <summary>
/// CancelAll uses a default reason of "leadership changed" when no reason is provided.
/// Go reference: jetstream_cluster.go — default cancellation reason matches NATS cluster semantics.
/// </summary>
[Fact]
public async Task CancelAll_uses_default_reason()
{
var processor = new ClusteredRequestProcessor(timeout: TimeSpan.FromSeconds(30));
var id = processor.RegisterPending();
var task = processor.WaitForResultAsync(id);
processor.CancelAll(); // no reason argument
var result = await task;
result.Error.ShouldNotBeNull();
result.Error!.Description.ShouldContain("leadership changed");
}
// ---------------------------------------------------------------
// Concurrency
// ---------------------------------------------------------------
/// <summary>
/// Concurrent registrations and deliveries all receive the correct response.
/// Go reference: jetstream_cluster.go — in a cluster, many API requests may be in-flight
/// simultaneously, each waiting for its own RAFT entry to be applied.
/// </summary>
[Fact]
public async Task Concurrent_register_and_deliver()
{
const int count = 50;
var processor = new ClusteredRequestProcessor(timeout: TimeSpan.FromSeconds(10));
var requestIds = new string[count];
for (var i = 0; i < count; i++)
requestIds[i] = processor.RegisterPending();
// Start all waits concurrently.
var waitTasks = requestIds.Select(id => processor.WaitForResultAsync(id)).ToArray();
// Deliver all results concurrently — no delay needed; the ThreadPool provides
// sufficient interleaving to exercise concurrent access patterns.
var deliverTasks = requestIds.Select((id, i) => Task.Run(() =>
{
processor.DeliverResult(id, JetStreamApiResponse.ErrorResponse(200 + i, $"response-{i}"));
})).ToArray();
await Task.WhenAll(deliverTasks);
var results = await Task.WhenAll(waitTasks);
// Every result should be a valid response (no null errors from "not found").
results.Length.ShouldBe(count);
foreach (var result in results)
{
// Each result was an explicitly delivered response with a known code.
result.Error.ShouldNotBeNull();
result.Error!.Code.ShouldBeGreaterThanOrEqualTo(200);
result.Error.Code.ShouldBeLessThan(200 + count);
}
}
}