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.
373 lines
14 KiB
C#
373 lines
14 KiB
C#
// 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");
|
|
}
|
|
}
|