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:
@@ -0,0 +1,372 @@
|
||||
// Reference: golang/nats-server/server/filestore.go:4443 (setupWriteCache)
|
||||
// Reference: golang/nats-server/server/filestore.go:6148 (expireCache)
|
||||
// Reference: golang/nats-server/server/filestore.go:6220 (expireCacheLocked)
|
||||
//
|
||||
// Tests for WriteCacheManager (Gap 1.8) — bounded write cache with TTL eviction
|
||||
// and background flush inside FileStore.
|
||||
|
||||
using NATS.Server.JetStream.Storage;
|
||||
|
||||
namespace NATS.Server.JetStream.Tests.JetStream.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="FileStore.WriteCacheManager"/>. Uses direct access to the
|
||||
/// internal class where needed, and tests through the public <see cref="FileStore"/>
|
||||
/// API for integration coverage.
|
||||
///
|
||||
/// Timing-sensitive eviction tests use <c>TrackWriteAt</c> to inject an explicit
|
||||
/// past timestamp rather than sleeping, avoiding flaky timing dependencies.
|
||||
/// </summary>
|
||||
public sealed class WriteCacheTests : IDisposable
|
||||
{
|
||||
private readonly DirectoryInfo _dir = Directory.CreateTempSubdirectory("wcache-");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { _dir.Delete(recursive: true); }
|
||||
catch (IOException) { /* best effort — OS may hold handles briefly */ }
|
||||
catch (UnauthorizedAccessException) { /* best effort on locked directories */ }
|
||||
}
|
||||
|
||||
private FileStore CreateStore(string sub, FileStoreOptions? opts = null)
|
||||
{
|
||||
var dir = Path.Combine(_dir.FullName, sub);
|
||||
opts ??= new FileStoreOptions();
|
||||
opts.Directory = dir;
|
||||
return new FileStore(opts);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TrackWrite / TrackedBlockCount / TotalCachedBytes
|
||||
// Go: filestore.go:4443 (setupWriteCache) — track write for a block.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TrackWrite_AddsSizeToEntry()
|
||||
{
|
||||
// Arrange
|
||||
using var block = MsgBlock.Create(1, Path.Combine(_dir.FullName, "blk1"), 1024 * 1024);
|
||||
var manager = new FileStore.WriteCacheManager(
|
||||
maxCacheSizeBytes: 64 * 1024 * 1024,
|
||||
cacheExpiry: TimeSpan.FromSeconds(2),
|
||||
blockLookup: id => id == 1 ? block : null);
|
||||
|
||||
// Act
|
||||
manager.TrackWrite(blockId: 1, bytes: 512);
|
||||
manager.TrackWrite(blockId: 1, bytes: 256);
|
||||
|
||||
// Assert
|
||||
manager.TrackedBlockCount.ShouldBe(1);
|
||||
manager.TotalCachedBytes.ShouldBe(768L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrackWrite_MultipleBlocks_AccumulatesSeparately()
|
||||
{
|
||||
// Arrange
|
||||
using var block1 = MsgBlock.Create(1, Path.Combine(_dir.FullName, "blk-m1"), 1024 * 1024);
|
||||
using var block2 = MsgBlock.Create(2, Path.Combine(_dir.FullName, "blk-m2"), 1024 * 1024);
|
||||
var manager = new FileStore.WriteCacheManager(
|
||||
maxCacheSizeBytes: 64 * 1024 * 1024,
|
||||
cacheExpiry: TimeSpan.FromSeconds(2),
|
||||
blockLookup: id => id == 1 ? block1 : id == 2 ? block2 : null);
|
||||
|
||||
// Act
|
||||
manager.TrackWrite(blockId: 1, bytes: 100);
|
||||
manager.TrackWrite(blockId: 2, bytes: 200);
|
||||
manager.TrackWrite(blockId: 1, bytes: 50);
|
||||
|
||||
// Assert
|
||||
manager.TrackedBlockCount.ShouldBe(2);
|
||||
manager.TotalCachedBytes.ShouldBe(350L);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// EvictBlock — flush then clear for a single block
|
||||
// Go: filestore.go:4499 (flushPendingMsgsLocked on rotation).
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void EvictBlock_ClearsBlockCache()
|
||||
{
|
||||
// Arrange: write a message to populate the write cache.
|
||||
var dir = Path.Combine(_dir.FullName, "evict1");
|
||||
Directory.CreateDirectory(dir);
|
||||
using var block = MsgBlock.Create(1, dir, 1024 * 1024);
|
||||
block.Write("test.subject", ReadOnlyMemory<byte>.Empty, "hello"u8.ToArray());
|
||||
|
||||
block.HasCache.ShouldBeTrue("block should have write cache after write");
|
||||
|
||||
var manager = new FileStore.WriteCacheManager(
|
||||
maxCacheSizeBytes: 64 * 1024 * 1024,
|
||||
cacheExpiry: TimeSpan.FromSeconds(10),
|
||||
blockLookup: id => id == 1 ? block : null);
|
||||
|
||||
manager.TrackWrite(blockId: 1, bytes: 64);
|
||||
|
||||
// Act
|
||||
manager.EvictBlock(blockId: 1);
|
||||
|
||||
// Assert: write cache must be cleared after eviction.
|
||||
block.HasCache.ShouldBeFalse("block cache should be cleared after EvictBlock");
|
||||
manager.TrackedBlockCount.ShouldBe(0);
|
||||
manager.TotalCachedBytes.ShouldBe(0L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvictBlock_NonExistentBlock_IsNoOp()
|
||||
{
|
||||
// Arrange
|
||||
var manager = new FileStore.WriteCacheManager(
|
||||
maxCacheSizeBytes: 64 * 1024 * 1024,
|
||||
cacheExpiry: TimeSpan.FromSeconds(2),
|
||||
blockLookup: _ => null);
|
||||
|
||||
// Act + Assert: should not throw for an unknown block ID
|
||||
Should.NotThrow(() => manager.EvictBlock(blockId: 99));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TTL eviction via RunEviction
|
||||
// Go: filestore.go:6220 (expireCacheLocked) — expire idle cache after TTL.
|
||||
//
|
||||
// Uses TrackWriteAt to inject a past timestamp so TTL tests do not depend
|
||||
// on real elapsed time (no Task.Delay).
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RunEviction_ExpiresCacheAfterTtl()
|
||||
{
|
||||
// Arrange: inject a write timestamp 5 seconds in the past so it is
|
||||
// well beyond the 2-second TTL when RunEviction fires immediately.
|
||||
var dir = Path.Combine(_dir.FullName, "ttl1");
|
||||
Directory.CreateDirectory(dir);
|
||||
using var block = MsgBlock.Create(1, dir, 1024 * 1024);
|
||||
block.Write("ttl.subject", ReadOnlyMemory<byte>.Empty, "data"u8.ToArray());
|
||||
block.HasCache.ShouldBeTrue();
|
||||
|
||||
var manager = new FileStore.WriteCacheManager(
|
||||
maxCacheSizeBytes: 64 * 1024 * 1024,
|
||||
cacheExpiry: TimeSpan.FromSeconds(2),
|
||||
blockLookup: id => id == 1 ? block : null);
|
||||
|
||||
// Place the entry 5 000 ms in the past — well past the 2 s TTL.
|
||||
var pastTimestamp = Environment.TickCount64 - 5_000;
|
||||
manager.TrackWriteAt(blockId: 1, bytes: 128, tickCount64Ms: pastTimestamp);
|
||||
|
||||
// Act: immediately trigger eviction without sleeping
|
||||
manager.RunEviction();
|
||||
|
||||
// Assert: cache cleared after TTL
|
||||
block.HasCache.ShouldBeFalse("cache should be cleared after TTL expiry");
|
||||
manager.TrackedBlockCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunEviction_DoesNotExpireRecentWrites()
|
||||
{
|
||||
// Arrange: write timestamp is now (fresh), TTL is 30 s — should not evict.
|
||||
var dir = Path.Combine(_dir.FullName, "ttl2");
|
||||
Directory.CreateDirectory(dir);
|
||||
using var block = MsgBlock.Create(1, dir, 1024 * 1024);
|
||||
block.Write("ttl2.subject", ReadOnlyMemory<byte>.Empty, "data"u8.ToArray());
|
||||
|
||||
var manager = new FileStore.WriteCacheManager(
|
||||
maxCacheSizeBytes: 64 * 1024 * 1024,
|
||||
cacheExpiry: TimeSpan.FromSeconds(30),
|
||||
blockLookup: id => id == 1 ? block : null);
|
||||
|
||||
manager.TrackWrite(blockId: 1, bytes: 64);
|
||||
|
||||
// Act: trigger eviction immediately (well before TTL)
|
||||
manager.RunEviction();
|
||||
|
||||
// Assert: cache should still be intact
|
||||
block.HasCache.ShouldBeTrue("cache should remain since TTL has not expired");
|
||||
manager.TrackedBlockCount.ShouldBe(1);
|
||||
|
||||
await manager.DisposeAsync();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Size-cap eviction via RunEviction
|
||||
// Go: filestore.go:6220 (expireCacheLocked) — evict oldest when over cap.
|
||||
//
|
||||
// Uses TrackWriteAt to inject explicit timestamps, making block1 definitively
|
||||
// older than block2 without relying on Task.Delay.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RunEviction_EvictsOldestWhenOverSizeCap()
|
||||
{
|
||||
// Arrange: size cap = 300 bytes, two blocks, block1 is older.
|
||||
var dir1 = Path.Combine(_dir.FullName, "cap1");
|
||||
var dir2 = Path.Combine(_dir.FullName, "cap2");
|
||||
Directory.CreateDirectory(dir1);
|
||||
Directory.CreateDirectory(dir2);
|
||||
using var block1 = MsgBlock.Create(1, dir1, 1024 * 1024);
|
||||
using var block2 = MsgBlock.Create(2, dir2, 1024 * 1024);
|
||||
|
||||
block1.Write("s1", ReadOnlyMemory<byte>.Empty, "payload-one"u8.ToArray());
|
||||
block2.Write("s2", ReadOnlyMemory<byte>.Empty, "payload-two"u8.ToArray());
|
||||
|
||||
var manager = new FileStore.WriteCacheManager(
|
||||
maxCacheSizeBytes: 300,
|
||||
cacheExpiry: TimeSpan.FromSeconds(60),
|
||||
blockLookup: id => id == 1 ? block1 : id == 2 ? block2 : null);
|
||||
|
||||
var now = Environment.TickCount64;
|
||||
// block1 written 10 s ago (older), block2 written now (newer).
|
||||
manager.TrackWriteAt(blockId: 1, bytes: 200, tickCount64Ms: now - 10_000);
|
||||
manager.TrackWriteAt(blockId: 2, bytes: 200, tickCount64Ms: now);
|
||||
|
||||
// Total is 400 bytes — exceeds cap of 300.
|
||||
manager.TotalCachedBytes.ShouldBe(400L);
|
||||
|
||||
// Act
|
||||
manager.RunEviction();
|
||||
|
||||
// Assert: oldest (block1) should have been evicted to bring total <= cap.
|
||||
block1.HasCache.ShouldBeFalse("oldest block should be evicted to enforce size cap");
|
||||
manager.TotalCachedBytes.ShouldBeLessThanOrEqualTo(300L);
|
||||
|
||||
await manager.DisposeAsync();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// FlushAllAsync
|
||||
// Go: filestore.go:5499 (flushPendingMsgsLocked, all blocks).
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task FlushAllAsync_ClearsAllTrackedBlocks()
|
||||
{
|
||||
// Arrange
|
||||
var dir1 = Path.Combine(_dir.FullName, "flush1");
|
||||
var dir2 = Path.Combine(_dir.FullName, "flush2");
|
||||
Directory.CreateDirectory(dir1);
|
||||
Directory.CreateDirectory(dir2);
|
||||
using var block1 = MsgBlock.Create(1, dir1, 1024 * 1024);
|
||||
using var block2 = MsgBlock.Create(2, dir2, 1024 * 1024);
|
||||
|
||||
block1.Write("flush.a", ReadOnlyMemory<byte>.Empty, "aaa"u8.ToArray());
|
||||
block2.Write("flush.b", ReadOnlyMemory<byte>.Empty, "bbb"u8.ToArray());
|
||||
|
||||
var manager = new FileStore.WriteCacheManager(
|
||||
maxCacheSizeBytes: 64 * 1024 * 1024,
|
||||
cacheExpiry: TimeSpan.FromSeconds(60),
|
||||
blockLookup: id => id == 1 ? block1 : id == 2 ? block2 : null);
|
||||
|
||||
manager.TrackWrite(blockId: 1, bytes: 64);
|
||||
manager.TrackWrite(blockId: 2, bytes: 64);
|
||||
|
||||
manager.TrackedBlockCount.ShouldBe(2);
|
||||
|
||||
// Act
|
||||
await manager.FlushAllAsync();
|
||||
|
||||
// Assert
|
||||
manager.TrackedBlockCount.ShouldBe(0);
|
||||
manager.TotalCachedBytes.ShouldBe(0L);
|
||||
block1.HasCache.ShouldBeFalse("block1 cache should be cleared after FlushAllAsync");
|
||||
block2.HasCache.ShouldBeFalse("block2 cache should be cleared after FlushAllAsync");
|
||||
|
||||
await manager.DisposeAsync();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Integration with FileStore: TrackWrite called on AppendAsync / StoreMsg
|
||||
// Go: filestore.go:6700 (writeMsgRecord) — cache populated on write.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task FileStore_TracksWriteAfterAppend()
|
||||
{
|
||||
// Arrange
|
||||
await using var store = CreateStore("int-append", new FileStoreOptions
|
||||
{
|
||||
BlockSizeBytes = 1024 * 1024,
|
||||
MaxCacheSize = 64 * 1024 * 1024,
|
||||
CacheExpiry = TimeSpan.FromSeconds(60),
|
||||
});
|
||||
|
||||
// Act: write a few messages
|
||||
await store.AppendAsync("foo.bar", "hello world"u8.ToArray(), default);
|
||||
await store.AppendAsync("foo.baz", "second message"u8.ToArray(), default);
|
||||
|
||||
// Assert: blocks were created and messages are retrievable (cache is live).
|
||||
store.BlockCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileStore_EvictsBlockCacheOnRotation()
|
||||
{
|
||||
// Arrange: tiny block size so rotation happens quickly.
|
||||
var opts = new FileStoreOptions
|
||||
{
|
||||
BlockSizeBytes = 128, // Forces rotation after ~2 messages
|
||||
MaxCacheSize = 64 * 1024 * 1024,
|
||||
CacheExpiry = TimeSpan.FromSeconds(60),
|
||||
};
|
||||
await using var store = CreateStore("int-rotate", opts);
|
||||
|
||||
// Act: write enough to trigger rotation
|
||||
for (var i = 0; i < 10; i++)
|
||||
await store.AppendAsync($"subj.{i}", new byte[20], default);
|
||||
|
||||
// Assert: multiple blocks exist and all reads still succeed
|
||||
store.BlockCount.ShouldBeGreaterThan(1);
|
||||
|
||||
for (ulong seq = 1; seq <= 10; seq++)
|
||||
{
|
||||
var msg = await store.LoadAsync(seq, default);
|
||||
msg.ShouldNotBeNull($"message at seq={seq} should be recoverable after block rotation");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FileStore_StoreMsg_TracksWrite()
|
||||
{
|
||||
// Arrange
|
||||
using var store = CreateStore("int-storemsg", new FileStoreOptions
|
||||
{
|
||||
BlockSizeBytes = 1024 * 1024,
|
||||
MaxCacheSize = 64 * 1024 * 1024,
|
||||
CacheExpiry = TimeSpan.FromSeconds(60),
|
||||
});
|
||||
|
||||
// Act
|
||||
var (seq, _) = store.StoreMsg("test.subject", hdr: null, msg: "payload"u8.ToArray(), ttl: 0);
|
||||
|
||||
// Assert: message is retrievable (write was tracked, cache is alive)
|
||||
seq.ShouldBe(1UL);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// IAsyncDisposable: DisposeAsync flushes then stops the timer
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_FlushesAndStopsBackgroundTask()
|
||||
{
|
||||
// Arrange
|
||||
var dir = Path.Combine(_dir.FullName, "dispose-test");
|
||||
Directory.CreateDirectory(dir);
|
||||
using var block = MsgBlock.Create(1, dir, 1024 * 1024);
|
||||
block.Write("d.subject", ReadOnlyMemory<byte>.Empty, "data"u8.ToArray());
|
||||
|
||||
var manager = new FileStore.WriteCacheManager(
|
||||
maxCacheSizeBytes: 64 * 1024 * 1024,
|
||||
cacheExpiry: TimeSpan.FromSeconds(60),
|
||||
blockLookup: id => id == 1 ? block : null);
|
||||
|
||||
manager.TrackWrite(blockId: 1, bytes: 64);
|
||||
|
||||
// Act: dispose should complete within a reasonable time and clear entries
|
||||
await manager.DisposeAsync();
|
||||
|
||||
// Assert
|
||||
manager.TrackedBlockCount.ShouldBe(0);
|
||||
block.HasCache.ShouldBeFalse("cache should be flushed/cleared during DisposeAsync");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user