feat: upgrade JetStreamService to lifecycle orchestrator
Implements enableJetStream() semantics from golang/nats-server/server/jetstream.go:414-523. - JetStreamService.StartAsync(): validates config, creates store directory (including nested paths via Directory.CreateDirectory), registers all $JS.API.> subjects, logs startup stats; idempotent on double-start - JetStreamService.DisposeAsync(): clears registered subjects, marks not running - New properties: RegisteredApiSubjects, MaxStreams, MaxConsumers, MaxMemory, MaxStore - JetStreamOptions: adds MaxStreams and MaxConsumers limits (0 = unlimited) - FileStoreConfig: removes duplicate StoreCipher/StoreCompression enum declarations now that AeadEncryptor.cs owns them; updates defaults to NoCipher/NoCompression - FileStoreOptions/FileStore: align enum member names with AeadEncryptor.cs (NoCipher, NoCompression, S2Compression) to fix cross-task naming conflict - 13 new tests in JetStreamServiceOrchestrationTests covering all lifecycle paths
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
// Ported from golang/nats-server/server/jetstream.go:414-523 (enableJetStream)
|
||||
// Tests for JetStreamService lifecycle orchestration: store directory creation,
|
||||
// API subject registration, configuration property exposure, and dispose semantics.
|
||||
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.JetStream;
|
||||
|
||||
namespace NATS.Server.Tests.JetStream;
|
||||
|
||||
public sealed class JetStreamServiceOrchestrationTests : IDisposable
|
||||
{
|
||||
private readonly List<string> _tempDirs = [];
|
||||
|
||||
private string MakeTempDir()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "nats-js-test-" + Guid.NewGuid().ToString("N"));
|
||||
_tempDirs.Add(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var dir in _tempDirs)
|
||||
{
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
// Go: enableJetStream — jetstream.go:414 — happy path creates store dir and marks running
|
||||
[Fact]
|
||||
public async Task StartAsync_creates_store_directory_and_marks_running()
|
||||
{
|
||||
var storeDir = MakeTempDir();
|
||||
var options = new JetStreamOptions { StoreDir = storeDir };
|
||||
await using var svc = new JetStreamService(options);
|
||||
|
||||
Directory.Exists(storeDir).ShouldBeFalse("directory must not exist before start");
|
||||
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
|
||||
svc.IsRunning.ShouldBeTrue();
|
||||
Directory.Exists(storeDir).ShouldBeTrue("StartAsync must create the store directory");
|
||||
}
|
||||
|
||||
// Go: enableJetStream — jetstream.go:430 — existing dir is accepted without error
|
||||
[Fact]
|
||||
public async Task StartAsync_accepts_preexisting_store_directory()
|
||||
{
|
||||
var storeDir = MakeTempDir();
|
||||
Directory.CreateDirectory(storeDir);
|
||||
var options = new JetStreamOptions { StoreDir = storeDir };
|
||||
await using var svc = new JetStreamService(options);
|
||||
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
|
||||
svc.IsRunning.ShouldBeTrue();
|
||||
Directory.Exists(storeDir).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: enableJetStream — memory-only mode when StoreDir is empty
|
||||
[Fact]
|
||||
public async Task StartAsync_with_empty_StoreDir_starts_in_memory_only_mode()
|
||||
{
|
||||
var options = new JetStreamOptions { StoreDir = string.Empty };
|
||||
await using var svc = new JetStreamService(options);
|
||||
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
|
||||
svc.IsRunning.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: setJetStreamExportSubs — jetstream.go:489 — all $JS.API subjects registered
|
||||
[Fact]
|
||||
public async Task RegisteredApiSubjects_contains_expected_subjects_after_start()
|
||||
{
|
||||
var options = new JetStreamOptions();
|
||||
await using var svc = new JetStreamService(options);
|
||||
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
|
||||
var subjects = svc.RegisteredApiSubjects;
|
||||
subjects.ShouldNotBeEmpty();
|
||||
subjects.ShouldContain("$JS.API.>");
|
||||
subjects.ShouldContain("$JS.API.INFO");
|
||||
subjects.ShouldContain("$JS.API.META.LEADER.STEPDOWN");
|
||||
subjects.ShouldContain("$JS.API.STREAM.NAMES");
|
||||
subjects.ShouldContain("$JS.API.STREAM.LIST");
|
||||
}
|
||||
|
||||
// Go: setJetStreamExportSubs — all consumer-related wildcards registered
|
||||
[Fact]
|
||||
public async Task RegisteredApiSubjects_includes_consumer_and_stream_wildcard_subjects()
|
||||
{
|
||||
var options = new JetStreamOptions();
|
||||
await using var svc = new JetStreamService(options);
|
||||
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
|
||||
var subjects = svc.RegisteredApiSubjects;
|
||||
|
||||
// Stream management
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.CREATE."), "stream create wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.DELETE."), "stream delete wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.INFO."), "stream info wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.UPDATE."), "stream update wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.PURGE."), "stream purge wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.MSG.GET."), "stream msg get wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.MSG.DELETE."), "stream msg delete wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.SNAPSHOT."), "stream snapshot wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.RESTORE."), "stream restore wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.STREAM.LEADER.STEPDOWN."), "stream leader stepdown wildcard");
|
||||
|
||||
// Consumer management
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.CONSUMER.CREATE."), "consumer create wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.CONSUMER.DELETE."), "consumer delete wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.CONSUMER.INFO."), "consumer info wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.CONSUMER.NAMES."), "consumer names wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.CONSUMER.LIST."), "consumer list wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.CONSUMER.PAUSE."), "consumer pause wildcard");
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.CONSUMER.MSG.NEXT."), "consumer msg next wildcard");
|
||||
|
||||
// Direct get
|
||||
subjects.ShouldContain(s => s.StartsWith("$JS.API.DIRECT.GET."), "direct get wildcard");
|
||||
}
|
||||
|
||||
// RegisteredApiSubjects should be empty before start
|
||||
[Fact]
|
||||
public void RegisteredApiSubjects_is_empty_before_start()
|
||||
{
|
||||
var options = new JetStreamOptions();
|
||||
var svc = new JetStreamService(options);
|
||||
|
||||
svc.RegisteredApiSubjects.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// Go: shutdown path — DisposeAsync clears subjects and marks not running
|
||||
[Fact]
|
||||
public async Task DisposeAsync_clears_subjects_and_marks_not_running()
|
||||
{
|
||||
var options = new JetStreamOptions();
|
||||
var svc = new JetStreamService(options);
|
||||
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
svc.IsRunning.ShouldBeTrue();
|
||||
svc.RegisteredApiSubjects.ShouldNotBeEmpty();
|
||||
|
||||
await svc.DisposeAsync();
|
||||
|
||||
svc.IsRunning.ShouldBeFalse();
|
||||
svc.RegisteredApiSubjects.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// MaxStreams and MaxConsumers reflect config values
|
||||
[Fact]
|
||||
public async Task MaxStreams_and_MaxConsumers_reflect_config_values()
|
||||
{
|
||||
var options = new JetStreamOptions
|
||||
{
|
||||
MaxStreams = 100,
|
||||
MaxConsumers = 500,
|
||||
};
|
||||
await using var svc = new JetStreamService(options);
|
||||
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
|
||||
svc.MaxStreams.ShouldBe(100);
|
||||
svc.MaxConsumers.ShouldBe(500);
|
||||
}
|
||||
|
||||
// MaxMemory and MaxStore reflect config values
|
||||
[Fact]
|
||||
public async Task MaxMemory_and_MaxStore_reflect_config_values()
|
||||
{
|
||||
var options = new JetStreamOptions
|
||||
{
|
||||
MaxMemoryStore = 1_073_741_824L, // 1 GiB
|
||||
MaxFileStore = 10_737_418_240L, // 10 GiB
|
||||
};
|
||||
await using var svc = new JetStreamService(options);
|
||||
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
|
||||
svc.MaxMemory.ShouldBe(1_073_741_824L);
|
||||
svc.MaxStore.ShouldBe(10_737_418_240L);
|
||||
}
|
||||
|
||||
// Default config values are zero (unlimited)
|
||||
[Fact]
|
||||
public void Default_config_values_are_unlimited_zero()
|
||||
{
|
||||
var options = new JetStreamOptions();
|
||||
var svc = new JetStreamService(options);
|
||||
|
||||
svc.MaxStreams.ShouldBe(0);
|
||||
svc.MaxConsumers.ShouldBe(0);
|
||||
svc.MaxMemory.ShouldBe(0L);
|
||||
svc.MaxStore.ShouldBe(0L);
|
||||
}
|
||||
|
||||
// Go: enableJetStream idempotency — double-start is safe (not an error)
|
||||
[Fact]
|
||||
public async Task Double_start_is_idempotent()
|
||||
{
|
||||
var options = new JetStreamOptions();
|
||||
await using var svc = new JetStreamService(options);
|
||||
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
var subjectCountAfterFirst = svc.RegisteredApiSubjects.Count;
|
||||
|
||||
// Second start must not throw and must not duplicate subjects
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
|
||||
svc.IsRunning.ShouldBeTrue();
|
||||
svc.RegisteredApiSubjects.Count.ShouldBe(subjectCountAfterFirst);
|
||||
}
|
||||
|
||||
// Store directory is created with a nested path (MkdirAll semantics)
|
||||
[Fact]
|
||||
public async Task StartAsync_creates_nested_store_directory()
|
||||
{
|
||||
var baseDir = MakeTempDir();
|
||||
var nestedDir = Path.Combine(baseDir, "level1", "level2", "jetstream");
|
||||
var options = new JetStreamOptions { StoreDir = nestedDir };
|
||||
await using var svc = new JetStreamService(options);
|
||||
|
||||
await svc.StartAsync(CancellationToken.None);
|
||||
|
||||
svc.IsRunning.ShouldBeTrue();
|
||||
Directory.Exists(nestedDir).ShouldBeTrue("nested store directory must be created");
|
||||
}
|
||||
|
||||
// Service is not running before start
|
||||
[Fact]
|
||||
public void IsRunning_is_false_before_start()
|
||||
{
|
||||
var options = new JetStreamOptions();
|
||||
var svc = new JetStreamService(options);
|
||||
|
||||
svc.IsRunning.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user