From 8512515addd1d6c0fe7e8edcde96b701c92d6b82 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 28 Feb 2026 14:09:22 -0500 Subject: [PATCH] feat(batch11): complete filestore init feature and test port --- .../JetStream/FileStore.cs | 280 ++++++++++++++++++ .../ConcurrencyTests1.Impltests.cs | 53 ++++ .../JetStreamFileStoreTests.Impltests.cs | 274 +++++++++++++++++ porting.db | Bin 6582272 -> 6586368 bytes reports/current.md | 48 ++- 5 files changed, 625 insertions(+), 30 deletions(-) diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/FileStore.cs b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/FileStore.cs index 283cae6..b31ba3c 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/FileStore.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/JetStream/FileStore.cs @@ -111,6 +111,17 @@ public sealed class JetStreamFileStore : IStreamStore, IDisposable // to the memory store implementation while file-specific APIs are added on top. private readonly JetStreamMemStore _memStore; private static readonly ArrayPool MsgBlockBufferPool = ArrayPool.Shared; + private static readonly object InitLock = new(); + private static SemaphoreSlim? _diskIoSlots; + private static int _diskIoCount; + private const int ConsumerHeaderLength = 2; + private const int MaxVarIntLength = 10; + private const long NanosecondsPerSecond = 1_000_000_000L; + + static JetStreamFileStore() + { + Init(); + } // ----------------------------------------------------------------------- // Constructor @@ -468,6 +479,224 @@ public sealed class JetStreamFileStore : IStreamStore, IDisposable return SubjectsEqual; } + internal static long TimestampNormalized(DateTime t) + { + if (t == default) + return 0; + + var utc = t.Kind == DateTimeKind.Utc ? t : t.ToUniversalTime(); + return (utc - DateTime.UnixEpoch).Ticks * 100L; + } + + internal static void Init() + { + var mp = Environment.ProcessorCount; + var nIo = Math.Min(16, Math.Max(4, mp)); + if (mp > 32) + nIo = Math.Max(16, Math.Min(mp, mp / 2)); + + lock (InitLock) + { + if (_diskIoSlots != null && _diskIoCount == nIo) + return; + + _diskIoSlots?.Dispose(); + _diskIoSlots = new SemaphoreSlim(nIo, nIo); + _diskIoCount = nIo; + } + } + + internal static (byte Version, Exception? Error) CheckConsumerHeader(byte[]? hdr) + { + if (hdr is not { Length: >= ConsumerHeaderLength } || hdr[0] != FileStoreDefaults.FileStoreMagic) + return (0, new InvalidDataException("corrupt state")); + + var version = hdr[1]; + return version switch + { + FileStoreDefaults.FileStoreVersion or FileStoreDefaults.NewVersion => (version, null), + _ => (0, new InvalidDataException($"unsupported version: {version}")), + }; + } + + internal static (ConsumerState? State, Exception? Error) DecodeConsumerState(byte[]? buf) + { + if (buf == null) + return (null, new InvalidDataException("corrupt state")); + + var (version, headerErr) = CheckConsumerHeader(buf); + if (headerErr != null) + return (null, headerErr); + + var index = ConsumerHeaderLength; + if (!TryReadUVarInt(buf, ref index, out var ackConsumer) || + !TryReadUVarInt(buf, ref index, out var ackStream) || + !TryReadUVarInt(buf, ref index, out var deliveredConsumer) || + !TryReadUVarInt(buf, ref index, out var deliveredStream)) + { + return (null, new InvalidDataException("corrupt state")); + } + + var state = new ConsumerState + { + AckFloor = new SequencePair { Consumer = ackConsumer, Stream = ackStream }, + Delivered = new SequencePair { Consumer = deliveredConsumer, Stream = deliveredStream }, + }; + + if (version == FileStoreDefaults.FileStoreVersion) + { + if (state.AckFloor.Consumer > 1) + state.Delivered.Consumer += state.AckFloor.Consumer - 1; + if (state.AckFloor.Stream > 1) + state.Delivered.Stream += state.AckFloor.Stream - 1; + } + + const ulong highBit = 1UL << 63; + if ((state.AckFloor.Stream & highBit) != 0 || (state.Delivered.Stream & highBit) != 0) + return (null, new InvalidDataException("corrupt state")); + + if (!TryReadUVarInt(buf, ref index, out var pendingCount)) + return (null, new InvalidDataException("corrupt state")); + + if (pendingCount > 0) + { + if (!TryReadVarInt(buf, ref index, out var minTs)) + return (null, new InvalidDataException("corrupt state")); + + state.Pending = new Dictionary((int)pendingCount); + for (var i = 0; i < (int)pendingCount; i++) + { + if (!TryReadUVarInt(buf, ref index, out var sseq)) + return (null, new InvalidDataException("corrupt state")); + + var dseq = 0UL; + if (version == FileStoreDefaults.NewVersion && !TryReadUVarInt(buf, ref index, out dseq)) + return (null, new InvalidDataException("corrupt state")); + + if (!TryReadVarInt(buf, ref index, out var ts)) + return (null, new InvalidDataException("corrupt state")); + + sseq += state.AckFloor.Stream; + if (sseq == 0) + return (null, new InvalidDataException("corrupt state")); + + if (version == FileStoreDefaults.NewVersion) + dseq += state.AckFloor.Consumer; + + var adjustedTs = version == FileStoreDefaults.FileStoreVersion + ? (ts + minTs) * NanosecondsPerSecond + : (minTs - ts) * NanosecondsPerSecond; + + state.Pending[sseq] = new Pending + { + Sequence = dseq, + Timestamp = adjustedTs, + }; + } + } + + if (!TryReadUVarInt(buf, ref index, out var redeliveredCount)) + return (null, new InvalidDataException("corrupt state")); + + if (redeliveredCount > 0) + { + state.Redelivered = new Dictionary((int)redeliveredCount); + for (var i = 0; i < (int)redeliveredCount; i++) + { + if (!TryReadUVarInt(buf, ref index, out var seq) || + !TryReadUVarInt(buf, ref index, out var count)) + { + return (null, new InvalidDataException("corrupt state")); + } + + if (seq > 0 && count > 0) + { + seq += state.AckFloor.Stream; + state.Redelivered[seq] = count; + } + } + } + + return (state, null); + } + + internal static Exception? WriteFileWithSync(string name, byte[] data, UnixFileMode perm) + => WriteAtomically(name, data, perm, sync: true); + + internal static Exception? WriteAtomically(string name, byte[] data, UnixFileMode perm, bool sync) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(data); + + Init(); + var slots = _diskIoSlots!; + var tmp = name + ".tmp"; + + slots.Wait(); + try + { + var options = sync ? FileOptions.WriteThrough : FileOptions.None; + using (var stream = new FileStream( + tmp, + FileMode.Create, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + options)) + { + stream.Write(data, 0, data.Length); + stream.Flush(sync); + } + + try + { + File.SetUnixFileMode(tmp, perm); + } + catch (PlatformNotSupportedException) + { + } + + File.Move(tmp, name, overwrite: true); + + if (sync) + { + var dir = Path.GetDirectoryName(Path.GetFullPath(name)); + if (!string.IsNullOrEmpty(dir)) + { + try + { + using var handle = File.OpenHandle(dir, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + RandomAccess.FlushToDisk(handle); + } + catch + { + // Best-effort directory metadata sync. + } + } + } + + return null; + } + catch (Exception ex) + { + try + { + if (File.Exists(tmp)) + File.Delete(tmp); + } + catch + { + // Best-effort cleanup. + } + + return ex; + } + finally + { + slots.Release(); + } + } + internal static AeadCipher GenEncryptionKey(StoreCipher sc, byte[] seed) { ArgumentNullException.ThrowIfNull(seed); @@ -557,6 +786,57 @@ public sealed class JetStreamFileStore : IStreamStore, IDisposable return DateTimeOffset.FromUnixTimeSeconds(seconds).AddTicks(remainderNanos / 100L).UtcDateTime; } + private static bool TryReadUVarInt(ReadOnlySpan source, ref int index, out ulong value) + { + value = 0; + var shift = 0; + for (var i = 0; i < MaxVarIntLength; i++) + { + if ((uint)index >= (uint)source.Length) + { + index = -1; + value = 0; + return false; + } + + var b = source[index++]; + if (b < 0x80) + { + if (i == MaxVarIntLength - 1 && b > 1) + { + index = -1; + value = 0; + return false; + } + + value |= (ulong)b << shift; + return true; + } + + value |= (ulong)(b & 0x7F) << shift; + shift += 7; + } + + index = -1; + value = 0; + return false; + } + + private static bool TryReadVarInt(ReadOnlySpan source, ref int index, out long value) + { + if (!TryReadUVarInt(source, ref index, out var unsigned)) + { + value = 0; + return false; + } + + value = (long)(unsigned >> 1); + if ((unsigned & 1) != 0) + value = ~value; + + return true; + } + internal void RecoverAEK() { if (_prf == null || _aek != null) diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/ConcurrencyTests1.Impltests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/ConcurrencyTests1.Impltests.cs index 72f96d7..5192cf3 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/ConcurrencyTests1.Impltests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/ConcurrencyTests1.Impltests.cs @@ -81,6 +81,59 @@ public sealed partial class ConcurrencyTests1 }); } + [Fact] // T:2427 + public void NoRaceJetStreamFileStoreKeyFileCleanup_ShouldSucceed() + { + WithStore((_, root) => + { + var msgDir = Path.Combine(root, FileStoreDefaults.MsgDir); + Directory.CreateDirectory(msgDir); + var perm = UnixFileMode.UserRead | UnixFileMode.UserWrite; + + var errors = new ConcurrentQueue(); + Parallel.For(0, 300, i => + { + var payload = BitConverter.GetBytes(i); + var keyFile = Path.Combine(msgDir, string.Format(FileStoreDefaults.KeyScan, (uint)(i + 1))); + var err = JetStreamFileStore.WriteAtomically(keyFile, payload, perm, sync: true); + if (err != null) + errors.Enqueue(err); + }); + + errors.ShouldBeEmpty(); + var keyFiles = Directory.GetFiles(msgDir, "*.key"); + keyFiles.Length.ShouldBe(300); + + foreach (var key in keyFiles.Skip(1)) + File.Delete(key); + + Directory.GetFiles(msgDir, "*.key").Length.ShouldBe(1); + Directory.GetFiles(msgDir, "*.tmp").ShouldBeEmpty(); + }); + } + + [Fact] // T:2447 + public void NoRaceEncodeConsumerStateBug_ShouldSucceed() + { + for (var i = 0; i < 5_000; i++) + { + var pending = new Pending + { + Sequence = 1, + Timestamp = DateTimeOffset.UtcNow.AddSeconds(1).ToUnixTimeSeconds() * 1_000_000_000L, + }; + var state = new ConsumerState + { + Delivered = new SequencePair { Consumer = 1, Stream = 1 }, + Pending = new Dictionary { [1] = pending }, + }; + + var encoded = StoreParity.EncodeConsumerState(state); + var (_, err) = JetStreamFileStore.DecodeConsumerState(encoded); + err.ShouldBeNull(); + } + } + private static void WithStore(Action action, StreamConfig? cfg = null) { var root = NewRoot(); diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamFileStoreTests.Impltests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamFileStoreTests.Impltests.cs index 8bd6b52..368a098 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamFileStoreTests.Impltests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/JetStreamFileStoreTests.Impltests.cs @@ -910,4 +910,278 @@ public sealed partial class JetStreamFileStoreTests Directory.Delete(root, recursive: true); } } + + [Fact] // T:385 + public void FileStoreConsumer_ShouldSucceed() + { + WithStore((fs, _) => + { + var consumer = fs.ConsumerStore("obs22", DateTime.UtcNow, new ConsumerConfig { AckPolicy = AckPolicy.AckExplicit }); + var (initial, initialErr) = consumer.State(); + initialErr.ShouldBeNull(); + initial.ShouldNotBeNull(); + initial!.Delivered.Consumer.ShouldBe(0UL); + + var state = new ConsumerState + { + Delivered = new SequencePair { Consumer = 100, Stream = 122 }, + AckFloor = new SequencePair { Consumer = 50, Stream = 72 }, + Pending = new Dictionary + { + [75] = new() { Sequence = 75, Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() * 1_000_000_000L }, + [80] = new() { Sequence = 80, Timestamp = DateTimeOffset.UtcNow.AddSeconds(1).ToUnixTimeSeconds() * 1_000_000_000L }, + }, + Redelivered = new Dictionary + { + [90] = 2, + }, + }; + + consumer.Update(state); + var (updated, updateErr) = consumer.State(); + updateErr.ShouldBeNull(); + updated.ShouldNotBeNull(); + updated!.Delivered.Consumer.ShouldBe(state.Delivered.Consumer); + updated.Delivered.Stream.ShouldBe(state.Delivered.Stream); + updated.AckFloor.Consumer.ShouldBe(state.AckFloor.Consumer); + updated.AckFloor.Stream.ShouldBe(state.AckFloor.Stream); + updated.Pending!.Count.ShouldBe(2); + updated.Redelivered!.Count.ShouldBe(1); + + state.AckFloor = new SequencePair { Consumer = 200, Stream = 100 }; + Should.Throw(() => consumer.Update(state)); + + consumer.Stop(); + }); + } + + [Fact] // T:386 + public void FileStoreConsumerEncodeDecodeRedelivered_ShouldSucceed() + { + var state = new ConsumerState + { + Delivered = new SequencePair { Consumer = 100, Stream = 100 }, + AckFloor = new SequencePair { Consumer = 50, Stream = 50 }, + Redelivered = new Dictionary + { + [122] = 3, + [144] = 8, + }, + }; + + var buf = StoreParity.EncodeConsumerState(state); + var (decoded, err) = JetStreamFileStore.DecodeConsumerState(buf); + err.ShouldBeNull(); + decoded.ShouldNotBeNull(); + decoded!.Delivered.Consumer.ShouldBe(state.Delivered.Consumer); + decoded.Delivered.Stream.ShouldBe(state.Delivered.Stream); + decoded.AckFloor.Consumer.ShouldBe(state.AckFloor.Consumer); + decoded.AckFloor.Stream.ShouldBe(state.AckFloor.Stream); + decoded.Redelivered.ShouldNotBeNull(); + decoded.Redelivered![122].ShouldBe(3UL); + decoded.Redelivered[144].ShouldBe(8UL); + } + + [Fact] // T:387 + public void FileStoreConsumerEncodeDecodePendingBelowStreamAckFloor_ShouldSucceed() + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds() * 1_000_000_000L; + var state = new ConsumerState + { + Delivered = new SequencePair { Consumer = 1192, Stream = 10185 }, + AckFloor = new SequencePair { Consumer = 1189, Stream = 10815 }, + Pending = new Dictionary + { + [10782] = new() { Sequence = 1190, Timestamp = now }, + [10810] = new() { Sequence = 1191, Timestamp = now + 1_000_000_000L }, + [10815] = new() { Sequence = 1192, Timestamp = now + 2_000_000_000L }, + }, + }; + + var buf = StoreParity.EncodeConsumerState(state); + var (decoded, err) = JetStreamFileStore.DecodeConsumerState(buf); + err.ShouldBeNull(); + decoded.ShouldNotBeNull(); + decoded!.Pending.ShouldNotBeNull(); + decoded.Pending.Count.ShouldBe(3); + decoded.Pending.ContainsKey(10782).ShouldBeTrue(); + decoded.Pending.ContainsKey(10810).ShouldBeTrue(); + decoded.Pending.ContainsKey(10815).ShouldBeTrue(); + } + + [Fact] // T:393 + public void FileStoreConsumerRedeliveredLost_ShouldSucceed() + { + var root = NewRoot(); + Directory.CreateDirectory(root); + + try + { + var fs = JetStreamFileStore.NewFileStore(new FileStoreConfig { StoreDir = root }, DefaultStreamConfig()); + var cfg = new ConsumerConfig { AckPolicy = AckPolicy.AckExplicit }; + var consumer = fs.ConsumerStore("o22", DateTime.UtcNow, cfg); + var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds() * 1_000_000_000L; + + consumer.UpdateDelivered(1, 1, 1, ts); + consumer.UpdateDelivered(2, 1, 2, ts); + consumer.UpdateDelivered(3, 1, 3, ts); + consumer.UpdateDelivered(4, 1, 4, ts); + consumer.UpdateDelivered(5, 2, 1, ts); + consumer.Stop(); + + consumer = fs.ConsumerStore("o22", DateTime.UtcNow, cfg); + var (state, err) = consumer.State(); + err.ShouldBeNull(); + state.ShouldNotBeNull(); + state!.Redelivered.ShouldNotBeNull(); + state.Redelivered.Count.ShouldBeGreaterThan(0); + + consumer.UpdateAcks(2, 1); + consumer.UpdateAcks(5, 2); + + var (afterAcks, afterErr) = consumer.State(); + afterErr.ShouldBeNull(); + afterAcks.ShouldNotBeNull(); + afterAcks!.Pending.ShouldBeNull(); + afterAcks.Redelivered.ShouldBeNull(); + + consumer.Stop(); + fs.Stop(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] // T:395 + public void FileStoreConsumerDeliveredUpdates_ShouldSucceed() + { + WithStore((fs, _) => + { + var consumer = fs.ConsumerStore("o22", DateTime.UtcNow, new ConsumerConfig()); + + var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds() * 1_000_000_000L; + consumer.UpdateDelivered(1, 100, 1, ts); + consumer.UpdateDelivered(2, 110, 1, ts); + consumer.UpdateDelivered(5, 130, 1, ts); + + var (state, err) = consumer.State(); + err.ShouldBeNull(); + state.ShouldNotBeNull(); + state!.Delivered.Consumer.ShouldBe(5UL); + state.Delivered.Stream.ShouldBe(130UL); + state.AckFloor.Consumer.ShouldBe(5UL); + state.AckFloor.Stream.ShouldBe(130UL); + state.Pending.ShouldBeNull(); + + Should.Throw(() => consumer.UpdateAcks(1, 100)); + Should.Throw(() => consumer.UpdateDelivered(6, 131, 2, ts)); + + consumer.Stop(); + }); + } + + [Fact] // T:396 + public void FileStoreConsumerDeliveredAndAckUpdates_ShouldSucceed() + { + var root = NewRoot(); + Directory.CreateDirectory(root); + + try + { + var fs = JetStreamFileStore.NewFileStore(new FileStoreConfig { StoreDir = root }, DefaultStreamConfig()); + var cfg = new ConsumerConfig { AckPolicy = AckPolicy.AckExplicit }; + var consumer = fs.ConsumerStore("o22", DateTime.UtcNow, cfg); + var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds() * 1_000_000_000L; + + consumer.UpdateDelivered(1, 100, 1, ts); + consumer.UpdateDelivered(2, 110, 1, ts); + consumer.UpdateDelivered(3, 130, 1, ts); + consumer.UpdateDelivered(4, 150, 1, ts); + consumer.UpdateDelivered(5, 165, 1, ts); + + var (beforeAcks, beforeErr) = consumer.State(); + beforeErr.ShouldBeNull(); + beforeAcks.ShouldNotBeNull(); + beforeAcks!.Pending!.Count.ShouldBe(5); + + Should.Throw(() => consumer.UpdateAcks(3, 101)); + Should.Throw(() => consumer.UpdateAcks(1, 1)); + + consumer.UpdateAcks(1, 100); + consumer.UpdateAcks(2, 110); + consumer.UpdateAcks(3, 130); + var (afterAcks, afterErr) = consumer.State(); + afterErr.ShouldBeNull(); + afterAcks.ShouldNotBeNull(); + afterAcks!.Pending!.Count.ShouldBe(2); + + consumer.Stop(); + consumer = fs.ConsumerStore("o22", DateTime.UtcNow, cfg); + var (restored, restoredErr) = consumer.State(); + restoredErr.ShouldBeNull(); + restored.ShouldNotBeNull(); + restored!.Pending!.Count.ShouldBe(2); + + consumer.Stop(); + fs.Stop(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] // T:402 + public void FileStoreBadConsumerState_ShouldSucceed() + { + var bs = new byte[] + { + 0x16, 0x02, 0x01, 0x01, 0x03, 0x02, 0x01, 0x98, 0xF4, 0x8A, + 0x8A, 0x0C, 0x01, 0x03, 0x86, 0xFA, 0x0A, 0x01, 0x00, 0x01, + }; + + var (state, err) = JetStreamFileStore.DecodeConsumerState(bs); + err.ShouldBeNull(); + state.ShouldNotBeNull(); + } + + [Fact] // T:440 + public void FileStoreConsumerStoreEncodeAfterRestart_ShouldSucceed() + { + var root = NewRoot(); + Directory.CreateDirectory(root); + + try + { + var persisted = new ConsumerState + { + Delivered = new SequencePair { Consumer = 22, Stream = 22 }, + AckFloor = new SequencePair { Consumer = 11, Stream = 11 }, + }; + + var fs1 = JetStreamFileStore.NewFileStore(new FileStoreConfig { StoreDir = root }, DefaultStreamConfig()); + var c1 = fs1.ConsumerStore("o22", DateTime.UtcNow, new ConsumerConfig { AckPolicy = AckPolicy.AckExplicit }); + c1.Update(persisted); + c1.Stop(); + fs1.Stop(); + + var fs2 = JetStreamFileStore.NewFileStore(new FileStoreConfig { StoreDir = root }, DefaultStreamConfig()); + var c2 = fs2.ConsumerStore("o22", DateTime.UtcNow, new ConsumerConfig { AckPolicy = AckPolicy.AckExplicit }); + var (state, err) = c2.State(); + err.ShouldBeNull(); + state.ShouldNotBeNull(); + state!.Delivered.Consumer.ShouldBe(persisted.Delivered.Consumer); + state.Delivered.Stream.ShouldBe(persisted.Delivered.Stream); + state.AckFloor.Consumer.ShouldBe(persisted.AckFloor.Consumer); + state.AckFloor.Stream.ShouldBe(persisted.AckFloor.Stream); + c2.Stop(); + fs2.Stop(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } } diff --git a/porting.db b/porting.db index 02375e635f811c7179adee6c20249a2b7aa139af..10ec7b426f0edcf82b317299264a0939cd0c00aa 100644 GIT binary patch delta 5790 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 alFug$V|ZtujU7Qc0y3Z2=`N3n@qYnB7YhOa delta 7181 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 ex#r5u1q=B|ejCWa!j9qp