diff --git a/src/Nethermind/Nethermind.Benchmark/State/ReadOnlySnapshotBundleBenchmark.cs b/src/Nethermind/Nethermind.Benchmark/State/ReadOnlySnapshotBundleBenchmark.cs
index 98f615509c19..a4582c530b73 100644
--- a/src/Nethermind/Nethermind.Benchmark/State/ReadOnlySnapshotBundleBenchmark.cs
+++ b/src/Nethermind/Nethermind.Benchmark/State/ReadOnlySnapshotBundleBenchmark.cs
@@ -14,6 +14,7 @@
using Nethermind.Logging;
using Nethermind.State.Flat;
using Nethermind.State.Flat.Persistence;
+using Nethermind.State.Flat.PersistedSnapshots;
using Nethermind.State.Flat.ScopeProvider;
using Nethermind.Trie;
using FlatSnapshot = Nethermind.State.Flat.Snapshot;
@@ -73,7 +74,6 @@ public void Setup()
int storageAccountCount = 20 * multiplier;
int slotsPerStorageAccount = 100 * multiplier;
- // Build ReadOnlySnapshotBundle from previously captured snapshots
SnapshotPooledList prevSnapshots = new(allSnapshots.Count);
foreach (FlatSnapshot s in allSnapshots)
{
@@ -81,8 +81,10 @@ public void Setup()
prevSnapshots.Add(s);
}
+ // Build ReadOnlySnapshotBundle from previously captured snapshots
ReadOnlySnapshotBundle readOnly = new(
- prevSnapshots, new NoopPersistenceReader(), recordDetailedMetrics: false);
+ prevSnapshots, new NoopPersistenceReader(), recordDetailedMetrics: false,
+ PersistedSnapshotStack.Empty());
NullTrieNodeCache cache = new();
SnapshotBundle bundle = new(
readOnly, cache, resourcePool, ResourcePool.Usage.MainBlockProcessing);
@@ -154,7 +156,6 @@ public void Setup()
maxSlotsPerStorageAccount = slotsPerStorageAccount;
}
- // Build final ReadOnlySnapshotBundle with all 8 snapshots
SnapshotPooledList finalSnapshots = new(allSnapshots.Count);
foreach (FlatSnapshot s in allSnapshots)
{
@@ -162,8 +163,10 @@ public void Setup()
finalSnapshots.Add(s);
}
+ // Build final ReadOnlySnapshotBundle with all 8 snapshots
_bundle = new ReadOnlySnapshotBundle(
- finalSnapshots, new NoopPersistenceReader(), recordDetailedMetrics: false);
+ finalSnapshots, new NoopPersistenceReader(), recordDetailedMetrics: false,
+ PersistedSnapshotStack.Empty());
// --- Hit arrays ---
_hitAccounts = new Address[ArraySize];
diff --git a/src/Nethermind/Nethermind.Benchmark/State/WriteBatchBenchmark.cs b/src/Nethermind/Nethermind.Benchmark/State/WriteBatchBenchmark.cs
index 8abbc86b8200..147723cc7bed 100644
--- a/src/Nethermind/Nethermind.Benchmark/State/WriteBatchBenchmark.cs
+++ b/src/Nethermind/Nethermind.Benchmark/State/WriteBatchBenchmark.cs
@@ -13,6 +13,7 @@
using Nethermind.Logging;
using Nethermind.State.Flat;
using Nethermind.State.Flat.Persistence;
+using Nethermind.State.Flat.PersistedSnapshots;
using Nethermind.State.Flat.ScopeProvider;
using Nethermind.Trie;
using FlatSnapshot = Nethermind.State.Flat.Snapshot;
@@ -65,7 +66,8 @@ public void GlobalSetup()
}
ReadOnlySnapshotBundle readOnly = new(
- prevSnapshots, new NoopPersistenceReader(), recordDetailedMetrics: false);
+ prevSnapshots, new NoopPersistenceReader(), recordDetailedMetrics: false,
+ PersistedSnapshotStack.Empty());
NullTrieNodeCache cache = new();
SnapshotBundle bundle = new(
readOnly, cache, _resourcePool, ResourcePool.Usage.MainBlockProcessing);
@@ -147,7 +149,8 @@ public void IterationSetup()
}
ReadOnlySnapshotBundle readOnly = new(
- prevSnapshots, new NoopPersistenceReader(), recordDetailedMetrics: false);
+ prevSnapshots, new NoopPersistenceReader(), recordDetailedMetrics: false,
+ PersistedSnapshotStack.Empty());
NullTrieNodeCache cache = new();
SnapshotBundle bundle = new(
readOnly, cache, _resourcePool, ResourcePool.Usage.MainBlockProcessing);
diff --git a/src/Nethermind/Nethermind.Core/Utils/SmallRefCountingDisposable.cs b/src/Nethermind/Nethermind.Core/Utils/SmallRefCountingDisposable.cs
new file mode 100644
index 000000000000..2096ee03760a
--- /dev/null
+++ b/src/Nethermind/Nethermind.Core/Utils/SmallRefCountingDisposable.cs
@@ -0,0 +1,127 @@
+// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using System;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+
+namespace Nethermind.Core.Utils;
+
+///
+/// Variant of that stores its lease counter inline as a single
+/// instead of a cache-line-padded one, trading false-sharing protection for a much
+/// smaller per-instance footprint. Prefer it for types that exist in large numbers and whose lease
+/// counts are rarely contended across cores.
+///
+public abstract class SmallRefCountingDisposable(int initialCount = 1) : IDisposable
+{
+ private const int Single = 1;
+ private const int NoAccessors = 0;
+ private const int Disposing = -1;
+
+ private long _leases = initialCount;
+
+ public void AcquireLease()
+ {
+ if (!TryAcquireLease())
+ {
+ ThrowCouldNotAcquire();
+ }
+
+ [DoesNotReturn]
+ [StackTraceHidden]
+ static void ThrowCouldNotAcquire() => throw new InvalidOperationException("The lease cannot be acquired");
+ }
+
+ protected bool TryAcquireLease()
+ {
+ // Volatile read for starting value
+ long current = Volatile.Read(ref _leases);
+
+ while (true)
+ {
+ // Reject once the count has reached zero (NoAccessors) or gone to Disposing: the object is
+ // being torn down. Acquiring at NoAccessors would resurrect an object whose owner has
+ // already observed the zero count and begun teardown — the release path moves the count
+ // 1 → 0 and only then CASes 0 → Disposing, so a concurrent acquirer can briefly see 0.
+ // Checking inside the loop (not just on the initial read) also closes the window where a
+ // failed CAS hands back a now-zero count.
+ if (current <= NoAccessors)
+ {
+ return false;
+ }
+
+ long prev = Interlocked.CompareExchange(ref _leases, current + Single, current);
+ if (prev == current)
+ {
+ // Successfully acquired
+ return true;
+ }
+
+ // Try again with the observed value
+ current = prev;
+ // Add PAUSE instruction to reduce shared core contention
+ Thread.SpinWait(1);
+ }
+ }
+
+ ///
+ /// Disposes it once, decreasing the lease count by 1.
+ ///
+ public void Dispose() => ReleaseLeaseOnce();
+
+ private void ReleaseLeaseOnce()
+ {
+ // Volatile read for starting value
+ long current = Volatile.Read(ref _leases);
+ if (current <= NoAccessors)
+ {
+ // Mismatched Acquire/Release
+ ThrowOverDisposed();
+ }
+
+ while (true)
+ {
+ long prev = Interlocked.CompareExchange(ref _leases, current - Single, current);
+ if (prev != current)
+ {
+ current = prev;
+ // Add PAUSE instruction to reduce shared core contention
+ Thread.SpinWait(1);
+ continue;
+ }
+ if (prev == Single)
+ {
+ // Last use, try to dispose underlying
+ break;
+ }
+ if (prev <= NoAccessors)
+ {
+ // Mismatched Acquire/Release
+ ThrowOverDisposed();
+ }
+
+ // Successfully released
+ return;
+ }
+
+ if (Interlocked.CompareExchange(ref _leases, Disposing, NoAccessors) == NoAccessors)
+ {
+ // set to disposed by this Release
+ CleanUp();
+ }
+
+ [DoesNotReturn]
+ [StackTraceHidden]
+ static void ThrowOverDisposed() => throw new ObjectDisposedException("The lease has already been disposed");
+ }
+
+ protected abstract void CleanUp();
+
+ public override string ToString()
+ {
+ long leases = Volatile.Read(ref _leases);
+ return leases == Disposing ? "Disposed" : $"Leases: {leases}";
+ }
+}
diff --git a/src/Nethermind/Nethermind.Db/DbNames.cs b/src/Nethermind/Nethermind.Db/DbNames.cs
index ef8355873016..c576515e0a57 100644
--- a/src/Nethermind/Nethermind.Db/DbNames.cs
+++ b/src/Nethermind/Nethermind.Db/DbNames.cs
@@ -23,5 +23,6 @@ public static class DbNames
public const string PeersDb = "peers";
public const string LogIndex = "logIndex";
public const string Preimage = "preimage";
+ public const string PersistedSnapshotCatalog = "persistedSnapshotCatalog";
}
}
diff --git a/src/Nethermind/Nethermind.Db/FlatDbConfig.cs b/src/Nethermind/Nethermind.Db/FlatDbConfig.cs
index 3442d43504b2..49e440ce1111 100644
--- a/src/Nethermind/Nethermind.Db/FlatDbConfig.cs
+++ b/src/Nethermind/Nethermind.Db/FlatDbConfig.cs
@@ -24,4 +24,13 @@ public class FlatDbConfig : IFlatDbConfig
public long BlockCacheSizeBudget { get; set; } = 1.GiB;
public long CompactionOffset { get; set; } = -1;
public long TrieCacheMemoryBudget { get; set; } = 512.MiB;
+ public bool EnableLongFinality { get; set; } = false;
+ public int LongFinalityMaxReorgDepth { get; set; } = 90000;
+ public int MaxInMemoryBaseSnapshotCount { get; set; } = 128;
+ public long ArenaFileSizeBytes { get; set; } = 1.GiB;
+ public long PersistedSnapshotDedicatedArenaThresholdBytes { get; set; } = 1.GiB;
+ public bool PersistedSnapshotPunchHoleOnReclaim { get; set; } = true;
+ public int PersistedSnapshotMaxCompactSize { get; set; } = 1024 * 1024;
+ public bool ValidatePersistedSnapshot { get; set; } = false;
+ public double PersistedSnapshotBloomBitsPerKey { get; set; } = 14.0;
}
diff --git a/src/Nethermind/Nethermind.Db/IFlatDbConfig.cs b/src/Nethermind/Nethermind.Db/IFlatDbConfig.cs
index 021022328484..895f03dca283 100644
--- a/src/Nethermind/Nethermind.Db/IFlatDbConfig.cs
+++ b/src/Nethermind/Nethermind.Db/IFlatDbConfig.cs
@@ -34,7 +34,7 @@ public interface IFlatDbConfig : IConfig
[ConfigItem(Description = "Max in flight compact job", DefaultValue = "32")]
int MaxInFlightCompactJob { get; set; }
- [ConfigItem(Description = "Max reorg depth", DefaultValue = "256")]
+ [ConfigItem(Description = "Max reorg depth — the force-persist backstop used when EnableLongFinality is off: once the in-memory depth exceeds it while finality is stalled, persistence is forced to bound memory.", DefaultValue = "256")]
int MaxReorgDepth { get; set; }
[ConfigItem(Description = "Minimum reorg depth", DefaultValue = "128")]
@@ -55,6 +55,33 @@ public interface IFlatDbConfig : IConfig
[ConfigItem(Description = "Verify with trie", DefaultValue = "false")]
bool VerifyWithTrie { get; set; }
+ [ConfigItem(Description = "Enable long finality support with persisted snapshots", DefaultValue = "false")]
+ bool EnableLongFinality { get; set; }
+
+ [ConfigItem(Description = "Force-persist backstop used when EnableLongFinality is on, in place of MaxReorgDepth. The persisted-snapshot tier serves deep reorgs, so this is much larger than the non-long-finality backstop.", DefaultValue = "90000")]
+ int LongFinalityMaxReorgDepth { get; set; }
+
+ [ConfigItem(Description = "Maximum number of in-memory base snapshots before conversion to the persisted-snapshot tier kicks in. Counted as `SnapshotCount` of the in-memory repository, not a block-distance depth.", DefaultValue = "128")]
+ int MaxInMemoryBaseSnapshotCount { get; set; }
+
+ [ConfigItem(Description = "Maximum size in bytes for a single arena file before a new one is started.", DefaultValue = "1073741824")]
+ long ArenaFileSizeBytes { get; set; }
+
+ [ConfigItem(Description = "Estimated-size threshold (bytes) at or above which a persisted-snapshot arena write goes to its own dedicated file instead of being packed into a shared arena.", DefaultValue = "1073741824")]
+ long PersistedSnapshotDedicatedArenaThresholdBytes { get; set; }
+
+ [ConfigItem(Description = "When reclaiming dead persisted-snapshot arena ranges — metadata reservation cleanup and blob-file frontier reset — call fallocate(FALLOC_FL_PUNCH_HOLE) to free the underlying disk blocks. Linux-only; automatically and permanently disabled per arena pool if the filesystem reports the operation unsupported. Set false to skip hole-punching entirely (the page-cache posix_fadvise still runs).", DefaultValue = "true")]
+ bool PersistedSnapshotPunchHoleOnReclaim { get; set; }
+
+ [ConfigItem(Description = "Max persisted snapshot compaction size (hierarchical compaction ceiling for persisted layer), in blocks", DefaultValue = "1048576")]
+ int PersistedSnapshotMaxCompactSize { get; set; }
+
+ [ConfigItem(Description = "Validate persisted snapshots against in-memory snapshots after conversion (debug/diagnostic only)", DefaultValue = "false")]
+ bool ValidatePersistedSnapshot { get; set; }
+
+ [ConfigItem(Description = "Bits per key for the per-snapshot in-memory bloom filter. One unified filter covers address/slot/self-destruct keys plus state-trie and storage-trie node paths. Higher = lower false-positive rate but more RAM. 0 disables the filter (lookups behave as full sweeps).", DefaultValue = "14.0")]
+ double PersistedSnapshotBloomBitsPerKey { get; set; }
+
[ConfigItem(Description = "Persistent dedicated reader threads used to resolve hinted BAL read sets into the pre-block cache. -1 for 4x logical processor count capped at 64. Values below 1 are clamped to 1. Use --Blocks.ParallelExecutionBatchRead=false to disable BAL warming entirely.", DefaultValue = "-1")]
int WarmReadConcurrency { get; set; }
}
diff --git a/src/Nethermind/Nethermind.Init/Modules/FlatWorldStateModule.cs b/src/Nethermind/Nethermind.Init/Modules/FlatWorldStateModule.cs
index d00faa04bd6a..20e7baa805c5 100644
--- a/src/Nethermind/Nethermind.Init/Modules/FlatWorldStateModule.cs
+++ b/src/Nethermind/Nethermind.Init/Modules/FlatWorldStateModule.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: LGPL-3.0-only
using System;
+using System.IO;
using Autofac;
using Nethermind.Api.Steps;
using Nethermind.Blockchain;
@@ -17,9 +18,12 @@
using Nethermind.JsonRpc.Modules.Admin;
using Nethermind.Logging;
using Nethermind.Monitoring.Config;
+using Nethermind.Api;
using Nethermind.State.Flat;
using Nethermind.State.Flat.Persistence;
+using Nethermind.State.Flat.PersistedSnapshots;
using Nethermind.State.Flat.ScopeProvider;
+using Nethermind.State.Flat.PersistedSnapshots.Storage;
using Nethermind.State.Flat.Sync;
using Nethermind.State.Flat.Sync.Snap;
@@ -46,6 +50,7 @@ protected override void Load(ContainerBuilder builder)
ctx.Resolve(),
ctx.Resolve(),
ctx.Resolve(),
+ ctx.Resolve(),
ctx.Resolve(),
ctx.Resolve(),
ctx.Resolve(),
@@ -55,7 +60,26 @@ protected override void Load(ContainerBuilder builder)
.AddSingleton()
.AddSingleton()
.AddSingleton()
+ // Shared ArenaManager + BlobArenaManager singletons: the persisted-snapshot repo and
+ // the compactor MUST resolve the same instances, otherwise compaction would write
+ // through a different mmap than the repository reads from.
+ .AddSingleton((cfg, initConfig, logManager) =>
+ {
+ string basePath = Path.Combine(initConfig.BaseDbPath, "persisted_snapshot");
+ return new ArenaManager(Path.Combine(basePath, "arena"), cfg, logManager);
+ })
+ .AddSingleton(ctx => ctx.Resolve())
+ .AddSingleton((cfg, initConfig) =>
+ {
+ string basePath = Path.Combine(initConfig.BaseDbPath, "persisted_snapshot");
+ return new BlobArenaManager(
+ Path.Combine(basePath, "blob"),
+ cfg.ArenaFileSizeBytes);
+ })
+ .AddSingleton()
.AddSingleton()
+ // Registered after ISnapshotRepository so DI disposes it first.
+ .AddSingleton()
.AddSingleton(flatDbConfig.TrieWarmerWorkerCount == 0
? _ => new NoopTrieWarmer()
: ctx => ctx.Resolve())
@@ -72,6 +96,17 @@ protected override void Load(ContainerBuilder builder)
// Persistences
.AddColumnDatabase(DbNames.Flat)
+ // Persisted snapshot catalog: dedicated RocksDB co-located with the arena/blob files it
+ // indexes under /persisted_snapshot/catalog/. Wiping persisted_snapshot/
+ // therefore wipes the catalog alongside the data.
+ .AddKeyedSingleton(DbNames.PersistedSnapshotCatalog, ctx => ctx
+ .Resolve()
+ .CreateDb(new DbSettings(
+ nameof(DbNames.PersistedSnapshotCatalog),
+ Path.Combine("persisted_snapshot", "catalog"))))
+ .AddSingleton(ctx =>
+ new SnapshotCatalog(ctx.ResolveKeyed(DbNames.PersistedSnapshotCatalog)))
+ .AddSingleton(ctx => ctx.Resolve())
.AddSingleton()
.AddSingleton()
.AddDecorator()
@@ -99,6 +134,20 @@ protected override void Load(ContainerBuilder builder)
})
;
+ // EnableLongFinality off: inert the whole persisted tier. The Null loader skips loading any
+ // on-disk tier at startup and never converts in-memory snapshots into it; the Null catalog keeps
+ // it empty (nothing recorded or loaded); the Null compactor runs no background compaction. The
+ // conversion paths in PersistenceManager.DetermineSnapshotAction are also gated on this flag.
+ // SnapshotRepository still constructs its arena/blob/catalog stores under
+ // `/persisted_snapshot/`, but they stay empty and unread.
+ if (!flatDbConfig.EnableLongFinality)
+ {
+ builder
+ .AddSingleton(NullSnapshotCatalog.Instance)
+ .AddSingleton(NullPersistedSnapshotLoader.Instance)
+ .AddSingleton(NullPersistedSnapshotCompactor.Instance);
+ }
+
if (flatDbConfig.ImportFromPruningTrieState)
{
builder
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/ArenaMetricsTests.cs b/src/Nethermind/Nethermind.State.Flat.Test/ArenaMetricsTests.cs
new file mode 100644
index 000000000000..bbd6a86fac41
--- /dev/null
+++ b/src/Nethermind/Nethermind.State.Flat.Test/ArenaMetricsTests.cs
@@ -0,0 +1,123 @@
+// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using System;
+using System.IO;
+using Nethermind.Db;
+using Nethermind.Logging;
+using Nethermind.State.Flat.PersistedSnapshots.Storage;
+using NUnit.Framework;
+
+namespace Nethermind.State.Flat.Test;
+
+///
+/// Arena / blob allocated-bytes gauges. Verifies that the metric reflects
+/// Frontier (bytes actually written), not the pre-extended sparse mmap size, and
+/// that arena vs blob files surface in distinct gauges.
+///
+[TestFixture]
+public class ArenaMetricsTests
+{
+ private string _testDir = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _testDir = Path.Combine(Path.GetTempPath(), $"nm_arena_metrics_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_testDir);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ try { Directory.Delete(_testDir, recursive: true); } catch { /* best-effort */ }
+ }
+
+ [Test]
+ public void ArenaWriter_Complete_AdvancesAllocatedBytes_ByFrontierDelta_NotMappedSize()
+ {
+ // Use a delta from the baseline so parallel-running tests don't interfere.
+ const long maxArenaSize = 64 * 1024; // 64 KiB sparse arena file
+ const int payloadBytes = 4096;
+
+ long arenaBytesBefore = Metrics.ArenaAllocatedBytes;
+ long arenaCountBefore = Metrics.ArenaFileCount;
+ long blobBytesBefore = Metrics.BlobAllocatedBytes;
+ long blobCountBefore = Metrics.BlobFileCount;
+ long resvBytesBefore = Metrics.ArenaReservationBytes;
+
+ string arenaDir = Path.Combine(_testDir, "arena");
+ using ArenaManager arena = new(arenaDir, new FlatDbConfig
+ {
+ ArenaFileSizeBytes = maxArenaSize,
+ }, LimboLogs.Instance);
+
+ // Before any write the file isn't materialised yet (CreateArenaFile fires on first writer).
+ Assert.That(Metrics.ArenaAllocatedBytes, Is.EqualTo(arenaBytesBefore));
+ Assert.That(Metrics.ArenaFileCount, Is.EqualTo(arenaCountBefore));
+
+ ArenaReservation reservation;
+ using (ArenaWriter writer = arena.CreateWriter(payloadBytes))
+ {
+ // File materialised — count +1, allocated bytes still 0 (frontier == 0 at open).
+ Assert.That(Metrics.ArenaFileCount, Is.EqualTo(arenaCountBefore + 1));
+ Assert.That(Metrics.ArenaAllocatedBytes, Is.EqualTo(arenaBytesBefore));
+
+ ref ArenaBufferWriter buf = ref writer.GetWriter();
+ buf.GetSpan(payloadBytes).Clear();
+ buf.Advance(payloadBytes);
+ (_, reservation) = writer.Complete();
+ }
+
+ // After Complete the frontier delta lands in ArenaAllocatedBytes — exactly the
+ // payload size, NOT the 64 KiB sparse MaxSize.
+ Assert.That((Metrics.ArenaAllocatedBytes - arenaBytesBefore), Is.EqualTo(payloadBytes));
+
+ Assert.That((Metrics.ArenaReservationBytes - resvBytesBefore), Is.EqualTo(payloadBytes));
+
+ // Arena and blob gauges are independent — no blob activity here.
+ Assert.That(Metrics.BlobAllocatedBytes, Is.EqualTo(blobBytesBefore));
+ Assert.That(Metrics.BlobFileCount, Is.EqualTo(blobCountBefore));
+
+ // Dropping the reservation marks all its bytes dead → MarkDead drops the file →
+ // OnArenaRemoved returns the count and allocated-bytes contributions to baseline.
+ reservation.Dispose();
+ Assert.That(Metrics.ArenaReservationBytes, Is.EqualTo(resvBytesBefore));
+ Assert.That(Metrics.ArenaFileCount, Is.EqualTo(arenaCountBefore));
+ Assert.That(Metrics.ArenaAllocatedBytes, Is.EqualTo(arenaBytesBefore));
+ }
+
+ [Test]
+ public void BlobArenaWriter_Complete_AdvancesBlobAllocatedBytes_AndKeepsArenaGaugeAtZero()
+ {
+ const long maxFileSize = 64 * 1024;
+ const int blobBytes = 1024;
+
+ long arenaBytesBefore = Metrics.ArenaAllocatedBytes;
+ long arenaCountBefore = Metrics.ArenaFileCount;
+ long blobBytesBefore = Metrics.BlobAllocatedBytes;
+ long blobCountBefore = Metrics.BlobFileCount;
+
+ string blobDir = Path.Combine(_testDir, "blob");
+ using BlobArenaManager blobs = new(blobDir, maxFileSize);
+
+ using (BlobArenaWriter writer = blobs.CreateWriter(blobBytes))
+ {
+ // File materialised on first writer — count +1, allocated still 0.
+ Assert.That(Metrics.BlobFileCount, Is.EqualTo(blobCountBefore + 1));
+ Assert.That(Metrics.BlobAllocatedBytes, Is.EqualTo(blobBytesBefore));
+
+ byte[] rlp = new byte[blobBytes];
+ writer.WriteRlp(rlp);
+ writer.Complete();
+ }
+
+ // After Complete: blob allocated bytes advance by exactly the written size (not the
+ // 64 KiB MaxSize of the sparse file).
+ Assert.That((Metrics.BlobAllocatedBytes - blobBytesBefore), Is.EqualTo(blobBytes));
+
+ // Arena gauges stay flat — blob writes never touch them.
+ Assert.That(Metrics.ArenaAllocatedBytes, Is.EqualTo(arenaBytesBefore));
+ Assert.That(Metrics.ArenaFileCount, Is.EqualTo(arenaCountBefore));
+ }
+}
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/ArenaReclaimPunchHoleTests.cs b/src/Nethermind/Nethermind.State.Flat.Test/ArenaReclaimPunchHoleTests.cs
new file mode 100644
index 000000000000..bb77c28dc9a5
--- /dev/null
+++ b/src/Nethermind/Nethermind.State.Flat.Test/ArenaReclaimPunchHoleTests.cs
@@ -0,0 +1,149 @@
+// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using Nethermind.Db;
+using Nethermind.Logging;
+using Nethermind.State.Flat.PersistedSnapshots.Storage;
+using NUnit.Framework;
+
+namespace Nethermind.State.Flat.Test;
+
+///
+/// Verifies that dead persisted-snapshot arena ranges have their disk blocks reclaimed via
+/// fallocate(FALLOC_FL_PUNCH_HOLE) — on metadata-reservation cleanup and on blob-file
+/// frontier reset — and that the PersistedSnapshotPunchHoleOnReclaim flag gates it.
+/// Linux-only; gracefully ignored when the temp filesystem does not support hole-punching.
+///
+[TestFixture]
+public class ArenaReclaimPunchHoleTests
+{
+ private string _testDir = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _testDir = Path.Combine(Path.GetTempPath(), $"nm_punchhole_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_testDir);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ try { Directory.Delete(_testDir, recursive: true); } catch { /* best-effort */ }
+ }
+
+ [TestCase(true)]
+ [TestCase(false)]
+ public void ReservationCleanup_PunchesHole_ForDeadRange_WhenEnabled(bool punchHoleOnReclaim)
+ {
+ if (!OperatingSystem.IsLinux()) Assert.Ignore("fallocate punch-hole is Linux-only");
+ int pageSize = Environment.SystemPageSize;
+ string arenaDir = Path.Combine(_testDir, "arena");
+
+ using ArenaManager manager = new(arenaDir, new FlatDbConfig
+ {
+ ArenaFileSizeBytes = 8L * 1024 * 1024,
+ PersistedSnapshotPunchHoleOnReclaim = punchHoleOnReclaim,
+ }, LimboLogs.Instance);
+
+ // Two reservations in one shared arena file: disposing the first leaves the file
+ // alive (the second keeps DeadBytes < Frontier), so cleanup actually punches.
+ (SnapshotLocation locA, ArenaReservation reservationA) = WriteReservation(manager, 64 * pageSize);
+ (SnapshotLocation locB, ArenaReservation reservationB) = WriteReservation(manager, pageSize);
+ Assert.That(locA.ArenaId, Is.EqualTo(locB.ArenaId), "both writes must pack into the same shared arena file");
+
+ string arenaPath = Directory.GetFiles(arenaDir).Single();
+ Fsync(arenaPath);
+ long blocksBefore = StatBlocks(arenaPath);
+ Assert.That(blocksBefore, Is.GreaterThan(0), "the written reservations should occupy real disk blocks");
+
+ reservationA.Dispose();
+
+ if (punchHoleOnReclaim && !manager.PunchHoleSupported)
+ Assert.Ignore("filesystem does not support fallocate punch-hole");
+
+ long blocksAfter = StatBlocks(arenaPath);
+ if (punchHoleOnReclaim)
+ Assert.That(blocksAfter, Is.LessThan(blocksBefore), "cleanup should punch-hole reservation A's dead range");
+ else
+ Assert.That(blocksAfter, Is.EqualTo(blocksBefore), "punch-hole is disabled");
+
+ reservationB.Dispose();
+ }
+
+ [Test]
+ public void BlobFrontierReset_TruncatesFile_ForOrphanedRange()
+ {
+ const int rlpSize = 4096;
+ const int rlpCount = 64;
+ string blobDir = Path.Combine(_testDir, "blob");
+
+ using BlobArenaManager blobs = new(blobDir, 8L * 1024 * 1024);
+
+ ushort blobId;
+ using (BlobArenaWriter writer = blobs.CreateWriter(rlpSize * rlpCount))
+ {
+ byte[] rlp = new byte[rlpSize];
+ for (int i = 0; i < rlpCount; i++)
+ {
+ Random.Shared.NextBytes(rlp);
+ writer.WriteRlp(rlp);
+ }
+ writer.Complete();
+ blobId = writer.BlobArenaId;
+ }
+
+ string blobPath = Directory.GetFiles(blobDir).Single();
+ long lengthBefore = new FileInfo(blobPath).Length;
+ Assert.That(lengthBefore, Is.GreaterThan(0), "the writer's appends should have grown the file");
+
+ // The writer's lease is gone, so the file is orphaned — frontier reset recycles it
+ // by truncating the file back to length 0 (frees disk blocks + zeros logical length
+ // in one syscall, eliminating the sparse-tail mismatch the old punch-hole path left).
+ BlobArenaFile file = blobs.GetFile(blobId);
+ blobs.TryResetOrphanedFrontier(file);
+
+ Assert.That(file.Frontier, Is.EqualTo(0), "in-memory frontier reset");
+ Assert.That(new FileInfo(blobPath).Length, Is.EqualTo(0), "on-disk file truncated by frontier reset");
+ }
+
+ private static (SnapshotLocation, ArenaReservation) WriteReservation(ArenaManager manager, int size)
+ {
+ using ArenaWriter writer = manager.CreateWriter(size);
+ ref ArenaBufferWriter buf = ref writer.GetWriter();
+ int remaining = size;
+ while (remaining > 0)
+ {
+ int chunk = Math.Min(remaining, 64 * 1024);
+ Random.Shared.NextBytes(buf.GetSpan(chunk)[..chunk]);
+ buf.Advance(chunk);
+ remaining -= chunk;
+ }
+ return writer.Complete();
+ }
+
+ // Force the OS page cache to disk so st_blocks reflects the written data before the
+ // punch — ext4 delayed allocation otherwise leaves freshly-written blocks uncounted.
+ private static void Fsync(string path)
+ {
+ using FileStream fs = new(path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);
+ fs.Flush(flushToDisk: true);
+ }
+
+ // .NET exposes no st_blocks accessor; shell out to coreutils stat (512-byte block count).
+ private static long StatBlocks(string path)
+ {
+ ProcessStartInfo psi = new() { FileName = "stat", RedirectStandardOutput = true, UseShellExecute = false };
+ psi.ArgumentList.Add("-c");
+ psi.ArgumentList.Add("%b");
+ psi.ArgumentList.Add(path);
+ using Process proc = Process.Start(psi)!;
+ string output = proc.StandardOutput.ReadToEnd().Trim();
+ proc.WaitForExit();
+ return long.Parse(output);
+ }
+}
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/CompactionScheduleTests.cs b/src/Nethermind/Nethermind.State.Flat.Test/CompactionScheduleTests.cs
index 5865dde0b6cc..1e5d04c26cba 100644
--- a/src/Nethermind/Nethermind.State.Flat.Test/CompactionScheduleTests.cs
+++ b/src/Nethermind/Nethermind.State.Flat.Test/CompactionScheduleTests.cs
@@ -201,4 +201,55 @@ public void NextFullCompactionAfter_CompactSizeDisabled_ReturnsLongMaxValue()
public void Constructor_NonPowerOf2CompactSize_Throws() =>
Assert.Throws(() =>
new CompactionSchedule(new MemDb(), new FlatDbConfig { CompactSize = 10 }, LimboLogs.Instance));
+
+ [TestCase(0, 0, 8192, false)] // block 0 → size 1
+ [TestCase(0, 16, 8192, false)] // exactly CompactSize — not "large"
+ [TestCase(0, 8, 8192, false)] // intermediate (< CompactSize)
+ [TestCase(0, 32, 8192, true)] // 2× CompactSize
+ [TestCase(0, 64, 8192, true)] // 4×
+ [TestCase(3, 13, 8192, false)] // (13+3) = 16, exactly CompactSize
+ [TestCase(3, 16, 8192, false)] // (16+3) = 19, alignment 1
+ [TestCase(3, 29, 8192, true)] // (29+3) = 32, > CompactSize
+ [TestCase(0, 32, 16, false)] // max == CompactSize: alignment 32 capped to 16 → not large
+ public void IsLargeCompactionBoundary_TrueWhenWindowExceedsCompactSize(int offset, long blockNumber, int maxCompactSize, bool expected)
+ {
+ FlatDbConfig config = new() { CompactSize = 16, PersistedSnapshotMaxCompactSize = maxCompactSize };
+ CompactionSchedule schedule = ScheduleHelper.CreateWithOffset(config, offset);
+
+ Assert.That(schedule.IsLargeCompactionBoundary(blockNumber), Is.EqualTo(expected));
+ }
+
+ [TestCase(0, 0, 8192, 1L)] // block 0 → 1
+ [TestCase(0, 16, 8192, 16L)] // natural CompactSize boundary
+ [TestCase(0, 32, 8192, 32L)] // tier above CompactSize, below cap
+ [TestCase(0, 48, 8192, 16L)] // 48 & -48 = 16
+ [TestCase(0, 64, 8192, 64L)] // 4×, below cap
+ [TestCase(3, 13, 8192, 16L)] // shifted: (13+3) & -(13+3) = 16
+ [TestCase(3, 29, 8192, 32L)] // shifted: 32 (above CompactSize=16)
+ [TestCase(0, 64, 32, 32L)] // raw alignment 64 capped at PersistedSnapshotMaxCompactSize=32
+ [TestCase(0, 128, 32, 32L)] // raw alignment 128 capped at 32
+ public void GetPersistedSnapshotCompactSize_CappedAndOffsetAware(int offset, long blockNumber, int maxCompactSize, long expected)
+ {
+ FlatDbConfig config = new() { CompactSize = 16, PersistedSnapshotMaxCompactSize = maxCompactSize };
+ CompactionSchedule schedule = ScheduleHelper.CreateWithOffset(config, offset);
+
+ Assert.That(schedule.GetPersistedSnapshotCompactSize(blockNumber), Is.EqualTo(expected));
+ }
+
+ [TestCase(0, 0, 8192, false)] // block 0 → size 1
+ [TestCase(0, 16, 8192, true)] // exactly CompactSize
+ [TestCase(0, 48, 8192, true)] // 48 & -48 = 16
+ [TestCase(0, 8, 8192, false)] // intermediate (< CompactSize)
+ [TestCase(0, 32, 8192, false)] // large (> CompactSize)
+ [TestCase(0, 64, 8192, false)] // large
+ [TestCase(3, 13, 8192, true)] // shifted: (13+3) = 16
+ [TestCase(3, 29, 8192, false)] // shifted large: 32
+ [TestCase(0, 32, 16, true)] // max == CompactSize: alignment 32 capped to 16, exactly equals CompactSize
+ public void IsCompactSizeBoundary_TrueOnlyWhenWindowEqualsCompactSize(int offset, long blockNumber, int maxCompactSize, bool expected)
+ {
+ FlatDbConfig config = new() { CompactSize = 16, PersistedSnapshotMaxCompactSize = maxCompactSize };
+ CompactionSchedule schedule = ScheduleHelper.CreateWithOffset(config, offset);
+
+ Assert.That(schedule.IsCompactSizeBoundary(blockNumber), Is.EqualTo(expected));
+ }
}
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/FlatDbManagerPersistedTests.cs b/src/Nethermind/Nethermind.State.Flat.Test/FlatDbManagerPersistedTests.cs
new file mode 100644
index 000000000000..23bf86b62afe
--- /dev/null
+++ b/src/Nethermind/Nethermind.State.Flat.Test/FlatDbManagerPersistedTests.cs
@@ -0,0 +1,147 @@
+// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Nethermind.Config;
+using Nethermind.Core;
+using Nethermind.Core.Crypto;
+using Nethermind.Core.Test.Builders;
+using Nethermind.Db;
+using Nethermind.Logging;
+using Nethermind.State.Flat.Persistence;
+using Nethermind.State.Flat.PersistedSnapshots;
+using Nethermind.Trie;
+using NSubstitute;
+using NUnit.Framework;
+
+namespace Nethermind.State.Flat.Test;
+
+[TestFixture]
+public class FlatDbManagerPersistedTests
+{
+ private string _testDir = null!;
+ private ResourcePool _pool = null!;
+ private IProcessExitSource _processExitSource = null!;
+ private CancellationTokenSource _cts = null!;
+ private IFlatDbConfig _config = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _testDir = Path.Combine(Path.GetTempPath(), $"nethermind_test_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_testDir);
+ _pool = new ResourcePool(new FlatDbConfig());
+ _cts = new CancellationTokenSource();
+ _processExitSource = Substitute.For();
+ _processExitSource.Token.Returns(_cts.Token);
+ _config = new FlatDbConfig { CompactSize = 16, MaxInFlightCompactJob = 4, InlineCompaction = true };
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ _cts.Cancel();
+ _cts.Dispose();
+ if (Directory.Exists(_testDir))
+ Directory.Delete(_testDir, recursive: true);
+ }
+
+ [Test]
+ public async Task ConstructorAcceptsPersistedRepository()
+ {
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 4096);
+ SnapshotRepository repo = tier.Repository;
+
+ await using FlatDbManager manager = new(
+ Substitute.For(),
+ _processExitSource,
+ Substitute.For(),
+ Substitute.For(),
+ repo,
+ Substitute.For(),
+ Substitute.For(),
+ _config,
+ new BlocksConfig(),
+ LimboLogs.Instance,
+ enableDetailedMetrics: false);
+
+ Assert.That(manager, Is.Not.Null);
+ }
+
+ [Test]
+ public async Task GatherReadOnlySnapshotBundle_IncludesPersistedSnapshots()
+ {
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+
+ TreePath path = new(Keccak.Compute("path"), 4);
+ byte[] nodeRlp = [0xC2, 0x80, 0x80];
+ SnapshotContent content = new();
+ content.StateNodes[path] = new TrieNode(NodeType.Leaf, nodeRlp);
+ Snapshot snap = new(s0, s1, content, _pool, ResourcePool.Usage.MainBlockProcessing);
+
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 4096);
+ SnapshotRepository repo = tier.Repository;
+ tier.ConvertToPersistedBase(snap).Dispose();
+
+ // Persisted snapshot covers s0→s1; mock reader anchored at s0 so the manager sees it as the persisted base.
+ IPersistenceManager persistenceManager = Substitute.For();
+ IPersistence.IPersistenceReader reader = Substitute.For();
+ reader.CurrentState.Returns(s0);
+ persistenceManager.LeaseReader().Returns(reader);
+ persistenceManager.GetCurrentPersistedStateId().Returns(s0);
+
+ await using FlatDbManager manager = new(
+ Substitute.For(),
+ _processExitSource,
+ Substitute.For(),
+ Substitute.For(),
+ repo,
+ persistenceManager,
+ Substitute.For(),
+ _config,
+ new BlocksConfig(),
+ LimboLogs.Instance,
+ enableDetailedMetrics: false);
+
+ ReadOnlySnapshotBundle bundle = manager.GatherReadOnlySnapshotBundle(s1);
+
+ byte[]? result = bundle.TryLoadStateRlp(path, Keccak.Compute("hash"), ReadFlags.None);
+ Assert.That(result, Is.EqualTo(nodeRlp));
+
+ bundle.Dispose();
+ }
+
+ [Test]
+ public async Task DisposeAsync_DisposesPersistedRepository()
+ {
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 4096);
+ SnapshotRepository repo = tier.Repository;
+
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+ SnapshotContent content = new();
+ content.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(1).TestObject;
+ tier.ConvertToPersistedBase(new Snapshot(s0, s1, content, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+
+ FlatDbManager manager = new(
+ Substitute.For(),
+ _processExitSource,
+ Substitute.For(),
+ Substitute.For(),
+ repo,
+ Substitute.For(),
+ Substitute.For(),
+ _config,
+ new BlocksConfig(),
+ LimboLogs.Instance,
+ enableDetailedMetrics: false);
+
+ await manager.DisposeAsync();
+
+ Assert.Pass("Dispose completed without error");
+ }
+}
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/FlatDbManagerTests.cs b/src/Nethermind/Nethermind.State.Flat.Test/FlatDbManagerTests.cs
index f780fd1c5803..3ba745b3e3a5 100644
--- a/src/Nethermind/Nethermind.State.Flat.Test/FlatDbManagerTests.cs
+++ b/src/Nethermind/Nethermind.State.Flat.Test/FlatDbManagerTests.cs
@@ -9,6 +9,7 @@
using Nethermind.Db;
using Nethermind.Logging;
using Nethermind.State.Flat.Persistence;
+using Nethermind.State.Flat.PersistedSnapshots;
using NSubstitute;
using NUnit.Framework;
@@ -23,6 +24,7 @@ public class FlatDbManagerTests
private ISnapshotCompactor _snapshotCompactor = null!;
private ISnapshotRepository _snapshotRepository = null!;
private IPersistenceManager _persistenceManager = null!;
+ private IPersistedSnapshotLoader _persistedSnapshotLoader = null!;
private IFlatDbConfig _config = null!;
private IBlocksConfig _blocksConfig = null!;
private CancellationTokenSource _cts = null!;
@@ -38,6 +40,7 @@ public void SetUp()
_snapshotCompactor = Substitute.For();
_snapshotRepository = Substitute.For();
_persistenceManager = Substitute.For();
+ _persistedSnapshotLoader = Substitute.For();
_config = new FlatDbConfig { CompactSize = 16, MaxInFlightCompactJob = 4, InlineCompaction = true };
_blocksConfig = Substitute.For();
_blocksConfig.SecondsPerSlot.Returns(12UL);
@@ -46,6 +49,7 @@ public void SetUp()
[TearDown]
public void TearDown()
{
+ _persistedSnapshotLoader.Dispose();
_cts.Cancel();
_cts.Dispose();
}
@@ -57,6 +61,7 @@ public void TearDown()
_snapshotCompactor,
_snapshotRepository,
_persistenceManager,
+ _persistedSnapshotLoader,
_config,
_blocksConfig,
LimboLogs.Instance,
@@ -123,7 +128,7 @@ public async Task AddSnapshot_BlockBelowPersistedState_ReturnsEarlyAndLogsWarnin
await using FlatDbManager manager = CreateManager();
manager.AddSnapshot(snapshot, transientResource);
- _snapshotRepository.DidNotReceive().TryAddSnapshot(Arg.Any());
+ _snapshotRepository.DidNotReceive().TryAdd(Arg.Any(), SnapshotTier.InMemoryBase);
_snapshotRepository.DidNotReceive().SetLastCommittedStateId(Arg.Any());
}
@@ -132,7 +137,7 @@ public async Task AddSnapshot_ValidSnapshot_AddsToRepository()
{
StateId persistedStateId = CreateStateId(5);
_persistenceManager.GetCurrentPersistedStateId().Returns(persistedStateId);
- _snapshotRepository.TryAddSnapshot(Arg.Any()).Returns(true);
+ _snapshotRepository.TryAdd(Arg.Any(), SnapshotTier.InMemoryBase).Returns(true);
ResourcePool realResourcePool = new(_config);
StateId snapshotFrom = CreateStateId(10);
@@ -143,7 +148,7 @@ public async Task AddSnapshot_ValidSnapshot_AddsToRepository()
await using FlatDbManager manager = CreateManager();
manager.AddSnapshot(snapshot, transientResource);
- _snapshotRepository.Received(1).TryAddSnapshot(snapshot);
+ _snapshotRepository.Received(1).TryAdd(snapshot, SnapshotTier.InMemoryBase);
_snapshotRepository.Received(1).SetLastCommittedStateId(snapshotTo);
}
@@ -157,7 +162,7 @@ public async Task GatherReadOnlySnapshotBundle_CacheClearedPeriodically()
_persistenceManager.LeaseReader().Returns(mockReader);
_snapshotRepository.AssembleSnapshots(stateId, stateId, Arg.Any())
- .Returns(new SnapshotPooledList(0));
+ .Returns(new AssembledSnapshotResult(new SnapshotPooledList(0), PersistedSnapshotList.Empty()));
await using FlatDbManager manager = CreateManager();
@@ -183,7 +188,7 @@ public async Task AddSnapshot_DuplicateSnapshot_DisposesSnapshotAndReturnsResour
{
StateId persistedStateId = CreateStateId(5);
_persistenceManager.GetCurrentPersistedStateId().Returns(persistedStateId);
- _snapshotRepository.TryAddSnapshot(Arg.Any()).Returns(false);
+ _snapshotRepository.TryAdd(Arg.Any(), SnapshotTier.InMemoryBase).Returns(false);
ResourcePool realResourcePool = new(_config);
StateId snapshotFrom = CreateStateId(10);
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/FlatOverridableWorldScopeTests.cs b/src/Nethermind/Nethermind.State.Flat.Test/FlatOverridableWorldScopeTests.cs
index 88644a104556..433dd9cd99d1 100644
--- a/src/Nethermind/Nethermind.State.Flat.Test/FlatOverridableWorldScopeTests.cs
+++ b/src/Nethermind/Nethermind.State.Flat.Test/FlatOverridableWorldScopeTests.cs
@@ -16,6 +16,7 @@
using Nethermind.Int256;
using Nethermind.Logging;
using Nethermind.State.Flat.Persistence;
+using Nethermind.State.Flat.PersistedSnapshots;
using Nethermind.State.Flat.ScopeProvider;
using NSubstitute;
using NUnit.Framework;
@@ -60,7 +61,7 @@ public TestContext(FlatDbConfig? config = null)
.Returns(_ =>
{
SnapshotPooledList snapshotList = new(0);
- return new ReadOnlySnapshotBundle(snapshotList, Substitute.For(), false);
+ return new ReadOnlySnapshotBundle(snapshotList, Substitute.For(), false, PersistedSnapshotStack.Empty());
});
flatDbManager.HasStateForBlock(Arg.Any())
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/FlatPersistenceTestExtensions.cs b/src/Nethermind/Nethermind.State.Flat.Test/FlatPersistenceTestExtensions.cs
new file mode 100644
index 000000000000..40cc28dca063
--- /dev/null
+++ b/src/Nethermind/Nethermind.State.Flat.Test/FlatPersistenceTestExtensions.cs
@@ -0,0 +1,21 @@
+// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using Nethermind.Core.Crypto;
+using Nethermind.State.Flat.Persistence;
+
+namespace Nethermind.State.Flat.Test;
+
+///
+/// Test-only convenience overloads for that iterate
+/// the full key range. Production callers always pass explicit bounds, so these whole-range
+/// forwarders live with the tests rather than on the production interface.
+///
+internal static class FlatPersistenceTestExtensions
+{
+ public static IPersistence.IFlatIterator CreateAccountIterator(this IPersistence.IPersistenceReader reader)
+ => reader.CreateAccountIterator(ValueKeccak.Zero, ValueKeccak.MaxValue);
+
+ public static IPersistence.IFlatIterator CreateStorageIterator(this IPersistence.IPersistenceReader reader, in ValueHash256 accountKey)
+ => reader.CreateStorageIterator(accountKey, ValueKeccak.Zero, ValueKeccak.MaxValue);
+}
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/FlatTestContainer.cs b/src/Nethermind/Nethermind.State.Flat.Test/FlatTestContainer.cs
new file mode 100644
index 000000000000..68e81e884a08
--- /dev/null
+++ b/src/Nethermind/Nethermind.State.Flat.Test/FlatTestContainer.cs
@@ -0,0 +1,134 @@
+// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using System;
+using System.IO;
+using System.Threading;
+using Autofac;
+using Nethermind.Api;
+using Nethermind.Config;
+using Nethermind.Core;
+using Nethermind.Core.Test.IO;
+using Nethermind.Db;
+using Nethermind.Init.Modules;
+using Nethermind.Logging;
+using Nethermind.State.Flat.PersistedSnapshots;
+using Nethermind.State.Flat.PersistedSnapshots.Storage;
+using NSubstitute;
+
+namespace Nethermind.State.Flat.Test;
+
+///
+/// Builds the persisted-tier flatdb component graph the way production does — by loading
+/// into an Autofac container — then overlays the handful of
+/// test-only overrides every fixture needs: a temp BaseDbPath, in-memory catalog/metadata
+/// s, , a cancellable , and a
+/// blob arena sized independently of the trie-RLP arena. Resolving any persisted-tier component returns
+/// the same singletons the production module wires, so tests run against a prod-representative graph.
+///
+///
+/// The container builds lazily on first resolve; building runs ,
+/// and disposal tears down the loader before the temp dir is removed. Reopen/restart tests build a second
+/// over the same and the same
+/// instance to verify data survives a restart.
+///
+internal sealed class FlatTestContainer : IDisposable
+{
+ private readonly ContainerBuilder _builder;
+ private readonly CancellationTokenSource _cts = new();
+ private readonly TempPath? _ownedTempDir;
+ private IContainer? _container;
+
+ public FlatDbConfig Config { get; }
+
+ /// Data directory the persisted tier lives under; pass it to a second container to reopen.
+ public string BaseDbPath { get; }
+
+ /// The in-memory catalog; pass it to a second container to simulate a restart.
+ public IDb CatalogDb { get; }
+
+ public FlatTestContainer(
+ FlatDbConfig? config = null,
+ long arenaFileSizeBytes = 1024L * 1024 * 1024,
+ long blobFileSizeBytes = 1024L * 1024,
+ string? baseDbPath = null,
+ IDb? catalogDb = null,
+ Action? configure = null)
+ {
+ Config = config ?? new FlatDbConfig();
+ Config.ArenaFileSizeBytes = arenaFileSizeBytes;
+
+ if (baseDbPath is null)
+ {
+ _ownedTempDir = TempPath.GetTempDirectory();
+ BaseDbPath = _ownedTempDir.Path;
+ }
+ else
+ {
+ BaseDbPath = baseDbPath;
+ }
+
+ CatalogDb = catalogDb ?? new MemDb();
+
+ IProcessExitSource processExitSource = Substitute.For();
+ processExitSource.Token.Returns(_cts.Token);
+
+ _builder = new ContainerBuilder()
+ .AddModule(new FlatWorldStateModule(Config))
+ .AddSingleton(Config)
+ .AddSingleton(LimboLogs.Instance)
+ .AddSingleton(new InitConfig { BaseDbPath = BaseDbPath })
+ .AddSingleton(processExitSource)
+ // The production module wires the catalog and metadata to columned RocksDB via IDbFactory,
+ // which the test project does not provide; an in-memory db is behavior-equivalent here.
+ .AddKeyedSingleton(DbNames.PersistedSnapshotCatalog, CatalogDb)
+ .AddKeyedSingleton(DbNames.Metadata, new MemDb())
+ // The module sizes the blob arena off ArenaFileSizeBytes (shared with the trie-RLP arena);
+ // tests size the two independently, so override the blob arena's file size.
+ .AddSingleton(initConfig =>
+ new BlobArenaManager(Path.Combine(initConfig.BaseDbPath, "persisted_snapshot", "blob"), blobFileSizeBytes))
+ // Config defaults to EnableLongFinality=false, which makes the module swap in the Null
+ // catalog/loader. These fixtures exercise the real persisted tier, so force the real catalog
+ // back (last-registration wins); the real loader is reached via concrete resolves below.
+ .AddSingleton(ctx => ctx.Resolve());
+
+ configure?.Invoke(_builder);
+ }
+
+ private IContainer Container => _container ??= BuildAndLoad();
+
+ private IContainer BuildAndLoad()
+ {
+ IContainer container = _builder.Build();
+ container.Resolve().Load();
+ return container;
+ }
+
+ public T Resolve() where T : notnull => Container.Resolve();
+
+ public SnapshotRepository Repository => Resolve();
+ public IPersistedSnapshotLoader Loader => Resolve();
+ public ResourcePool ResourcePool => Resolve();
+ public ArenaManager Arena => Resolve();
+ public BlobArenaManager Blobs => Resolve();
+ public PersistedSnapshotCompactor Compactor => Resolve();
+
+ /// Converts to a persisted base via the production loader and
+ /// returns it pre-leased from the repository so callers hold a disposable handle for assertions.
+ public PersistedSnapshot ConvertToPersistedBase(Snapshot snapshot)
+ {
+ Loader.ConvertAndRegister(snapshot);
+ using PersistedSnapshotList bases = Repository.LeaseBaseSnapshotsInRange(snapshot.From, snapshot.To);
+ PersistedSnapshot persisted = bases[0];
+ _ = persisted.TryAcquire();
+ return persisted;
+ }
+
+ public void Dispose()
+ {
+ _cts.Cancel();
+ _container?.Dispose();
+ _cts.Dispose();
+ _ownedTempDir?.Dispose();
+ }
+}
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/FlatTestHelpers.cs b/src/Nethermind/Nethermind.State.Flat.Test/FlatTestHelpers.cs
index bc1b2f3ae33a..54070df528e5 100644
--- a/src/Nethermind/Nethermind.State.Flat.Test/FlatTestHelpers.cs
+++ b/src/Nethermind/Nethermind.State.Flat.Test/FlatTestHelpers.cs
@@ -7,6 +7,7 @@
using Nethermind.Core.Crypto;
using Nethermind.Int256;
using Nethermind.State.Flat.Persistence;
+using Nethermind.State.Flat.PersistedSnapshots;
using Nethermind.Trie;
using NSubstitute;
@@ -33,7 +34,8 @@ public static SnapshotPooledList SnapshotList(params Snapshot[] snapshots)
/// optionally pre-populating the snapshot content via .
///
public static ReadOnlySnapshotBundle MakeBundle(ResourcePool pool, Action? populate = null) =>
- new(SnapshotList(MakeSnapshot(pool, populate)), Substitute.For(), recordDetailedMetrics: false);
+ new(SnapshotList(MakeSnapshot(pool, populate)), Substitute.For(),
+ recordDetailedMetrics: false, PersistedSnapshotStack.Empty());
}
///
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/FlatWorldStateScopeProviderTests.cs b/src/Nethermind/Nethermind.State.Flat.Test/FlatWorldStateScopeProviderTests.cs
index bac8aa3d3c5b..269ccce10152 100644
--- a/src/Nethermind/Nethermind.State.Flat.Test/FlatWorldStateScopeProviderTests.cs
+++ b/src/Nethermind/Nethermind.State.Flat.Test/FlatWorldStateScopeProviderTests.cs
@@ -5,6 +5,7 @@
using System.Threading;
using System.Threading.Tasks;
using Autofac;
+using Nethermind.Api;
using Nethermind.Config;
using Nethermind.Core;
using Nethermind.Core.Crypto;
@@ -16,6 +17,7 @@
using Nethermind.Int256;
using Nethermind.Logging;
using Nethermind.State.Flat.Persistence;
+using Nethermind.State.Flat.PersistedSnapshots;
using Nethermind.State.Flat.ScopeProvider;
using Nethermind.Trie;
using Nethermind.Trie.Pruning;
@@ -78,12 +80,14 @@ public TestContext(FlatDbConfig? config = null)
.AddSingleton(LimboLogs.Instance)
.AddSingleton(config)
.AddSingleton(_ => new TrieStoreScopeProvider.KeyValueWithBatchingBackedCodeDb(new TestMemDb()))
+ .AddSingleton(_ => Substitute.For())
;
// Externally owned because snapshot bundle take ownership
_containerBuilder.RegisterType()
.WithParameter(TypedParameter.From(false)) // recordDetailedMetrics
.WithParameter(TypedParameter.From(ReadOnlySnapshots))
+ .WithParameter(TypedParameter.From(PersistedSnapshotStack.Empty()))
.ExternallyOwned();
ConfigureSnapshotBundle();
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/Io/PooledByteBufferWriterTests.cs b/src/Nethermind/Nethermind.State.Flat.Test/Io/PooledByteBufferWriterTests.cs
new file mode 100644
index 000000000000..b233b4e81e5f
--- /dev/null
+++ b/src/Nethermind/Nethermind.State.Flat.Test/Io/PooledByteBufferWriterTests.cs
@@ -0,0 +1,47 @@
+// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using Nethermind.State.Flat.Io;
+using NUnit.Framework;
+
+namespace Nethermind.State.Flat.Test.Io;
+
+[TestFixture]
+public class PooledByteBufferWriterTests
+{
+ [TestCase(1)]
+ [TestCase(5000)]
+ public void ZeroCapacity_GrowsToFitFirstWrite(int size)
+ {
+ using PooledByteBufferWriter pooled = new(initialCapacity: 0);
+ ref PooledByteBufferWriter.Writer w = ref pooled.GetWriter();
+
+ System.Span span = w.GetSpan(size);
+ for (int i = 0; i < size; i++) span[i] = (byte)(i & 0xff);
+ w.Advance(size);
+
+ System.ReadOnlySpan written = pooled.WrittenSpan;
+ Assert.That(written.Length, Is.EqualTo(size));
+ for (int i = 0; i < size; i++) Assert.That(written[i], Is.EqualTo((byte)(i & 0xff)));
+ }
+
+ // Exercises the Buffer.MemoryCopy branch inside Grow (_written > 0).
+ [Test]
+ public void Grow_PreservesExistingContentAcrossMultipleGrows()
+ {
+ using PooledByteBufferWriter pooled = new(initialCapacity: 4);
+ ref PooledByteBufferWriter.Writer w = ref pooled.GetWriter();
+
+ for (int chunk = 0; chunk < 6; chunk++)
+ {
+ const int len = 100;
+ System.Span span = w.GetSpan(len);
+ for (int i = 0; i < len; i++) span[i] = (byte)((chunk * 100 + i) & 0xff);
+ w.Advance(len);
+ }
+
+ System.ReadOnlySpan written = pooled.WrittenSpan;
+ Assert.That(written.Length, Is.EqualTo(600));
+ for (int j = 0; j < 600; j++) Assert.That(written[j], Is.EqualTo((byte)(j & 0xff)));
+ }
+}
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/LongFinalityIntegrationTests.cs b/src/Nethermind/Nethermind.State.Flat.Test/LongFinalityIntegrationTests.cs
new file mode 100644
index 000000000000..e04d9a1b1016
--- /dev/null
+++ b/src/Nethermind/Nethermind.State.Flat.Test/LongFinalityIntegrationTests.cs
@@ -0,0 +1,414 @@
+// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Autofac;
+using Nethermind.Config;
+using Nethermind.Init.Modules;
+using Nethermind.Core;
+using Nethermind.Core.Crypto;
+using Nethermind.Core.Test.Builders;
+using Nethermind.Db;
+using Nethermind.Int256;
+using Nethermind.Logging;
+using Nethermind.State.Flat.PersistedSnapshots;
+using Nethermind.State.Flat.Persistence;
+using Nethermind.Serialization.Rlp;
+using Nethermind.State.Flat.PersistedSnapshots.Storage;
+using Nethermind.Trie;
+using NSubstitute;
+using NUnit.Framework;
+
+namespace Nethermind.State.Flat.Test;
+
+[TestFixture]
+public class LongFinalityIntegrationTests
+{
+ private string _testDir = null!;
+ private ResourcePool _pool = null!;
+ private IProcessExitSource _processExitSource = null!;
+ private CancellationTokenSource _cts = null!;
+ private IFlatDbConfig _config = null!;
+ private ArenaManager _memArena = null!;
+ private BlobArenaManager _helperBlobs = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _testDir = Path.Combine(Path.GetTempPath(), $"nethermind_test_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_testDir);
+ _pool = new ResourcePool(new FlatDbConfig());
+ _cts = new CancellationTokenSource();
+ _processExitSource = Substitute.For();
+ _processExitSource.Token.Returns(_cts.Token);
+ _config = new FlatDbConfig { CompactSize = 16, MaxInFlightCompactJob = 4, InlineCompaction = true };
+ _memArena = TestFixtureHelpers.CreateArenaManager(Path.Combine(_testDir, "mem-arena"));
+ _helperBlobs = new BlobArenaManager(Path.Combine(_testDir, "helper-blobs"), 4L * 1024 * 1024);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ _cts.Cancel();
+ _cts.Dispose();
+ _helperBlobs.Dispose();
+ _memArena.Dispose();
+ if (Directory.Exists(_testDir))
+ Directory.Delete(_testDir, recursive: true);
+ }
+
+ private Snapshot CreateSnapshot(StateId from, StateId to, Action configure)
+ {
+ SnapshotContent content = new();
+ configure(content);
+ return new Snapshot(from, to, content, _pool, ResourcePool.Usage.MainBlockProcessing);
+ }
+
+ private PersistedSnapshot CreatePersistedSnapshot(StateId from, StateId to, byte[] data) =>
+ TestFixtureHelpers.CreatePersistedSnapshot(_memArena, _helperBlobs, from, to, data);
+
+ [Test]
+ public void FullStack_PersistAndQuery_AccountsStorageAndTrieNodes()
+ {
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 4096);
+ SnapshotRepository repo = tier.Repository;
+
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+
+ TreePath statePath = new(Keccak.Compute("state_path"), 4);
+ Hash256 storageAddr = Keccak.Compute("storage_address");
+ TreePath storagePath = new(Keccak.Compute("storage_path"), 6);
+ byte[] stateRlp = [0xC2, 0x80, 0x80];
+ byte[] storageRlp = [0xC1, 0x80];
+
+ Snapshot snap = CreateSnapshot(s0, s1, c =>
+ {
+ c.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(500).TestObject;
+ byte[] slotVal = new byte[32]; slotVal[31] = 0xFF;
+ c.Storages[(TestItem.AddressA, (UInt256)42)] = new SlotValue(slotVal);
+ c.SelfDestructedStorageAddresses[TestItem.AddressB] = false;
+ c.StateNodes[statePath] = new TrieNode(NodeType.Leaf, stateRlp);
+ c.StorageNodes[(storageAddr, storagePath)] = new TrieNode(NodeType.Branch, storageRlp);
+ });
+
+ tier.ConvertToPersistedBase(snap).Dispose();
+ Assert.That(repo.TryLeasePersistedState(s1, SnapshotTier.PersistedBase, out PersistedSnapshot? persisted), Is.True);
+
+ Assert.That(persisted!.TryLoadStateNodeRlp(statePath, out byte[]? stateResult), Is.True);
+ Assert.That(stateResult, Is.EqualTo(stateRlp));
+ Assert.That(persisted.TryLoadStorageNodeRlp(storageAddr.ValueHash256, storagePath, out byte[]? storageResult), Is.True);
+ Assert.That(storageResult, Is.EqualTo(storageRlp));
+ persisted.Dispose();
+ }
+
+ // 4 KiB — each snapshot's metadata reservation page-rounds to fill the whole arena
+ // file, so the file fully-dies on the sole reservation's MarkDead and the punch path
+ // is short-circuited. 1 MiB — both snapshots' reservations pack into one arena file,
+ // so snap1's dispose finds snap2 still live, MarkDead returns true, and the bare
+ // ArenaReservation.CleanUp would (without the PersistOnShutdown-aware fix) punch the
+ // dead range in a live preserve-flagged file, zeroing snap1's metadata for session 2.
+ [TestCase(4096L, TestName = "Repository_Restart_PreservesAllData_PerSnapshotArenaFiles")]
+ [TestCase(1L * 1024 * 1024, TestName = "Repository_Restart_PreservesAllData_SharedArenaAcrossSnapshots")]
+ public void Repository_Restart_PreservesAllData(long maxArenaSize)
+ {
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+ StateId s2 = new(2, Keccak.Compute("2"));
+
+ // Per-snapshot trie nodes are capped at 568 bytes (MaxTrieNodeRlpBytes), so use
+ // many smaller RLPs per snapshot to push the cumulative blob frontier well past
+ // 1 OS page (4 KiB). Without enough total blob bytes, a stray
+ // BlobArenaManager.TryResetOrphanedFrontier punch over [0, frontier) is a no-op
+ // on tmpfs (sub-page punches are dropped), letting the test silently pass with
+ // the bug present. 10 × ~500 bytes per snap = ~5 KiB per snap = ~10 KiB shared
+ // blob frontier → punch reliably zeros page 0.
+ const int nodesPerSnap = 10;
+ byte[] body1 = new byte[500]; Array.Fill(body1, (byte)0xAA);
+ byte[] body2 = new byte[500]; Array.Fill(body2, (byte)0xBB);
+ byte[] rlp1 = Rlp.Encode(body1).Bytes; // ~503 bytes — under MaxTrieNodeRlpBytes
+ byte[] rlp2 = Rlp.Encode(body2).Bytes;
+ TreePath[] paths1 = new TreePath[nodesPerSnap];
+ TreePath[] paths2 = new TreePath[nodesPerSnap];
+ for (int i = 0; i < nodesPerSnap; i++)
+ {
+ paths1[i] = new TreePath(Keccak.Compute($"path1_{i}"), 4);
+ paths2[i] = new TreePath(Keccak.Compute($"path2_{i}"), 4);
+ }
+ MemDb catalogDb = new();
+
+ using (FlatTestContainer tier1 = new(arenaFileSizeBytes: maxArenaSize, baseDbPath: _testDir, catalogDb: catalogDb))
+ {
+ SnapshotRepository repo = tier1.Repository;
+
+ tier1.ConvertToPersistedBase(CreateSnapshot(s0, s1, c =>
+ {
+ foreach (TreePath p in paths1) c.StateNodes[p] = new TrieNode(NodeType.Leaf, rlp1);
+ c.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(100).TestObject;
+ })).Dispose();
+
+ tier1.ConvertToPersistedBase(CreateSnapshot(s1, s2, c =>
+ {
+ foreach (TreePath p in paths2) c.StateNodes[p] = new TrieNode(NodeType.Leaf, rlp2);
+ c.Accounts[TestItem.AddressB] = Build.An.Account.WithBalance(200).TestObject;
+ })).Dispose();
+ }
+
+ // Repository.Dispose flags every loaded snapshot's arena reservation AND every
+ // referenced blob file with PersistOnShutdown before tearing down the managers,
+ // so both file kinds must survive on disk for the catalog to re-bind in session 2.
+ // Split assertions so a missing flag on one side fingerprints which side regressed.
+ string arenaDir = Path.Combine(_testDir, "persisted_snapshot", "arena");
+ string blobDir = Path.Combine(_testDir, "persisted_snapshot", "blob");
+ // PersistedBase metadata lives in the small-arena pool (sub-CompactSize tier).
+ Assert.That(Directory.GetFiles(arenaDir, "small_arena_*.bin"), Is.Not.Empty,
+ "arena files were deleted on Dispose — PersistOnShutdown flag did not propagate to ArenaFile");
+ string[] blobFiles = Directory.GetFiles(blobDir, "blob_*.bin");
+ Assert.That(blobFiles, Is.Not.Empty,
+ "blob files were deleted on Dispose — PersistOnShutdown flag did not propagate to BlobArenaFile");
+ // No pre-extension: blob length tracks the actual data extent. If we ever drift
+ // back into pre-extending or punch-zero-on-shutdown, a preserve-flagged file ends
+ // up with length 0 (truncated) or length MaxSize (pre-extended sparse) — neither
+ // matches the snapshot's written extent. Either symptom would be caught here.
+ foreach (string blobFile in blobFiles)
+ {
+ long len = new FileInfo(blobFile).Length;
+ Assert.That(len, Is.GreaterThan(0),
+ $"{blobFile} truncated on Dispose — preserve flag did not protect a referenced blob");
+ Assert.That(len, Is.LessThanOrEqualTo(1024 * 1024),
+ $"{blobFile} length {len} > 1 MiB cap — pre-extension regressed");
+ }
+
+ using (FlatTestContainer tier2 = new(arenaFileSizeBytes: 4096, baseDbPath: _testDir, catalogDb: catalogDb))
+ {
+ SnapshotRepository repo = tier2.Repository;
+ Assert.That(repo.PersistedSnapshotCount, Is.EqualTo(2));
+
+ // s0→s1 carries paths1[] + AddressA; s1→s2 carries paths2[] + AddressB. Every
+ // state node round-trips intact — a stray BlobArenaManager.TryResetOrphanedFrontier
+ // punch during the session-1 dispose would zero at least the first 4 KiB of the
+ // blob, so the early-index nodes' RLPs would either not decode or read as zeros.
+ // The cross-snapshot misses verify the snapshot boundary survives reload (i.e.
+ // AddressB does NOT bleed into snap1's view, and vice versa).
+ Assert.That(repo.TryLeasePersistedState(s1, SnapshotTier.PersistedBase, out PersistedSnapshot? snap1), Is.True);
+ foreach (TreePath p in paths1)
+ {
+ Assert.That(snap1!.TryLoadStateNodeRlp(p, out byte[]? r), Is.True, $"snap1 missing {p}");
+ Assert.That(r, Is.EqualTo(rlp1), $"snap1 state node at {p} read back corrupted");
+ }
+ Assert.That(snap1!.TryGetAccount(TestItem.AddressA, out Account? a1), Is.True);
+ Assert.That(snap1.TryGetAccount(TestItem.AddressB, out Account? snap1MissB), Is.False);
+ snap1.Dispose();
+
+ Assert.That(repo.TryLeasePersistedState(s2, SnapshotTier.PersistedBase, out PersistedSnapshot? snap2), Is.True);
+ foreach (TreePath p in paths2)
+ {
+ Assert.That(snap2!.TryLoadStateNodeRlp(p, out byte[]? r), Is.True, $"snap2 missing {p}");
+ Assert.That(r, Is.EqualTo(rlp2), $"snap2 state node at {p} read back corrupted");
+ }
+ Assert.That(snap2!.TryGetAccount(TestItem.AddressB, out Account? a2), Is.True);
+ Assert.That(snap2.TryGetAccount(TestItem.AddressA, out Account? snap2MissA), Is.False);
+ snap2.Dispose();
+
+ Assert.That(a1!.Balance, Is.EqualTo((UInt256)100));
+ Assert.That(a2!.Balance, Is.EqualTo((UInt256)200));
+ Assert.That(snap1MissB, Is.Null);
+ Assert.That(snap2MissA, Is.Null);
+ }
+ }
+
+
+ [Test]
+ public void MergeSnapshotData_AllEntryTypes()
+ {
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+ StateId s2 = new(2, Keccak.Compute("2"));
+
+ TreePath statePath = new(Keccak.Compute("state"), 4);
+ Hash256 storageAddr = Keccak.Compute("addr");
+ TreePath storagePath = new(Keccak.Compute("stor_path"), 6);
+
+ Snapshot snap1 = CreateSnapshot(s0, s1, c =>
+ {
+ c.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(100).TestObject;
+ c.StateNodes[statePath] = new TrieNode(NodeType.Leaf, [0xC0]);
+ c.StorageNodes[(storageAddr, storagePath)] = new TrieNode(NodeType.Branch, [0xC1, 0x80]);
+ });
+
+ Snapshot snap2 = CreateSnapshot(s1, s2, c =>
+ {
+ c.Accounts[TestItem.AddressB] = Build.An.Account.WithBalance(200).TestObject;
+ c.StateNodes[statePath] = new TrieNode(NodeType.Leaf, [0xC2, 0x80, 0x80]); // Override
+ });
+
+ byte[] data1 = PersistedSnapshotBuilderTestExtensions.Build(snap1, _helperBlobs);
+ byte[] data2 = PersistedSnapshotBuilderTestExtensions.Build(snap2, _helperBlobs);
+ PersistedSnapshot baseSnap1 = CreatePersistedSnapshot(s0, s1, data1);
+ PersistedSnapshot baseSnap2 = CreatePersistedSnapshot(s1, s2, data2);
+ PersistedSnapshotList toMerge = new(2) { baseSnap1, baseSnap2 };
+ byte[] merged = PersistedSnapshotBuilderTestExtensions.NWayMergeSnapshots(toMerge);
+
+ PersistedSnapshot mergedSnap = CreatePersistedSnapshot(s0, s2, merged);
+
+ // State node should have newer value
+ Assert.That(mergedSnap.TryLoadStateNodeRlp(statePath, out byte[]? stateRlpResult), Is.True);
+ Assert.That(stateRlpResult, Is.EqualTo(new byte[] { 0xC2, 0x80, 0x80 }));
+
+ // Storage node from older should be preserved
+ Assert.That(mergedSnap.TryLoadStorageNodeRlp(storageAddr.ValueHash256, storagePath, out byte[]? storageRlpResult), Is.True);
+ Assert.That(storageRlpResult, Is.EqualTo(new byte[] { 0xC1, 0x80 }));
+
+ Assert.That(mergedSnap.TryGetAccount(TestItem.AddressA, out _), Is.True);
+ Assert.That(mergedSnap.TryGetAccount(TestItem.AddressB, out _), Is.True);
+ }
+
+ [TestCase(10)]
+ [TestCase(100)]
+ public void ManySnapshots_PersistAndQuery(int snapshotCount)
+ {
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 64 * 1024);
+ SnapshotRepository repo = tier.Repository;
+
+ StateId prev = new(0, Keccak.EmptyTreeHash);
+ for (int i = 1; i <= snapshotCount; i++)
+ {
+ StateId current = new(i, Keccak.Compute(i.ToString()));
+ tier.ConvertToPersistedBase(CreateSnapshot(prev, current, c =>
+ c.Accounts[new Address(Keccak.Compute(i.ToString()))] =
+ Build.An.Account.WithBalance((UInt256)i).TestObject)).Dispose();
+ prev = current;
+ }
+
+ Assert.That(repo.PersistedSnapshotCount, Is.EqualTo(snapshotCount));
+ }
+
+
+ [Test]
+ public async Task FlatDbManager_EndToEnd_WithPersistedSnapshots()
+ {
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 4096);
+ SnapshotRepository repo = tier.Repository;
+
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+ TreePath path = new(Keccak.Compute("e2e_path"), 4);
+ byte[] nodeRlp = [0xC1, 0x80];
+
+ tier.ConvertToPersistedBase(CreateSnapshot(s0, s1, c =>
+ c.StateNodes[path] = new TrieNode(NodeType.Leaf, nodeRlp))).Dispose();
+
+ // Set up persistence reader at s0 — persisted snapshot fills gap s0→s1
+ IPersistenceManager persistenceManager = Substitute.For();
+ IPersistence.IPersistenceReader reader = Substitute.For();
+ reader.CurrentState.Returns(s0);
+ persistenceManager.LeaseReader().Returns(reader);
+ persistenceManager.GetCurrentPersistedStateId().Returns(s0);
+
+ await using FlatDbManager manager = new(
+ Substitute.For(),
+ _processExitSource,
+ Substitute.For(),
+ Substitute.For(),
+ repo,
+ persistenceManager,
+ Substitute.For(),
+ _config,
+ new BlocksConfig(),
+ LimboLogs.Instance,
+ enableDetailedMetrics: false);
+
+ ReadOnlySnapshotBundle bundle = manager.GatherReadOnlySnapshotBundle(s1);
+
+ byte[]? result = bundle.TryLoadStateRlp(path, Keccak.Compute("hash"), ReadFlags.None);
+ Assert.That(result, Is.EqualTo(nodeRlp));
+
+ bundle.Dispose();
+ }
+
+ [Test]
+ public void Prune_AfterRestart_Works()
+ {
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+ StateId s2 = new(2, Keccak.Compute("2"));
+ StateId s5 = new(5, Keccak.Compute("5"));
+ MemDb catalogDb = new();
+
+ using (FlatTestContainer tier1 = new(arenaFileSizeBytes: 4096, baseDbPath: _testDir, catalogDb: catalogDb))
+ {
+ SnapshotRepository repo = tier1.Repository;
+ tier1.ConvertToPersistedBase(CreateSnapshot(s0, s1, c =>
+ c.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(1).TestObject)).Dispose();
+ tier1.ConvertToPersistedBase(CreateSnapshot(s1, s2, c =>
+ c.Accounts[TestItem.AddressB] = Build.An.Account.WithBalance(2).TestObject)).Dispose();
+ tier1.ConvertToPersistedBase(CreateSnapshot(s2, s5, c =>
+ c.Accounts[TestItem.AddressC] = Build.An.Account.WithBalance(5).TestObject)).Dispose();
+ }
+
+ using (FlatTestContainer tier2 = new(arenaFileSizeBytes: 4096, baseDbPath: _testDir, catalogDb: catalogDb))
+ {
+ SnapshotRepository repo = tier2.Repository;
+ Assert.That(repo.PersistedSnapshotCount, Is.EqualTo(3));
+
+ repo.RemovePersistedStatesUntil(3); // s1 and s2 removed
+ Assert.That(repo.PersistedSnapshotCount, Is.EqualTo(1));
+ }
+
+ using (FlatTestContainer tier3 = new(arenaFileSizeBytes: 4096, baseDbPath: _testDir, catalogDb: catalogDb))
+ {
+ SnapshotRepository repo = tier3.Repository;
+ Assert.That(repo.PersistedSnapshotCount, Is.EqualTo(1));
+ }
+ }
+
+ [Test]
+ public void EmptySnapshot_PersistsAndLoads()
+ {
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 4096);
+ SnapshotRepository repo = tier.Repository;
+
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+
+ Snapshot empty = CreateSnapshot(s0, s1, _ => { });
+ tier.ConvertToPersistedBase(empty).Dispose();
+
+ Assert.That(repo.TryLeasePersistedState(s1, SnapshotTier.PersistedBase, out PersistedSnapshot? persisted), Is.True);
+ Assert.That(persisted!.TryGetAccount(TestItem.AddressA, out _), Is.False);
+ Assert.That(persisted.TryLoadStateNodeRlp(new TreePath(Keccak.Compute("any"), 4), out _), Is.False);
+ persisted.Dispose();
+ }
+
+ [Test]
+ public void Configuration_DefaultValues()
+ {
+ FlatDbConfig config = new();
+ Assert.That(config.EnableLongFinality, Is.False);
+ Assert.That(config.MaxReorgDepth, Is.EqualTo(256));
+ Assert.That(config.LongFinalityMaxReorgDepth, Is.EqualTo(90000));
+ Assert.That(config.ArenaFileSizeBytes, Is.EqualTo(1L * 1024 * 1024 * 1024));
+ }
+
+ [Test]
+ public void DisabledLongFinality_WiresInertPersistedTier()
+ {
+ FlatDbConfig config = new() { EnableLongFinality = false };
+ using IContainer container = new ContainerBuilder()
+ .AddModule(new FlatWorldStateModule(config))
+ .AddSingleton(config)
+ .AddSingleton(LimboLogs.Instance)
+ .Build();
+
+ Assert.That(container.Resolve(), Is.SameAs(NullSnapshotCatalog.Instance));
+ Assert.That(container.Resolve(), Is.SameAs(NullPersistedSnapshotLoader.Instance));
+ Assert.That(container.Resolve(), Is.SameAs(NullPersistedSnapshotCompactor.Instance));
+
+ // The Null loader/catalog keep the tier inert: loading is a no-op and nothing is ever recorded.
+ container.Resolve().Load();
+ Assert.That(container.Resolve().Load(), Is.Empty);
+ }
+}
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/Nethermind.State.Flat.Test.csproj b/src/Nethermind/Nethermind.State.Flat.Test/Nethermind.State.Flat.Test.csproj
index a9ef96f63d55..8601141c49fe 100644
--- a/src/Nethermind/Nethermind.State.Flat.Test/Nethermind.State.Flat.Test.csproj
+++ b/src/Nethermind/Nethermind.State.Flat.Test/Nethermind.State.Flat.Test.csproj
@@ -5,6 +5,7 @@
Nethermind.State.Flat.Test
enable
+ true
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/PersistedSnapshotBuilderTestExtensions.cs b/src/Nethermind/Nethermind.State.Flat.Test/PersistedSnapshotBuilderTestExtensions.cs
new file mode 100644
index 000000000000..81b5d33945f1
--- /dev/null
+++ b/src/Nethermind/Nethermind.State.Flat.Test/PersistedSnapshotBuilderTestExtensions.cs
@@ -0,0 +1,66 @@
+// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using System;
+using Nethermind.Core.Collections;
+using Nethermind.State.Flat.Io;
+using Nethermind.State.Flat.PersistedSnapshots;
+using Nethermind.State.Flat.PersistedSnapshots.Storage;
+
+namespace Nethermind.State.Flat.Test;
+
+///
+/// Allocates output buffers internally, which production code avoids.
+///
+internal static class PersistedSnapshotBuilderTestExtensions
+{
+ ///
+ /// The caller must keep alive across the test fixture so that a
+ /// constructed from the returned bytes can lease the blob
+ /// file via the same manager — mirroring how production wires BlobArenaManager as
+ /// a long-lived shared component.
+ ///
+ public static byte[] Build(Snapshot snapshot, BlobArenaManager blobs)
+ {
+ int estimatedSize = checked((int)PersistedSnapshotBuilder.EstimateSize(snapshot));
+ using PooledByteBufferWriter pooled = new(estimatedSize);
+ using BlobArenaWriter blobWriter = blobs.CreateWriter(estimatedSize);
+ using Nethermind.State.Flat.Persistence.BloomFilter.BloomFilter bloom =
+ Nethermind.State.Flat.Persistence.BloomFilter.BloomFilter.AlwaysTrue();
+ PersistedSnapshotBuilder.Build(
+ snapshot, ref pooled.GetWriter(), blobWriter, bloom);
+ blobWriter.Complete();
+ return pooled.WrittenSpan.ToArray();
+ }
+
+ public static byte[] NWayMergeSnapshots(PersistedSnapshotList snapshots)
+ {
+ if (snapshots.Count == 0) throw new ArgumentException("Cannot merge empty snapshot list");
+ if (snapshots.Count == 1)
+ {
+ using WholeReadSession session = snapshots[0].BeginWholeReadSession();
+ return TestFixtureHelpers.ReadAll(session);
+ }
+
+ long totalSize = 0;
+ for (int i = 0; i < snapshots.Count; i++) totalSize += snapshots[i].Size;
+ totalSize += 4096;
+
+ using PooledByteBufferWriter pooled = new(checked((int)totalSize));
+ int n = snapshots.Count;
+ using ArrayPoolList sessionsList = new(n, n);
+ WholeReadSession[] sessionArr = sessionsList.UnsafeGetInternalArray();
+ try
+ {
+ for (int i = 0; i < n; i++)
+ sessionArr[i] = snapshots[i].BeginWholeReadSession();
+ PersistedSnapshotMerger.NWayMergeSnapshots(
+ sessionsList.AsSpan(), ref pooled.GetWriter(), bloom: Nethermind.State.Flat.Persistence.BloomFilter.BloomFilter.AlwaysTrue());
+ }
+ finally
+ {
+ for (int i = 0; i < n; i++) sessionArr[i]?.Dispose();
+ }
+ return pooled.WrittenSpan.ToArray();
+ }
+}
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/PersistedSnapshotCompactorTests.cs b/src/Nethermind/Nethermind.State.Flat.Test/PersistedSnapshotCompactorTests.cs
new file mode 100644
index 000000000000..e04ff456cec6
--- /dev/null
+++ b/src/Nethermind/Nethermind.State.Flat.Test/PersistedSnapshotCompactorTests.cs
@@ -0,0 +1,1462 @@
+// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Nethermind.Core;
+using Nethermind.Core.Crypto;
+using Nethermind.Core.Test.Builders;
+using Nethermind.Int256;
+using Nethermind.Db;
+using Nethermind.State.Flat.Io;
+using Nethermind.State.Flat.PersistedSnapshots;
+using Nethermind.State.Flat.Persistence.BloomFilter;
+using Nethermind.State.Flat.PersistedSnapshots.Storage;
+using Nethermind.Trie;
+using NUnit.Framework;
+
+namespace Nethermind.State.Flat.Test;
+
+[TestFixture]
+public class PersistedSnapshotCompactorTests
+{
+ private ResourcePool _pool = null!;
+ private ArenaManager _memArena = null!;
+ private string _memArenaDir = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _pool = new ResourcePool(new FlatDbConfig());
+ _memArenaDir = Path.Combine(Path.GetTempPath(), $"nm-compactortest-arena-{Guid.NewGuid():N}");
+ _memArena = TestFixtureHelpers.CreateArenaManager(_memArenaDir);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ _memArena.Dispose();
+ try { Directory.Delete(_memArenaDir, recursive: true); } catch { /* best-effort */ }
+ }
+
+ ///
+ /// Regression for large-tier compactions where N approaches the typical
+ /// compactSize/CompactSize ceiling (~32). Each source carries a unique account
+ /// plus a shared overlapping account (AddressA) with a distinct slot per block, so the
+ /// per-address sub-tag merge runs with matchCount == N on every iteration and
+ /// the slot merge exercises the fused inline bloom path with N slot inputs. Failures
+ /// here flag mis-cached keys, missed bound refresh after MoveNext, or
+ /// destruct-barrier/slot-bound mismatches in MergeEntries.
+ ///
+ [TestCase(8)]
+ [TestCase(16)]
+ [TestCase(32)]
+ public void TryCompactPersistedSnapshots_MergesNBaseSnapshots(int n)
+ {
+ // CompactSize=4. n is a power of 2 in {8, 16, 32}, so n & -n == n: block n's natural
+ // window covers the whole (0, n] range and DoCompactSnapshot triggers a single merge.
+ using FlatTestContainer tier = new(
+ arenaFileSizeBytes: 256 * 1024,
+ blobFileSizeBytes: 4 * 1024 * 1024,
+ configure: b => b.AddSingleton(ScheduleHelper.CreateWithOffset(new FlatDbConfig { CompactSize = 4 }, 0)));
+ SnapshotRepository repo = tier.Repository;
+ PersistedSnapshotCompactor compactor = tier.Compactor;
+
+ StateId prev = new(0, Keccak.EmptyTreeHash);
+ for (int i = 1; i <= n; i++)
+ {
+ StateId next = new(i, Keccak.Compute($"s{i}"));
+ SnapshotContent c = new();
+ c.Accounts[TestItem.Addresses[i - 1]] = Build.An.Account.WithBalance((UInt256)(i * 100)).TestObject;
+ // Shared overlapping account: same AddressA every block, distinct balance and
+ // a distinct slot — drives matchCount == N through MergeEntries,
+ // and the slot merge sees N inputs with N unique slot keys.
+ c.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance((UInt256)i).TestObject;
+ c.Storages[(TestItem.AddressA, (UInt256)i)] = new SlotValue(new byte[] { (byte)i });
+ tier.ConvertToPersistedBase(new Snapshot(prev, next, c, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+ prev = next;
+ }
+
+ compactor.DoCompactSnapshot(prev);
+
+ Assert.That(repo.TryLeasePersistedState(prev, SnapshotTier.PersistedLargeCompacted, out PersistedSnapshot? compacted), Is.True);
+ try
+ {
+ Assert.That(compacted!.From.BlockNumber, Is.EqualTo(0));
+ Assert.That(compacted.To.BlockNumber, Is.EqualTo(n));
+
+ for (int i = 1; i <= n; i++)
+ {
+ Assert.That(compacted.TryGetAccount(TestItem.Addresses[i - 1], out _), Is.True,
+ $"Account from block {i} missing");
+ }
+
+ Assert.That(compacted.TryGetAccount(TestItem.AddressA, out Account? a), Is.True);
+ Assert.That(a!.Balance, Is.EqualTo((UInt256)n), "Newest balance must win on the overlapping account");
+
+ for (int i = 1; i <= n; i++)
+ {
+ SlotValue slot = default;
+ Assert.That(compacted.TryGetSlot(TestItem.AddressA, (UInt256)i, ref slot), Is.True,
+ $"Slot {i} must survive merge");
+ Assert.That(slot.AsReadOnlySpan.ToArray(), Is.EqualTo(new SlotValue(new byte[] { (byte)i }).AsReadOnlySpan.ToArray()),
+ $"Slot {i} value mismatch");
+ }
+ }
+ finally { compacted!.Dispose(); }
+ }
+
+ ///
+ /// Regression for large-tier boundary compaction of an address with 256k sequential
+ /// storage slots. Each big-endian-contiguous run of 65536 slots forms one dense 30-byte
+ /// slot-prefix group; merging the per-block slices accumulates a group's inner sub-slot
+ /// table past ArenaBufferWriter's 1 MiB buffer. No single source snapshot crosses
+ /// that threshold (16384 slots per block), so the oversized value first appears inside
+ /// NWayNestedStreamingSlotMerge during the merge — the mainnet crash site.
+ ///
+ [Test]
+ public void DoCompactSnapshot_SequentialSlotsAcrossDensePrefixGroups_RoundTrips()
+ {
+ const int snapshotCount = 16;
+ const int slotsPerSnapshot = 16 * 1024; // 16 × 16384 = 256k merged slots
+
+ // 64 MiB shared arena: the per-block snapshots and the ~10 MiB compacted output
+ // stay below the 512 MiB dedicated-arena threshold, so each must fit a shared file.
+ using FlatTestContainer tier = new(
+ arenaFileSizeBytes: 64 * 1024 * 1024,
+ blobFileSizeBytes: 4 * 1024 * 1024,
+ configure: b => b.AddSingleton(ScheduleHelper.CreateWithOffset(new FlatDbConfig { CompactSize = 4 }, 0)));
+ SnapshotRepository repo = tier.Repository;
+ PersistedSnapshotCompactor compactor = tier.Compactor;
+
+ // Each block writes a contiguous 16384-slot slice on AddressA. A slice stays well
+ // under ArenaBufferWriter's 1 MiB buffer, so every per-block build succeeds; only
+ // the merged 65536-slot prefix groups cross the threshold.
+ StateId prev = new(0, Keccak.EmptyTreeHash);
+ for (int i = 1; i <= snapshotCount; i++)
+ {
+ StateId next = new(i, Keccak.Compute($"s{i}"));
+ SnapshotContent c = new();
+ TestFixtureHelpers.AddSequentialSlots(c, TestItem.AddressA,
+ firstSlot: (i - 1) * slotsPerSnapshot + 1, count: slotsPerSnapshot);
+ tier.ConvertToPersistedBase(
+ new Snapshot(prev, next, c, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+ prev = next;
+ }
+
+ compactor.DoCompactSnapshot(prev);
+
+ Assert.That(repo.TryLeasePersistedState(prev, SnapshotTier.PersistedLargeCompacted, out PersistedSnapshot? compacted), Is.True);
+ try
+ {
+ int totalSlots = snapshotCount * slotsPerSnapshot;
+ foreach (int probe in new[] { 1, 65535, 65536, 131072, totalSlots })
+ {
+ SlotValue slot = default;
+ Assert.That(compacted!.TryGetSlot(TestItem.AddressA, (UInt256)probe, ref slot), Is.True, $"slot {probe} missing");
+ Assert.That(slot.AsReadOnlySpan.SequenceEqual(TestFixtureHelpers.SequentialSlotValue(probe)), Is.True,
+ $"slot {probe} value mismatch");
+ }
+ }
+ finally { compacted!.Dispose(); }
+ }
+
+ ///
+ /// Regression for bloom completeness on a single matching source (matchCount==1), which
+ /// routes through the value mergers' MergeValues like any other key. We pack
+ /// AddressA into one source with slots plus storage-trie nodes at every depth tier (top /
+ /// compact / fallback) and pair it with an unrelated address in the second source so that
+ /// matchCount==1 for AddressA. The merge must still bloom-add the address key, every slot
+ /// key, and all three storage-trie sub-tag node keys. The bloom manager is shared with the
+ /// compactor so bloomCapacity is non-zero and the merger produces a real
+ /// (non-AlwaysTrue) bloom we can probe.
+ ///
+ [Test]
+ public void Compact_SingleSourceAddress_AddsAllSubTagBloomKeys()
+ {
+ using FlatTestContainer tier = new(
+ arenaFileSizeBytes: 64 * 1024,
+ configure: b => b.AddSingleton(ScheduleHelper.CreateWithOffset(new FlatDbConfig { CompactSize = 1, PersistedSnapshotMaxCompactSize = 2 }, 0)));
+ SnapshotRepository repo = tier.Repository;
+ PersistedSnapshotCompactor compactor = tier.Compactor;
+
+ Hash256 addrHash256 = Keccak.Compute(TestItem.AddressA.Bytes);
+ TreePath shortPath = new(Keccak.Compute("trie_top"), 4); // → StorageCompactSubTag (8-byte key; storage has no top tier)
+ TreePath compactPath = new(Keccak.Compute("trie_compact"), 10); // → StorageCompactSubTag (8-byte key)
+ TreePath fallbackPath = new(Keccak.Compute("trie_fb"), 20); // → StorageFallbackSubTag (33-byte key)
+ UInt256 slotIndex = 7;
+
+ SnapshotContent c0 = new();
+ c0.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(100).TestObject;
+ c0.Storages[(TestItem.AddressA, slotIndex)] = new SlotValue(new byte[] { 0x42 });
+ c0.StorageNodes[(addrHash256, shortPath)] = new TrieNode(NodeType.Leaf, [0xC1, 0x80]);
+ c0.StorageNodes[(addrHash256, compactPath)] = new TrieNode(NodeType.Leaf, [0xC1, 0x81]);
+ c0.StorageNodes[(addrHash256, fallbackPath)] = new TrieNode(NodeType.Leaf, [0xC1, 0x82]);
+
+ // Different address in the second source so AddressA has matchCount==1 (single
+ // matching source) while still having ≥ 2 sources to compact.
+ SnapshotContent c1 = new();
+ c1.Accounts[TestItem.AddressB] = Build.An.Account.WithBalance(200).TestObject;
+
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("s1"));
+ StateId s2 = new(2, Keccak.Compute("s2"));
+ tier.ConvertToPersistedBase(new Snapshot(s0, s1, c0, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+ tier.ConvertToPersistedBase(new Snapshot(s1, s2, c1, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+
+ compactor.DoCompactSnapshot(s2);
+
+ Assert.That(repo.TryLeasePersistedState(s2, SnapshotTier.PersistedLargeCompacted, out PersistedSnapshot? compacted), Is.True);
+ using (compacted)
+ {
+ BloomFilter bloom = compacted!.Bloom;
+ Assert.That(bloom.Count, Is.GreaterThan(0),
+ "Compacted snapshot must have a real bloom — the merge populates it from both sources");
+ ValueHash256 addrHash = ValueKeccak.Compute(TestItem.AddressA.Bytes);
+ ulong addrKey = PersistedSnapshotBloomBuilder.AddressKey(TestItem.AddressA);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(bloom.MightContain(addrKey), Is.True, "Address key");
+ Assert.That(bloom.MightContain(PersistedSnapshotBloomBuilder.SlotKey(addrKey, slotIndex)), Is.True, "Slot key");
+ Assert.That(bloom.MightContain(PersistedSnapshotBloomBuilder.StorageNodeKey(in addrHash, in shortPath)), Is.True,
+ "Storage-trie short (compact) — fails when sibling TrySeek bound isn't reset between sub-tag seeks");
+ Assert.That(bloom.MightContain(PersistedSnapshotBloomBuilder.StorageNodeKey(in addrHash, in compactPath)), Is.True,
+ "Storage-trie compact");
+ Assert.That(bloom.MightContain(PersistedSnapshotBloomBuilder.StorageNodeKey(in addrHash, in fallbackPath)), Is.True,
+ "Storage-trie fallback");
+ });
+ }
+ }
+
+ ///
+ /// Regression for the 4 KiB page-alignment pad applied by the BTree builder
+ /// (BlockBuilder.Add → TryAlign) when an about-to-straddle entry is pushed
+ /// onto a fresh page. The leading pad bytes must be inert so the outer leaf's
+ /// ValueStart = MetadataStart − ValueLength derivation lands inside the value and
+ /// decoding succeeds. Drives many distinct single-source addresses (matchCount==1) through
+ /// compaction with non-trivial inner tables (slots + a storage-trie node each) so positions
+ /// sweep across multiple page boundaries — at least some entries trigger the pad code path,
+ /// and all must round-trip read intact post-compaction.
+ ///
+ [TestCase(40)]
+ [TestCase(120)]
+ public void Compact_SingleSourceAddress_PageAlignPaddingPreservesValues(int accountCount)
+ {
+ using FlatTestContainer tier = new(
+ arenaFileSizeBytes: 256 * 1024,
+ blobFileSizeBytes: 4 * 1024 * 1024,
+ configure: b => b.AddSingleton(ScheduleHelper.CreateWithOffset(new FlatDbConfig { CompactSize = 1, PersistedSnapshotMaxCompactSize = 2 }, 0)));
+ SnapshotRepository repo = tier.Repository;
+ PersistedSnapshotCompactor compactor = tier.Compactor;
+
+ // Source 0: accountCount addresses with varying slot counts so inner-table
+ // sizes span ~tens to ~hundreds of bytes — repeated fast-path writes
+ // sweep across 4 KiB page boundaries in the destination arena.
+ SnapshotContent c0 = new();
+ for (int i = 0; i < accountCount; i++)
+ {
+ Address addr = TestItem.Addresses[i];
+ c0.Accounts[addr] = Build.An.Account.WithBalance((UInt256)(i + 1)).TestObject;
+ int slots = 1 + (i % 7);
+ for (int s = 0; s < slots; s++)
+ c0.Storages[(addr, (UInt256)(s + 1))] = new SlotValue(new byte[] { (byte)((i * 13 + s) & 0xFF) });
+ c0.StorageNodes[(Keccak.Compute(addr.Bytes), new TreePath(Keccak.Compute($"p{i}"), 4))]
+ = new TrieNode(NodeType.Leaf, [0xC1, (byte)(i & 0xFF)]);
+ }
+
+ // Source 1: a single unrelated address so matchCount == 1 for every
+ // address in source 0 (drives them all through the fast path).
+ SnapshotContent c1 = new();
+ c1.Accounts[TestItem.AddressB] = Build.An.Account.WithBalance(999).TestObject;
+
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("p1"));
+ StateId s2 = new(2, Keccak.Compute("p2"));
+ tier.ConvertToPersistedBase(new Snapshot(s0, s1, c0, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+ tier.ConvertToPersistedBase(new Snapshot(s1, s2, c1, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+
+ compactor.DoCompactSnapshot(s2);
+
+ Assert.That(repo.TryLeasePersistedState(s2, SnapshotTier.PersistedLargeCompacted, out PersistedSnapshot? compacted), Is.True);
+ using (compacted)
+ {
+ Assert.Multiple(() =>
+ {
+ for (int i = 0; i < accountCount; i++)
+ {
+ Address addr = TestItem.Addresses[i];
+ Assert.That(compacted!.TryGetAccount(addr, out Account? a), Is.True,
+ $"Account {i} must survive fast-path compaction");
+ Assert.That(a!.Balance, Is.EqualTo((UInt256)(i + 1)),
+ $"Account {i} balance mismatch — pad bytes leaked into the value range");
+
+ int slots = 1 + (i % 7);
+ for (int s = 0; s < slots; s++)
+ {
+ SlotValue slot = default;
+ Assert.That(compacted.TryGetSlot(addr, (UInt256)(s + 1), ref slot), Is.True,
+ $"Slot {s + 1} for account {i} must survive fast-path compaction");
+ SlotValue expected = new(new byte[] { (byte)((i * 13 + s) & 0xFF) });
+ Assert.That(slot.AsReadOnlySpan.ToArray(),
+ Is.EqualTo(expected.AsReadOnlySpan.ToArray()),
+ $"Slot value mismatch for account {i} slot {s + 1}");
+ }
+ }
+ });
+ }
+ }
+
+ ///
+ /// Metadata invariants for the blob-arena layout: base snapshots carry no
+ /// noderefs flag and a single ref_ids entry (their own blob arena id);
+ /// the compacted snapshot carries the noderefs flag and a ref_ids set
+ /// equal to the union of source base-snapshot blob arena ids.
+ ///
+ [Test]
+ public void CompactedSnapshot_Metadata_NodeRefsFlagAndRefIdsUnion()
+ {
+ using FlatTestContainer tier = new(
+ arenaFileSizeBytes: 64 * 1024,
+ configure: b => b.AddSingleton(ScheduleHelper.CreateWithOffset(new FlatDbConfig { CompactSize = 4 }, 0)));
+ SnapshotRepository repo = tier.Repository;
+ PersistedSnapshotCompactor compactor = tier.Compactor;
+
+ StateId prev = new(0, Keccak.EmptyTreeHash);
+ StateId[] states = new StateId[9];
+ states[0] = prev;
+ HashSet baseRefIds = [];
+ for (int i = 1; i <= 8; i++)
+ {
+ states[i] = new StateId(i, Keccak.Compute($"{i}"));
+ SnapshotContent c = new();
+ c.Accounts[TestItem.Addresses[i - 1]] = Build.An.Account.WithBalance((UInt256)(i * 100)).TestObject;
+ c.StateNodes[new TreePath(Keccak.Compute($"path{i}"), 4)] = new TrieNode(NodeType.Leaf, [(byte)(0xC1), (byte)i]);
+ tier.ConvertToPersistedBase(new Snapshot(prev, states[i], c, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+ prev = states[i];
+ }
+
+ for (int i = 1; i <= 8; i++)
+ {
+ Assert.That(repo.TryLeasePersistedState(states[i], SnapshotTier.PersistedBase, out PersistedSnapshot? baseSnap), Is.True);
+ using (baseSnap)
+ {
+ using WholeReadSession session = baseSnap!.BeginWholeReadSession();
+ WholeReadSessionReader reader = session.CreateReader();
+ ushort[]? ids = TestFixtureHelpers.ReadRefIdsFromMetadata(in reader);
+ Assert.That(ids, Is.Not.Null.And.Length.EqualTo(1),
+ $"Base snapshot {i} must carry exactly one blob-arena ref_id");
+ baseRefIds.Add(ids![0]);
+ }
+ }
+
+ compactor.DoCompactSnapshot(states[8]);
+
+ Assert.That(repo.TryLeasePersistedState(states[8], SnapshotTier.PersistedLargeCompacted, out PersistedSnapshot? compacted), Is.True);
+ using (compacted)
+ {
+ using WholeReadSession session = compacted!.BeginWholeReadSession();
+ WholeReadSessionReader reader = session.CreateReader();
+ ushort[]? mergedIds = TestFixtureHelpers.ReadRefIdsFromMetadata(in reader);
+ Assert.That(mergedIds, Is.Not.Null);
+ Assert.That(new HashSet(mergedIds!), Is.EquivalentTo(baseRefIds),
+ "Compacted ref_ids must equal the union of source base blob-arena ids");
+ }
+ }
+
+ private static IEnumerable MergeValidationTestCases()
+ {
+ // Basic: two snapshots with overlapping accounts — newer balance wins.
+ {
+ SnapshotContent c0 = new();
+ c0.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(100).TestObject;
+ SnapshotContent c1 = new();
+ c1.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(200).TestObject;
+ yield return new TestCaseData(
+ (object)new[] { c0, c1 },
+ (Action)(s =>
+ {
+ Assert.That(s.TryGetAccount(TestItem.AddressA, out Account? a), Is.True);
+ Assert.That(a!.Balance, Is.EqualTo((UInt256)200));
+ }))
+ .SetName("Merge_AccountOverride");
+ }
+
+ // Regression: advance-corrupts-minKey bug in NWayPackedArrayMerge (StateTopNodes).
+ // snapshot[0] has paths {A, B}, snapshot[1] has only {B} with different RLP.
+ {
+ TreePath pathA = new(Hash256.Zero, 4);
+ TreePath pathB = new(new Hash256("0x1000000000000000000000000000000000000000000000000000000000000000"), 4);
+ SnapshotContent c0 = new();
+ c0.StateNodes[pathA] = new TrieNode(NodeType.Leaf, [0xC0]);
+ c0.StateNodes[pathB] = new TrieNode(NodeType.Leaf, [0xC0]);
+ SnapshotContent c1 = new();
+ c1.StateNodes[pathB] = new TrieNode(NodeType.Leaf, [0xC1, 0x80]);
+ yield return new TestCaseData(
+ (object)new[] { c0, c1 },
+ (Action)(s =>
+ {
+ Assert.That(s.TryLoadStateNodeRlp(pathA, out byte[]? rlpA), Is.True);
+ Assert.That(rlpA, Is.EqualTo(new byte[] { 0xC0 }), "State node only in older source must survive");
+ Assert.That(s.TryLoadStateNodeRlp(pathB, out byte[]? rlpB), Is.True);
+ Assert.That(rlpB, Is.EqualTo(new byte[] { 0xC1, 0x80 }), "Overlapping state node — newer RLP must win");
+ }))
+ .SetName("Merge_AdvanceOrder_StateTopNodes");
+ }
+
+ // Regression: same bug in NWayInnerMerge (StorageNodes inner merge).
+ {
+ Hash256 storageAddr = Keccak.Compute("storageAddr");
+ TreePath pathA = new(Hash256.Zero, 8);
+ TreePath pathB = new(new Hash256("0x1000000000000000000000000000000000000000000000000000000000000000"), 8);
+ SnapshotContent c0 = new();
+ c0.StorageNodes[(storageAddr, pathA)] = new TrieNode(NodeType.Leaf, [0xC1, 0x80]);
+ c0.StorageNodes[(storageAddr, pathB)] = new TrieNode(NodeType.Leaf, [0xC1, 0x80]);
+ SnapshotContent c1 = new();
+ c1.StorageNodes[(storageAddr, pathB)] = new TrieNode(NodeType.Leaf, [0xC2, 0x80, 0x81]);
+ yield return new TestCaseData(
+ (object)new[] { c0, c1 },
+ (Action)(s =>
+ {
+ Assert.That(s.TryLoadStorageNodeRlp(storageAddr.ValueHash256, pathA, out byte[]? rlpA), Is.True);
+ Assert.That(rlpA, Is.EqualTo(new byte[] { 0xC1, 0x80 }), "Storage node only in older source must survive");
+ Assert.That(s.TryLoadStorageNodeRlp(storageAddr.ValueHash256, pathB, out byte[]? rlpB), Is.True);
+ Assert.That(rlpB, Is.EqualTo(new byte[] { 0xC2, 0x80, 0x81 }), "Overlapping storage node — newer RLP must win");
+ }))
+ .SetName("Merge_AdvanceOrder_StorageNodes");
+ }
+
+ // Single-source per-sub-tag merge: the same addressHash is present in both sources
+ // (matchCount==2 for the storage-trie column). The Fallback (33-byte key) sub-tag and a
+ // c0-only node in the Compact (8-byte key) sub-tag are present only in the older source,
+ // while another Compact node overlaps both. This drives MergeStorageSubTag with active==1
+ // for Fallback and active==2 for Compact (with both a unique and an overlapping node in the
+ // compact width). Storage has no top tier — a length-4 path lands in the compact sub-tag.
+ {
+ Hash256 addrHash = Keccak.Compute(TestItem.AddressA.Bytes);
+ TreePath shortPath = new(Keccak.Compute("trie_top"), 4); // StorageCompactSubTag (8-byte key; c0-only)
+ TreePath compactPath = new(Keccak.Compute("trie_compact"), 10); // StorageCompactSubTag (8-byte key; overlaps)
+ TreePath fallbackPath = new(Keccak.Compute("trie_fb"), 20); // StorageFallbackSubTag (33-byte key)
+ SnapshotContent c0 = new();
+ c0.StorageNodes[(addrHash, shortPath)] = new TrieNode(NodeType.Leaf, [0xC1, 0x80]);
+ c0.StorageNodes[(addrHash, compactPath)] = new TrieNode(NodeType.Leaf, [0xC1, 0x81]);
+ c0.StorageNodes[(addrHash, fallbackPath)] = new TrieNode(NodeType.Leaf, [0xC1, 0x82]);
+ SnapshotContent c1 = new();
+ c1.StorageNodes[(addrHash, compactPath)] = new TrieNode(NodeType.Leaf, [0xC2, 0x80, 0x81]);
+ yield return new TestCaseData(
+ (object)new[] { c0, c1 },
+ (Action)(s =>
+ {
+ Assert.That(s.TryLoadStorageNodeRlp(addrHash.ValueHash256, shortPath, out byte[]? shortRlp), Is.True);
+ Assert.That(shortRlp, Is.EqualTo(new byte[] { 0xC1, 0x80 }), "c0-only compact node (shortPath) must survive");
+ Assert.That(s.TryLoadStorageNodeRlp(addrHash.ValueHash256, compactPath, out byte[]? compactRlp), Is.True);
+ Assert.That(compactRlp, Is.EqualTo(new byte[] { 0xC2, 0x80, 0x81 }), "Compact sub-tag (active==2) — newer wins");
+ Assert.That(s.TryLoadStorageNodeRlp(addrHash.ValueHash256, fallbackPath, out byte[]? fallbackRlp), Is.True);
+ Assert.That(fallbackRlp, Is.EqualTo(new byte[] { 0xC1, 0x82 }), "Fallback sub-tag (active==1) must survive");
+ }))
+ .SetName("Merge_SingleSourceSubTag_CompactAndFallback");
+ }
+
+ // Mixed: all data types across two snapshots.
+ {
+ Hash256 storageAddr = Keccak.Compute("storageAddr");
+ TreePath statePath = new(Keccak.Compute("statePath"), 4);
+ TreePath storagePath = new(Hash256.Zero, 4);
+ SnapshotContent c0 = new();
+ c0.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(100).TestObject;
+ c0.Storages[(TestItem.AddressA, 1)] = new SlotValue(new byte[] { 0x42 });
+ c0.SelfDestructedStorageAddresses[TestItem.AddressB] = true;
+ c0.StateNodes[statePath] = new TrieNode(NodeType.Leaf, [0xC0, 0x80]);
+ c0.StorageNodes[(storageAddr, storagePath)] = new TrieNode(NodeType.Leaf, [0xC1, 0x80]);
+ SnapshotContent c1 = new();
+ c1.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance((UInt256)200).TestObject;
+ c1.Storages[(TestItem.AddressA, 2)] = new SlotValue(new byte[] { 0x99 });
+ c1.StateNodes[statePath] = new TrieNode(NodeType.Leaf, [0xC1, 0x80]);
+ c1.StorageNodes[(storageAddr, storagePath)] = new TrieNode(NodeType.Leaf, [0xC2, 0x80, 0x81]);
+ yield return new TestCaseData(
+ (object)new[] { c0, c1 },
+ (Action)(s =>
+ {
+ Assert.That(s.TryGetAccount(TestItem.AddressA, out Account? a), Is.True);
+ Assert.That(a!.Balance, Is.EqualTo((UInt256)200), "Account override");
+
+ SlotValue slot1 = default;
+ Assert.That(s.TryGetSlot(TestItem.AddressA, 1, ref slot1), Is.True, "Older-only slot must survive (no self-destruct on A)");
+ Assert.That(slot1.AsReadOnlySpan.ToArray(), Is.EqualTo(new SlotValue(new byte[] { 0x42 }).AsReadOnlySpan.ToArray()));
+
+ SlotValue slot2 = default;
+ Assert.That(s.TryGetSlot(TestItem.AddressA, 2, ref slot2), Is.True);
+ Assert.That(slot2.AsReadOnlySpan.ToArray(), Is.EqualTo(new SlotValue(new byte[] { 0x99 }).AsReadOnlySpan.ToArray()));
+
+ Assert.That(s.TryGetSelfDestructFlag(TestItem.AddressB), Is.Not.Null,
+ "Self-destruct flag for B (set in c0) must be present after compaction");
+ Assert.That(s.TryGetAccount(TestItem.AddressB, out _), Is.False,
+ "self-destruct-only address reports no account change");
+
+ Assert.That(s.TryLoadStateNodeRlp(statePath, out byte[]? stateRlp), Is.True);
+ Assert.That(stateRlp, Is.EqualTo(new byte[] { 0xC1, 0x80 }), "State node — newer wins");
+
+ Assert.That(s.TryLoadStorageNodeRlp(storageAddr.ValueHash256, storagePath, out byte[]? storageRlp), Is.True);
+ Assert.That(storageRlp, Is.EqualTo(new byte[] { 0xC2, 0x80, 0x81 }), "Storage node — newer wins");
+ }))
+ .SetName("Merge_MixedDataTypes");
+ }
+
+ // Cross-source per-address merge: an account-only entry in the older source and a
+ // self-destruct-only (account-Absent) entry in the newer source must merge to the older
+ // account paired with the newer self-destruct — exercising the newest-non-Absent account rule.
+ {
+ SnapshotContent c0 = new();
+ c0.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(500).WithNonce(3).TestObject;
+ SnapshotContent c1 = new();
+ c1.SelfDestructedStorageAddresses[TestItem.AddressA] = true; // "new"; no account change in c1
+ yield return new TestCaseData(
+ (object)new[] { c0, c1 },
+ (Action)(s =>
+ {
+ Assert.That(s.TryGetAccount(TestItem.AddressA, out Account? a), Is.True,
+ "older account survives the newer self-destruct-only entry");
+ Assert.That(a!.Balance, Is.EqualTo((UInt256)500));
+ Assert.That(a.Nonce, Is.EqualTo((UInt256)3));
+ Assert.That(s.TryGetSelfDestructFlag(TestItem.AddressA), Is.True, "newer self-destruct (new) wins the flag");
+ }))
+ .SetName("Merge_AccountOnly_Then_SelfDestructOnly");
+ }
+
+ // Overlapping state node (newer wins) + non-overlapping accounts (both preserved).
+ {
+ TreePath path = new(Keccak.Compute("path"), 4);
+ SnapshotContent c0 = new();
+ c0.StateNodes[path] = new TrieNode(NodeType.Leaf, [0xC0]);
+ c0.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(100).TestObject;
+ SnapshotContent c1 = new();
+ c1.StateNodes[path] = new TrieNode(NodeType.Leaf, [0xC1, 0x80]);
+ c1.Accounts[TestItem.AddressB] = Build.An.Account.WithBalance(200).TestObject;
+ yield return new TestCaseData(
+ (object)new[] { c0, c1 },
+ (Action)(s =>
+ {
+ Assert.That(s.TryLoadStateNodeRlp(path, out byte[]? rlp), Is.True);
+ Assert.That(rlp, Is.EqualTo(new byte[] { 0xC1, 0x80 }), "Newer state-node RLP wins");
+ Assert.That(s.TryGetAccount(TestItem.AddressA, out Account? a), Is.True);
+ Assert.That(a!.Balance, Is.EqualTo((UInt256)100));
+ Assert.That(s.TryGetAccount(TestItem.AddressB, out Account? b), Is.True);
+ Assert.That(b!.Balance, Is.EqualTo((UInt256)200));
+ }))
+ .SetName("Merge_NewerOverridesOlder");
+ }
+
+ // Two distinct state node paths, both survive merge.
+ {
+ TreePath p1 = new(Keccak.Compute("path1"), 4);
+ TreePath p2 = new(Keccak.Compute("path2"), 4);
+ SnapshotContent c0 = new();
+ c0.StateNodes[p1] = new TrieNode(NodeType.Leaf, [0xC0]);
+ SnapshotContent c1 = new();
+ c1.StateNodes[p2] = new TrieNode(NodeType.Leaf, [0xC1, 0x80]);
+ yield return new TestCaseData(
+ (object)new[] { c0, c1 },
+ (Action)(s =>
+ {
+ Assert.That(s.TryLoadStateNodeRlp(p1, out byte[]? r1), Is.True);
+ Assert.That(r1, Is.EqualTo(new byte[] { 0xC0 }));
+ Assert.That(s.TryLoadStateNodeRlp(p2, out byte[]? r2), Is.True);
+ Assert.That(r2, Is.EqualTo(new byte[] { 0xC1, 0x80 }));
+ }))
+ .SetName("Merge_PreservesNonOverlapping");
+ }
+
+ // Older slot cleared by self-destruct, newer slot + flag preserved.
+ {
+ SnapshotContent c0 = new();
+ c0.Storages[(TestItem.AddressA, 1)] = new SlotValue(new byte[] { 0x42 });
+ SnapshotContent c1 = new();
+ c1.SelfDestructedStorageAddresses[TestItem.AddressA] = false;
+ c1.Storages[(TestItem.AddressA, 2)] = new SlotValue(new byte[] { 0x99 });
+ yield return new TestCaseData(
+ (object)new[] { c0, c1 },
+ (Action)(s =>
+ {
+ SlotValue slot1 = default;
+ Assert.That(s.TryGetSlot(TestItem.AddressA, 1, ref slot1), Is.False, "Older slot must be cleared by newer destruct");
+ SlotValue slot2 = default;
+ Assert.That(s.TryGetSlot(TestItem.AddressA, 2, ref slot2), Is.True);
+ Assert.That(slot2.AsReadOnlySpan.ToArray(), Is.EqualTo(new SlotValue(new byte[] { 0x99 }).AsReadOnlySpan.ToArray()));
+ Assert.That(s.TryGetSelfDestructFlag(TestItem.AddressA), Is.False, "Destruct flag must be present and value must be `false` (destructed)");
+ }))
+ .SetName("Merge_SelfDestruct_ClearsOlderStorage");
+ }
+
+ // Barrier isolation: a self-destruct truncates only its own address's older slots; a sibling
+ // address with no self-destruct keeps its slots. Slots live in their own column now, so this
+ // exercises the merge's cross-address self-destruct-barrier walk.
+ {
+ SnapshotContent c0 = new();
+ c0.Storages[(TestItem.AddressA, 1)] = new SlotValue(new byte[] { 0x11 });
+ c0.Storages[(TestItem.AddressB, 1)] = new SlotValue(new byte[] { 0x22 });
+ SnapshotContent c1 = new();
+ c1.SelfDestructedStorageAddresses[TestItem.AddressA] = false;
+ c1.Storages[(TestItem.AddressA, 2)] = new SlotValue(new byte[] { 0x33 });
+ yield return new TestCaseData(
+ (object)new[] { c0, c1 },
+ (Action)(s =>
+ {
+ SlotValue a1 = default, a2 = default, b1 = default;
+ Assert.That(s.TryGetSlot(TestItem.AddressA, 1, ref a1), Is.False, "A's older slot truncated by A's destruct");
+ Assert.That(s.TryGetSlot(TestItem.AddressA, 2, ref a2), Is.True, "A's post-destruct slot survives");
+ Assert.That(a2.AsReadOnlySpan.ToArray(), Is.EqualTo(new SlotValue(new byte[] { 0x33 }).AsReadOnlySpan.ToArray()));
+ Assert.That(s.TryGetSlot(TestItem.AddressB, 1, ref b1), Is.True, "B (no destruct) keeps its slot");
+ Assert.That(b1.AsReadOnlySpan.ToArray(), Is.EqualTo(new SlotValue(new byte[] { 0x22 }).AsReadOnlySpan.ToArray()));
+ }))
+ .SetName("Merge_SelfDestruct_BarrierIsolation_AcrossAddresses");
+ }
+
+ // Newer true flag doesn't overwrite older false (destructed) — TryAdd semantics.
+ {
+ SnapshotContent c0 = new();
+ c0.SelfDestructedStorageAddresses[TestItem.AddressA] = false;
+ SnapshotContent c1 = new();
+ c1.SelfDestructedStorageAddresses[TestItem.AddressA] = true;
+ yield return new TestCaseData(
+ (object)new[] { c0, c1 },
+ (Action)(s =>
+ {
+ Assert.That(s.TryGetSelfDestructFlag(TestItem.AddressA), Is.False,
+ "Older `false` (destructed) flag must win over newer `true` (new-account) flag");
+ }))
+ .SetName("Merge_SelfDestruct_TryAddSemantics");
+ }
+
+ // Storage trie nodes survive self-destruct (only storage *slot* data is cleared).
+ {
+ Hash256 addrHash = Keccak.Compute(TestItem.AddressA.Bytes);
+ TreePath storagePath = new(Keccak.Compute("storage_path"), 4);
+ SnapshotContent c0 = new();
+ c0.StorageNodes[(addrHash, storagePath)] = new TrieNode(NodeType.Leaf, [0xC1, 0x80]);
+ SnapshotContent c1 = new();
+ c1.SelfDestructedStorageAddresses[TestItem.AddressA] = false;
+ yield return new TestCaseData(
+ (object)new[] { c0, c1 },
+ (Action)(s =>
+ {
+ Assert.That(s.TryLoadStorageNodeRlp(addrHash.ValueHash256, storagePath, out byte[]? rlp), Is.True,
+ "Storage trie node must survive self-destruct of the account");
+ Assert.That(rlp, Is.EqualTo(new byte[] { 0xC1, 0x80 }));
+ }))
+ .SetName("Merge_SelfDestruct_StorageNodesKept");
+ }
+
+ // Single-source, no-slot verbatim fast path: A (account-only EOA) and C (account +
+ // self-destruct flag) appear in only one source and carry no slots, so each is
+ // byte-copied verbatim through the outer builder; B keeps the second source non-empty.
+ {
+ SnapshotContent c0 = new();
+ c0.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(100).TestObject;
+ c0.Accounts[TestItem.AddressC] = Build.An.Account.WithBalance(300).TestObject;
+ c0.SelfDestructedStorageAddresses[TestItem.AddressC] = false;
+ SnapshotContent c1 = new();
+ c1.Accounts[TestItem.AddressB] = Build.An.Account.WithBalance(200).TestObject;
+ yield return new TestCaseData(
+ (object)new[] { c0, c1 },
+ (Action)(s =>
+ {
+ Assert.That(s.TryGetAccount(TestItem.AddressA, out Account? a), Is.True);
+ Assert.That(a!.Balance, Is.EqualTo((UInt256)100), "Account-only EOA copied verbatim");
+ SlotValue slotA = default;
+ Assert.That(s.TryGetSlot(TestItem.AddressA, 1, ref slotA), Is.False, "EOA has no slots");
+
+ Assert.That(s.TryGetAccount(TestItem.AddressC, out Account? c), Is.True);
+ Assert.That(c!.Balance, Is.EqualTo((UInt256)300), "Account survives verbatim copy");
+ Assert.That(s.TryGetSelfDestructFlag(TestItem.AddressC), Is.False,
+ "Self-destruct flag survives verbatim copy alongside the account sub-tag");
+
+ Assert.That(s.TryGetAccount(TestItem.AddressB, out Account? b), Is.True);
+ Assert.That(b!.Balance, Is.EqualTo((UInt256)200));
+ }))
+ .SetName("Merge_SingleSource_NoSlot_Verbatim");
+ }
+ }
+
+ [TestCaseSource(nameof(MergeValidationTestCases))]
+ public void MergeSnapshots_ValidatesCorrectly(SnapshotContent[] contents, Action assertCompacted)
+ {
+ // maxCompactSize == 2 — only a size-2 compaction is attempted, so
+ // exactly two consecutive base snapshots are merged into one compacted snapshot.
+ using FlatTestContainer tier = new(
+ arenaFileSizeBytes: 64 * 1024,
+ configure: b => b.AddSingleton(ScheduleHelper.CreateWithOffset(new FlatDbConfig { CompactSize = 1, PersistedSnapshotMaxCompactSize = 2 }, 0)));
+ SnapshotRepository repo = tier.Repository;
+ PersistedSnapshotCompactor compactor = tier.Compactor;
+
+ StateId[] states = new StateId[contents.Length + 1];
+ states[0] = new StateId(0, Keccak.EmptyTreeHash);
+ for (int i = 0; i < contents.Length; i++)
+ {
+ states[i + 1] = new StateId(i + 1, Keccak.Compute($"{i + 1}"));
+ tier.ConvertToPersistedBase(
+ new Snapshot(states[i], states[i + 1], contents[i], _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+ }
+
+ compactor.DoCompactSnapshot(states[contents.Length]);
+
+ Assert.That(repo.TryLeasePersistedState(states[contents.Length], SnapshotTier.PersistedLargeCompacted, out PersistedSnapshot? compacted), Is.True,
+ "Expected a compacted snapshot to exist after DoCompactSnapshot");
+ using (compacted)
+ {
+ assertCompacted(compacted!);
+ }
+ }
+
+ // Config: compactSize=1 (PersistenceManager boundary), maxCompactSize=8.
+ // blockNumber=8 → 8 & -8 = 8, so the compaction window is [0, 8].
+ //
+ // presentBlocks: which block-slots are populated (snapshot From=states[b-1], To=states[b]).
+ // The window need not be fully populated — whatever contiguous chain of ≥2 snapshots
+ // assembles back from block 8 is compacted into a single snapshot.
+ // expectCompacted=false means no compaction expected.
+ private static IEnumerable PartialWindowCompactionCases()
+ {
+ // Full 8-block range present: compacts the whole window. Linked s0→s8.
+ yield return new TestCaseData(new[] { 1, 2, 3, 4, 5, 6, 7, 8 }, true, 0L, 8L)
+ .SetName("PartialWindow_FullRange_Compacts0To8");
+
+ // Blocks 3–8 present: the chain reaches back to s2, a non-power-of-2 boundary.
+ // The old power-of-2 step-down would have compacted only [4,8]; now the whole
+ // assembled chain [2,8] is compacted instead.
+ yield return new TestCaseData(new[] { 3, 4, 5, 6, 7, 8 }, true, 2L, 8L)
+ .SetName("PartialWindow_NonPowerOfTwoStart_Compacts2To8");
+
+ // Only blocks 5–8 present: chain reaches back to s4. Compacts [4,8].
+ yield return new TestCaseData(new[] { 5, 6, 7, 8 }, true, 4L, 8L)
+ .SetName("PartialWindow_Half_Compacts4To8");
+
+ // Only blocks 7–8 present: chain reaches back to s6. Compacts [6,8].
+ yield return new TestCaseData(new[] { 7, 8 }, true, 6L, 8L)
+ .SetName("PartialWindow_Quarter_Compacts6To8");
+
+ // Only 1 block present: no pair available, no compaction.
+ yield return new TestCaseData(new[] { 8 }, false, 0L, 0L)
+ .SetName("PartialWindow_NoRange_NoCompact");
+ }
+
+ [TestCaseSource(nameof(PartialWindowCompactionCases))]
+ public void DoCompactSnapshot_CompactsPartialWindow(
+ int[] presentBlocks, bool expectCompacted, long expectedFromBlock, long expectedToBlock)
+ {
+ // CompactSize=1 makes every block a boundary; block 8 → window [0, 8].
+ using FlatTestContainer tier = new(
+ arenaFileSizeBytes: 64 * 1024,
+ configure: b => b.AddSingleton(ScheduleHelper.CreateWithOffset(new FlatDbConfig { CompactSize = 1, PersistedSnapshotMaxCompactSize = 8 }, 0)));
+ SnapshotRepository repo = tier.Repository;
+ PersistedSnapshotCompactor compactor = tier.Compactor;
+
+ StateId[] states = new StateId[9];
+ states[0] = new StateId(0, Keccak.EmptyTreeHash);
+ for (int i = 1; i <= 8; i++)
+ states[i] = new StateId(i, Keccak.Compute($"{i}"));
+
+ foreach (int block in presentBlocks)
+ {
+ SnapshotContent content = new();
+ content.Accounts[TestItem.Addresses[block - 1]] = Build.An.Account.WithBalance((ulong)block * 100).TestObject;
+ tier.ConvertToPersistedBase(new Snapshot(states[block - 1], states[block], content, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+ }
+
+ compactor.DoCompactSnapshot(states[8]);
+
+ if (!expectCompacted)
+ {
+ Assert.That(repo.TryLeasePersistedState(states[8], SnapshotTier.PersistedLargeCompacted, out PersistedSnapshot? none), Is.False,
+ "Expected no compacted snapshot");
+ _ = none;
+ }
+ else
+ {
+ Assert.That(repo.TryLeasePersistedState(states[8], SnapshotTier.PersistedLargeCompacted, out PersistedSnapshot? compacted), Is.True,
+ "Expected a compacted snapshot");
+ Assert.That(compacted!.From.BlockNumber, Is.EqualTo(expectedFromBlock));
+ Assert.That(compacted.To.BlockNumber, Is.EqualTo(expectedToBlock));
+ compacted.Dispose();
+ }
+ }
+
+ // A [0,8] large-compacted (To=8) survives until persistence passes block 8, so its From=0 sits
+ // below any persistence point in (0, 8]. The widest-skip-first assemble walk would follow that
+ // edge and drag block 16's compaction down to From=0. Clamping the window to the persistence
+ // point makes the walk reject the below-P edge and assemble from P upward via the bases instead.
+ private static IEnumerable ClampToPersistenceCases()
+ {
+ // P at genesis: no clamp, the walk follows the [0,8] large-compacted skip-pointer to From=0.
+ yield return new TestCaseData(0L, 0L).SetName("ClampToPersistence_GenesisP_NoClamp_From0");
+ // P inside the [0,8] span: the below-P edge is skipped, the walk wins at From=P via the bases.
+ yield return new TestCaseData(4L, 4L).SetName("ClampToPersistence_PInsideSpan_ClampsFrom4");
+ // P at the [0,8] To boundary: still clamped, never reaching the From=0 edge.
+ yield return new TestCaseData(8L, 8L).SetName("ClampToPersistence_PAtBoundary_ClampsFrom8");
+ }
+
+ [TestCaseSource(nameof(ClampToPersistenceCases))]
+ public void DoCompactSnapshot_ClampsWindowToPersistencePoint(long persistedBlock, long expectedFromBlock)
+ {
+ // CompactSize=1 makes every block a boundary; MaxCompactSize=16 so block 16's window is [0, 16].
+ using FlatTestContainer tier = new(
+ arenaFileSizeBytes: 256 * 1024,
+ blobFileSizeBytes: 4 * 1024 * 1024,
+ configure: b => b.AddSingleton(
+ ScheduleHelper.CreateWithOffset(new FlatDbConfig { CompactSize = 1, PersistedSnapshotMaxCompactSize = 16 }, 0)));
+ SnapshotRepository repo = tier.Repository;
+ PersistedSnapshotCompactor compactor = tier.Compactor;
+
+ StateId[] states = new StateId[17];
+ states[0] = new StateId(0, Keccak.EmptyTreeHash);
+ for (int i = 1; i <= 16; i++)
+ states[i] = new StateId(i, Keccak.Compute($"{i}"));
+
+ // Build base snapshots [0..8], then the [0,8] large-compacted skip-pointer.
+ for (int i = 1; i <= 8; i++)
+ BuildBase(tier, states, i);
+ compactor.DoCompactSnapshot(states[8], persistedBlockNumber: 0);
+ Assert.That(repo.TryLeasePersistedState(states[8], SnapshotTier.PersistedLargeCompacted, out PersistedSnapshot? seed), Is.True,
+ "precondition: the [0,8] large-compacted skip-pointer must exist");
+ seed!.Dispose();
+
+ // Build base snapshots [9..16] so narrower edges exist above the persistence point.
+ for (int i = 9; i <= 16; i++)
+ BuildBase(tier, states, i);
+
+ // Compact block 16's [0,16] window, clamped to the persistence point.
+ compactor.DoCompactSnapshot(states[16], persistedBlockNumber: persistedBlock);
+
+ Assert.That(repo.TryLeasePersistedState(states[16], SnapshotTier.PersistedLargeCompacted, out PersistedSnapshot? compacted), Is.True,
+ "Expected a large-compacted snapshot at block 16");
+ using (compacted)
+ {
+ Assert.That(compacted!.To.BlockNumber, Is.EqualTo(16));
+ Assert.That(compacted.From.BlockNumber, Is.EqualTo(expectedFromBlock),
+ persistedBlock == 0
+ ? "Unclamped: the walk follows the [0,8] large-compacted edge down to From=0"
+ : "Clamped: the below-P [0,8] edge is rejected and the walk wins at From=P");
+ }
+ }
+
+ private void BuildBase(FlatTestContainer tier, StateId[] states, int block)
+ {
+ SnapshotContent content = new();
+ content.Accounts[TestItem.Addresses[block - 1]] = Build.An.Account.WithBalance((ulong)block * 100).TestObject;
+ tier.ConvertToPersistedBase(new Snapshot(states[block - 1], states[block], content, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+ }
+
+ ///
+ /// After compaction, /
+ /// must dereference the merged
+ /// snapshot's per-key NodeRefs through the union of referenced blob arenas
+ /// and yield the newest-writer RLP for overlapping paths, the only-writer RLP for
+ /// non-overlapping paths.
+ ///
+ [Test]
+ public void CompactedSnapshot_TrieNodeResolution_NewerOverridesOlder()
+ {
+ using FlatTestContainer tier = new(
+ arenaFileSizeBytes: 64 * 1024,
+ configure: b => b.AddSingleton(ScheduleHelper.CreateWithOffset(new FlatDbConfig { CompactSize = 4 }, 0)));
+ SnapshotRepository repo = tier.Repository;
+ PersistedSnapshotCompactor compactor = tier.Compactor;
+
+ TreePath sharedStatePath = new(Keccak.Compute("shared_state"), 4);
+ TreePath onlyOldStatePath = new(Keccak.Compute("only_old_state"), 4);
+ TreePath onlyNewStatePath = new(Keccak.Compute("only_new_state"), 4);
+ Hash256 storageTrieAddr = Keccak.Compute("storage_trie_addr");
+ TreePath sharedStoragePath = new(Keccak.Compute("shared_storage"), 6);
+
+ byte[] oldStateRlp = [0xC1, 0x80];
+ byte[] newStateRlp = [0xC2, 0x81, 0x42];
+ byte[] onlyOldRlp = [0xC1, 0x33];
+ byte[] onlyNewRlp = [0xC1, 0x55];
+ byte[] oldStorageRlp = [0xC1, 0x80];
+ byte[] newStorageRlp = [0xC2, 0x82, 0x99];
+
+ StateId prev = new(0, Keccak.EmptyTreeHash);
+ for (int i = 1; i <= 8; i++)
+ {
+ StateId next = new(i, Keccak.Compute($"{i}"));
+ SnapshotContent c = new();
+ if (i == 1)
+ {
+ c.StateNodes[sharedStatePath] = new TrieNode(NodeType.Leaf, oldStateRlp);
+ c.StateNodes[onlyOldStatePath] = new TrieNode(NodeType.Leaf, onlyOldRlp);
+ c.StorageNodes[(storageTrieAddr, sharedStoragePath)] = new TrieNode(NodeType.Leaf, oldStorageRlp);
+ }
+ else if (i == 8)
+ {
+ c.StateNodes[sharedStatePath] = new TrieNode(NodeType.Leaf, newStateRlp);
+ c.StateNodes[onlyNewStatePath] = new TrieNode(NodeType.Leaf, onlyNewRlp);
+ c.StorageNodes[(storageTrieAddr, sharedStoragePath)] = new TrieNode(NodeType.Leaf, newStorageRlp);
+ }
+ else
+ {
+ c.Accounts[TestItem.Addresses[i - 1]] = Build.An.Account.WithBalance((UInt256)(i * 10)).TestObject;
+ }
+ tier.ConvertToPersistedBase(new Snapshot(prev, next, c, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+ prev = next;
+ }
+
+ compactor.DoCompactSnapshot(prev);
+
+ Assert.That(repo.TryLeasePersistedState(prev, SnapshotTier.PersistedLargeCompacted, out PersistedSnapshot? compacted), Is.True);
+ using (compacted)
+ {
+ Assert.That(compacted!.TryLoadStateNodeRlp(sharedStatePath, out byte[]? sharedResult), Is.True);
+ Assert.That(sharedResult, Is.EqualTo(newStateRlp),
+ "Overlapping state-node path must resolve to newest writer's RLP");
+
+ Assert.That(compacted.TryLoadStateNodeRlp(onlyOldStatePath, out byte[]? oldOnly), Is.True);
+ Assert.That(oldOnly, Is.EqualTo(onlyOldRlp),
+ "State node only in the oldest source must survive the merge with its original RLP");
+
+ Assert.That(compacted.TryLoadStateNodeRlp(onlyNewStatePath, out byte[]? newOnly), Is.True);
+ Assert.That(newOnly, Is.EqualTo(onlyNewRlp),
+ "State node only in the newest source must survive the merge with its original RLP");
+
+ Assert.That(compacted.TryLoadStorageNodeRlp(storageTrieAddr.ValueHash256, sharedStoragePath, out byte[]? storageResult), Is.True);
+ Assert.That(storageResult, Is.EqualTo(newStorageRlp),
+ "Overlapping storage-node path must resolve to newest writer's RLP");
+ }
+ }
+
+ ///
+ /// Regression for the builder no-storage fast path in
+ /// PersistedSnapshotBuilder.WritePerAddressColumn: when an address has no
+ /// slots and no storage-trie nodes the per-address inner table is staged into a
+ /// pooled buffer so its length is known up-front, and the outer leaf entry applies
+ /// 4 KiB page-alignment padding. Drives many EOAs so writer positions sweep across
+ /// page boundaries; every address must round-trip read intact and every self-destruct
+ /// flag must survive the staging path. A mix of plain EOAs, EOA-with-SD and a few
+ /// contracts (which take the streaming path) confirms both branches coexist.
+ ///
+ [TestCase(40)]
+ [TestCase(120)]
+ public void WritePerAddressColumn_NoStorageFastPath_RoundTripsEoaSnapshot(int accountCount)
+ {
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 256 * 1024, blobFileSizeBytes: 4 * 1024 * 1024);
+ SnapshotRepository repo = tier.Repository;
+
+ // Every 7th address gets storage (so the streaming path also fires) and the
+ // routing decision flips per-address; every 5th address gets a self-destruct
+ // flag (so the SD sub-tag is exercised on the staged DenseByteIndex).
+ SnapshotContent c = new();
+ for (int i = 0; i < accountCount; i++)
+ {
+ Address addr = TestItem.Addresses[i];
+ c.Accounts[addr] = Build.An.Account.WithBalance((UInt256)(i + 1)).TestObject;
+ if (i % 5 == 0)
+ c.SelfDestructedStorageAddresses[addr] = (i % 10 == 0);
+ if (i % 7 == 0)
+ c.Storages[(addr, 1)] = new SlotValue(new byte[] { (byte)(i & 0xFF) });
+ }
+
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("p1"));
+ tier.ConvertToPersistedBase(new Snapshot(s0, s1, c, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+
+ Assert.That(repo.TryLeasePersistedState(s1, SnapshotTier.PersistedBase, out PersistedSnapshot? built), Is.True);
+ using (built)
+ {
+ Assert.Multiple(() =>
+ {
+ for (int i = 0; i < accountCount; i++)
+ {
+ Address addr = TestItem.Addresses[i];
+ Assert.That(built!.TryGetAccount(addr, out Account? a), Is.True,
+ $"Account {i} ({(i % 7 == 0 ? "with-storage" : "no-storage")}) must survive WritePerAddressColumn");
+ Assert.That(a!.Balance, Is.EqualTo((UInt256)(i + 1)),
+ $"Account {i} balance mismatch — pad bytes leaked into the value range");
+ if (i % 5 == 0)
+ {
+ Assert.That(built.TryGetSelfDestructFlag(addr), Is.EqualTo((bool?)(i % 10 == 0)),
+ $"Self-destruct flag for account {i} must survive the staged DenseByteIndex path");
+ }
+ if (i % 7 == 0)
+ {
+ SlotValue slot = default;
+ Assert.That(built.TryGetSlot(addr, 1, ref slot), Is.True,
+ $"Slot for storage-bearing account {i} must come back from the streaming path");
+ SlotValue expected = new(new byte[] { (byte)(i & 0xFF) });
+ Assert.That(slot.AsReadOnlySpan.ToArray(), Is.EqualTo(expected.AsReadOnlySpan.ToArray()));
+ }
+ }
+ });
+ }
+ }
+
+ ///
+ /// Regression for the merger no-storage fast path in
+ /// PersistedSnapshotMerger.NWayMergePerAddressColumn: two snapshots covering
+ /// the SAME set of EOAs collide on every address (matchCount > 1) without any
+ /// source contributing slots or storage-trie nodes, so the staged-and-padded helper
+ /// runs for every cursor address. Newest-wins on Account / first-non-empty on Address
+ /// preimage / TryAdd on SD must all hold after the staged DenseByteIndex round-trips.
+ ///
+ [TestCase(40)]
+ [TestCase(120)]
+ public void Compact_MultiSourceMerge_NoStorageFastPath_RoundTrips(int accountCount)
+ {
+ using FlatTestContainer tier = new(
+ arenaFileSizeBytes: 256 * 1024,
+ blobFileSizeBytes: 4 * 1024 * 1024,
+ configure: b => b.AddSingleton(ScheduleHelper.CreateWithOffset(new FlatDbConfig { CompactSize = 1, PersistedSnapshotMaxCompactSize = 2 }, 0)));
+ SnapshotRepository repo = tier.Repository;
+ PersistedSnapshotCompactor compactor = tier.Compactor;
+
+ // Both sources touch every address with a different balance — collision on
+ // every cursor address forces matchCount==2, and the absence of slots /
+ // storage-trie nodes in either source flips the no-storage routing on.
+ SnapshotContent c0 = new();
+ SnapshotContent c1 = new();
+ for (int i = 0; i < accountCount; i++)
+ {
+ Address addr = TestItem.Addresses[i];
+ c0.Accounts[addr] = Build.An.Account.WithBalance((UInt256)(i + 1)).TestObject;
+ c1.Accounts[addr] = Build.An.Account.WithBalance((UInt256)((i + 1) * 1000)).TestObject;
+ // Every 5th address: set the destruct flag only in c0 (older). TryAdd
+ // semantics must preserve it through the merge with c1 (which doesn't set
+ // it), and the staged DenseByteIndex must emit it as sub-tag 0x03.
+ if (i % 5 == 0)
+ c0.SelfDestructedStorageAddresses[addr] = false;
+ }
+
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("p1"));
+ StateId s2 = new(2, Keccak.Compute("p2"));
+ tier.ConvertToPersistedBase(new Snapshot(s0, s1, c0, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+ tier.ConvertToPersistedBase(new Snapshot(s1, s2, c1, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+
+ compactor.DoCompactSnapshot(s2);
+
+ Assert.That(repo.TryLeasePersistedState(s2, SnapshotTier.PersistedLargeCompacted, out PersistedSnapshot? compacted), Is.True);
+ using (compacted)
+ {
+ Assert.Multiple(() =>
+ {
+ for (int i = 0; i < accountCount; i++)
+ {
+ Address addr = TestItem.Addresses[i];
+ Assert.That(compacted!.TryGetAccount(addr, out Account? a), Is.True,
+ $"Account {i} must survive the staged multi-source merge");
+ Assert.That(a!.Balance, Is.EqualTo((UInt256)((i + 1) * 1000)),
+ $"Account {i}: newest balance (c1) must win — pad bytes must not leak into the value range");
+ if (i % 5 == 0)
+ {
+ Assert.That(compacted.TryGetSelfDestructFlag(addr), Is.False,
+ $"Self-destruct flag for account {i} must survive the staged DenseByteIndex merge");
+ }
+ }
+ });
+ }
+ }
+
+ ///
+ /// Regression for the offset-vs-block-number mismatch in
+ /// DoCompactSnapshot's startingBlockNumber. The alignment value comes
+ /// from the offset-shifted schedule but the start-of-window was computed in raw
+ /// block-number space — the previous
+ /// startingBlockNumber = ((blockNumber - 1) / alignment) * alignment formula
+ /// only matched the trigger's actual window when offset == 0. With a non-zero
+ /// offset it produced a span of (blockNumber mod alignment) instead of
+ /// alignment.
+ ///
+ /// Test geometry: offset=3, CompactSize=64, maxCompactSize=32. At block 45,
+ /// (45 + 3) & -(45 + 3) = 48 & -48 = 16, so alignment=16 fires.
+ /// Window must be (29, 45] (span 16), not the buggy (32, 45] (span 13).
+ ///
+ [Test]
+ public void DoCompactSnapshot_WithNonZeroScheduleOffset_StartingBlockSpansFullAlignment()
+ {
+ using FlatTestContainer tier = new(
+ arenaFileSizeBytes: 256 * 1024,
+ blobFileSizeBytes: 4 * 1024 * 1024,
+ configure: b => b.AddSingleton(ScheduleHelper.CreateWithOffset(new FlatDbConfig { CompactSize = 64, PersistedSnapshotMaxCompactSize = 32 }, 3)));
+ SnapshotRepository repo = tier.Repository;
+ PersistedSnapshotCompactor compactor = tier.Compactor;
+
+ // 45 base snapshots, blocks 1..45. No intermediate compactions so
+ // AssemblePersistedSnapshotsForCompaction sees only bases.
+ StateId prev = new(0, Keccak.EmptyTreeHash);
+ StateId tip = prev;
+ for (int i = 1; i <= 45; i++)
+ {
+ StateId next = new(i, Keccak.Compute($"s{i}"));
+ SnapshotContent c = new();
+ c.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance((UInt256)i).TestObject;
+ tier.ConvertToPersistedBase(new Snapshot(prev, next, c, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+ prev = next;
+ if (i == 45) tip = next;
+ }
+
+ // At block 45 with offset=3, alignment=16. Window must be (29, 45].
+ compactor.DoCompactSnapshot(tip);
+
+ Assert.That(repo.TryLeasePersistedState(tip, SnapshotTier.PersistedSmallCompacted, out PersistedSnapshot? compacted), Is.True);
+ try
+ {
+ Assert.That(compacted!.From.BlockNumber, Is.EqualTo(29),
+ "startingBlockNumber must be (blockNumber - alignment) — the left edge of the window the offset-shifted alignment trigger selects");
+ Assert.That(compacted.To.BlockNumber, Is.EqualTo(45));
+ Assert.That(compacted.To.BlockNumber - compacted.From.BlockNumber, Is.EqualTo(16),
+ "compacted span must equal alignment, not (blockNumber mod alignment)");
+ }
+ finally { compacted!.Dispose(); }
+ }
+
+ private static FlatTestContainer NewTier(int compactSize) => new(
+ arenaFileSizeBytes: 256 * 1024,
+ blobFileSizeBytes: 4 * 1024 * 1024,
+ configure: b => b.AddSingleton(ScheduleHelper.CreateWithOffset(new FlatDbConfig { CompactSize = compactSize }, 0)));
+
+ [Test]
+ public void DoCompactSnapshot_NoOp_WhenWindowSizeOneOrTooFewSnapshots()
+ {
+ using FlatTestContainer tier = NewTier(compactSize: 4);
+ PersistedSnapshotCompactor compactor = tier.Compactor;
+
+ // Block 1: natural window size is 1 → nothing to merge.
+ compactor.DoCompactSnapshot(new StateId(1, Keccak.Compute("b1")));
+ // Block 4: window size 4, but the empty repo has < 2 snapshots.
+ compactor.DoCompactSnapshot(new StateId(4, Keccak.Compute("b4")));
+
+ Assert.That(tier.Repository.PersistedSnapshotCount, Is.EqualTo(0), "no compaction should have run");
+ }
+
+ [Test]
+ public void DoCompactCompactSized_NoOp_WhenNotBoundaryOrTooFewSnapshots()
+ {
+ using FlatTestContainer tier = NewTier(compactSize: 4);
+ PersistedSnapshotCompactor compactor = tier.Compactor;
+
+ compactor.DoCompactCompactSized(new StateId(3, Keccak.Compute("b3"))); // not a boundary
+ compactor.DoCompactCompactSized(new StateId(4, Keccak.Compute("b4"))); // boundary, but empty repo
+
+ Assert.That(tier.Repository.PersistedSnapshotCount, Is.EqualTo(0), "no CompactSized snapshot should have been produced");
+ }
+
+ [Test]
+ public void DoCompactCompactSized_AtBoundary_ProducesCompactSizedSnapshot()
+ {
+ using FlatTestContainer tier = NewTier(compactSize: 4);
+ SnapshotRepository repo = tier.Repository;
+ PersistedSnapshotCompactor compactor = tier.Compactor;
+
+ StateId prev = new(0, Keccak.EmptyTreeHash);
+ StateId tip = prev;
+ for (int i = 1; i <= 4; i++)
+ {
+ tip = new(i, Keccak.Compute($"p{i}"));
+ SnapshotContent c = new();
+ c.Accounts[TestItem.Addresses[i - 1]] = Build.An.Account.WithBalance((UInt256)(i * 10)).TestObject;
+ tier.ConvertToPersistedBase(new Snapshot(prev, tip, c, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+ prev = tip;
+ }
+
+ compactor.DoCompactCompactSized(tip);
+
+ Assert.That(repo.TryLeasePersistedState(tip, SnapshotTier.PersistedCompactSized, out PersistedSnapshot? compactSized), Is.True);
+ try
+ {
+ Assert.That(compactSized!.From.BlockNumber, Is.EqualTo(0));
+ Assert.That(compactSized.To.BlockNumber, Is.EqualTo(4));
+ for (int i = 1; i <= 4; i++)
+ Assert.That(compactSized.TryGetAccount(TestItem.Addresses[i - 1], out _), Is.True, $"account from block {i} missing");
+ }
+ finally { compactSized!.Dispose(); }
+ }
+
+ [Test]
+ public void DoCompactSnapshot_AtBoundary_NoAddressColumn_WarmsGracefully()
+ {
+ using FlatTestContainer tier = NewTier(compactSize: 2);
+ SnapshotRepository repo = tier.Repository;
+ PersistedSnapshotCompactor compactor = tier.Compactor;
+
+ StateId prev = new(0, Keccak.EmptyTreeHash);
+ StateId tip = prev;
+ for (int i = 1; i <= 2; i++)
+ {
+ tip = new(i, Keccak.Compute($"sn{i}"));
+ SnapshotContent c = new();
+ TreePath path = new(Keccak.Compute($"node{i}"), 4);
+ c.StateNodes[path] = new TrieNode(NodeType.Leaf, [0xC2, 0x80, (byte)i]);
+ tier.ConvertToPersistedBase(new Snapshot(prev, tip, c, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+ prev = tip;
+ }
+
+ compactor.DoCompactSnapshot(tip); // block 2 is a CompactSize=2 boundary → WarmAddressColumnIndex path
+
+ Assert.That(repo.TryLeasePersistedState(tip, SnapshotTier.PersistedSmallCompacted, out PersistedSnapshot? compacted), Is.True);
+ try
+ {
+ Assert.That(compacted!.To.BlockNumber, Is.EqualTo(2));
+ TreePath probe = new(Keccak.Compute("node2"), 4);
+ Assert.That(compacted.TryLoadStateNodeRlp(probe, out _), Is.True, "state node must survive the no-address-column compaction");
+ }
+ finally { compacted!.Dispose(); }
+ }
+
+ ///
+ /// A sub-CompactSize intermediate merge lands in the
+ /// tier; a >CompactSize large-boundary merge lands in .
+ /// Each tier resolves only from its own bucket — a lease for the other tier at the same To misses.
+ ///
+ [Test]
+ public void DoCompactSnapshot_SplitsCompactedAndLargeCompactedByWindowWidth()
+ {
+ // CompactSize=4: block 2's window (0,2] spans 2 (< 4) → compacted; block 8's window (0,8] spans 8 (> 4) → large.
+ using FlatTestContainer tier = NewTier(compactSize: 4);
+ SnapshotRepository repo = tier.Repository;
+ PersistedSnapshotCompactor compactor = tier.Compactor;
+
+ StateId prev = new(0, Keccak.EmptyTreeHash);
+ StateId[] states = new StateId[9];
+ states[0] = prev;
+ for (int i = 1; i <= 8; i++)
+ {
+ states[i] = new StateId(i, Keccak.Compute($"s{i}"));
+ SnapshotContent c = new();
+ c.Accounts[TestItem.Addresses[i - 1]] = Build.An.Account.WithBalance((UInt256)(i * 100)).TestObject;
+ tier.ConvertToPersistedBase(new Snapshot(prev, states[i], c, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+ prev = states[i];
+ }
+
+ compactor.DoCompactSnapshot(states[2]); // sub-CompactSize intermediate
+ compactor.DoCompactSnapshot(states[8]); // >CompactSize large-boundary merge
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(repo.TryLeasePersistedState(states[2], SnapshotTier.PersistedSmallCompacted, out PersistedSnapshot? compacted), Is.True,
+ "sub-CompactSize window must be a PersistedSmallCompacted snapshot");
+ using (compacted) Assert.That(compacted!.To.BlockNumber, Is.EqualTo(2));
+
+ Assert.That(repo.TryLeasePersistedState(states[2], SnapshotTier.PersistedLargeCompacted, out _), Is.False,
+ "PersistedSmallCompacted must not resolve from the large-compacted bucket");
+
+ Assert.That(repo.TryLeasePersistedState(states[8], SnapshotTier.PersistedLargeCompacted, out PersistedSnapshot? large), Is.True,
+ ">CompactSize window must be a PersistedLargeCompacted snapshot");
+ using (large) Assert.That(large!.To.BlockNumber, Is.EqualTo(8));
+
+ Assert.That(repo.TryLeasePersistedState(states[8], SnapshotTier.PersistedSmallCompacted, out _), Is.False,
+ "PersistedLargeCompacted must not resolve from the compacted bucket");
+ });
+ }
+
+ ///
+ /// A demoted sub-CompactSize intermediate that no wider compaction has covered keeps its real,
+ /// populated merged bloom — Demote only advises its pages cold. Regression for reverting the
+ /// AlwaysTrue-sentinel-on-demote behaviour.
+ ///
+ [Test]
+ public void Demote_KeepsIntermediateRealBloom()
+ {
+ // CompactSize=4: block 2's window (0,2] spans 2 (< 4) → demoted intermediate. No large boundary
+ // is compacted, so nothing shares over it.
+ using FlatTestContainer tier = NewTier(compactSize: 4);
+ SnapshotRepository repo = tier.Repository;
+ PersistedSnapshotCompactor compactor = tier.Compactor;
+
+ StateId prev = new(0, Keccak.EmptyTreeHash);
+ StateId[] states = new StateId[3];
+ states[0] = prev;
+ for (int i = 1; i <= 2; i++)
+ {
+ states[i] = new StateId(i, Keccak.Compute($"s{i}"));
+ SnapshotContent c = new();
+ c.Accounts[TestItem.Addresses[i - 1]] = Build.An.Account.WithBalance((UInt256)(i * 100)).TestObject;
+ tier.ConvertToPersistedBase(new Snapshot(prev, states[i], c, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+ prev = states[i];
+ }
+
+ compactor.DoCompactSnapshot(states[2]); // sub-CompactSize intermediate → demoted, keeps its real bloom
+
+ Assert.That(repo.TryLeasePersistedState(states[2], SnapshotTier.PersistedSmallCompacted, out PersistedSnapshot? intermediate), Is.True);
+ using (intermediate)
+ {
+ Assert.Multiple(() =>
+ {
+ // A real merge over the window's two accounts carries keys (Count > 0), unlike the
+ // Count==0 AlwaysTrue sentinel the reverted demote path installed.
+ Assert.That(intermediate!.Bloom.Count, Is.GreaterThan(0), "demoted intermediate must keep its real bloom");
+ Assert.That(intermediate.TryGetAccount(TestItem.Addresses[0], out Account? a1), Is.True);
+ Assert.That(a1!.Balance, Is.EqualTo((UInt256)100));
+ Assert.That(intermediate.TryGetAccount(TestItem.Addresses[1], out Account? a2), Is.True);
+ Assert.That(a2!.Balance, Is.EqualTo((UInt256)200));
+ });
+ }
+ }
+
+ ///
+ /// A >CompactSize large-boundary merge adopts its own (superset) bloom across every persisted
+ /// snapshot fully contained in its (from, to] window — base, sub-CompactSize intermediate
+ /// and CompactSized alike. Each contained snapshot ends up reference-equal to the big merge's bloom (so
+ /// its own bloom is freed) and still reads back correctly. Regression for bloom sharing.
+ ///
+ [Test]
+ public void LargeBoundary_SharesBloomAcrossContainedSnapshots()
+ {
+ // CompactSize=4: block 8's window (0,8] spans 8 (> 4) → large boundary → shares its bloom.
+ using FlatTestContainer tier = NewTier(compactSize: 4);
+ SnapshotRepository repo = tier.Repository;
+ PersistedSnapshotCompactor compactor = tier.Compactor;
+
+ StateId prev = new(0, Keccak.EmptyTreeHash);
+ StateId[] states = new StateId[9];
+ states[0] = prev;
+ for (int i = 1; i <= 8; i++)
+ {
+ states[i] = new StateId(i, Keccak.Compute($"s{i}"));
+ SnapshotContent c = new();
+ c.Accounts[TestItem.Addresses[i - 1]] = Build.An.Account.WithBalance((UInt256)(i * 100)).TestObject;
+ tier.ConvertToPersistedBase(new Snapshot(prev, states[i], c, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+ prev = states[i];
+ }
+
+ compactor.DoCompactSnapshot(states[2]); // sub-CompactSize intermediate (small compacted)
+ compactor.DoCompactCompactSized(states[4]); // CompactSize boundary → CompactSized
+ compactor.DoCompactSnapshot(states[8]); // large boundary → shares its bloom across (0,8]
+
+ Assert.That(repo.TryLeasePersistedState(states[8], SnapshotTier.PersistedLargeCompacted, out PersistedSnapshot? big), Is.True);
+ using (big)
+ {
+ BloomFilter shared = big!.Bloom;
+ Assert.That(shared.Count, Is.GreaterThan(0), "the large merge keeps a real, populated bloom");
+
+ // The sub-CompactSize intermediate and the CompactSized both adopt the shared bloom.
+ AssertShares(repo, states[2], SnapshotTier.PersistedSmallCompacted, shared);
+ AssertShares(repo, states[4], SnapshotTier.PersistedCompactSized, shared);
+
+ // Every contained base snapshot adopts the shared bloom and still resolves its account.
+ for (int i = 1; i <= 8; i++)
+ {
+ Assert.That(repo.TryLeasePersistedState(states[i], SnapshotTier.PersistedBase, out PersistedSnapshot? baseSnap), Is.True);
+ using (baseSnap)
+ {
+ Assert.That(ReferenceEquals(baseSnap!.Bloom, shared), Is.True, $"base {i} should share the big merge's bloom");
+ Assert.That(baseSnap.TryGetAccount(TestItem.Addresses[i - 1], out Account? a), Is.True, $"account from block {i} must still resolve");
+ Assert.That(a!.Balance, Is.EqualTo((UInt256)(i * 100)));
+ }
+ }
+ }
+
+ static void AssertShares(SnapshotRepository repo, StateId at, SnapshotTier tier, BloomFilter shared)
+ {
+ Assert.That(repo.TryLeasePersistedState(at, tier, out PersistedSnapshot? s), Is.True, $"{tier} at {at.BlockNumber} must exist");
+ using (s)
+ Assert.That(ReferenceEquals(s!.Bloom, shared), Is.True, $"{tier} at {at.BlockNumber} should share the big merge's bloom");
+ }
+ }
+
+ ///
+ /// A snapshot extending below the big merge's from (its keys are not a subset of the merge's
+ /// window) must NOT adopt the merge's bloom — sharing it would yield false negatives. Builds a [0,8]
+ /// large skip-pointer, then a [4,16] big merge clamped to persistence block 4, and asserts the [0,8]
+ /// snapshot keeps its own bloom.
+ ///
+ [Test]
+ public void LargeBoundary_DoesNotShareBloomIntoSnapshotExtendingBelowFrom()
+ {
+ // CompactSize=1 makes every block a boundary; MaxCompactSize=16 so block 16's window is [0, 16].
+ using FlatTestContainer tier = new(
+ arenaFileSizeBytes: 256 * 1024,
+ blobFileSizeBytes: 4 * 1024 * 1024,
+ configure: b => b.AddSingleton(
+ ScheduleHelper.CreateWithOffset(new FlatDbConfig { CompactSize = 1, PersistedSnapshotMaxCompactSize = 16 }, 0)));
+ SnapshotRepository repo = tier.Repository;
+ PersistedSnapshotCompactor compactor = tier.Compactor;
+
+ StateId[] states = new StateId[17];
+ states[0] = new StateId(0, Keccak.EmptyTreeHash);
+ for (int i = 1; i <= 16; i++)
+ states[i] = new StateId(i, Keccak.Compute($"{i}"));
+
+ // Build base [0..8], then the [0,8] large-compacted skip-pointer.
+ for (int i = 1; i <= 8; i++)
+ BuildBase(tier, states, i);
+ compactor.DoCompactSnapshot(states[8], persistedBlockNumber: 0);
+
+ // Build base [9..16], then the [0,16] window clamped to persistence point 4 → big merge is [4,16].
+ for (int i = 9; i <= 16; i++)
+ BuildBase(tier, states, i);
+ compactor.DoCompactSnapshot(states[16], persistedBlockNumber: 4);
+
+ Assert.That(repo.TryLeasePersistedState(states[16], SnapshotTier.PersistedLargeCompacted, out PersistedSnapshot? big), Is.True);
+ using (big)
+ {
+ Assert.That(big!.From.BlockNumber, Is.EqualTo(4), "precondition: the big merge is clamped to From=4");
+ Assert.That(repo.TryLeasePersistedState(states[8], SnapshotTier.PersistedLargeCompacted, out PersistedSnapshot? below), Is.True);
+ using (below)
+ Assert.That(ReferenceEquals(below!.Bloom, big.Bloom), Is.False,
+ "a [0,8] snapshot extending below from=4 must keep its own bloom");
+ }
+ }
+
+ ///
+ /// Sizing the merged bloom must count a filter shared by several sources only once. A large
+ /// compaction adopts its (superset) bloom across the snapshots it contains, so a later compaction
+ /// can assemble several sources that all point at that one filter — each reporting its whole-window
+ /// key count. Summing per source inflates bloomCapacity (and thus the merged filter) by the
+ /// number of sharers. Builds a [0,8] large skip-pointer that shares its bloom across bases [1,8],
+ /// then a [4,16] merge clamped to persistence 4 assembling bases [5,16] — bases [5,8] share the
+ /// [0,8] bloom — and asserts the merged filter's capacity equals the deduplicated source-bloom sum,
+ /// not the inflated per-source sum.
+ ///
+ [Test]
+ public void LargeBoundary_MergedBloomCapacity_DeduplicatesSharedSourceBloom()
+ {
+ // CompactSize=1 makes every block a boundary; MaxCompactSize=16 so block 16's window is [0, 16].
+ using FlatTestContainer tier = new(
+ arenaFileSizeBytes: 256 * 1024,
+ blobFileSizeBytes: 4 * 1024 * 1024,
+ configure: b => b.AddSingleton(
+ ScheduleHelper.CreateWithOffset(new FlatDbConfig { CompactSize = 1, PersistedSnapshotMaxCompactSize = 16 }, 0)));
+ SnapshotRepository repo = tier.Repository;
+ PersistedSnapshotCompactor compactor = tier.Compactor;
+
+ StateId[] states = new StateId[17];
+ states[0] = new StateId(0, Keccak.EmptyTreeHash);
+ for (int i = 1; i <= 16; i++)
+ states[i] = new StateId(i, Keccak.Compute($"{i}"));
+
+ // Build base [0..8], then the [0,8] large-compacted skip-pointer — it shares its bloom over [1,8].
+ for (int i = 1; i <= 8; i++)
+ BuildBase(tier, states, i);
+ compactor.DoCompactSnapshot(states[8], persistedBlockNumber: 0);
+
+ // Build base [9..16]; the [0,16] window clamps to persistence 4, so the merge spans [4,16] and
+ // assembles bases [5,16] — bases [5,8] still carry the shared [0,8] bloom.
+ for (int i = 9; i <= 16; i++)
+ BuildBase(tier, states, i);
+
+ // Capture the source blooms the merge will see, BEFORE it runs and replaces them with its own
+ // shared bloom. dedupedSum counts each distinct filter once (the [0,8] bloom across bases [5,8]);
+ // naiveSum is the buggy per-source sum that double-counts it.
+ long dedupedSum = 0, naiveSum = 0;
+ HashSet distinct = [];
+ for (int i = 5; i <= 16; i++)
+ {
+ Assert.That(repo.TryLeasePersistedState(states[i], SnapshotTier.PersistedBase, out PersistedSnapshot? src), Is.True);
+ using (src)
+ {
+ naiveSum += src!.Bloom.Count;
+ if (distinct.Add(src.Bloom)) dedupedSum += src.Bloom.Count;
+ }
+ }
+ Assert.That(distinct.Count, Is.LessThan(12), "precondition: bases [5,8] must share one bloom, so fewer than 12 distinct filters");
+ Assert.That(dedupedSum, Is.LessThan(naiveSum), "precondition: the shared bloom is double-counted by a naive per-source sum");
+
+ compactor.DoCompactSnapshot(states[16], persistedBlockNumber: 4);
+
+ Assert.That(repo.TryLeasePersistedState(states[16], SnapshotTier.PersistedLargeCompacted, out PersistedSnapshot? big), Is.True);
+ using (big)
+ {
+ Assert.That(big!.From.BlockNumber, Is.EqualTo(4), "precondition: the merge is clamped to From=4");
+ Assert.That(big.Bloom.Capacity, Is.EqualTo(dedupedSum),
+ "merged bloom capacity must count the shared source bloom once, not once per sharer");
+ }
+ }
+}
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/PersistedSnapshotPerAddressTests.cs b/src/Nethermind/Nethermind.State.Flat.Test/PersistedSnapshotPerAddressTests.cs
new file mode 100644
index 000000000000..976487d9aafe
--- /dev/null
+++ b/src/Nethermind/Nethermind.State.Flat.Test/PersistedSnapshotPerAddressTests.cs
@@ -0,0 +1,92 @@
+// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using Nethermind.Core;
+using Nethermind.Core.Crypto;
+using Nethermind.Core.Extensions;
+using Nethermind.Core.Test.Builders;
+using Nethermind.Serialization.Rlp;
+using Nethermind.State.Flat.PersistedSnapshots;
+using NUnit.Framework;
+using AccountState = Nethermind.State.Flat.PersistedSnapshots.PersistedSnapshotPerAddress.AccountState;
+using SelfDestructState = Nethermind.State.Flat.PersistedSnapshots.PersistedSnapshotPerAddress.SelfDestructState;
+
+namespace Nethermind.State.Flat.Test;
+
+// The internal nested enums can't appear in public test-method signatures (CS0051), so the
+// parameterized cases run via private helpers driven from a couple of public [Test] methods.
+[TestFixture]
+public class PersistedSnapshotPerAddressTests
+{
+ private static readonly SelfDestructState[] AllSelfDestructStates =
+ [SelfDestructState.None, SelfDestructState.Destructed, SelfDestructState.New];
+
+ // Every (account state × self-destruct state) the codec must round-trip. Absent+None is not
+ // emitted by the builder (storage-only addresses write nothing) but the codec stays agnostic.
+ [Test]
+ public void Encode_then_decode_round_trips_all_states()
+ {
+ Account present = Build.An.Account.WithBalance(12345).WithNonce(7).TestObject;
+ Account presentWithCodeAndStorage = Build.An.Account.WithBalance(0).WithNonce(0)
+ .WithCode([0x60, 0x00]).WithStorageRoot(Keccak.Compute("storage")).TestObject;
+
+ Assert.Multiple(() =>
+ {
+ foreach (SelfDestructState sd in AllSelfDestructStates)
+ {
+ AssertRoundTrip(AccountState.Present, present, sd);
+ AssertRoundTrip(AccountState.Deleted, null, sd);
+ AssertRoundTrip(AccountState.Absent, null, sd);
+ }
+ AssertRoundTrip(AccountState.Present, presentWithCodeAndStorage, SelfDestructState.New);
+ });
+ }
+
+ // Item 0 discriminates Deleted (single byte 0x00) from Absent (empty string 0x80) positionally;
+ // item 1 is the self-destruct int (None=0 → 0x80, Destructed=1 → 0x01, New=2 → 0x02). 0xc2 is the
+ // outer two-byte-content list header.
+ [Test]
+ public void NonPresent_account_item_encodes_expected_bytes() => Assert.Multiple(() =>
+ {
+ Assert.That(Encode(AccountState.Deleted, null, SelfDestructState.None), Is.EqualTo(Bytes.FromHexString("c20080")));
+ Assert.That(Encode(AccountState.Absent, null, SelfDestructState.None), Is.EqualTo(Bytes.FromHexString("c28080")));
+ Assert.That(Encode(AccountState.Deleted, null, SelfDestructState.Destructed), Is.EqualTo(Bytes.FromHexString("c20001")));
+ Assert.That(Encode(AccountState.Absent, null, SelfDestructState.New), Is.EqualTo(Bytes.FromHexString("c28002")));
+ });
+
+ private static void AssertRoundTrip(AccountState state, Account? account, SelfDestructState sd)
+ {
+ byte[] value = Encode(state, account, sd);
+ string label = $"{state}+{sd}";
+
+ PersistedSnapshotPerAddress.Decode(value, out AccountState decodedState, out Account? decodedAccount, out SelfDestructState decodedSd);
+ Assert.That(decodedState, Is.EqualTo(state), label);
+ Assert.That(decodedSd, Is.EqualTo(sd), label);
+ if (state == AccountState.Present)
+ {
+ Assert.That(decodedAccount, Is.Not.Null, label);
+ Assert.That(decodedAccount!.Balance, Is.EqualTo(account!.Balance), label);
+ Assert.That(decodedAccount.Nonce, Is.EqualTo(account.Nonce), label);
+ Assert.That(decodedAccount.StorageRoot, Is.EqualTo(account.StorageRoot), label);
+ Assert.That(decodedAccount.CodeHash, Is.EqualTo(account.CodeHash), label);
+ }
+ else
+ {
+ Assert.That(decodedAccount, Is.Null, label);
+ }
+
+ // The split read helpers must agree with the combined decode.
+ Assert.That(PersistedSnapshotPerAddress.TryDecodeAccount(value, out Account? viaTry), Is.EqualTo(state != AccountState.Absent), label);
+ Assert.That(viaTry?.Balance, Is.EqualTo(decodedAccount?.Balance), label);
+ Assert.That(PersistedSnapshotPerAddress.DecodeSelfDestructState(value), Is.EqualTo(sd), label);
+ Assert.That(PersistedSnapshotPerAddress.DecodeSelfDestruct(value), Is.EqualTo(PersistedSnapshotPerAddress.ToFlag(sd)), label);
+ }
+
+ private static byte[] Encode(AccountState state, Account? account, SelfDestructState sd)
+ {
+ byte[] buf = new byte[256];
+ RlpStream stream = new(buf);
+ int len = PersistedSnapshotPerAddress.Encode(stream, state, account, sd);
+ return buf[..len];
+ }
+}
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/PersistedSnapshotRepositoryTests.cs b/src/Nethermind/Nethermind.State.Flat.Test/PersistedSnapshotRepositoryTests.cs
new file mode 100644
index 000000000000..0b124e36fb43
--- /dev/null
+++ b/src/Nethermind/Nethermind.State.Flat.Test/PersistedSnapshotRepositoryTests.cs
@@ -0,0 +1,613 @@
+// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using System;
+using System.IO;
+using Nethermind.Core;
+using Nethermind.Core.Crypto;
+using Nethermind.Core.Test.Builders;
+using Nethermind.Db;
+using Nethermind.Int256;
+using Nethermind.State.Flat.Persistence.BloomFilter;
+using Nethermind.State.Flat.PersistedSnapshots;
+using Nethermind.Trie;
+using NUnit.Framework;
+
+namespace Nethermind.State.Flat.Test;
+
+[TestFixture]
+public class PersistedSnapshotRepositoryTests
+{
+ private string _testDir = null!;
+ private ResourcePool _pool = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _testDir = Path.Combine(Path.GetTempPath(), $"nethermind_test_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_testDir);
+ _pool = new ResourcePool(new FlatDbConfig());
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(_testDir))
+ Directory.Delete(_testDir, recursive: true);
+ }
+
+ private Snapshot CreateTestSnapshot(StateId from, StateId to, Address? account = null, UInt256 balance = default)
+ {
+ SnapshotContent content = new();
+ if (account is not null)
+ content.Accounts[account] = Build.An.Account.WithBalance(balance == 0 ? 1000 : balance).TestObject;
+ return new Snapshot(from, to, content, _pool, ResourcePool.Usage.MainBlockProcessing);
+ }
+
+ [Test]
+ public void PersistSnapshot_And_Query()
+ {
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 4096);
+ SnapshotRepository repo = tier.Repository;
+
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+ Snapshot snap = CreateTestSnapshot(s0, s1, TestItem.AddressA);
+
+ tier.ConvertToPersistedBase(snap).Dispose();
+ Assert.That(repo.PersistedSnapshotCount, Is.EqualTo(1));
+
+ Assert.That(repo.TryLeasePersistedState(s1, SnapshotTier.PersistedBase, out PersistedSnapshot? persisted), Is.True);
+ Assert.That(persisted!.From, Is.EqualTo(s0));
+ Assert.That(persisted.To, Is.EqualTo(s1));
+ Assert.That(persisted.TryGetAccount(TestItem.AddressA, out Account? decoded), Is.True);
+ Assert.That(decoded!.Balance, Is.EqualTo((UInt256)1000));
+ persisted.Dispose();
+ }
+
+ ///
+ /// Regression: an address with 256k sequential storage slots fills four fully-dense
+ /// 30-byte slot-prefix groups (65536 slots each). The builder writes the per-address
+ /// slot column through ArenaBufferWriter (see ),
+ /// and a full prefix group's inner sub-slot table exceeds that writer's 1 MiB buffer — so the
+ /// single BlockBuilder.Add for the oversized prefix-group value must still round-trip.
+ ///
+ [Test]
+ public void ConvertSnapshot_SequentialSlotsAcrossDensePrefixGroups_RoundTrips()
+ {
+ // 64 MiB shared arena: a 256k-slot snapshot (~10 MiB) stays below the 512 MiB
+ // dedicated-arena threshold, so it must fit within a single shared arena file.
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 64 * 1024 * 1024, blobFileSizeBytes: 4 * 1024 * 1024);
+ SnapshotRepository repo = tier.Repository;
+
+ const int slotCount = 256 * 1024;
+ SnapshotContent content = new();
+ TestFixtureHelpers.AddSequentialSlots(content, TestItem.AddressA, firstSlot: 1, count: slotCount);
+
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("seq-slots"));
+ using PersistedSnapshot persisted = tier.ConvertToPersistedBase(
+ new Snapshot(s0, s1, content, _pool, ResourcePool.Usage.MainBlockProcessing));
+
+ // Probe slots spanning multiple prefix groups (group boundaries fall on multiples of 65536).
+ foreach (int probe in new[] { 1, 65535, 65536, 131072, slotCount })
+ {
+ SlotValue slot = default;
+ Assert.That(persisted.TryGetSlot(TestItem.AddressA, (UInt256)probe, ref slot), Is.True, $"slot {probe} missing");
+ Assert.That(slot.AsReadOnlySpan.SequenceEqual(TestFixtureHelpers.SequentialSlotValue(probe)), Is.True,
+ $"slot {probe} value mismatch");
+ }
+ }
+
+ [Test]
+ public void NewerSnapshot_OverridesOlderValue()
+ {
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 4096);
+ SnapshotRepository repo = tier.Repository;
+
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+ StateId s2 = new(2, Keccak.Compute("2"));
+
+ TreePath path = new(Keccak.Compute("path"), 4);
+ byte[] rlp1 = [0xC0];
+ byte[] rlp2 = [0xC1, 0x80];
+
+ SnapshotContent content1 = new();
+ content1.StateNodes[path] = new TrieNode(NodeType.Leaf, rlp1);
+ Snapshot snap1 = new(s0, s1, content1, _pool, ResourcePool.Usage.MainBlockProcessing);
+
+ SnapshotContent content2 = new();
+ content2.StateNodes[path] = new TrieNode(NodeType.Leaf, rlp2);
+ Snapshot snap2 = new(s1, s2, content2, _pool, ResourcePool.Usage.MainBlockProcessing);
+
+ tier.ConvertToPersistedBase(snap1).Dispose();
+ tier.ConvertToPersistedBase(snap2).Dispose();
+
+ Assert.That(repo.TryLeasePersistedState(s2, SnapshotTier.PersistedBase, out PersistedSnapshot? newest), Is.True);
+ Assert.That(newest!.TryLoadStateNodeRlp(path, out byte[]? result), Is.True);
+ Assert.That(result, Is.EqualTo(rlp2));
+ newest.Dispose();
+ }
+
+ [Test]
+ public void LoadFromCatalog_RestoresSnapshots()
+ {
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+ MemDb catalogDb = new();
+
+ using (FlatTestContainer tier1 = new(arenaFileSizeBytes: 4096, baseDbPath: _testDir, catalogDb: catalogDb))
+ {
+ SnapshotRepository repo = tier1.Repository;
+ Snapshot snap = CreateTestSnapshot(s0, s1, TestItem.AddressA);
+ tier1.ConvertToPersistedBase(snap).Dispose();
+ }
+
+ using (FlatTestContainer tier2 = new(arenaFileSizeBytes: 4096, baseDbPath: _testDir, catalogDb: catalogDb))
+ {
+ SnapshotRepository repo = tier2.Repository;
+ Assert.That(repo.PersistedSnapshotCount, Is.EqualTo(1));
+ Assert.That(repo.TryLeasePersistedState(s1, SnapshotTier.PersistedBase, out PersistedSnapshot? snapshot), Is.True);
+ snapshot!.Dispose();
+ }
+ }
+
+ [Test]
+ public void ConvertSnapshot_RoundTrip_AllDataCategories()
+ {
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 4096);
+ SnapshotRepository repo = tier.Repository;
+
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+
+ Address acctAddr = TestItem.AddressA;
+ Address selfDestructAddr = TestItem.AddressB;
+ Address storageAddr = TestItem.AddressC;
+ UInt256 slotIndex = (UInt256)42;
+ byte[] slotBytes = new byte[32];
+ slotBytes[31] = 0xAB;
+ slotBytes[30] = 0xCD;
+ SlotValue slotValue = new(slotBytes);
+
+ TreePath statePath = new(Keccak.Compute("state_path"), 4);
+ byte[] stateRlp = [0xC2, 0x80, 0x80];
+ Hash256 storageTrieAddr = Keccak.Compute("storage_trie_addr");
+ TreePath storagePath = new(Keccak.Compute("storage_path"), 6);
+ byte[] storageRlp = [0xC1, 0x80];
+
+ SnapshotContent content = new();
+ content.Accounts[acctAddr] = Build.An.Account.WithBalance(500).TestObject;
+ content.Storages[(storageAddr, slotIndex)] = slotValue;
+ content.SelfDestructedStorageAddresses[selfDestructAddr] = false;
+ content.StateNodes[statePath] = new TrieNode(NodeType.Leaf, stateRlp);
+ content.StorageNodes[(storageTrieAddr, storagePath)] = new TrieNode(NodeType.Branch, storageRlp);
+ Snapshot snap = new(s0, s1, content, _pool, ResourcePool.Usage.MainBlockProcessing);
+
+ tier.ConvertToPersistedBase(snap).Dispose();
+
+ Assert.That(repo.TryLeasePersistedState(s1, SnapshotTier.PersistedBase, out PersistedSnapshot? persisted), Is.True);
+ using PersistedSnapshot _ = persisted!;
+
+ Assert.That(persisted!.TryGetAccount(acctAddr, out Account? account), Is.True);
+ Assert.That(account, Is.Not.Null);
+ Assert.That(account!.Balance, Is.EqualTo((UInt256)500));
+
+ SlotValue readSlot = default;
+ Assert.That(persisted.TryGetSlot(storageAddr, slotIndex, ref readSlot), Is.True);
+ Assert.That(readSlot.AsReadOnlySpan.ToArray(), Is.EqualTo(slotBytes));
+
+ Assert.That(persisted.TryGetSelfDestructFlag(selfDestructAddr), Is.Not.Null);
+
+ Assert.That(persisted.TryLoadStateNodeRlp(statePath, out byte[]? stateResult), Is.True);
+ Assert.That(stateResult, Is.EqualTo(stateRlp));
+
+ Assert.That(persisted.TryLoadStorageNodeRlp(storageTrieAddr.ValueHash256, storagePath, out byte[]? storageResult), Is.True);
+ Assert.That(storageResult, Is.EqualTo(storageRlp));
+ }
+
+ [Test]
+ public void RemoveStatesUntil_RemovesOldSnapshots()
+ {
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 4096);
+ SnapshotRepository repo = tier.Repository;
+
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+ StateId s2 = new(2, Keccak.Compute("2"));
+ StateId s3 = new(3, Keccak.Compute("3"));
+
+ Snapshot snap1 = CreateTestSnapshot(s0, s1, TestItem.AddressA);
+ Snapshot snap2 = CreateTestSnapshot(s1, s2, TestItem.AddressB);
+ Snapshot snap3 = CreateTestSnapshot(s2, s3, TestItem.AddressC);
+
+ tier.ConvertToPersistedBase(snap1).Dispose();
+ tier.ConvertToPersistedBase(snap2).Dispose();
+ tier.ConvertToPersistedBase(snap3).Dispose();
+ Assert.That(repo.PersistedSnapshotCount, Is.EqualTo(3));
+
+ // Remove states until block 2 (removes snap1 with To=1)
+ repo.RemovePersistedStatesUntil(2);
+ Assert.That(repo.PersistedSnapshotCount, Is.EqualTo(2));
+ }
+
+ [TestCase(100)]
+ [TestCase(1000)]
+ public void ManyBaseSnapshots_ShareUnderlyingFiles(int count)
+ {
+ // Regression for the old "Blob arena id space exhausted (65535 arenas per tier)"
+ // bug: ids were minted per base-conversion call, so 65k base
+ // snapshots used 65k blob arena ids. Per-file ids pack many writers into one file —
+ // file count stays bounded under steady state.
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 64 * 1024);
+ SnapshotRepository repo = tier.Repository;
+
+ StateId prev = new(0, Keccak.EmptyTreeHash);
+ for (int i = 1; i <= count; i++)
+ {
+ StateId next = new(i, Keccak.Compute($"s{i}"));
+ Snapshot snap = CreateTestSnapshot(prev, next, TestItem.Addresses[i % TestItem.Addresses.Length]);
+ tier.ConvertToPersistedBase(snap).Dispose();
+ prev = next;
+ }
+
+ Assert.That(repo.PersistedSnapshotCount, Is.EqualTo(count));
+ // Files stay packed: bounded by max file size / typical write size, not by snapshot count.
+ int blobFileCount = Directory.GetFiles(Path.Combine(tier.BaseDbPath, "persisted_snapshot", "blob"), "blob_*.bin").Length;
+ Assert.That(blobFileCount, Is.LessThan(count),
+ "expected many base snapshots to share blob arena files");
+ }
+
+ [TestCase(true, TestName = "ConvertSnapshot_RecordsBlobRange(with trie nodes)")]
+ [TestCase(false, TestName = "ConvertSnapshot_RecordsBlobRange(no trie nodes)")]
+ public void ConvertSnapshot_RecordsBlobRange(bool withTrieNode)
+ {
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 64 * 1024);
+ SnapshotRepository repo = tier.Repository;
+
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+ SnapshotContent content = new();
+ content.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(1000).TestObject;
+ if (withTrieNode)
+ content.StateNodes[new TreePath(Keccak.Compute("p"), 4)] = new TrieNode(NodeType.Leaf, [0xC2, 0x80, 0x80]);
+
+ using PersistedSnapshot persisted = tier.ConvertToPersistedBase(
+ new Snapshot(s0, s1, content, _pool, ResourcePool.Usage.MainBlockProcessing));
+
+ if (withTrieNode)
+ {
+ Assert.That(persisted.BlobRange.IsEmpty, Is.False, "a base snapshot with trie nodes records a non-empty blob range");
+ Assert.That(persisted.BlobRange.Length, Is.GreaterThan(0));
+ }
+ else
+ {
+ Assert.That(persisted.BlobRange.IsEmpty, Is.True, "a base snapshot with no trie nodes has no blob region");
+ }
+ }
+
+ [TestCase(true, TestName = "BlobRange_SurvivesReloadViaMetadata(with trie nodes)")]
+ [TestCase(false, TestName = "BlobRange_SurvivesReloadViaMetadata(no trie nodes)")]
+ public void BlobRange_SurvivesReloadViaMetadata(bool withTrieNode)
+ {
+ // The blob range lives in the snapshot's own metadata table (blob_range key), not the
+ // catalog, so it must round-trip a restart: read back by the PersistedSnapshot ctor.
+ MemDb catalogDb = new();
+
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+
+ using (FlatTestContainer tier1 = new(arenaFileSizeBytes: 64 * 1024, baseDbPath: _testDir, catalogDb: catalogDb))
+ {
+ SnapshotRepository repo1 = tier1.Repository;
+ SnapshotContent content = new();
+ content.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(1000).TestObject;
+ if (withTrieNode)
+ content.StateNodes[new TreePath(Keccak.Compute("p"), 4)] = new TrieNode(NodeType.Leaf, [0xC2, 0x80, 0x80]);
+ tier1.ConvertToPersistedBase(
+ new Snapshot(s0, s1, content, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+ }
+
+ using FlatTestContainer tier2 = new(arenaFileSizeBytes: 64 * 1024, baseDbPath: _testDir, catalogDb: catalogDb);
+ SnapshotRepository repo2 = tier2.Repository;
+
+ Assert.That(repo2.TryLeasePersistedState(s1, SnapshotTier.PersistedBase, out PersistedSnapshot? reloaded), Is.True);
+ using (reloaded)
+ Assert.That(reloaded!.BlobRange.IsEmpty, Is.EqualTo(!withTrieNode),
+ "the base's blob range must round-trip a restart via its metadata table");
+ }
+
+ [Test]
+ public void LeaseBaseSnapshotsInRange_ReturnsBasesTilingWindow()
+ {
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 64 * 1024);
+ SnapshotRepository repo = tier.Repository;
+
+ StateId[] ids = new StateId[4];
+ ids[0] = new(0, Keccak.EmptyTreeHash);
+ for (int i = 1; i < 4; i++)
+ {
+ ids[i] = new(i, Keccak.Compute($"s{i}"));
+ tier.ConvertToPersistedBase(
+ CreateTestSnapshot(ids[i - 1], ids[i], TestItem.Addresses[i])).Dispose();
+ }
+
+ using PersistedSnapshotList bases = repo.LeaseBaseSnapshotsInRange(ids[0], ids[3]);
+ Assert.That(bases.Count, Is.EqualTo(3));
+ // Walk-back order: newest first.
+ Assert.That(bases[0].To, Is.EqualTo(ids[3]));
+ Assert.That(bases[^1].From, Is.EqualTo(ids[0]));
+ }
+
+ ///
+ /// Regression for the ReconstructBloom pass inside LoadFromCatalog: after a restart, a bloom is
+ /// rebuilt only for the widest snapshot covering each range and shared across it. The CompactSized
+ /// covering (0, 4] holds every address written across the four bases, and each contained base adopts
+ /// that one wide bloom (the same instance) rather than the AlwaysTrue placeholder or its own.
+ ///
+ [Test]
+ public void LoadFromCatalog_ReconstructsBloom_SharedFromWidest()
+ {
+ StateId[] ids = new StateId[5];
+ ids[0] = new(0, Keccak.EmptyTreeHash);
+ for (int i = 1; i <= 4; i++) ids[i] = new(i, Keccak.Compute($"s{i}"));
+
+ MemDb catalogDb = new();
+
+ // Session 1: 4 bases + a CompactSize=4 CompactSized covering all 4 of them.
+ using (FlatTestContainer tier1 = new(
+ arenaFileSizeBytes: 64 * 1024, baseDbPath: _testDir, catalogDb: catalogDb,
+ configure: b => b.AddSingleton(ScheduleHelper.CreateWithOffset(new FlatDbConfig { CompactSize = 4 }, 0))))
+ {
+ SnapshotRepository repo = tier1.Repository;
+ for (int i = 1; i <= 4; i++)
+ tier1.ConvertToPersistedBase(
+ CreateTestSnapshot(ids[i - 1], ids[i], TestItem.Addresses[i - 1])).Dispose();
+
+ tier1.Compactor.DoCompactCompactSized(ids[4]); // CompactSized at To=4 covering (0, 4]
+ }
+
+ // Session 2: reload. LoadFromCatalog now auto-calls ReconstructBloom.
+ using FlatTestContainer tier2 = new(arenaFileSizeBytes: 64 * 1024, baseDbPath: _testDir, catalogDb: catalogDb);
+ SnapshotRepository repo2 = tier2.Repository;
+
+ // With the v7 (To, depth)-keyed catalog the base at ids[4] survives alongside the
+ // CompactSized at the same To — both buckets must lease independently.
+ Assert.That(repo2.TryLeasePersistedState(ids[4], SnapshotTier.PersistedCompactSized, out PersistedSnapshot? compactSizedAt4), Is.True);
+ using (compactSizedAt4)
+ {
+ // The widest snapshot covering (0, 4] — the chain's starting snapshot. Its bloom is rebuilt
+ // from its own merged table and holds every address written across the four bases.
+ BloomFilter shared = compactSizedAt4!.Bloom;
+ Assert.That(shared.Count, Is.GreaterThan(0),
+ "ReconstructBloom must have built a real bloom for the widest (starting) snapshot");
+ Assert.That(compactSizedAt4.From.BlockNumber, Is.EqualTo(0));
+ Assert.That(compactSizedAt4.To.BlockNumber, Is.EqualTo(4));
+ for (int i = 1; i <= 4; i++)
+ {
+ ulong key = PersistedSnapshotBloomBuilder.AddressKey(TestItem.Addresses[i - 1]);
+ Assert.That(shared.MightContain(key), Is.True,
+ $"AddressKey for base {i} must be in the widest snapshot's merged bloom");
+ }
+
+ // Each contained base adopts the widest snapshot's bloom (the same instance), not its own.
+ for (int i = 1; i <= 4; i++)
+ {
+ Assert.That(repo2.TryLeasePersistedState(ids[i], SnapshotTier.PersistedBase, out PersistedSnapshot? baseAt), Is.True,
+ $"base at ids[{i}] must round-trip under v7");
+ using (baseAt)
+ Assert.That(ReferenceEquals(baseAt!.Bloom, shared), Is.True,
+ $"base {i} must share the widest snapshot's bloom");
+ }
+ }
+ }
+
+ ///
+ /// Regression for the v7 (To, depth)-keyed catalog: before v7, a CompactSized at the
+ /// same To as a base overwrote the base's catalog entry, so a restart would lose the
+ /// base. With v7 both round-trip independently — SnapshotCount on reload equals the
+ /// number of Add calls in the prior session.
+ ///
+ [Test]
+ public void LoadFromCatalog_RoundTripsBaseAndCompactSizedAtSameTo()
+ {
+ StateId[] ids = new StateId[5];
+ ids[0] = new(0, Keccak.EmptyTreeHash);
+ for (int i = 1; i <= 4; i++) ids[i] = new(i, Keccak.Compute($"s{i}"));
+
+ MemDb catalogDb = new();
+
+ using (FlatTestContainer tier1 = new(
+ arenaFileSizeBytes: 64 * 1024, baseDbPath: _testDir, catalogDb: catalogDb,
+ configure: b => b.AddSingleton(ScheduleHelper.CreateWithOffset(new FlatDbConfig { CompactSize = 4 }, 0))))
+ {
+ SnapshotRepository repo = tier1.Repository;
+ for (int i = 1; i <= 4; i++)
+ tier1.ConvertToPersistedBase(
+ CreateTestSnapshot(ids[i - 1], ids[i], TestItem.Addresses[i - 1])).Dispose();
+
+ tier1.Compactor.DoCompactCompactSized(ids[4]);
+
+ Assert.That(repo.PersistedSnapshotCount, Is.EqualTo(5), "session 1 must hold 4 bases + 1 CompactSized");
+ }
+
+ using FlatTestContainer tier2 = new(arenaFileSizeBytes: 64 * 1024, baseDbPath: _testDir, catalogDb: catalogDb);
+ SnapshotRepository repo2 = tier2.Repository;
+
+ Assert.That(repo2.PersistedSnapshotCount, Is.EqualTo(5),
+ "all five snapshots (4 bases + 1 CompactSized at the last base's To) must round-trip under v7");
+ for (int i = 1; i <= 4; i++)
+ {
+ Assert.That(repo2.TryLeasePersistedState(ids[i], SnapshotTier.PersistedBase, out PersistedSnapshot? b), Is.True,
+ $"base at ids[{i}] must survive reload");
+ b!.Dispose();
+ }
+ Assert.That(repo2.TryLeasePersistedState(ids[4], SnapshotTier.PersistedCompactSized, out PersistedSnapshot? compactSized), Is.True);
+ compactSized!.Dispose();
+ }
+
+ ///
+ /// Exercise the parallel-then-serial split in LoadFromCatalog: build enough
+ /// snapshots in session 1 to spread across multiple
+ /// partitions, reload in session 2, and verify the parallel construction + serial
+ /// sorted-set rebuild preserves: snapshot count, per-bucket leasability, ordered-id
+ /// invariants (the From/To chain reachable via LeaseBaseSnapshotsInRange), and the
+ /// ReconstructBloom end-state (snapshots in a compacted range share that range's bloom).
+ /// Stays below ParallelLoadThreshold so the progress logger is bypassed —
+ /// that codepath is a one-line gate we trust by inspection.
+ ///
+ [Test]
+ public void LoadFromCatalog_Parallel_PreservesOrderingAndDicts()
+ {
+ const int N = 32;
+ StateId[] ids = new StateId[N + 1];
+ ids[0] = new(0, Keccak.EmptyTreeHash);
+ for (int i = 1; i <= N; i++) ids[i] = new(i, Keccak.Compute($"s{i}"));
+
+ MemDb catalogDb = new();
+
+ using (FlatTestContainer tier1 = new(
+ arenaFileSizeBytes: 64 * 1024, baseDbPath: _testDir, catalogDb: catalogDb,
+ configure: b => b.AddSingleton(ScheduleHelper.CreateWithOffset(new FlatDbConfig { CompactSize = 8 }, 0))))
+ {
+ SnapshotRepository repo = tier1.Repository;
+ for (int i = 1; i <= N; i++)
+ tier1.ConvertToPersistedBase(
+ CreateTestSnapshot(ids[i - 1], ids[i], TestItem.Addresses[(i - 1) % TestItem.Addresses.Length])).Dispose();
+
+ // Throw in two CompactSized snapshots (CompactSize=8) at boundaries 8 and 16 so the
+ // catalog has multi-bucket entries that exercise the bucket-routing branch
+ // in the parallel LoadSnapshot.
+ tier1.Compactor.DoCompactCompactSized(ids[8]);
+ tier1.Compactor.DoCompactCompactSized(ids[16]);
+ }
+
+ using FlatTestContainer tier2 = new(arenaFileSizeBytes: 64 * 1024, baseDbPath: _testDir, catalogDb: catalogDb);
+ SnapshotRepository repo2 = tier2.Repository;
+
+ Assert.That(repo2.PersistedSnapshotCount, Is.EqualTo(N + 2));
+ for (int i = 1; i <= N; i++)
+ {
+ Assert.That(repo2.TryLeasePersistedState(ids[i], SnapshotTier.PersistedBase, out PersistedSnapshot? b), Is.True, $"base ids[{i}] missing");
+ b!.Dispose();
+ }
+ Assert.That(repo2.TryLeasePersistedState(ids[8], SnapshotTier.PersistedCompactSized, out PersistedSnapshot? p8), Is.True);
+ p8!.Dispose();
+ Assert.That(repo2.TryLeasePersistedState(ids[16], SnapshotTier.PersistedCompactSized, out PersistedSnapshot? p16), Is.True);
+ p16!.Dispose();
+
+ // Ordered-id invariant: the bases tile the whole (0, N] window via their From chain.
+ // Catches a missing or mis-routed sorted-set entry.
+ using (PersistedSnapshotList chain = repo2.LeaseBaseSnapshotsInRange(ids[0], ids[N]))
+ Assert.That(chain.Count, Is.EqualTo(N), "every base must be reachable via the From chain");
+
+ // Bloom end-state: a bloom is rebuilt for the widest snapshot covering each range and shared
+ // across it — base ids[1] adopts the CompactSized covering (0, 8] rather than carrying its own.
+ Assert.That(repo2.TryLeasePersistedState(ids[8], SnapshotTier.PersistedCompactSized, out PersistedSnapshot? compactSizedAt8), Is.True);
+ using (compactSizedAt8)
+ {
+ Assert.That(compactSizedAt8!.Bloom.Count, Is.GreaterThan(0), "CompactSized at ids[8] must have a real bloom");
+ Assert.That(repo2.TryLeasePersistedState(ids[1], SnapshotTier.PersistedBase, out PersistedSnapshot? baseAt1), Is.True);
+ using (baseAt1)
+ Assert.That(ReferenceEquals(baseAt1!.Bloom, compactSizedAt8.Bloom), Is.True,
+ "base ids[1] must share the CompactSized's bloom");
+ }
+ }
+
+ // With bloom disabled (bits-per-key 0) the loader's Convert path uses the AlwaysTrue
+ // sentinel and ReconstructBloom returns early on restart — data must still survive.
+ [Test]
+ public void LoadFromCatalog_BloomDisabled_SkipsReconstructionButDataSurvives()
+ {
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("nb1"));
+ MemDb catalogDb = new();
+
+ using (FlatTestContainer tier1 = new(
+ config: new FlatDbConfig { PersistedSnapshotBloomBitsPerKey = 0 },
+ arenaFileSizeBytes: 64 * 1024, baseDbPath: _testDir, catalogDb: catalogDb))
+ {
+ tier1.ConvertToPersistedBase(CreateTestSnapshot(s0, s1, TestItem.AddressA)).Dispose();
+ }
+
+ using FlatTestContainer tier2 = new(
+ config: new FlatDbConfig { PersistedSnapshotBloomBitsPerKey = 0 },
+ arenaFileSizeBytes: 64 * 1024, baseDbPath: _testDir, catalogDb: catalogDb);
+
+ Assert.That(tier2.Repository.TryLeasePersistedState(s1, SnapshotTier.PersistedBase, out PersistedSnapshot? p), Is.True);
+ using (p)
+ {
+ Assert.That(p!.Bloom.Count, Is.EqualTo(0), "bloom disabled → AlwaysTrue sentinel, no reconstruction");
+ Assert.That(p.TryGetAccount(TestItem.AddressA, out _), Is.True, "data must survive restart with bloom disabled");
+ }
+ }
+
+ // With validation enabled, Convert runs PersistedSnapshotUtils.ValidatePersistedSnapshot
+ // on the freshly written base; a valid snapshot must convert and round-trip without throwing.
+ [Test]
+ public void ConvertToPersistedBase_WithValidationEnabled_RoundTrips()
+ {
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("val1"));
+
+ using FlatTestContainer tier = new(
+ config: new FlatDbConfig { ValidatePersistedSnapshot = true },
+ arenaFileSizeBytes: 64 * 1024, baseDbPath: _testDir);
+
+ using PersistedSnapshot p = tier.ConvertToPersistedBase(CreateTestSnapshot(s0, s1, TestItem.AddressA, 77));
+ Assert.That(p.TryGetAccount(TestItem.AddressA, out Account? acc), Is.True);
+ Assert.That(acc!.Balance, Is.EqualTo((UInt256)77));
+ }
+
+ // A converted base records a contiguous trie-RLP blob run, so its blob-range advise calls
+ // hit the non-empty fadvise branch (a no-op against the test arena, but must not throw).
+ [Test]
+ public void AdviseBlobRange_OnConvertedBaseWithTrieNodes_DoesNotThrow()
+ {
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("blob1"));
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 64 * 1024, baseDbPath: _testDir);
+
+ SnapshotContent content = new();
+ Nethermind.Trie.TreePath path = new(Keccak.Compute("bp"), 4);
+ content.StateNodes[path] = new Nethermind.Trie.TrieNode(Nethermind.Trie.NodeType.Leaf, [0xC2, 0x80, 0x80]);
+ using PersistedSnapshot p = tier.ConvertToPersistedBase(
+ new Snapshot(s0, s1, content, _pool, ResourcePool.Usage.MainBlockProcessing));
+
+ Assert.DoesNotThrow(() => p.AdviseWillNeedBlobRange());
+ Assert.DoesNotThrow(() => p.AdviseDontNeedBlobRange());
+ Assert.That(p.TryLoadStateNodeRlp(path, out _), Is.True);
+ }
+
+ // End-to-end-ish read-through: a base converted with a REAL bloom (default config),
+ // wrapped in a PersistedSnapshotStack, resolves a present account/slot and skips absent
+ // addresses — exercising the stack's real-bloom gate (MightContain == false → continue).
+ [Test]
+ public void Stack_RealBloom_AdmitsPresentSkipsAbsentAddresses()
+ {
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("rb1"));
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 64 * 1024, baseDbPath: _testDir);
+
+ SnapshotContent content = new();
+ content.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(123).TestObject;
+ byte[] slot = new byte[32]; slot[31] = 0x55;
+ content.Storages[(TestItem.AddressA, (UInt256)1)] = new SlotValue(slot);
+ PersistedSnapshot persisted = tier.ConvertToPersistedBase(
+ new Snapshot(s0, s1, content, _pool, ResourcePool.Usage.MainBlockProcessing));
+
+ PersistedSnapshotList list = new(1) { persisted };
+ using PersistedSnapshotStack stack = new(list, recordDetailedMetrics: false);
+
+ Assert.That(stack.TryGetAccount(TestItem.AddressA, out Account? a), Is.True);
+ Assert.That(a!.Balance, Is.EqualTo((UInt256)123));
+ long start = System.Diagnostics.Stopwatch.GetTimestamp();
+ Assert.That(stack.TryGetSlot(TestItem.AddressA, (UInt256)1, -1, start, out byte[]? sv), Is.True);
+ Assert.That(sv![^1], Is.EqualTo((byte)0x55));
+
+ // Absent addresses: the real bloom excludes them (or the snapshot misses) → fall through.
+ foreach (Address absent in new[] { TestItem.AddressB, TestItem.AddressC, TestItem.AddressD, TestItem.AddressE, TestItem.AddressF })
+ Assert.That(stack.TryGetAccount(absent, out _), Is.False, $"{absent} must not resolve");
+ }
+}
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/PersistedSnapshotTests.cs b/src/Nethermind/Nethermind.State.Flat.Test/PersistedSnapshotTests.cs
new file mode 100644
index 000000000000..0b4eae57bf74
--- /dev/null
+++ b/src/Nethermind/Nethermind.State.Flat.Test/PersistedSnapshotTests.cs
@@ -0,0 +1,895 @@
+// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using System;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+using System.IO;
+using Nethermind.Core;
+using Nethermind.Core.Crypto;
+using Nethermind.Core.Test.Builders;
+using Nethermind.Db;
+using Nethermind.Int256;
+using Nethermind.State.Flat.PersistedSnapshots;
+using Nethermind.State.Flat.PersistedSnapshots.Storage;
+using Nethermind.Trie;
+using NUnit.Framework;
+using WholeReadScanner = Nethermind.State.Flat.PersistedSnapshots.PersistedSnapshotScanner<
+ Nethermind.State.Flat.PersistedSnapshots.Storage.WholeReadSession,
+ Nethermind.State.Flat.PersistedSnapshots.Storage.WholeReadSessionReader,
+ Nethermind.State.Flat.Io.NoOpPin>;
+
+namespace Nethermind.State.Flat.Test;
+
+[TestFixture]
+public class PersistedSnapshotTests
+{
+ private ResourcePool _resourcePool = null!;
+ private ArenaManager _memArena = null!;
+ private string _memArenaDir = null!;
+ private BlobArenaManager _blobs = null!;
+ private string _blobsDir = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _resourcePool = new ResourcePool(new FlatDbConfig());
+ _memArenaDir = Path.Combine(Path.GetTempPath(), $"nm-pstest-arena-{Guid.NewGuid():N}");
+ _memArena = TestFixtureHelpers.CreateArenaManager(_memArenaDir);
+ _blobsDir = Path.Combine(Path.GetTempPath(), $"nm-pstest-blobs-{Guid.NewGuid():N}");
+ _blobs = new BlobArenaManager(_blobsDir, 4L * 1024 * 1024);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ _blobs.Dispose();
+ _memArena.Dispose();
+ try { Directory.Delete(_blobsDir, recursive: true); } catch { /* best-effort */ }
+ try { Directory.Delete(_memArenaDir, recursive: true); } catch { /* best-effort */ }
+ }
+
+ private PersistedSnapshot CreatePersistedSnapshot(StateId from, StateId to, byte[] data) =>
+ TestFixtureHelpers.CreatePersistedSnapshot(_memArena, _blobs, from, to, data);
+
+ [Test]
+ public void Trie_key_encoding_matches_persistence_tiers()
+ {
+ Span key = stackalloc byte[PersistedSnapshotKey.MaxKeyLength];
+ Hash256 addr = Keccak.Compute("addr");
+ ReadOnlySpan addrHash = addr.Bytes;
+
+ TreePath stateTop = new(Keccak.Compute("s"), 5);
+ TreePath stateCompact = new(Keccak.Compute("s"), 6);
+ TreePath storShort = new(Keccak.Compute("s"), 4);
+ TreePath storCompactMax = new(Keccak.Compute("s"), 15);
+ TreePath storFallback = new(Keccak.Compute("s"), 16);
+
+ int stateTopLen = PersistedSnapshotKey.WriteStateNodeKey(key, in stateTop);
+ int stateCompactLen = PersistedSnapshotKey.WriteStateNodeKey(key, in stateCompact);
+ int storShortLen = PersistedSnapshotKey.WriteStorageNodeKey(key, addrHash, in storShort);
+ int storCompactMaxLen = PersistedSnapshotKey.WriteStorageNodeKey(key, addrHash, in storCompactMax);
+ int storFallbackLen = PersistedSnapshotKey.WriteStorageNodeKey(key, addrHash, in storFallback);
+
+ // Slots live in their own top-level column that sorts just before the account column.
+ Span slotKey = stackalloc byte[PersistedSnapshotKey.MaxKeyLength];
+ Span slot = stackalloc byte[32];
+ int slotLen = PersistedSnapshotKey.WriteSlotKey(slotKey, TestItem.AddressA.Bytes, slot);
+ int accountLen = PersistedSnapshotKey.WriteAccountKey(key, TestItem.AddressA.Bytes);
+ byte slotColumn = slotKey[0];
+ byte accountColumn = key[0];
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(stateTopLen, Is.EqualTo(4), "state top (0-5): column + 3-byte path");
+ Assert.That(stateCompactLen, Is.EqualTo(9), "state compact (6-15): column + 8-byte path");
+ Assert.That(storShortLen, Is.EqualTo(30), "storage 0-15: column + addrHash(20) + sub + 8-byte path");
+ Assert.That(storCompactMaxLen, Is.EqualTo(30), "storage upper bound (15) stays compact — never a 4-byte top key");
+ Assert.That(storFallbackLen, Is.EqualTo(55), "storage 16+: column + addrHash(20) + sub + 33-byte path");
+ Assert.That(slotLen, Is.EqualTo(53), "slot: own column + addr(20) + slot(32), no per-address sub-tag");
+ Assert.That(slotColumn, Is.EqualTo(PersistedSnapshotKey.SlotColumn));
+ Assert.That(slotColumn, Is.LessThan(accountColumn), "slot column sorts before the account column");
+ Assert.That(accountLen, Is.EqualTo(21), "per-address: account column + addr(20), no sub-tag");
+ });
+ }
+
+ private static IEnumerable RoundTripTestCases()
+ {
+ yield return new TestCaseData((Action)(c =>
+ {
+ c.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(1000).TestObject;
+ })).SetName("Account");
+
+ yield return new TestCaseData((Action)(c =>
+ {
+ c.SelfDestructedStorageAddresses[TestItem.AddressA] = false;
+ })).SetName("SelfDestruct");
+
+ yield return new TestCaseData((Action)(c =>
+ {
+ TreePath path = new(Keccak.Compute("path"), 4);
+ c.StateNodes[path] = new TrieNode(NodeType.Leaf, [0xC2, 0x80, 0x80]);
+ })).SetName("StateNode_TopPath");
+
+ yield return new TestCaseData((Action)(c =>
+ {
+ TreePath path = new(Keccak.Compute("path"), 8);
+ c.StateNodes[path] = new TrieNode(NodeType.Leaf, [0xC2, 0x80, 0x80]);
+ })).SetName("StateNode_CompactPath");
+
+ yield return new TestCaseData((Action)(c =>
+ {
+ TreePath longPath = new(Keccak.Compute("longpath"), 20);
+ c.StateNodes[longPath] = new TrieNode(NodeType.Extension, [0xC2, 0x80, 0x81]);
+ })).SetName("StateNode_LongPath");
+
+ yield return new TestCaseData((Action)(c =>
+ {
+ byte[] value = new byte[32];
+ value[31] = 0xFF;
+ c.Storages[(TestItem.AddressA, (UInt256)42)] = new SlotValue(value);
+ })).SetName("Storage_SingleSlot");
+
+ // Single significant byte < 0x80: RLP wraps it to the byte itself (1 byte), so the
+ // stored length is still 1 — distinct from the length-0 absent sentinel.
+ yield return new TestCaseData((Action)(c =>
+ {
+ byte[] value = new byte[32];
+ value[31] = 0x05;
+ c.Storages[(TestItem.AddressA, (UInt256)9)] = new SlotValue(value);
+ })).SetName("Storage_SmallSingleByteSlot");
+
+ yield return new TestCaseData((Action)(c =>
+ {
+ byte[] value = new byte[32];
+ value[31] = 0xAB;
+ c.Storages[(TestItem.AddressA, UInt256.Zero)] = new SlotValue(value);
+ })).SetName("Storage_ZeroSlot");
+
+ yield return new TestCaseData((Action)(c =>
+ {
+ c.Storages[(TestItem.AddressA, (UInt256)1)] = null;
+ byte[] val = new byte[32];
+ val[31] = 0xFF;
+ c.Storages[(TestItem.AddressA, (UInt256)2)] = new SlotValue(val);
+ })).SetName("Storage_NullSlot");
+
+ yield return new TestCaseData((Action)(c =>
+ {
+ byte[] val1 = new byte[32]; val1[31] = 0x01;
+ byte[] val2 = new byte[32]; val2[31] = 0x02;
+ byte[] val3 = new byte[32]; val3[31] = 0x03;
+ c.Storages[(TestItem.AddressA, (UInt256)1)] = new SlotValue(val1);
+ c.Storages[(TestItem.AddressA, (UInt256)2)] = new SlotValue(val2);
+ c.Storages[(TestItem.AddressB, (UInt256)5)] = new SlotValue(val3);
+ })).SetName("Storage_MultipleAddresses");
+
+ // Storage has no top tier — a length-4 path lands in the 8-byte compact encoding.
+ yield return new TestCaseData((Action)(c =>
+ {
+ Hash256 address = Keccak.Compute("address");
+ TreePath path = new(Keccak.Compute("path"), 4);
+ c.StorageNodes[(address, path)] = new TrieNode(NodeType.Branch, [0xC1, 0x80]);
+ })).SetName("StorageNode_ShortPath");
+
+ yield return new TestCaseData((Action)(c =>
+ {
+ Hash256 address = Keccak.Compute("address");
+ TreePath path = new(Keccak.Compute("path"), 6);
+ c.StorageNodes[(address, path)] = new TrieNode(NodeType.Branch, [0xC1, 0x80]);
+ })).SetName("StorageNode_CompactPath");
+
+ yield return new TestCaseData((Action)(c =>
+ {
+ Hash256 address = Keccak.Compute("address");
+ TreePath longPath = new(Keccak.Compute("longpath"), 18);
+ c.StorageNodes[(address, longPath)] = new TrieNode(NodeType.Branch, [0xC3, 0x80, 0x81, 0x82]);
+ })).SetName("StorageNode_LongPath");
+
+ yield return new TestCaseData((Action)(c =>
+ {
+ c.Accounts[TestItem.AddressA] = Build.An.Account
+ .WithBalance(12345).WithNonce(7).TestObject;
+ c.Accounts[TestItem.AddressB] = Build.An.Account
+ .WithBalance(0).WithNonce(0)
+ .WithCode([0x60, 0x00])
+ .WithStorageRoot(Keccak.Compute("storage")).TestObject;
+ c.Accounts[TestItem.AddressC] = null;
+
+ byte[] slotVal1 = new byte[32]; slotVal1[31] = 0xFF;
+ byte[] slotVal2 = new byte[32]; slotVal2[0] = 0x01; slotVal2[31] = 0x02;
+ c.Storages[(TestItem.AddressA, (UInt256)1)] = new SlotValue(slotVal1);
+ c.Storages[(TestItem.AddressA, (UInt256)2)] = new SlotValue(slotVal2);
+ c.Storages[(TestItem.AddressB, (UInt256)42)] = null;
+
+ c.SelfDestructedStorageAddresses[TestItem.AddressD] = false;
+ c.SelfDestructedStorageAddresses[TestItem.AddressE] = true;
+
+ TreePath topStatePath = new(Keccak.Compute("tp"), 3);
+ c.StateNodes[topStatePath] = new TrieNode(NodeType.Leaf, [0xC1, 0x80]);
+
+ TreePath shortStatePath = new(Keccak.Compute("sp"), 8);
+ c.StateNodes[shortStatePath] = new TrieNode(NodeType.Leaf, [0xC2, 0x80, 0x80]);
+
+ TreePath longStatePath = new(Keccak.Compute("lp"), 20);
+ c.StateNodes[longStatePath] = new TrieNode(NodeType.Extension, [0xC2, 0x80, 0x81]);
+
+ Hash256 storageAddr = Keccak.Compute("storageAddr");
+ TreePath topStoragePath = new(Keccak.Compute("tsp"), 3);
+ c.StorageNodes[(storageAddr, topStoragePath)] = new TrieNode(NodeType.Leaf, [0xC1, 0x80]);
+
+ TreePath shortStoragePath = new(Keccak.Compute("ssp"), 6);
+ c.StorageNodes[(storageAddr, shortStoragePath)] = new TrieNode(NodeType.Branch, [0xC1, 0x80]);
+
+ TreePath longStoragePath = new(Keccak.Compute("lsp"), 18);
+ c.StorageNodes[(storageAddr, longStoragePath)] = new TrieNode(NodeType.Leaf, [0xC3, 0x80, 0x81, 0x82]);
+ })).SetName("AllDataTypes");
+ }
+
+ [TestCaseSource(nameof(RoundTripTestCases))]
+ public void RoundTrip(Action populateContent)
+ {
+ StateId from = new(0, Keccak.EmptyTreeHash);
+ StateId to = new(1, Keccak.Compute("1"));
+
+ SnapshotContent content = new();
+ populateContent(content);
+
+ Snapshot snapshot = new(from, to, content, _resourcePool, ResourcePool.Usage.MainBlockProcessing);
+ byte[] data = PersistedSnapshotBuilderTestExtensions.Build(snapshot, _blobs);
+ PersistedSnapshot persisted = CreatePersistedSnapshot(from, to, data);
+
+ Assert.DoesNotThrow(() => PersistedSnapshotUtils.ValidatePersistedSnapshot(snapshot, persisted));
+ }
+
+ // Regression: a storage-trie node record can land within <12 bytes of a 4 KiB boundary in a
+ // region-relative (SpanByteReader-scoped) read; TryLoadNode used to clamp the speculative
+ // window to that short page remainder and overrun the 12-byte header. A single account with
+ // ~280 spread-out slots places such a node; reading every slot back must not throw.
+ [Test]
+ public void StorageNode_NearPageBoundary_RoundTrips()
+ {
+ Address a = TestItem.AddressA;
+ const int slotCount = 280;
+
+ SnapshotContent content = new();
+ content.Accounts[a] = Build.An.Account.WithBalance(1).TestObject;
+ SlotValue[] expected = new SlotValue[slotCount];
+ UInt256[] keys = new UInt256[slotCount];
+ for (int i = 0; i < slotCount; i++)
+ {
+ keys[i] = new UInt256(Keccak.Compute(i.ToString()).Bytes, isBigEndian: true);
+ byte[] v = new byte[32];
+ v[31] = (byte)((i % 255) + 1);
+ expected[i] = new SlotValue(v);
+ content.Storages[(a, keys[i])] = expected[i];
+ }
+
+ StateId from = new(0, Keccak.EmptyTreeHash), to = new(1, Keccak.Compute("to"));
+ string arenaDir = Path.Combine(Path.GetTempPath(), $"nm-regr-arena-{Guid.NewGuid():N}");
+ using ArenaManager arena = TestFixtureHelpers.CreateArenaManager(arenaDir, 64 * 1024 * 1024);
+ string blobsDir = Path.Combine(Path.GetTempPath(), $"nm-regr-{Guid.NewGuid():N}");
+ using BlobArenaManager blobs = new(blobsDir, 64L * 1024 * 1024);
+ try
+ {
+ Snapshot snapshot = new(from, to, content, _resourcePool, ResourcePool.Usage.MainBlockProcessing);
+ byte[] data = PersistedSnapshotBuilderTestExtensions.Build(snapshot, blobs);
+ using PersistedSnapshot persisted = TestFixtureHelpers.CreatePersistedSnapshot(arena, blobs, from, to, data);
+
+ Assert.DoesNotThrow(() =>
+ {
+ for (int i = 0; i < slotCount; i++)
+ {
+ SlotValue got = default;
+ Assert.That(persisted.TryGetSlot(a, keys[i], ref got), Is.True, $"slot {i} missing");
+ Assert.That(got.AsReadOnlySpan.SequenceEqual(expected[i].AsReadOnlySpan), Is.True, $"slot {i} mismatch");
+ }
+ });
+ }
+ finally
+ {
+ try { Directory.Delete(blobsDir, recursive: true); } catch { /* best-effort */ }
+ try { Directory.Delete(arenaDir, recursive: true); } catch { /* best-effort */ }
+ }
+ }
+
+ // Covers the scanner slot-decode path (PersistedSnapshotScanner.SlotEntry.Value), which
+ // PersistPersistedSnapshot uses to flush slots back into the flat DB. Slot values are now
+ // RLP-wrapped; this asserts varied widths (1-byte < 0x80, 1-byte >= 0x80, full 32 bytes)
+ // decode correctly and that a null/deleted slot is surfaced as null (length-0 sentinel).
+ [Test]
+ public void Slot_scanner_round_trips_rlp_wrapped_values()
+ {
+ StateId from = new(0, Keccak.EmptyTreeHash);
+ StateId to = new(1, Keccak.Compute("scan"));
+
+ byte[] small = new byte[32]; small[31] = 0x05; // RLP(0x05) = 0x05
+ byte[] high = new byte[32]; high[31] = 0xFF; // RLP(0xff) = 0x81 0xff
+ byte[] full = new byte[32];
+ for (int i = 0; i < 32; i++) full[i] = (byte)(i + 1); // RLP = 0xa0 + 32 bytes
+
+ SnapshotContent content = new();
+ content.Storages[(TestItem.AddressA, (UInt256)1)] = new SlotValue(small);
+ content.Storages[(TestItem.AddressA, (UInt256)2)] = new SlotValue(high);
+ content.Storages[(TestItem.AddressA, (UInt256)3)] = null;
+ content.Storages[(TestItem.AddressB, (UInt256)4)] = new SlotValue(full);
+
+ Snapshot snapshot = new(from, to, content, _resourcePool, ResourcePool.Usage.MainBlockProcessing);
+ byte[] data = PersistedSnapshotBuilderTestExtensions.Build(snapshot, _blobs);
+ using PersistedSnapshot persisted = CreatePersistedSnapshot(from, to, data);
+
+ Dictionary<(Address, UInt256), SlotValue?> scanned = [];
+ using (WholeReadSession session = persisted.BeginWholeReadSession())
+ {
+ WholeReadScanner scanner = PersistedSnapshotScanner.ForWholeRead(session, persisted);
+ foreach (WholeReadScanner.SlotEntry slot in scanner.Slots)
+ scanned[(slot.Address, slot.Slot)] = slot.Value;
+ }
+
+ Assert.That(scanned[(TestItem.AddressA, (UInt256)1)]!.Value.AsReadOnlySpan.ToArray(), Is.EqualTo(small));
+ Assert.That(scanned[(TestItem.AddressA, (UInt256)2)]!.Value.AsReadOnlySpan.ToArray(), Is.EqualTo(high));
+ Assert.That(scanned[(TestItem.AddressA, (UInt256)3)], Is.Null, "deleted slot must surface as null");
+ Assert.That(scanned[(TestItem.AddressB, (UInt256)4)]!.Value.AsReadOnlySpan.ToArray(), Is.EqualTo(full));
+ }
+
+ // Drives the scanner across every entry kind in one pass: normal vs deleted account,
+ // self-destruct destructed vs new, an address with a self-destruct but no account change,
+ // present vs deleted slot, and state/storage trie nodes spread across all three depth tiers.
+ [Test]
+ public void FullScan_DecodesAccounts_SelfDestruct_Slots_StateAndStorageNodes()
+ {
+ StateId from = new(0, Keccak.EmptyTreeHash);
+ StateId to = new(1, Keccak.Compute("fullscan"));
+
+ byte[] slotVal = new byte[32]; slotVal[31] = 0x11;
+
+ SnapshotContent content = new();
+ content.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(1000).WithNonce(3).TestObject;
+ content.Accounts[TestItem.AddressC] = null; // deleted marker
+ content.Storages[(TestItem.AddressA, (UInt256)1)] = new SlotValue(slotVal);
+ content.Storages[(TestItem.AddressA, (UInt256)2)] = null;
+ content.SelfDestructedStorageAddresses[TestItem.AddressD] = false; // destructed
+ content.SelfDestructedStorageAddresses[TestItem.AddressE] = true; // new-account
+ TreePath stTop = new(Keccak.Compute("st-top"), 3);
+ TreePath stMid = new(Keccak.Compute("st-mid"), 8);
+ TreePath stLong = new(Keccak.Compute("st-long"), 20);
+ content.StateNodes[stTop] = new TrieNode(NodeType.Leaf, [0xC1, 0x80]);
+ content.StateNodes[stMid] = new TrieNode(NodeType.Leaf, [0xC2, 0x80, 0x80]);
+ content.StateNodes[stLong] = new TrieNode(NodeType.Extension, [0xC2, 0x80, 0x81]);
+ Hash256 storageAddr = Keccak.Compute("storage-addr");
+ TreePath snTop = new(Keccak.Compute("sn-top"), 3);
+ TreePath snMid = new(Keccak.Compute("sn-mid"), 6);
+ TreePath snLong = new(Keccak.Compute("sn-long"), 18);
+ content.StorageNodes[(storageAddr, snTop)] = new TrieNode(NodeType.Leaf, [0xC1, 0x81]);
+ content.StorageNodes[(storageAddr, snMid)] = new TrieNode(NodeType.Branch, [0xC1, 0x82]);
+ content.StorageNodes[(storageAddr, snLong)] = new TrieNode(NodeType.Leaf, [0xC3, 0x80, 0x81, 0x82]);
+
+ Snapshot snapshot = new(from, to, content, _resourcePool, ResourcePool.Usage.MainBlockProcessing);
+ byte[] data = PersistedSnapshotBuilderTestExtensions.Build(snapshot, _blobs);
+ using PersistedSnapshot persisted = CreatePersistedSnapshot(from, to, data);
+
+ Dictionary perAddr = [];
+ Dictionary<(Address, UInt256), SlotValue?> slots = [];
+ int stateNodes = 0, storageNodes = 0;
+
+ using (WholeReadSession session = persisted.BeginWholeReadSession())
+ {
+ WholeReadScanner scanner = PersistedSnapshotScanner.ForWholeRead(session, persisted);
+ foreach (WholeReadScanner.PerAddressEntry e in scanner.PerAddresses)
+ perAddr[e.Address] = (e.HasAccount, e.Account?.Balance, e.SelfDestructFlag);
+ foreach (WholeReadScanner.SlotEntry s in scanner.Slots)
+ slots[(s.Address, s.Slot)] = s.Value;
+ foreach (WholeReadScanner.StateNodeEntry n in scanner.StateNodes)
+ {
+ _ = n.Path; // exercise the stage-specific path decode
+ Assert.That(n.Rlp.Length, Is.GreaterThan(0));
+ stateNodes++;
+ }
+ foreach (WholeReadScanner.StorageNodeEntry n in scanner.StorageNodes)
+ {
+ _ = n.Path;
+ _ = n.AddressHash;
+ Assert.That(n.Rlp.Length, Is.GreaterThan(0));
+ storageNodes++;
+ }
+ }
+
+ Assert.That(perAddr[TestItem.AddressA].HasAccount, Is.True);
+ Assert.That(perAddr[TestItem.AddressA].Balance, Is.EqualTo((UInt256)1000));
+ Assert.That(perAddr[TestItem.AddressA].Sd, Is.Null, "address with no self-destruct → null flag");
+ Assert.That(perAddr[TestItem.AddressC].HasAccount, Is.True, "deleted account still has a per-address entry");
+ Assert.That(perAddr[TestItem.AddressC].Balance, Is.Null, "deleted account decodes to null");
+ Assert.That(perAddr[TestItem.AddressD].HasAccount, Is.False, "self-destruct-only address has no account change");
+ Assert.That(perAddr[TestItem.AddressD].Sd, Is.False, "destructed → false");
+ Assert.That(perAddr[TestItem.AddressE].Sd, Is.True, "new account → true");
+
+ Assert.That(slots[(TestItem.AddressA, (UInt256)1)]!.Value.AsReadOnlySpan.ToArray(), Is.EqualTo(slotVal));
+ Assert.That(slots[(TestItem.AddressA, (UInt256)2)], Is.Null, "deleted slot surfaces as null");
+
+ Assert.That(stateNodes, Is.EqualTo(3), "one state node per depth tier");
+ Assert.That(storageNodes, Is.EqualTo(3), "one storage node per depth tier");
+ }
+
+ // When a column / sub-tag tier is absent, the enumerators must seek past it gracefully:
+ // state nodes only in the top tier, storage nodes only in the fallback tier, and no
+ // per-address column at all.
+ [Test]
+ public void Scan_AbsentTiers_SkipMissingColumnsAndSubTags()
+ {
+ StateId from = new(0, Keccak.EmptyTreeHash);
+ StateId to = new(1, Keccak.Compute("absent"));
+
+ SnapshotContent content = new();
+ TreePath onlyTop = new(Keccak.Compute("only-top"), 3);
+ content.StateNodes[onlyTop] = new TrieNode(NodeType.Leaf, [0xC1, 0x80]);
+ Hash256 storageAddr = Keccak.Compute("absent-storage");
+ TreePath onlyFallback = new(Keccak.Compute("only-fallback"), 18);
+ content.StorageNodes[(storageAddr, onlyFallback)] = new TrieNode(NodeType.Leaf, [0xC3, 0x80, 0x81, 0x82]);
+
+ Snapshot snapshot = new(from, to, content, _resourcePool, ResourcePool.Usage.MainBlockProcessing);
+ byte[] data = PersistedSnapshotBuilderTestExtensions.Build(snapshot, _blobs);
+ using PersistedSnapshot persisted = CreatePersistedSnapshot(from, to, data);
+
+ int perAddrCount = 0, stateNodes = 0, storageNodes = 0;
+ using (WholeReadSession session = persisted.BeginWholeReadSession())
+ {
+ WholeReadScanner scanner = PersistedSnapshotScanner.ForWholeRead(session, persisted);
+ foreach (WholeReadScanner.PerAddressEntry _ in scanner.PerAddresses) perAddrCount++;
+ foreach (WholeReadScanner.StateNodeEntry n in scanner.StateNodes) { _ = n.Path; stateNodes++; }
+ foreach (WholeReadScanner.StorageNodeEntry n in scanner.StorageNodes) { _ = n.Path; storageNodes++; }
+ }
+
+ Assert.That(perAddrCount, Is.EqualTo(0), "no per-address column → empty enumeration");
+ Assert.That(stateNodes, Is.EqualTo(1), "only the top-tier state node, compact/fallback columns absent");
+ Assert.That(storageNodes, Is.EqualTo(1), "only the fallback-tier storage node, top/compact sub-tags absent");
+ }
+
+ // Exercises the read-path miss branches: a present snapshot queried for keys that are
+ // absent at every level — unknown address, present-address/absent-slot, present-address/
+ // no-self-destruct, absent state node, absent storage addressHash, and present-addressHash/
+ // absent-path (same and different sub-tag tier).
+ [Test]
+ public void Queries_ForAbsentKeys_ReturnMisses()
+ {
+ StateId from = new(0, Keccak.EmptyTreeHash);
+ StateId to = new(1, Keccak.Compute("miss"));
+
+ byte[] slotVal = new byte[32]; slotVal[31] = 0x07;
+ SnapshotContent content = new();
+ content.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(5).TestObject;
+ content.Accounts[TestItem.AddressC] = Build.An.Account.WithBalance(9).TestObject; // 2nd address → real address BTree
+ content.Storages[(TestItem.AddressA, (UInt256)1)] = new SlotValue(slotVal);
+ content.SelfDestructedStorageAddresses[TestItem.AddressA] = true;
+ TreePath statePath = new(Keccak.Compute("sp"), 4);
+ content.StateNodes[statePath] = new TrieNode(NodeType.Leaf, [0xC1, 0x80]);
+ Hash256 storageHashObj = Keccak.Compute("sh");
+ TreePath storagePath = new(Keccak.Compute("stp"), 4);
+ content.StorageNodes[(storageHashObj, storagePath)] = new TrieNode(NodeType.Leaf, [0xC1, 0x81]);
+
+ Snapshot snapshot = new(from, to, content, _resourcePool, ResourcePool.Usage.MainBlockProcessing);
+ byte[] data = PersistedSnapshotBuilderTestExtensions.Build(snapshot, _blobs);
+ using PersistedSnapshot persisted = CreatePersistedSnapshot(from, to, data);
+
+ SlotValue sv = default;
+ // Unknown address: BTree seek misses.
+ Assert.That(persisted.TryGetAccount(TestItem.AddressB, out Account? accB), Is.False);
+ Assert.That(accB, Is.Null);
+ Assert.That(persisted.TryGetSlot(TestItem.AddressB, (UInt256)1, ref sv), Is.False);
+ Assert.That(persisted.TryGetSelfDestructFlag(TestItem.AddressB), Is.Null);
+
+ // Present address, absent slot index; present address with no slot/self-destruct sub-tag.
+ Assert.That(persisted.TryGetSlot(TestItem.AddressA, (UInt256)999, ref sv), Is.False);
+ Assert.That(persisted.TryGetSlot(TestItem.AddressC, (UInt256)1, ref sv), Is.False);
+ Assert.That(persisted.TryGetSelfDestructFlag(TestItem.AddressC), Is.Null);
+
+ // Absent state node.
+ Assert.That(persisted.TryLoadStateNodeRlp(new TreePath(Keccak.Compute("absent"), 4), out byte[]? sn), Is.False);
+ Assert.That(sn, Is.Null);
+
+ // Storage node: absent addressHash; present addressHash with absent path in the same
+ // sub-tag tier and in a different (absent) tier.
+ ValueHash256 storageHash = new(storageHashObj.Bytes);
+ Assert.That(persisted.TryLoadStorageNodeRlp(new ValueHash256(Keccak.Compute("nope").Bytes), storagePath, out _), Is.False);
+ Assert.That(persisted.TryLoadStorageNodeRlp(storageHash, new TreePath(Keccak.Compute("absentSameTier"), 4), out _), Is.False);
+ Assert.That(persisted.TryLoadStorageNodeRlp(storageHash, new TreePath(Keccak.Compute("absentDeep"), 18), out _), Is.False);
+
+ Assert.That(persisted.TryGetAccount(TestItem.AddressA, out _), Is.True);
+ Assert.That(persisted.TryLoadStorageNodeRlp(storageHash, storagePath, out _), Is.True);
+ }
+
+ // An empty snapshot has no address column (cached BTree bound is empty) and no node
+ // columns, so every read returns a miss without faulting.
+ [Test]
+ public void Queries_OnEmptySnapshot_ReturnMisses()
+ {
+ StateId from = new(0, Keccak.EmptyTreeHash);
+ StateId to = new(1, Keccak.Compute("empty-reads"));
+ Snapshot snapshot = new(from, to, new SnapshotContent(), _resourcePool, ResourcePool.Usage.MainBlockProcessing);
+ byte[] data = PersistedSnapshotBuilderTestExtensions.Build(snapshot, _blobs);
+ using PersistedSnapshot persisted = CreatePersistedSnapshot(from, to, data);
+
+ SlotValue sv = default;
+ Assert.That(persisted.TryGetAccount(TestItem.AddressA, out _), Is.False);
+ Assert.That(persisted.TryGetSlot(TestItem.AddressA, (UInt256)1, ref sv), Is.False);
+ Assert.That(persisted.TryGetSelfDestructFlag(TestItem.AddressA), Is.Null);
+ Assert.That(persisted.TryLoadStateNodeRlp(new TreePath(Keccak.Compute("p"), 4), out _), Is.False);
+ Assert.That(persisted.TryLoadStorageNodeRlp(new ValueHash256(Keccak.Compute("h").Bytes), new TreePath(Keccak.Compute("p"), 4), out _), Is.False);
+
+ // Build-based snapshots carry no blob_range metadata → BlobRange.None → advise is a no-op.
+ Assert.DoesNotThrow(() => persisted.AdviseWillNeedBlobRange());
+ Assert.DoesNotThrow(() => persisted.AdviseDontNeedBlobRange());
+ }
+
+ // Drives PersistedSnapshotStack's newest-first probe loops over a two-snapshot stack:
+ // hits in the newer and (after a newer miss) the older snapshot, full misses, the
+ // self-destruct slot boundary, and the detailed-metrics observations.
+ [Test]
+ public void Stack_ProbesNewestFirst_AcrossAllKinds()
+ {
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("st1"));
+ StateId s2 = new(2, Keccak.Compute("st2"));
+
+ byte[] v1 = new byte[32]; v1[31] = 0x11;
+ byte[] v2 = new byte[32]; v2[31] = 0x22;
+
+ SnapshotContent older = new();
+ older.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(100).TestObject;
+ older.Accounts[TestItem.AddressD] = Build.An.Account.WithBalance(40).TestObject;
+ older.Storages[(TestItem.AddressA, (UInt256)1)] = new SlotValue(v1);
+ older.SelfDestructedStorageAddresses[TestItem.AddressA] = false;
+ TreePath statePath = new(Keccak.Compute("st-p"), 4);
+ older.StateNodes[statePath] = new TrieNode(NodeType.Leaf, [0xC1, 0x80]);
+ Hash256 storageHashObj = Keccak.Compute("st-sh");
+ TreePath storagePath = new(Keccak.Compute("st-sp"), 4);
+ older.StorageNodes[(storageHashObj, storagePath)] = new TrieNode(NodeType.Leaf, [0xC1, 0x81]);
+
+ SnapshotContent newer = new();
+ newer.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(200).TestObject;
+ newer.Accounts[TestItem.AddressB] = Build.An.Account.WithBalance(7).TestObject;
+ newer.Storages[(TestItem.AddressA, (UInt256)2)] = new SlotValue(v2);
+
+ byte[] olderData = PersistedSnapshotBuilderTestExtensions.Build(
+ new Snapshot(s0, s1, older, _resourcePool, ResourcePool.Usage.MainBlockProcessing), _blobs);
+ byte[] newerData = PersistedSnapshotBuilderTestExtensions.Build(
+ new Snapshot(s1, s2, newer, _resourcePool, ResourcePool.Usage.MainBlockProcessing), _blobs);
+
+ PersistedSnapshotList list = new(2) { CreatePersistedSnapshot(s0, s1, olderData), CreatePersistedSnapshot(s1, s2, newerData) };
+ using PersistedSnapshotStack stack = new(list, recordDetailedMetrics: true);
+
+ // Account: newest wins; older-only address resolves after the newer miss; full miss.
+ Assert.That(stack.TryGetAccount(TestItem.AddressA, out Account? a), Is.True);
+ Assert.That(a!.Balance, Is.EqualTo((UInt256)200), "newest snapshot wins");
+ Assert.That(stack.TryGetAccount(TestItem.AddressD, out Account? d), Is.True);
+ Assert.That(d!.Balance, Is.EqualTo((UInt256)40), "older-only address resolves after newer miss");
+ Assert.That(stack.TryGetAccount(TestItem.AddressF, out _), Is.False);
+
+ // Self-destruct: only the older snapshot carries it.
+ Assert.That(stack.TryGetSelfDestruct(TestItem.AddressA, out int sdIdx), Is.True);
+ Assert.That(sdIdx, Is.EqualTo(0));
+ Assert.That(stack.TryGetSelfDestruct(TestItem.AddressF, out _), Is.False);
+
+ long start = System.Diagnostics.Stopwatch.GetTimestamp();
+ // Slot: newer holds slot 2, older holds slot 1; both resolve.
+ Assert.That(stack.TryGetSlot(TestItem.AddressA, (UInt256)2, -1, start, out byte[]? sv2), Is.True);
+ Assert.That(sv2![^1], Is.EqualTo((byte)0x22)); // ToEvmBytes strips leading zeros
+ Assert.That(stack.TryGetSlot(TestItem.AddressA, (UInt256)1, -1, start, out byte[]? sv1), Is.True);
+ Assert.That(sv1![^1], Is.EqualTo((byte)0x11));
+ // Slot below the self-destruct boundary resolves to null (storage wiped).
+ Assert.That(stack.TryGetSlot(TestItem.AddressA, (UInt256)999, 0, start, out byte[]? svNull), Is.True);
+ Assert.That(svNull, Is.Null);
+ // Slot fully absent (no boundary) falls through.
+ Assert.That(stack.TryGetSlot(TestItem.AddressF, (UInt256)1, -1, start, out _), Is.False);
+
+ // State / storage node RLP: present (in older) and absent.
+ Assert.That(stack.TryLoadStateRlp(statePath, out byte[]? srlp), Is.True);
+ Assert.That(srlp, Is.Not.Null);
+ Assert.That(stack.TryLoadStateRlp(new TreePath(Keccak.Compute("nope-st"), 4), out _), Is.False);
+ Assert.That(stack.TryLoadStorageRlp(storageHashObj, storagePath, out byte[]? strlp), Is.True);
+ Assert.That(strlp, Is.Not.Null);
+ Assert.That(stack.TryLoadStorageRlp(storageHashObj, new TreePath(Keccak.Compute("nope-sp"), 4), out _), Is.False);
+ }
+
+ [Test]
+ public void ActivePersistedSnapshotCount_TracksConstructionAndDisposal()
+ {
+ StateId from = new(0, Keccak.EmptyTreeHash);
+ StateId to1 = new(1, Keccak.Compute("one"));
+ StateId to2 = new(2, Keccak.Compute("two"));
+
+ Snapshot inMem1 = new(from, to1, new SnapshotContent(), _resourcePool, ResourcePool.Usage.MainBlockProcessing);
+ Snapshot inMem2 = new(from, to2, new SnapshotContent(), _resourcePool, ResourcePool.Usage.MainBlockProcessing);
+ byte[] data1 = PersistedSnapshotBuilderTestExtensions.Build(inMem1, _blobs);
+ byte[] data2 = PersistedSnapshotBuilderTestExtensions.Build(inMem2, _blobs);
+
+ long baseline = Active();
+
+ PersistedSnapshot s1 = CreatePersistedSnapshot(from, to1, data1);
+ PersistedSnapshot s2 = CreatePersistedSnapshot(from, to2, data2);
+
+ Assert.That(Active(), Is.EqualTo(baseline + 2));
+
+ s1.Dispose();
+ Assert.That(Active(), Is.EqualTo(baseline + 1));
+
+ s2.Dispose();
+ Assert.That(Active(), Is.EqualTo(baseline));
+
+ static long Active()
+ {
+ long total = 0;
+ foreach (KeyValuePair kv in Metrics.ActivePersistedSnapshotCount)
+ total += kv.Value;
+ return total;
+ }
+ }
+
+ [Test]
+ public void BlobArena_FrontierResets_WhenLastPersistedSnapshotDisposes()
+ {
+ StateId from = new(0, Keccak.EmptyTreeHash);
+ StateId to = new(1, Keccak.Compute("reset"));
+
+ Snapshot inMem = new(from, to, new SnapshotContent(), _resourcePool, ResourcePool.Usage.MainBlockProcessing);
+ TreePath path = new(Keccak.Compute("p"), 8);
+ inMem.Content.StateNodes[path] = new TrieNode(NodeType.Leaf, [0xC2, 0x80, 0x80]);
+
+ long baselineBytes = Metrics.BlobAllocatedBytes;
+ byte[] data = PersistedSnapshotBuilderTestExtensions.Build(inMem, _blobs);
+ long afterBuild = Metrics.BlobAllocatedBytes;
+ Assert.That(afterBuild, Is.GreaterThan(baselineBytes), "Building a snapshot with trie nodes should grow blob-allocated bytes");
+
+ // Skip LeaseBlobIds: it acquires an extra lease per blob id that other
+ // tests rely on but that this test must not leave dangling, otherwise the
+ // orphan-reset would correctly refuse to fire.
+ TestFixtureHelpers.CreatePersistedSnapshot(_memArena, _blobs, from, to, data, leaseBlobIds: false)
+ .Dispose();
+
+ // After the last external lease drops, the manager's TryResetOrphanedFrontier
+ // should have reset the file's frontier and pushed the delta back to the gauge.
+ Assert.That(Metrics.BlobAllocatedBytes, Is.EqualTo(baselineBytes),
+ "Blob-allocated bytes must drop back to baseline once the last referencing snapshot is disposed");
+ }
+
+ [TestCase((ushort)0, 0)]
+ [TestCase((ushort)42, 12345)]
+ [TestCase(ushort.MaxValue, int.MaxValue)]
+ public void NodeRef_ReadWrite_RoundTrip(ushort id, int offset)
+ {
+ Assert.That(NodeRef.Size, Is.EqualTo(6));
+ NodeRef original = new(id, offset);
+ byte[] buffer = new byte[NodeRef.Size];
+ NodeRef.Write(buffer, original);
+ NodeRef decoded = NodeRef.Read(buffer);
+
+ Assert.That(decoded.BlobArenaId, Is.EqualTo(id));
+ Assert.That(decoded.RlpDataOffset, Is.EqualTo(offset));
+ }
+
+ [Test]
+ public void PersistedSnapshotList_Queries_NewestFirst()
+ {
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+ StateId s2 = new(2, Keccak.Compute("2"));
+
+ // path length 4 → StateTopNodes column
+ TreePath path = new(Keccak.Compute("path"), 4);
+ byte[] rlp1 = [0xC0];
+ byte[] rlp2 = [0xC1, 0x80];
+
+ SnapshotContent content1 = new();
+ content1.StateNodes[path] = new TrieNode(NodeType.Leaf, rlp1);
+ Snapshot snap1 = new(s0, s1, content1, _resourcePool, ResourcePool.Usage.MainBlockProcessing);
+
+ SnapshotContent content2 = new();
+ content2.StateNodes[path] = new TrieNode(NodeType.Leaf, rlp2);
+ Snapshot snap2 = new(s1, s2, content2, _resourcePool, ResourcePool.Usage.MainBlockProcessing);
+
+ byte[] data1 = PersistedSnapshotBuilderTestExtensions.Build(snap1, _blobs);
+ byte[] data2 = PersistedSnapshotBuilderTestExtensions.Build(snap2, _blobs);
+
+ PersistedSnapshot p1 = CreatePersistedSnapshot(s0, s1, data1);
+ PersistedSnapshot p2 = CreatePersistedSnapshot(s1, s2, data2);
+
+ // Ordered oldest-first; query newest-first via indexer
+ PersistedSnapshotList list = new(2) { p1, p2 };
+ byte[]? result = null;
+ bool found = false;
+ for (int i = list.Count - 1; i >= 0; i--)
+ {
+ if (list[i].TryLoadStateNodeRlp(path, out result))
+ {
+ found = true;
+ break;
+ }
+ }
+
+ Assert.That(found, Is.True);
+ Assert.That(result, Is.EqualTo(rlp2));
+ }
+
+ [Test]
+ public void Storage_NestedMerge_OverlappingAddresses()
+ {
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+ StateId s2 = new(2, Keccak.Compute("2"));
+
+ Address addrA = TestItem.AddressA;
+ Address addrB = TestItem.AddressB;
+ byte[] val1 = new byte[32]; val1[31] = 0x01;
+ byte[] val2 = new byte[32]; val2[31] = 0x02;
+ byte[] val3 = new byte[32]; val3[31] = 0x03;
+
+ SnapshotContent content1 = new();
+ content1.Storages[(addrA, (UInt256)1)] = new SlotValue(val1);
+ content1.Storages[(addrB, (UInt256)5)] = new SlotValue(val2);
+ Snapshot snap1 = new(s0, s1, content1, _resourcePool, ResourcePool.Usage.MainBlockProcessing);
+ byte[] data1 = PersistedSnapshotBuilderTestExtensions.Build(snap1, _blobs);
+
+ SnapshotContent content2 = new();
+ content2.Storages[(addrA, (UInt256)1)] = new SlotValue(val3);
+ content2.Storages[(addrA, (UInt256)2)] = new SlotValue(val2);
+ Snapshot snap2 = new(s1, s2, content2, _resourcePool, ResourcePool.Usage.MainBlockProcessing);
+ byte[] data2 = PersistedSnapshotBuilderTestExtensions.Build(snap2, _blobs);
+
+ PersistedSnapshotList toMerge = new(2) { CreatePersistedSnapshot(s0, s1, data1), CreatePersistedSnapshot(s1, s2, data2) };
+ byte[] merged = PersistedSnapshotBuilderTestExtensions.NWayMergeSnapshots(toMerge);
+ PersistedSnapshot persisted = CreatePersistedSnapshot(s0, s2, merged);
+
+ SlotValue slot1 = default;
+ Assert.That(persisted.TryGetSlot(addrA, (UInt256)1, ref slot1), Is.True);
+ Assert.That(slot1.ToEvmBytes()[0], Is.EqualTo(0x03));
+
+ SlotValue slot2 = default;
+ Assert.That(persisted.TryGetSlot(addrA, (UInt256)2, ref slot2), Is.True);
+ Assert.That(slot2.ToEvmBytes()[0], Is.EqualTo(0x02));
+
+ SlotValue slot5 = default;
+ Assert.That(persisted.TryGetSlot(addrB, (UInt256)5, ref slot5), Is.True);
+ Assert.That(slot5.ToEvmBytes()[0], Is.EqualTo(0x02));
+ }
+
+ private static IEnumerable NullSlotMergeCases()
+ {
+ byte[] nonZero = new byte[32];
+ nonZero[31] = 0xFF;
+
+ yield return new TestCaseData(
+ (Action)(c => c.Storages[(TestItem.AddressA, (UInt256)1)] = new SlotValue(nonZero)),
+ (Action)(c => c.Storages[(TestItem.AddressA, (UInt256)1)] = null),
+ (Action)(persisted =>
+ {
+ SlotValue slot = default;
+ Assert.That(persisted.TryGetSlot(TestItem.AddressA, (UInt256)1, ref slot), Is.True);
+ Assert.That(slot.AsReadOnlySpan.IndexOfAnyExcept((byte)0), Is.EqualTo(-1), "Null slot should override value after merge");
+ })).SetName("NullOverridesValue");
+
+ yield return new TestCaseData(
+ (Action)(c => c.Storages[(TestItem.AddressA, (UInt256)1)] = null),
+ (Action)(c => c.Storages[(TestItem.AddressA, (UInt256)1)] = new SlotValue(nonZero)),
+ (Action)(persisted =>
+ {
+ SlotValue slot = default;
+ Assert.That(persisted.TryGetSlot(TestItem.AddressA, (UInt256)1, ref slot), Is.True);
+ Assert.That(slot.ToEvmBytes().Length, Is.GreaterThan(0), "Value should override null slot after merge");
+ })).SetName("ValueOverridesNull");
+
+ yield return new TestCaseData(
+ (Action)(c => c.Storages[(TestItem.AddressA, (UInt256)1)] = null),
+ (Action)(c => c.Storages[(TestItem.AddressA, (UInt256)2)] = new SlotValue(nonZero)),
+ (Action)(persisted =>
+ {
+ SlotValue slot1 = default;
+ Assert.That(persisted.TryGetSlot(TestItem.AddressA, (UInt256)1, ref slot1), Is.True);
+ Assert.That(slot1.AsReadOnlySpan.IndexOfAnyExcept((byte)0), Is.EqualTo(-1), "Null slot from older should be preserved");
+
+ SlotValue slot2 = default;
+ Assert.That(persisted.TryGetSlot(TestItem.AddressA, (UInt256)2, ref slot2), Is.True);
+ Assert.That(slot2.AsReadOnlySpan.IndexOfAnyExcept((byte)0), Is.GreaterThanOrEqualTo(0), "Value from newer should be present");
+ })).SetName("NullPreservedAndValueCarried");
+ }
+
+ [TestCaseSource(nameof(NullSlotMergeCases))]
+ public void Storage_NullSlot_Merge(
+ Action populateOlder,
+ Action populateNewer,
+ Action verify)
+ {
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+ StateId s2 = new(2, Keccak.Compute("2"));
+
+ SnapshotContent olderContent = new();
+ populateOlder(olderContent);
+ Snapshot older = new(s0, s1, olderContent, _resourcePool, ResourcePool.Usage.MainBlockProcessing);
+ byte[] dataOlder = PersistedSnapshotBuilderTestExtensions.Build(older, _blobs);
+
+ SnapshotContent newerContent = new();
+ populateNewer(newerContent);
+ Snapshot newer = new(s1, s2, newerContent, _resourcePool, ResourcePool.Usage.MainBlockProcessing);
+ byte[] dataNewer = PersistedSnapshotBuilderTestExtensions.Build(newer, _blobs);
+
+ PersistedSnapshotList toMerge = new(2) { CreatePersistedSnapshot(s0, s1, dataOlder), CreatePersistedSnapshot(s1, s2, dataNewer) };
+ byte[] merged = PersistedSnapshotBuilderTestExtensions.NWayMergeSnapshots(toMerge);
+ PersistedSnapshot persisted = CreatePersistedSnapshot(s0, s2, merged);
+
+ verify(persisted);
+ }
+
+ // Round-trips account / self-destruct / slot / storage-node across a range of slot counts,
+ // including a multi-page snapshot, then re-reads after AdviseDontNeed drops the kernel pages.
+ [TestCase(4)]
+ [TestCase(400)]
+ [TestCase(4000)]
+ public void RoundTrips_AcrossSlotCounts(int slotCount)
+ {
+ StateId from = new(0, Keccak.EmptyTreeHash);
+ StateId to = new(1, Keccak.Compute("warmup"));
+
+ Address addr = TestItem.AddressA;
+ Hash256 addrHashKey = new(addr.ToAccountPath.Bytes);
+ Account expectedAccount = Build.An.Account.WithBalance(987654321).WithNonce(11).TestObject;
+ TreePath storagePath = new(Keccak.Compute("warmup-spath"), 6);
+ TrieNode storageNode = new(NodeType.Branch, [0xC3, 0x80, 0x81, 0x82]);
+
+ SnapshotContent content = new();
+ content.Accounts[addr] = expectedAccount;
+ content.SelfDestructedStorageAddresses[addr] = true;
+ content.StorageNodes[(addrHashKey, storagePath)] = storageNode;
+ for (int i = 0; i < slotCount; i++)
+ {
+ byte[] val = new byte[32];
+ BinaryPrimitives.WriteInt32BigEndian(val.AsSpan(28, 4), i + 1);
+ content.Storages[(addr, (UInt256)i + 1)] = new SlotValue(val);
+ }
+
+ Snapshot snapshot = new(from, to, content, _resourcePool, ResourcePool.Usage.MainBlockProcessing);
+ byte[] data = PersistedSnapshotBuilderTestExtensions.Build(snapshot, _blobs);
+ // The flat sorted table materialises a full record per slot, so a large slot count exceeds
+ // the shared 64 KiB fixture arena — use a roomier local arena for this case.
+ string arenaDir = Path.Combine(Path.GetTempPath(), $"nm-pstest-rt-{Guid.NewGuid():N}");
+ using ArenaManager arena = TestFixtureHelpers.CreateArenaManager(arenaDir, 64 * 1024 * 1024);
+ using PersistedSnapshot persisted = TestFixtureHelpers.CreatePersistedSnapshot(arena, _blobs, from, to, data);
+
+ // Per-address entries are keyed by raw Address; storage-trie reads take the addressHash.
+ ValueHash256 addrHash = addr.ToAccountPath;
+
+ Assert.That(persisted.TryGetAccount(addr, out Account? acc1), Is.True);
+ Assert.That(acc1, Is.Not.Null);
+ Assert.That(acc1!.Balance, Is.EqualTo(expectedAccount.Balance));
+ Assert.That(acc1.Nonce, Is.EqualTo(expectedAccount.Nonce));
+
+ Assert.That(persisted.TryGetSelfDestructFlag(addr), Is.EqualTo((bool?)true));
+
+ UInt256 probeIndex = (UInt256)(Math.Min(slotCount, 3));
+ SlotValue slot1 = default;
+ Assert.That(persisted.TryGetSlot(addr, probeIndex, ref slot1), Is.True);
+ byte[] expectedSlotVal = new byte[32];
+ BinaryPrimitives.WriteInt32BigEndian(expectedSlotVal.AsSpan(28, 4), (int)probeIndex);
+ Assert.That(slot1.AsReadOnlySpan.SequenceEqual(expectedSlotVal), Is.True);
+
+ Assert.That(persisted.TryLoadStorageNodeRlp(addrHash, storagePath, out byte[]? nodeRlp1), Is.True);
+ Assert.That(nodeRlp1, Is.EqualTo(storageNode.FullRlp.ToArray()));
+
+ // Second pass: results must match.
+ Assert.That(persisted.TryGetAccount(addr, out Account? acc2), Is.True);
+ Assert.That(acc2!.Balance, Is.EqualTo(expectedAccount.Balance));
+ SlotValue slot2 = default;
+ Assert.That(persisted.TryGetSlot(addr, probeIndex, ref slot2), Is.True);
+ Assert.That(slot2.AsReadOnlySpan.SequenceEqual(expectedSlotVal), Is.True);
+
+ // AdviseDontNeed advises the mmap range cold; the next reads re-fault any dropped page
+ // and the binary search must still resolve correctly.
+ persisted.AdviseDontNeed();
+ Assert.That(persisted.TryGetAccount(addr, out Account? acc3), Is.True);
+ Assert.That(acc3!.Nonce, Is.EqualTo(expectedAccount.Nonce));
+ SlotValue slot3 = default;
+ Assert.That(persisted.TryGetSlot(addr, probeIndex, ref slot3), Is.True);
+ Assert.That(slot3.AsReadOnlySpan.SequenceEqual(expectedSlotVal), Is.True);
+
+ // Fresh miss for an unrelated address still works after AdviseDontNeed.
+ Assert.That(persisted.TryGetAccount(TestItem.AddressB, out _), Is.False);
+ }
+}
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/Persistence/BloomFilter/BloomFilterTests.cs b/src/Nethermind/Nethermind.State.Flat.Test/Persistence/BloomFilter/BloomFilterTests.cs
index 4d1e4e1e29f2..862f386d9a6a 100644
--- a/src/Nethermind/Nethermind.State.Flat.Test/Persistence/BloomFilter/BloomFilterTests.cs
+++ b/src/Nethermind/Nethermind.State.Flat.Test/Persistence/BloomFilter/BloomFilterTests.cs
@@ -97,6 +97,18 @@ public void Dispose_MultipleTimes_ShouldNotThrow()
Assert.DoesNotThrow(() => bloom.Dispose());
}
+ [TestCase(0UL)]
+ [TestCase(1UL)]
+ [TestCase(0xDEADBEEFCAFEBABEUL)]
+ [TestCase(ulong.MaxValue)]
+ public void AlwaysTrue_MightContain_AnyKey_ReturnsTrue(ulong key)
+ {
+ using Nethermind.State.Flat.Persistence.BloomFilter.BloomFilter bloom =
+ Nethermind.State.Flat.Persistence.BloomFilter.BloomFilter.AlwaysTrue();
+
+ Assert.That(bloom.MightContain(key), Is.True, "AlwaysTrue sentinel must match every probe");
+ }
+
[Test]
public void MightContain_BeforeAnyAdds_ShouldReturnFalse()
{
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/PersistenceManagerPersistedTests.cs b/src/Nethermind/Nethermind.State.Flat.Test/PersistenceManagerPersistedTests.cs
new file mode 100644
index 000000000000..27746f09ead9
--- /dev/null
+++ b/src/Nethermind/Nethermind.State.Flat.Test/PersistenceManagerPersistedTests.cs
@@ -0,0 +1,198 @@
+// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using System;
+using System.IO;
+using Nethermind.Core.Crypto;
+using Nethermind.Core.Test.Builders;
+using Nethermind.Db;
+using Nethermind.State.Flat.PersistedSnapshots;
+using NUnit.Framework;
+
+namespace Nethermind.State.Flat.Test;
+
+[TestFixture]
+public class PersistenceManagerPersistedTests
+{
+ private string _testDir = null!;
+ private ResourcePool _pool = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _testDir = Path.Combine(Path.GetTempPath(), $"nethermind_test_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_testDir);
+ _pool = new ResourcePool(new FlatDbConfig());
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(_testDir))
+ Directory.Delete(_testDir, recursive: true);
+ }
+
+ [Test]
+ public void ConvertToPersistedSnapshot_PersistsViaManager()
+ {
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 4096);
+ SnapshotRepository repo = tier.Repository;
+
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+ SnapshotContent content = new();
+ content.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(500).TestObject;
+ Snapshot snap = new(s0, s1, content, _pool, ResourcePool.Usage.MainBlockProcessing);
+
+ tier.ConvertToPersistedBase(snap).Dispose();
+
+ Assert.That(repo.PersistedSnapshotCount, Is.EqualTo(1));
+ Assert.That(repo.TryLeasePersistedState(s1, SnapshotTier.PersistedBase, out PersistedSnapshot? snapshot), Is.True);
+ snapshot!.Dispose();
+ }
+
+ [Test]
+ public void PrunePersistedSnapshots_RemovesOldSnapshots()
+ {
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 4096);
+ SnapshotRepository repo = tier.Repository;
+
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+ StateId s3 = new(3, Keccak.Compute("3"));
+ StateId s6 = new(6, Keccak.Compute("6"));
+
+ SnapshotContent c1 = new();
+ c1.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(1).TestObject;
+ tier.ConvertToPersistedBase(new Snapshot(s0, s1, c1, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+
+ SnapshotContent c2 = new();
+ c2.Accounts[TestItem.AddressB] = Build.An.Account.WithBalance(2).TestObject;
+ tier.ConvertToPersistedBase(new Snapshot(s1, s3, c2, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+
+ SnapshotContent c3 = new();
+ c3.Accounts[TestItem.AddressC] = Build.An.Account.WithBalance(3).TestObject;
+ tier.ConvertToPersistedBase(new Snapshot(s3, s6, c3, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+
+ Assert.That(repo.PersistedSnapshotCount, Is.EqualTo(3));
+
+ // Snapshots with To.BlockNumber < 5 are removed (s1, s3); s6 survives.
+ repo.RemovePersistedStatesUntil(5);
+
+ Assert.That(repo.PersistedSnapshotCount, Is.EqualTo(1));
+ }
+
+ [Test]
+ public void RemoveSiblingAndDescendents_CrossTier_PrunesPersistedOrphans_KeepsCanonicalThroughPersistedAncestor()
+ {
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 4096);
+ SnapshotRepository repo = tier.Repository;
+
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+ StateId s2 = new(2, Keccak.Compute("2"));
+ StateId c3 = new(3, Keccak.Compute("c3"));
+ StateId c4 = new(4, Keccak.Compute("c4"));
+ StateId nc3 = new(3, Keccak.Compute("nc3"));
+ StateId nc4 = new(4, Keccak.Compute("nc4"));
+ StateId c5 = new(5, Keccak.Compute("c5"));
+
+ // Persisted tier: common chain s0->s1->s2, canonical s2->C3->C4, and a non-canonical
+ // fork s2->NC3->NC4 diverging at block 3.
+ PersistToTier(tier, s0, s1);
+ PersistToTier(tier, s1, s2);
+ PersistToTier(tier, s2, c3);
+ PersistToTier(tier, c3, c4);
+ PersistToTier(tier, s2, nc3);
+ PersistToTier(tier, nc3, nc4);
+
+ // In-memory canonical C5 whose parent C4 lives only in the persisted tier — reachability
+ // to C3 therefore has to cross from the in-memory tier into the persisted tier.
+ AddInMemory(repo, c4, c5);
+
+ repo.RemoveSiblingAndDescendents(c3);
+
+ Assert.That(LeasePresent(repo, nc4), Is.False, "orphan NC4 above the persisted block should be pruned from the persisted tier");
+ Assert.That(LeasePresent(repo, c4), Is.True, "canonical C4 should be kept");
+ Assert.That(repo.HasBaseSnapshot(c3), Is.True, "canonical target C3 should be kept");
+ Assert.That(repo.HasBaseSnapshot(nc3), Is.True, "NC3 at the persisted block is left to RemoveStatesUntil");
+ Assert.That(repo.HasState(c5), Is.True, "canonical in-memory C5 reachable through persisted C4 must be kept");
+ }
+
+ [Test]
+ public void RemoveSiblingAndDescendents_PersistedOrphanAboveInMemoryTip_IsPruned()
+ {
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 4096);
+ SnapshotRepository repo = tier.Repository;
+
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+ StateId s2 = new(2, Keccak.Compute("2"));
+ StateId c3 = new(3, Keccak.Compute("c3"));
+ StateId nc3 = new(3, Keccak.Compute("nc3"));
+ StateId nc4 = new(4, Keccak.Compute("nc4"));
+
+ // Persisted tier: common chain s0->s1->s2, canonical s2->C3, and a non-canonical fork
+ // s2->NC3->NC4 diverging at block 3 — NC4 is an orphan at block 4.
+ PersistToTier(tier, s0, s1);
+ PersistToTier(tier, s1, s2);
+ PersistToTier(tier, s2, c3);
+ PersistToTier(tier, s2, nc3);
+ PersistToTier(tier, nc3, nc4);
+
+ // In-memory tip sits at the canonical block (3), BELOW the persisted orphan NC4 (block 4).
+ // The orphan walk's upper bound must come from the persisted tier, not the in-memory tip,
+ // or NC4 is never visited.
+ AddInMemory(repo, s2, c3);
+
+ repo.RemoveSiblingAndDescendents(c3);
+
+ Assert.That(LeasePresent(repo, nc4), Is.False, "persisted orphan NC4 above the in-memory tip should be pruned");
+ Assert.That(repo.HasBaseSnapshot(c3), Is.True, "canonical C3 should be kept");
+ Assert.That(repo.HasBaseSnapshot(nc3), Is.True, "NC3 at the persisted block is left to RemoveStatesUntil");
+ }
+
+ [Test]
+ public void RemoveSiblingAndDescendents_PersistedLinearChain_RemovesNothing()
+ {
+ using FlatTestContainer tier = new(arenaFileSizeBytes: 4096);
+ SnapshotRepository repo = tier.Repository;
+
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+ StateId s2 = new(2, Keccak.Compute("2"));
+ StateId s3 = new(3, Keccak.Compute("3"));
+ PersistToTier(tier, s0, s1);
+ PersistToTier(tier, s1, s2);
+ PersistToTier(tier, s2, s3);
+
+ int before = repo.PersistedSnapshotCount;
+ repo.RemoveSiblingAndDescendents(s1);
+
+ Assert.That(repo.PersistedSnapshotCount, Is.EqualTo(before), "a linear persisted chain has no fork; nothing should be pruned");
+ Assert.That(repo.HasBaseSnapshot(s2), Is.True);
+ Assert.That(repo.HasBaseSnapshot(s3), Is.True);
+ }
+
+ private void PersistToTier(FlatTestContainer tier, StateId from, StateId to)
+ {
+ SnapshotContent content = new();
+ content.Accounts[TestItem.AddressA] = Build.An.Account.WithBalance(1).TestObject;
+ tier.ConvertToPersistedBase(new Snapshot(from, to, content, _pool, ResourcePool.Usage.MainBlockProcessing)).Dispose();
+ }
+
+ private void AddInMemory(SnapshotRepository repo, StateId from, StateId to)
+ {
+ SnapshotContent content = new();
+ content.Accounts[TestItem.AddressB] = Build.An.Account.WithBalance(1).TestObject;
+ repo.TryAdd(new Snapshot(from, to, content, _pool, ResourcePool.Usage.MainBlockProcessing), SnapshotTier.InMemoryBase);
+ repo.AddStateId(to);
+ }
+
+ private static bool LeasePresent(SnapshotRepository repo, StateId to)
+ {
+ if (!repo.TryLeasePersistedState(to, SnapshotTier.PersistedBase, out PersistedSnapshot? snapshot)) return false;
+ snapshot!.Dispose();
+ return true;
+ }
+}
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/PersistenceManagerTests.cs b/src/Nethermind/Nethermind.State.Flat.Test/PersistenceManagerTests.cs
index 495a72a480c1..8aa835a30954 100644
--- a/src/Nethermind/Nethermind.State.Flat.Test/PersistenceManagerTests.cs
+++ b/src/Nethermind/Nethermind.State.Flat.Test/PersistenceManagerTests.cs
@@ -2,6 +2,8 @@
// SPDX-License-Identifier: LGPL-3.0-only
using System.Collections.Generic;
+using System.Threading.Tasks;
+using Nethermind.Config;
using Nethermind.Core;
using Nethermind.Core.Crypto;
using Nethermind.Core.Test.Builders;
@@ -9,6 +11,7 @@
using Nethermind.Int256;
using Nethermind.Logging;
using Nethermind.State.Flat.Persistence;
+using Nethermind.State.Flat.PersistedSnapshots;
using Nethermind.Trie;
using Nethermind.Trie.Pruning;
using NSubstitute;
@@ -22,8 +25,10 @@ public class PersistenceManagerTests
private PersistenceManager _persistenceManager = null!;
private FlatDbConfig _config = null!;
private TestFinalizedStateProvider _finalizedStateProvider = null!;
+ private FlatTestContainer _tier = null!;
private SnapshotRepository _snapshotRepository = null!;
private IPersistence _persistence = null!;
+ private IPersistedSnapshotCompactor _persistedSnapshotCompactor = null!;
private ResourcePool _resourcePool = null!;
private StateId Block0 = new(0, Keccak.EmptyTreeHash);
@@ -34,30 +39,45 @@ public void SetUp()
{
CompactSize = 16,
MinReorgDepth = 64,
- MaxReorgDepth = 256
+ MaxInMemoryBaseSnapshotCount = 128 + 32,
+ MaxReorgDepth = 90000,
+ LongFinalityMaxReorgDepth = 90000,
+ EnableLongFinality = true
};
_resourcePool = new ResourcePool(_config);
_finalizedStateProvider = new TestFinalizedStateProvider();
- _snapshotRepository = new SnapshotRepository(LimboLogs.Instance);
+ // SnapshotRepository owns both tiers over a real temp-dir-backed persisted store, wired the
+ // production way through FlatWorldStateModule; the container pairs it with its loader (load on
+ // build, teardown on dispose).
+ _tier = new FlatTestContainer();
+ _snapshotRepository = _tier.Repository;
_persistence = Substitute.For();
IPersistence.IPersistenceReader persistenceReader = Substitute.For();
persistenceReader.CurrentState.Returns(Block0);
_persistence.CreateReader().Returns(persistenceReader);
+ _persistedSnapshotCompactor = Substitute.For();
+
_persistenceManager = new PersistenceManager(
_config,
ScheduleHelper.CreateWithOffset(_config, 0),
_finalizedStateProvider,
_persistence,
_snapshotRepository,
- LimboLogs.Instance);
+ LimboLogs.Instance,
+ _persistedSnapshotCompactor,
+ _tier.Loader,
+ Substitute.For());
}
[TearDown]
- public void TearDown()
+ public async Task TearDown()
{
+ _persistenceManager.Dispose();
+ await _persistedSnapshotCompactor.DisposeAsync();
+ _tier.Dispose();
}
private StateId CreateStateId(long blockNumber, byte rootByte = 0)
@@ -74,11 +94,11 @@ private Snapshot CreateSnapshot(StateId from, StateId to, bool compacted = false
if (compacted)
{
- _snapshotRepository.TryAddCompactedSnapshot(snapshot);
+ _snapshotRepository.TryAdd(snapshot, SnapshotTier.InMemoryCompacted);
}
else
{
- _snapshotRepository.TryAddSnapshot(snapshot);
+ _snapshotRepository.TryAdd(snapshot, SnapshotTier.InMemoryBase);
}
// AddStateId is needed for GetStatesAtBlockNumber to work
@@ -87,6 +107,14 @@ private Snapshot CreateSnapshot(StateId from, StateId to, bool compacted = false
return snapshot;
}
+ // Persist a base directly into the (real) persisted tier, bypassing the in-memory tier.
+ private void PersistBase(StateId from, StateId to)
+ {
+ Snapshot snapshot = _resourcePool.CreateSnapshot(from, to, ResourcePool.Usage.MainBlockProcessing);
+ snapshot.Content.Accounts[TestItem.AddressA] = new Account(1, 100);
+ _tier.ConvertToPersistedBase(snapshot).Dispose();
+ }
+
private Snapshot CreateSnapshotWithSelfDestruct(StateId from, StateId to)
{
Snapshot snapshot = _resourcePool.CreateSnapshot(from, to, ResourcePool.Usage.ReadOnlyProcessingEnv);
@@ -94,104 +122,393 @@ private Snapshot CreateSnapshotWithSelfDestruct(StateId from, StateId to)
return snapshot;
}
- #region Basic Behavior Tests
-
[Test]
- public void DetermineSnapshotToPersist_InsufficientInMemoryDepth_ReturnsNull()
+ public void DetermineSnapshotAction_InsufficientInMemoryDepth_ReturnsNull()
{
- // Setup: persisted at Block0 (0), latest at 60, after persist would be < 64 minimum
+ // Gate passes (60+16=76 > 64) but GetFinalizedStateRootAt(16) is not configured → seed = null.
StateId persisted = Block0;
StateId latest = CreateStateId(60);
_finalizedStateProvider.SetFinalizedBlockNumber(100);
- Snapshot? result = _persistenceManager.DetermineSnapshotToPersist(latest);
+ (PersistedSnapshot? persistedToPersist, Snapshot? toPersist, PersistenceManager.ConversionCandidate? toConvert) = _persistenceManager.DetermineSnapshotAction(latest);
- Assert.That(result, Is.Null);
+ Assert.That(persistedToPersist, Is.Null);
+ Assert.That(toPersist, Is.Null);
+ Assert.That(toConvert, Is.Null);
}
- [TestCase(true, TestName = "DetermineSnapshotToPersist_SufficientDepthAndFinalized_ReturnsCompactedSnapshot")]
- [TestCase(false, TestName = "DetermineSnapshotToPersist_SufficientDepthAndFinalized_FallsBackToUncompacted")]
- public void DetermineSnapshotToPersist_SufficientDepthAndFinalized(bool useCompacted)
+ [TestCase(true, TestName = "DetermineSnapshotAction_SufficientDepthAndFinalized_ReturnsCompactedSnapshot")]
+ [TestCase(false, TestName = "DetermineSnapshotAction_SufficientDepthAndFinalized_BaseAtFinalizedBlock")]
+ public void DetermineSnapshotAction_SufficientDepthAndFinalized(bool useCompacted)
{
- // Setup: persisted at Block0, latest at 100, finalized at 100
+ // Persisted at Block0, latest at 100, finalized at the target block (= the single seed).
+ // With CompactSize=16, finalized must be >= persisted + 16 for the normal-trigger seed to
+ // engage; the non-compacted case uses a base at block 16 to satisfy that gate.
StateId persisted = Block0;
StateId latest = CreateStateId(100);
- // Vary target block and compaction based on parameter
- int targetBlock = useCompacted ? 16 : 1; // compacted uses 16, fallback uses 1
- StateId target = CreateStateId(targetBlock);
-
- _finalizedStateProvider.SetFinalizedBlockNumber(100);
- _finalizedStateProvider.SetFinalizedStateRootAt(targetBlock, new Hash256(target.StateRoot.Bytes));
+ StateId target = CreateStateId(16);
+ _finalizedStateProvider.SetFinalizedBlockNumber(16);
+ _finalizedStateProvider.SetFinalizedStateRootAt(16, new Hash256(target.StateRoot.Bytes));
- // Create snapshot (compacted or not based on parameter)
using Snapshot expectedSnapshot = CreateSnapshot(persisted, target, compacted: useCompacted);
- Snapshot? result = _persistenceManager.DetermineSnapshotToPersist(latest);
+ (PersistedSnapshot? persistedToPersist, Snapshot? toPersist, PersistenceManager.ConversionCandidate? toConvert) = _persistenceManager.DetermineSnapshotAction(latest);
- Assert.That(result, Is.Not.Null);
- Assert.That(result!.From, Is.EqualTo(persisted));
- Assert.That(result.To, Is.EqualTo(target));
+ Assert.That(persistedToPersist, Is.Null);
+ Assert.That(toPersist, Is.Not.Null);
+ Assert.That(toConvert, Is.Null);
+ Assert.That(toPersist!.From, Is.EqualTo(persisted));
+ Assert.That(toPersist.To, Is.EqualTo(target));
- result.Dispose();
+ toPersist.Dispose();
}
- #endregion
-
- #region Unfinalized State Tests
-
[Test]
- public void DetermineSnapshotToPersist_UnfinalizedButBelowForceLimit_ReturnsNull()
+ public void DetermineSnapshotAction_UnfinalizedButBelowForceLimit_ReturnsNull()
{
- // Setup: persisted at Block0, latest at 150, finalized at 10 (way behind)
- // After persist would be at 16, which is > finalized
- // But in-memory depth is 150 (< 256 forced boundary)
+ // Depth (150) is below LongFinalityMaxReorgDepth (90000), so the backstop doesn't fire.
+ // Finalized (10) < nextBoundary (16), so the normal-trigger gate also doesn't fire.
+ // Neither Phase 1 path activates; Phase 2 is below the SnapshotCount threshold.
StateId persisted = Block0;
StateId latest = CreateStateId(150);
_finalizedStateProvider.SetFinalizedBlockNumber(10);
- Snapshot? result = _persistenceManager.DetermineSnapshotToPersist(latest);
+ (PersistedSnapshot? persistedToPersist, Snapshot? toPersist, PersistenceManager.ConversionCandidate? toConvert) = _persistenceManager.DetermineSnapshotAction(latest);
- Assert.That(result, Is.Null);
+ Assert.That(persistedToPersist, Is.Null);
+ Assert.That(toPersist, Is.Null);
+ Assert.That(toConvert, Is.Null);
}
- [TestCase(true, TestName = "DetermineSnapshotToPersist_UnfinalizedAndAboveForceLimit_ForcePersistsCompacted")]
- [TestCase(false, TestName = "DetermineSnapshotToPersist_UnfinalizedAndAboveForceLimit_FallsBackToUncompacted")]
- public void DetermineSnapshotToPersist_UnfinalizedAndAboveForceLimit(bool useCompacted)
+ [Test]
+ public void DetermineSnapshotAction_LongFinalityDisabled_SkipsConversionPath()
{
- // Setup: persisted at Block0, latest at 300, finalized at 10
- // In-memory depth is ~301 (> 256 forced boundary)
+ // In-memory depth ~301, finality stalled at block 10. With EnableLongFinality off, the
+ // conversion path must not fire and we must not invoke the converter.
+ _config.EnableLongFinality = false;
+ _persistenceManager = new PersistenceManager(
+ _config,
+ ScheduleHelper.CreateWithOffset(_config, 0),
+ _finalizedStateProvider,
+ _persistence,
+ _snapshotRepository,
+ LimboLogs.Instance,
+ _persistedSnapshotCompactor,
+ _tier.Loader,
+ Substitute.For());
+
StateId persisted = Block0;
StateId latest = CreateStateId(300);
+ StateId target = CreateStateId(1);
+ _finalizedStateProvider.SetFinalizedBlockNumber(10);
+
+ using Snapshot expectedSnapshot = CreateSnapshot(persisted, target, compacted: false);
+
+ (PersistedSnapshot? persistedToPersist, Snapshot? toPersist, PersistenceManager.ConversionCandidate? toConvert) = _persistenceManager.DetermineSnapshotAction(latest);
- // Vary target block and compaction based on parameter
- int targetBlock = useCompacted ? 16 : 1; // compacted uses 16, fallback uses 1
- StateId target = CreateStateId(targetBlock);
+ // The load-bearing check: the long-finality conversion path is short-circuited.
+ // toPersist may still be populated by the normal finalized-snapshot-to-RocksDB
+ // fall-through (its behaviour is unchanged), but no persisted-snapshot conversion
+ // and no force-persisted-snapshot was returned.
+ Assert.That(persistedToPersist, Is.Null);
+ Assert.That(toConvert, Is.Null, "Conversion path must be gated when EnableLongFinality is false");
+ // Sanity: with the flag off no snapshot was converted into the persisted tier.
+ toPersist?.Dispose();
+ Assert.That(_snapshotRepository.PersistedSnapshotCount, Is.EqualTo(0));
+ }
+
+ [Test]
+ public void DetermineSnapshotAction_BackstopExceeded_SeedsFromInMemoryTier()
+ {
+ // Backstop: snapshotsDepth (95000) > LongFinalityMaxReorgDepth (90000), finalized not in range.
+ // Phase 1 must seed from the in-memory tier's latest registered state.
+ StateId latest = CreateStateId(95000);
+ // tierTip spans at most CompactSize from Block0 so the base it anchors is a persist candidate.
+ StateId tierTip = CreateStateId(_config.CompactSize);
_finalizedStateProvider.SetFinalizedBlockNumber(10);
- // Create snapshot (compacted or not based on parameter)
- using Snapshot expectedSnapshot = CreateSnapshot(persisted, target, compacted: useCompacted);
+ // CreateSnapshot registers the snapshot's To, so GetLastSnapshotId returns tierTip and the backstop
+ // seeds on it; emulate a one-hop graph by registering a base at tierTip with From = Block0.
+ using Snapshot expected = CreateSnapshot(Block0, tierTip, compacted: false);
+
+ (PersistedSnapshot? persistedToPersist, Snapshot? toPersist, PersistenceManager.ConversionCandidate? toConvert) = _persistenceManager.DetermineSnapshotAction(latest);
+
+ Assert.That(toConvert, Is.Null);
+ // The backstop seed lands on tierTip; the BFS finds the in-memory base whose From == Block0
+ // (currentPersistedState) and returns it as toPersist.
+ Assert.That(toPersist, Is.Not.Null);
+ Assert.That(toPersist!.From, Is.EqualTo(Block0));
+ Assert.That(toPersist.To, Is.EqualTo(tierTip));
+
+ toPersist.Dispose();
+ }
+
+ // With MinReorgDepth >= the configured backstop, the effective backstop is raised to
+ // MinReorgDepth + CompactSize, so a depth just past the configured 90000 does NOT force-persist,
+ // but one past MinReorgDepth + CompactSize does.
+ [TestCase(90001, false, TestName = "DetermineSnapshotAction_BackstopRaised_BelowMinPlusCompactSize_NoForce")]
+ [TestCase(90000 + 16 + 1, true, TestName = "DetermineSnapshotAction_BackstopRaised_AboveMinPlusCompactSize_Forces")]
+ public void DetermineSnapshotAction_BackstopRaisedAboveMinReorgDepth(long latestBlock, bool expectForcedPersist)
+ {
+ // MinReorgDepth == configured backstop == 90000, CompactSize 16 → effective backstop 90016.
+ FlatDbConfig config = new()
+ {
+ CompactSize = 16,
+ MinReorgDepth = 90000,
+ MaxReorgDepth = 90000,
+ LongFinalityMaxReorgDepth = 90000,
+ EnableLongFinality = true,
+ MaxInMemoryBaseSnapshotCount = 160,
+ };
+ using PersistenceManager pm = new(
+ config,
+ ScheduleHelper.CreateWithOffset(config, 0),
+ _finalizedStateProvider,
+ _persistence,
+ _snapshotRepository,
+ LimboLogs.Instance,
+ _persistedSnapshotCompactor,
+ _tier.Loader,
+ Substitute.For());
+
+ // Finalized below the next boundary so only the backstop (not the finalized trigger) can fire;
+ // a registered base at tierTip gives FindSnapshotToPersist a candidate.
+ StateId tierTip = CreateStateId(config.CompactSize);
+ using Snapshot expected = CreateSnapshot(Block0, tierTip, compacted: false);
+ _finalizedStateProvider.SetFinalizedBlockNumber(5);
+
+ (_, Snapshot? toPersist, _) = pm.DetermineSnapshotAction(CreateStateId(latestBlock));
+
+ Assert.That(toPersist is not null, Is.EqualTo(expectForcedPersist));
+ toPersist?.Dispose();
+ }
+
+ [Test]
+ public void DetermineSnapshotAction_FinalizedGatePassesButSeedMissing_BackstopStillForcesPersist()
+ {
+ // Regression: with MinReorgDepth == the configured backstop (both 90000), the finalized
+ // trigger's depth gate (depth + CompactSize > MinReorgDepth) is satisfied across the whole
+ // operating range above the backstop. When the finalized branch is entered but yields no seed
+ // (its synthetic boundary root resolves to null here), the backstop must STILL fire — it is an
+ // independent fallback, not an `else if` shadowed by the always-satisfied finalized depth gate.
+ // Before the fix this returned no persist candidate, so deep state never persisted.
+ FlatDbConfig config = new()
+ {
+ CompactSize = 16,
+ MinReorgDepth = 90000,
+ MaxReorgDepth = 90000,
+ LongFinalityMaxReorgDepth = 90000,
+ EnableLongFinality = true,
+ MaxInMemoryBaseSnapshotCount = 160,
+ };
+ using PersistenceManager pm = new(
+ config,
+ ScheduleHelper.CreateWithOffset(config, 0),
+ _finalizedStateProvider,
+ _persistence,
+ _snapshotRepository,
+ LimboLogs.Instance,
+ _persistedSnapshotCompactor,
+ _tier.Loader,
+ Substitute.For());
+
+ // Finalized at/above the next boundary so the finalized branch IS entered, but leave
+ // GetFinalizedStateRootAt(16) unset so its seed resolves to null. Depth (90017) exceeds the
+ // effective backstop (MinReorgDepth + CompactSize = 90016), so the backstop must persist.
+ StateId tierTip = CreateStateId(config.CompactSize);
+ using Snapshot expected = CreateSnapshot(Block0, tierTip, compacted: false);
+ _finalizedStateProvider.SetFinalizedBlockNumber(90000);
+
+ (_, Snapshot? toPersist, PersistenceManager.ConversionCandidate? toConvert) = pm.DetermineSnapshotAction(CreateStateId(90017));
+
+ Assert.That(toPersist, Is.Not.Null, "Backstop must force a persist even when the finalized branch ran but found no seed");
+ Assert.That(toPersist!.From, Is.EqualTo(Block0));
+ Assert.That(toPersist.To, Is.EqualTo(tierTip));
+ Assert.That(toConvert, Is.Null);
+ toPersist.Dispose();
+ }
+
+ [Test]
+ public void DetermineSnapshotAction_FinalizedBeyondHead_SeedsAtBoundary()
+ {
+ // Catch-up sync: CL reports a finalized block far beyond the local chain head.
+ // GetFinalizedStateRootAt(finalizedBlockNumber) would return null, but the boundary
+ // block (persisted + CompactSize) IS locally synced, so the canonical-root lookup
+ // resolves there. Phase 1 must seed at the boundary and persist the boundary snapshot.
+ StateId persisted = Block0;
+ StateId latest = CreateStateId(200);
+ StateId boundary = CreateStateId(_config.CompactSize);
+
+ _finalizedStateProvider.SetFinalizedBlockNumber(25_128_361);
+ // Deliberately leave GetFinalizedStateRootAt(25_128_361) unset → returns null;
+ // only the boundary block has a known canonical state root.
+ _finalizedStateProvider.SetFinalizedStateRootAt(_config.CompactSize, new Hash256(boundary.StateRoot.Bytes));
+
+ using Snapshot expected = CreateSnapshot(persisted, boundary, compacted: false);
- Snapshot? result = _persistenceManager.DetermineSnapshotToPersist(latest);
+ (PersistedSnapshot? persistedToPersist, Snapshot? toPersist, PersistenceManager.ConversionCandidate? toConvert) = _persistenceManager.DetermineSnapshotAction(latest);
+
+ Assert.That(toConvert, Is.Null);
+ Assert.That(toPersist, Is.Not.Null);
+ Assert.That(toPersist!.From, Is.EqualTo(persisted));
+ Assert.That(toPersist.To, Is.EqualTo(boundary));
+
+ toPersist.Dispose();
+ }
+
+ [Test]
+ public void TryFindSnapshotToConvert_PrefersBoundaryCompactedOverBase()
+ {
+ // Phase 2 must globally prefer a CompactSize-wide compacted (→ large repo via Branch A)
+ // over any in-memory base (→ small repo via Branch B), regardless of block-number
+ // ordering. Seed an in-memory base at state(1) and a CompactSize-wide (16-wide) compacted
+ // at state(16) — both have From == Block0 on disk — and assert the compacted is picked.
+ StateId persisted = Block0;
+ StateId baseTo = CreateStateId(1);
+ StateId compactedTo = CreateStateId(16);
+
+ // Base at state(1) — sub-CompactSize; Branch B candidate.
+ using Snapshot baseSnap = CreateSnapshot(persisted, baseTo, compacted: false);
+ // 16-wide compacted from Block0 — boundary, should win under the two-pass form.
+ using Snapshot compactedSnap = CreateSnapshot(persisted, compactedTo, compacted: true);
+
+ PersistenceManager.ConversionCandidate? result = InvokeTryFindSnapshotToConvert(persisted);
Assert.That(result, Is.Not.Null);
- Assert.That(result!.From, Is.EqualTo(persisted));
- Assert.That(result.To, Is.EqualTo(target));
+ Assert.That(result!.Compacted, Is.Not.Null);
+ Assert.That(result.Compacted!.From, Is.EqualTo(persisted));
+ Assert.That(result.Compacted.To, Is.EqualTo(compactedTo));
+ Assert.That(result.Base, Is.Null);
+
+ result.Compacted.Dispose();
+ }
+
+ [Test]
+ public void ConvertCompactedRange_BoundaryCompacted_RemovesOnlyConvertedStates_PreservingOutsider()
+ {
+ // Branch A converts the in-memory bases spanning the boundary compacted's range, then must
+ // remove ONLY those gathered states from the in-memory tier. A state outside the gathered
+ // range (here one below `start`, standing in for a snapshot added concurrently mid-convert)
+ // must survive — the old bulk RemoveStatesUntil(end) would have wrongly swept it.
+ StateId compactedFrom = CreateStateId(2);
+ StateId compactedTo = CreateStateId(2 + _config.CompactSize); // span == CompactSize → Branch A
+ StateId baseA = CreateStateId(5);
+ StateId baseB = CreateStateId(10);
+ StateId outsider = CreateStateId(1); // below start (= compactedFrom.BlockNumber + 1)
+
+ // ConvertCompactedRange persists the gathered snapshot into the real persisted tier.
+ // The converted/boundary snapshots are disposed by it (via RemoveAndRelease + the
+ // pre-leased candidate), so they are NOT wrapped in `using`. Only the survivor is.
+ CreateSnapshot(compactedFrom, compactedTo, compacted: true);
+ CreateSnapshot(compactedFrom, baseA, compacted: false);
+ CreateSnapshot(baseA, baseB, compacted: false);
+ using Snapshot outsiderSnap = CreateSnapshot(Block0, outsider, compacted: false);
+
+ Assert.That(_snapshotRepository.HasState(outsider), Is.True);
+
+ _snapshotRepository.TryLeaseInMemoryState(compactedTo, SnapshotTier.InMemoryCompacted, out Snapshot? compactedForConvert);
+ InvokeConvertCompactedRange(compactedForConvert!);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(_snapshotRepository.HasState(outsider), Is.True, "state below `start` must survive");
+ // Gathered states are converted into the persisted tier (so HasState still sees them) but
+ // must be dropped from the in-memory tier — check in-memory presence via TryLeaseInMemoryState.
+ Assert.That(_snapshotRepository.TryLeaseInMemoryState(baseA, SnapshotTier.InMemoryBase, out _), Is.False, "baseA removed from the in-memory tier");
+ Assert.That(_snapshotRepository.TryLeaseInMemoryState(baseB, SnapshotTier.InMemoryBase, out _), Is.False, "baseB removed from the in-memory tier");
+ Assert.That(_snapshotRepository.TryLeaseInMemoryState(compactedTo, SnapshotTier.InMemoryCompacted, out _), Is.False, "boundary compacted removed");
+ });
+ }
+
+ [Test]
+ public async Task AddToPersistence_InMemoryPersist_PrunesPersistedTier()
+ {
+ // Persisting an in-memory snapshot must trigger RemoveStatesUntil on both tier repos so
+ // superseded tier entries get cleared — the toPersist branch must prune, not only the
+ // persistedToPersist branch.
+ StateId from = Block0;
+ StateId to = CreateStateId(16);
+ StateId latest = CreateStateId(100);
+
+ // AddToPersistence persists then prunes this in-memory snapshot, so the repo owns its disposal.
+ _ = CreateSnapshot(from, to, compacted: true);
+
+ // A persisted entry below the new persisted block must be pruned by the persist.
+ StateId stale = CreateStateId(8);
+ PersistBase(Block0, stale);
+ Assert.That(_snapshotRepository.HasBaseSnapshot(stale), Is.True);
+
+ _finalizedStateProvider.SetFinalizedBlockNumber(16);
+ _finalizedStateProvider.SetFinalizedStateRootAt(16, new Hash256(to.StateRoot.Bytes));
+
+ IPersistence.IWriteBatch writeBatch = Substitute.For();
+ _persistence.CreateWriteBatch(Arg.Any(), Arg.Any()).Returns(writeBatch);
+
+ await _persistenceManager.AddToPersistence(latest);
+
+ // Persisting the in-memory snapshot at `to` must prune the persisted tier below `to`.
+ Assert.That(_snapshotRepository.HasBaseSnapshot(stale), Is.False);
+ }
+
+ [Test]
+ public async Task AddToPersistence_TierSourcePersist_PrunesPersistedTier()
+ {
+ // Sibling of AddToPersistence_InMemoryPersist_PrunesPersistedTier for the persistedToPersist
+ // branch. Tier-source persists must also drive RemoveStatesUntil so superseded entries are cleared.
+ StateId target = CreateStateId(16);
+ StateId latest = CreateStateId(100);
+ _finalizedStateProvider.SetFinalizedBlockNumber(16);
+ _finalizedStateProvider.SetFinalizedStateRootAt(16, new Hash256(target.StateRoot.Bytes));
+
+ // No in-memory snapshot — DetermineSnapshotAction takes the tier-fallback path and persists
+ // the base in the persisted tier whose From == the current persisted state (Block0).
+ PersistBase(Block0, target);
+ // A persisted entry below `target` must be pruned by the persist.
+ StateId stale = CreateStateId(8);
+ PersistBase(Block0, stale);
+
+ IPersistence.IWriteBatch writeBatch = Substitute.For();
+ _persistence.CreateWriteBatch(Arg.Any(), Arg.Any()).Returns(writeBatch);
+
+ await _persistenceManager.AddToPersistence(latest);
- result.Dispose();
+ Assert.That(_snapshotRepository.HasBaseSnapshot(stale), Is.False);
}
[Test]
- public void DetermineSnapshotToPersist_UnfinalizedForkAtBoundary_PersistsHeadReachableFork()
+ public void DetermineSnapshotAction_UnfinalizedBelowBackstop_ReturnsNull()
{
- // Two unfinalized forks at the boundary block 16, both starting from Block0. The head's chain runs
- // through target2 (the higher root, not the arbitrary "first"). The forced persist must follow the
- // head's chain (target2), otherwise persisting target1 would orphan the head.
+ // Unfinalized (finalized at 10, persisted at 0 — not in range for the CompactSize=16
+ // gate) AND in-memory depth (300) below LongFinalityMaxReorgDepth (90000): no force-persist,
+ // no Phase 1 candidate. Phase 2 entry guard (SnapshotCount > 160) also not satisfied with
+ // a single created snapshot. Action: do nothing.
StateId persisted = Block0;
- StateId target1 = CreateStateId(16, rootByte: 1); // arbitrary "first" (lowest root)
- StateId target2 = CreateStateId(16, rootByte: 2); // on the head's chain
- StateId head = CreateStateId(300);
+ StateId latest = CreateStateId(300);
+ StateId target = CreateStateId(1);
+
+ _finalizedStateProvider.SetFinalizedBlockNumber(10);
+
+ using Snapshot expectedSnapshot = CreateSnapshot(persisted, target, compacted: false);
+
+ (PersistedSnapshot? persistedToPersist, Snapshot? toPersist, PersistenceManager.ConversionCandidate? toConvert) = _persistenceManager.DetermineSnapshotAction(latest);
+
+ Assert.That(persistedToPersist, Is.Null);
+ Assert.That(toPersist, Is.Null);
+ Assert.That(toConvert, Is.Null);
+ }
+
+ [Test]
+ public void DetermineSnapshotAction_UnfinalizedForkAtBoundary_PersistsHeadReachableFork()
+ {
+ // Two unfinalized forks at the boundary block 16, both starting from Block0. The committed head's
+ // chain runs through target2, not the arbitrary target1. The backstop force-persist must follow the
+ // committed head's chain (target2) — persisting target1 would orphan the head.
+ StateId persisted = Block0;
+ StateId target1 = CreateStateId(16, rootByte: 1); // off-chain fork
+ StateId target2 = CreateStateId(16, rootByte: 2); // on the committed head's chain
+ StateId head = CreateStateId(95000); // depth > LongFinalityMaxReorgDepth (90000) → backstop fires
_finalizedStateProvider.SetFinalizedBlockNumber(10); // unfinalized at the boundary
@@ -200,68 +517,86 @@ public void DetermineSnapshotToPersist_UnfinalizedForkAtBoundary_PersistsHeadRea
using Snapshot toHead = CreateSnapshot(target2, head, compacted: true); // head reachable only via target2
_snapshotRepository.SetLastCommittedStateId(head);
- Snapshot? result = _persistenceManager.DetermineSnapshotToPersist(head);
+ (_, Snapshot? toPersist, _) = _persistenceManager.DetermineSnapshotAction(head);
- Assert.That(result, Is.Not.Null);
- Assert.That(result!.From, Is.EqualTo(persisted));
- Assert.That(result.To, Is.EqualTo(target2));
+ Assert.That(toPersist, Is.Not.Null);
+ Assert.That(toPersist!.From, Is.EqualTo(persisted));
+ Assert.That(toPersist.To, Is.EqualTo(target2));
- result.Dispose();
+ toPersist.Dispose();
}
[Test]
- public void DetermineSnapshotToPersist_LongerNonCanonicalFork_PersistsCommittedHeadChain()
+ public void DetermineSnapshotAction_LongerNonCanonicalFork_PersistsCommittedHeadChain()
{
- // The longest in-memory chain runs through target1 up to block 300, but the committed head is the
- // shorter chain through target2 (at block 32). The forced persist must follow the committed head
- // (target2), not the longer fork (target1) that GetLastSnapshotId would pick.
+ // The longest in-memory chain runs through target1 (longHead is the max, so GetLastSnapshotId would
+ // pick it), but the committed head is the shorter chain through target2. The backstop must follow the
+ // committed head (target2), not the longer fork (target1) that the GetLastSnapshotId fallback would pick.
StateId persisted = Block0;
StateId target1 = CreateStateId(16, rootByte: 1); // boundary state on the longer, non-canonical fork
StateId target2 = CreateStateId(16, rootByte: 2); // boundary state on the committed head's chain
- StateId longHead = CreateStateId(300); // longest chain (the max), but not committed
- StateId committedHead = CreateStateId(32, rootByte: 2);
+ StateId longHead = CreateStateId(95001, rootByte: 1); // longest chain, but not committed
+ StateId committedHead = CreateStateId(95000, rootByte: 2);
_finalizedStateProvider.SetFinalizedBlockNumber(0); // unfinalized at the boundary
- using Snapshot fork1 = CreateSnapshot(persisted, target1, compacted: true);
+ // longHead (block 95001) is the max, so the GetLastSnapshotId fallback would pick the longer fork —
+ // only honouring the committed head selects target2.
using Snapshot fork2 = CreateSnapshot(persisted, target2, compacted: true);
- using Snapshot toLongHead = CreateSnapshot(target1, longHead, compacted: true); // makes target1 the max chain
using Snapshot toCommittedHead = CreateSnapshot(target2, committedHead, compacted: true);
+ using Snapshot fork1 = CreateSnapshot(persisted, target1, compacted: true);
+ using Snapshot toLongHead = CreateSnapshot(target1, longHead, compacted: true);
_snapshotRepository.SetLastCommittedStateId(committedHead);
- // latestSnapshot at 300 (the longest chain) makes the in-memory depth exceed MaxReorgDepth (256),
- // triggering the force-persist branch.
- Snapshot? result = _persistenceManager.DetermineSnapshotToPersist(longHead);
+ // latestSnapshot at the longest chain makes the in-memory depth exceed LongFinalityMaxReorgDepth, triggering the
+ // force-persist (backstop) branch.
+ (_, Snapshot? toPersist, _) = _persistenceManager.DetermineSnapshotAction(longHead);
- Assert.That(result, Is.Not.Null);
- Assert.That(result!.From, Is.EqualTo(persisted));
- Assert.That(result.To, Is.EqualTo(target2));
+ Assert.That(toPersist, Is.Not.Null);
+ Assert.That(toPersist!.From, Is.EqualTo(persisted));
+ Assert.That(toPersist.To, Is.EqualTo(target2));
- result.Dispose();
+ toPersist.Dispose();
}
- #endregion
-
- #region Edge Cases
-
[Test]
- public void DetermineSnapshotToPersist_NoSnapshotAvailable_ReturnsNull()
+ public void DetermineSnapshotAction_NoSnapshotAvailable_ReturnsNull()
{
- // Setup: sufficient depth but no snapshots in repository
StateId persisted = Block0;
StateId latest = CreateStateId(100);
_finalizedStateProvider.SetFinalizedBlockNumber(100);
_finalizedStateProvider.SetFinalizedStateRootAt(16, new Hash256(CreateStateId(16).StateRoot.Bytes));
- // Don't create any snapshots
+ (PersistedSnapshot? persistedToPersist, Snapshot? toPersist, _) = _persistenceManager.DetermineSnapshotAction(latest);
+
+ Assert.That(persistedToPersist, Is.Null);
+ Assert.That(toPersist, Is.Null);
+ }
+
+ [Test]
+ public void DetermineSnapshotAction_FinalizedNoInMemory_FallsBackToPersistedSnapshot()
+ {
+ // Setup: persisted at Block0, latest at 100, finalized at 16 — the BFS seeds with the
+ // finalized state, which corresponds exactly to the persisted snapshot we mock below.
+ StateId latest = CreateStateId(100);
+ StateId target = CreateStateId(16);
+ _finalizedStateProvider.SetFinalizedBlockNumber(16);
+ _finalizedStateProvider.SetFinalizedStateRootAt(16, new Hash256(target.StateRoot.Bytes));
+
+ // Don't create any in-memory snapshots — persist a base into the tier so the fallback finds it.
+ PersistBase(Block0, target);
- Snapshot? result = _persistenceManager.DetermineSnapshotToPersist(latest);
+ (PersistedSnapshot? persistedToPersist, Snapshot? toPersist, PersistenceManager.ConversionCandidate? toConvert) = _persistenceManager.DetermineSnapshotAction(latest);
- Assert.That(result, Is.Null);
+ Assert.That(persistedToPersist, Is.Not.Null);
+ Assert.That(toPersist, Is.Null);
+ Assert.That(toConvert, Is.Null);
+
+ persistedToPersist!.Dispose();
}
[Test]
- public void DetermineSnapshotToPersist_SnapshotWithWrongFromState_ReturnsNull()
+ public void DetermineSnapshotAction_SnapshotWithWrongFromState_ReturnsNull()
{
// Setup: snapshot exists but doesn't start from current persisted state
StateId persisted = Block0;
@@ -271,73 +606,75 @@ public void DetermineSnapshotToPersist_SnapshotWithWrongFromState_ReturnsNull()
_finalizedStateProvider.SetFinalizedBlockNumber(100);
_finalizedStateProvider.SetFinalizedStateRootAt(16, new Hash256(target.StateRoot.Bytes));
- // Create snapshot with wrong "from" state
using Snapshot wrongSnapshot = CreateSnapshot(wrongFrom, target, compacted: true);
- Snapshot? result = _persistenceManager.DetermineSnapshotToPersist(latest);
+ (PersistedSnapshot? persistedToPersist, Snapshot? toPersist, _) = _persistenceManager.DetermineSnapshotAction(latest);
- Assert.That(result, Is.Null);
+ Assert.That(persistedToPersist, Is.Null);
+ Assert.That(toPersist, Is.Null);
}
[Test]
- public void DetermineSnapshotToPersist_MultipleStatesAtBlock_SelectsCorrectOne()
+ public void DetermineSnapshotAction_MultipleStatesAtBlock_SelectsCorrectOne()
{
- // Setup: multiple state roots at same block number (reorg scenario)
+ // Setup: multiple state roots at same block number (reorg scenario). Set finalized at the
+ // candidate block so the single-seed BFS lands directly on the finalized state root.
StateId persisted = Block0;
StateId latest = CreateStateId(100);
StateId target1 = CreateStateId(16, rootByte: 1);
- StateId target2 = CreateStateId(16, rootByte: 2); // Different root
- _finalizedStateProvider.SetFinalizedBlockNumber(100);
- _finalizedStateProvider.SetFinalizedStateRootAt(16, new Hash256(target2.StateRoot.Bytes)); // target2 is finalized
+ StateId target2 = CreateStateId(16, rootByte: 2);
+ _finalizedStateProvider.SetFinalizedBlockNumber(16);
+ _finalizedStateProvider.SetFinalizedStateRootAt(16, new Hash256(target2.StateRoot.Bytes));
- // Create both snapshots
using Snapshot snapshot1 = CreateSnapshot(persisted, target1, compacted: true);
using Snapshot snapshot2 = CreateSnapshot(persisted, target2, compacted: true);
- Snapshot? result = _persistenceManager.DetermineSnapshotToPersist(latest);
+ (PersistedSnapshot? persistedToPersist, Snapshot? toPersist, _) = _persistenceManager.DetermineSnapshotAction(latest);
- Assert.That(result, Is.Not.Null);
- Assert.That(result!.To.StateRoot.Bytes.ToArray(), Is.EqualTo(target2.StateRoot.Bytes.ToArray())); // Should select finalized one
+ Assert.That(persistedToPersist, Is.Null);
+ Assert.That(toPersist, Is.Not.Null);
+ Assert.That(toPersist!.To.StateRoot.Bytes.ToArray(), Is.EqualTo(target2.StateRoot.Bytes.ToArray()));
- result.Dispose();
+ toPersist.Dispose();
}
[Test]
- public void DetermineSnapshotToPersist_ExactlyAtMinimumBoundary_ReturnsNull()
+ public void DetermineSnapshotAction_ExactlyAtMinimumBoundary_ReturnsNull()
{
- // Setup: persisted at Block0 (0), latest at 79
- // After persist would be at 15, leaving depth of 64 (exactly at minimum boundary)
+ // Gate passes (79+16=95 > 64), but GetFinalizedStateRootAt(16) is not configured →
+ // returns null → seed = null. No backstop (79 << LongFinalityMaxReorgDepth). Result: null.
StateId persisted = Block0;
StateId latest = CreateStateId(79);
_finalizedStateProvider.SetFinalizedBlockNumber(100);
- Snapshot? result = _persistenceManager.DetermineSnapshotToPersist(latest);
+ (PersistedSnapshot? persistedToPersist, Snapshot? toPersist, _) = _persistenceManager.DetermineSnapshotAction(latest);
- Assert.That(result, Is.Null);
+ Assert.That(persistedToPersist, Is.Null);
+ Assert.That(toPersist, Is.Null);
}
[Test]
- public void DetermineSnapshotToPersist_OneAboveMinimumBoundary_ReturnsSnapshot()
+ public void DetermineSnapshotAction_OneAboveMinimumBoundary_ReturnsSnapshot()
{
- // Setup: persisted at Block0 (0), latest at 80
- // After persist would be at 15, leaving depth of 65 (one above minimum boundary)
+ // Setup: persisted at Block0, latest at 80, finalized at the candidate block (16) so the
+ // single-seed BFS lands directly on it. Depth (80) + CompactSize (16) = 96 > MinReorgDepth
+ // (64) — passes the normal-trigger gate.
StateId persisted = Block0;
StateId latest = CreateStateId(80);
StateId target = CreateStateId(16);
- _finalizedStateProvider.SetFinalizedBlockNumber(100);
+ _finalizedStateProvider.SetFinalizedBlockNumber(16);
_finalizedStateProvider.SetFinalizedStateRootAt(16, new Hash256(target.StateRoot.Bytes));
using Snapshot expectedSnapshot = CreateSnapshot(persisted, target, compacted: true);
- Snapshot? result = _persistenceManager.DetermineSnapshotToPersist(latest);
+ (PersistedSnapshot? persistedToPersist, Snapshot? toPersist, _) = _persistenceManager.DetermineSnapshotAction(latest);
- Assert.That(result, Is.Not.Null);
+ Assert.That(persistedToPersist, Is.Null);
+ Assert.That(toPersist, Is.Not.Null);
- result!.Dispose();
+ toPersist!.Dispose();
}
- #endregion
-
#region PersistSnapshot Tests
[Test]
@@ -414,71 +751,29 @@ public void PersistSnapshot_EmptySnapshot_CreatesWriteBatch()
#endregion
- #region AddToPersistence Tests
-
[Test]
- public void AddToPersistence_WithAvailableSnapshot_PersistsAndUpdatesState()
+ public async Task AddToPersistence_WithAvailableSnapshot_PersistsAndUpdatesState()
{
- // Arrange
+ // Finalized at the candidate block so the single-seed BFS lands directly on it.
StateId from = Block0;
StateId to = CreateStateId(16);
StateId latest = CreateStateId(100);
- // Create a snapshot that should be persisted
- using Snapshot snapshot = CreateSnapshot(from, to, compacted: true);
+ // AddToPersistence persists then prunes this in-memory snapshot, so the repo owns its disposal.
+ _ = CreateSnapshot(from, to, compacted: true);
- _finalizedStateProvider.SetFinalizedBlockNumber(100);
+ _finalizedStateProvider.SetFinalizedBlockNumber(16);
_finalizedStateProvider.SetFinalizedStateRootAt(16, new Hash256(to.StateRoot.Bytes));
IPersistence.IWriteBatch writeBatch = Substitute.For();
_persistence.CreateWriteBatch(Arg.Any(), Arg.Any()).Returns(writeBatch);
- // Act
- _persistenceManager.AddToPersistence(latest);
+ await _persistenceManager.AddToPersistence(latest);
- // Assert
- // Verify write batch was created (persistence happened)
_persistence.Received().CreateWriteBatch(from, to);
-
- // Verify current persisted state was updated
Assert.That(_persistenceManager.GetCurrentPersistedStateId(), Is.EqualTo(to));
}
- #endregion
-
- #region Offset Behavior
-
- [TestCase(3, 13)]
- [TestCase(5, 11)]
- [TestCase(0, 16)]
- public void DetermineSnapshotToPersist_WithOffset_FirstBoundaryShifted(int offset, int expectedTargetBlock)
- {
- // Fresh DB: currentPersistedState = Block0 (block 0).
- // With CompactSize=16 and offset=N, the next full compaction boundary is at block 16-N.
- PersistenceManager pm = new(
- _config,
- ScheduleHelper.CreateWithOffset(_config, offset),
- _finalizedStateProvider,
- _persistence,
- _snapshotRepository,
- LimboLogs.Instance);
-
- StateId target = CreateStateId(expectedTargetBlock);
- StateId latest = CreateStateId(200);
- _finalizedStateProvider.SetFinalizedBlockNumber(200);
- _finalizedStateProvider.SetFinalizedStateRootAt(expectedTargetBlock, new Hash256(target.StateRoot.Bytes));
-
- using Snapshot expected = CreateSnapshot(Block0, target, compacted: true);
-
- Snapshot? result = pm.DetermineSnapshotToPersist(latest);
-
- Assert.That(result, Is.Not.Null);
- Assert.That(result!.To, Is.EqualTo(target));
- result.Dispose();
- }
-
- #endregion
-
#region FlushToPersistence Tests
[Test]
@@ -497,7 +792,6 @@ public void FlushToPersistence_NoSnapshots_ReturnsCurrentPersistedState()
[Test]
public void FlushToPersistence_WithFinalizedSnapshots_PersistsFinalizedFirst()
{
- // Arrange
StateId state16 = CreateStateId(16);
StateId state32 = CreateStateId(32);
@@ -505,16 +799,15 @@ public void FlushToPersistence_WithFinalizedSnapshots_PersistsFinalizedFirst()
_finalizedStateProvider.SetFinalizedStateRootAt(16, new Hash256(state16.StateRoot.Bytes));
_finalizedStateProvider.SetFinalizedStateRootAt(32, new Hash256(state32.StateRoot.Bytes));
- using Snapshot snapshot1 = CreateSnapshot(Block0, state16, compacted: true);
- using Snapshot snapshot2 = CreateSnapshot(state16, state32, compacted: true);
+ // Repo-owned; FlushToPersistence prunes (disposes) them once persisted, so don't double-own.
+ CreateSnapshot(Block0, state16, compacted: true);
+ CreateSnapshot(state16, state32, compacted: true);
IPersistence.IWriteBatch writeBatch = Substitute.For();
_persistence.CreateWriteBatch(Arg.Any(), Arg.Any()).Returns(writeBatch);
- // Act
StateId result = _persistenceManager.FlushToPersistence();
- // Assert
Assert.That(result, Is.EqualTo(state32));
_persistence.Received().CreateWriteBatch(Block0, state16);
_persistence.Received().CreateWriteBatch(state16, state32);
@@ -523,19 +816,17 @@ public void FlushToPersistence_WithFinalizedSnapshots_PersistsFinalizedFirst()
[Test]
public void FlushToPersistence_WithUnfinalizedSnapshots_FallsBackToFirstAvailable()
{
- // Arrange - no finalization info available
StateId state16 = CreateStateId(16);
- _finalizedStateProvider.SetFinalizedBlockNumber(0); // Nothing finalized
+ _finalizedStateProvider.SetFinalizedBlockNumber(0);
- using Snapshot snapshot = CreateSnapshot(Block0, state16, compacted: true);
+ // Repo-owned; FlushToPersistence prunes (disposes) it once persisted, so don't double-own.
+ CreateSnapshot(Block0, state16, compacted: true);
IPersistence.IWriteBatch writeBatch = Substitute.For();
_persistence.CreateWriteBatch(Arg.Any(), Arg.Any()).Returns(writeBatch);
- // Act
StateId result = _persistenceManager.FlushToPersistence();
- // Assert
Assert.That(result, Is.EqualTo(state16));
_persistence.Received().CreateWriteBatch(Block0, state16);
}
@@ -551,9 +842,10 @@ public void FlushToPersistence_UnfinalizedForkAtBoundary_PersistsHeadReachableFo
_finalizedStateProvider.SetFinalizedBlockNumber(0); // nothing finalized
- using Snapshot fork1 = CreateSnapshot(Block0, target1, compacted: true);
- using Snapshot fork2 = CreateSnapshot(Block0, target2, compacted: true);
- using Snapshot toHead = CreateSnapshot(target2, head, compacted: true); // head reachable only via target2
+ // Repo-owned; FlushToPersistence persists/prunes (disposes) them, so don't double-own.
+ CreateSnapshot(Block0, target1, compacted: true);
+ CreateSnapshot(Block0, target2, compacted: true);
+ CreateSnapshot(target2, head, compacted: true); // head reachable only via target2
_snapshotRepository.SetLastCommittedStateId(head);
IPersistence.IWriteBatch writeBatch = Substitute.For();
@@ -579,11 +871,11 @@ public void FlushToPersistence_LongerNonCanonicalFork_PersistsCommittedHeadChain
_finalizedStateProvider.SetFinalizedBlockNumber(0); // nothing finalized
- using Snapshot fork1 = CreateSnapshot(Block0, target1, compacted: true);
- using Snapshot fork2 = CreateSnapshot(Block0, target2, compacted: true);
- // Not `using`: the flush prunes this orphaned non-canonical descendant and disposes it itself.
- Snapshot toLongHead = CreateSnapshot(target1, longHead, compacted: true);
- using Snapshot toCommittedHead = CreateSnapshot(target2, committedHead, compacted: true);
+ // Repo-owned; FlushToPersistence persists/prunes (disposes) them, so don't double-own.
+ CreateSnapshot(Block0, target1, compacted: true);
+ CreateSnapshot(Block0, target2, compacted: true);
+ CreateSnapshot(target1, longHead, compacted: true);
+ CreateSnapshot(target2, committedHead, compacted: true);
_snapshotRepository.SetLastCommittedStateId(committedHead);
IPersistence.IWriteBatch writeBatch = Substitute.For();
@@ -599,49 +891,45 @@ public void FlushToPersistence_LongerNonCanonicalFork_PersistsCommittedHeadChain
[Test]
public void FlushToPersistence_PrefersFinalizedOverUnfinalized()
{
- // Arrange - two snapshots at same block, one finalized
+ // Two snapshots at the same block, one finalized. Set finalized block to the
+ // candidate block so the BFS seed lands directly on the finalized state.
StateId finalizedState = CreateStateId(16, rootByte: 1);
StateId unfinalizedState = CreateStateId(16, rootByte: 2);
- _finalizedStateProvider.SetFinalizedBlockNumber(100);
+ _finalizedStateProvider.SetFinalizedBlockNumber(16);
_finalizedStateProvider.SetFinalizedStateRootAt(16, new Hash256(finalizedState.StateRoot.Bytes));
- // Create both snapshots
- using Snapshot finalizedSnapshot = CreateSnapshot(Block0, finalizedState, compacted: true);
- using Snapshot unfinalizedSnapshot = CreateSnapshot(Block0, unfinalizedState, compacted: true);
+ // Repo-owned; FlushToPersistence prunes (disposes) them once persisted, so don't double-own.
+ CreateSnapshot(Block0, finalizedState, compacted: true);
+ CreateSnapshot(Block0, unfinalizedState, compacted: true);
IPersistence.IWriteBatch writeBatch = Substitute.For();
_persistence.CreateWriteBatch(Arg.Any(), Arg.Any()).Returns(writeBatch);
- // Act
StateId result = _persistenceManager.FlushToPersistence();
- // Assert - should persist finalized state
Assert.That(result.StateRoot.Bytes.ToArray(), Is.EqualTo(finalizedState.StateRoot.Bytes.ToArray()));
}
[Test]
public void FlushToPersistence_PersistsMultipleSnapshots_InOrder()
{
- // Arrange
StateId state1 = CreateStateId(1);
StateId state2 = CreateStateId(2);
StateId state3 = CreateStateId(3);
- // No finalization - will use first available
_finalizedStateProvider.SetFinalizedBlockNumber(0);
- using Snapshot snapshot1 = CreateSnapshot(Block0, state1, compacted: false);
- using Snapshot snapshot2 = CreateSnapshot(state1, state2, compacted: false);
- using Snapshot snapshot3 = CreateSnapshot(state2, state3, compacted: false);
+ // Repo-owned; FlushToPersistence prunes (disposes) them once persisted, so don't double-own.
+ CreateSnapshot(Block0, state1, compacted: false);
+ CreateSnapshot(state1, state2, compacted: false);
+ CreateSnapshot(state2, state3, compacted: false);
IPersistence.IWriteBatch writeBatch = Substitute.For();
_persistence.CreateWriteBatch(Arg.Any(), Arg.Any()).Returns(writeBatch);
- // Act
StateId result = _persistenceManager.FlushToPersistence();
- // Assert
Assert.That(result, Is.EqualTo(state3));
Received.InOrder(() =>
{
@@ -651,8 +939,51 @@ public void FlushToPersistence_PersistsMultipleSnapshots_InOrder()
});
}
+ [Test]
+ public void FlushToPersistence_PersistedOnlyTier_WalksAndPrunes()
+ {
+ // No in-memory snapshot above the persisted point and nothing finalized: the flush must
+ // still reach the persisted-tier backlog via the tier-aware latest tip (GetLastSnapshotId
+ // folds in the persisted maxes) and prune entries the persist supersedes. Regression for
+ // FlushToPersistence early-returning on a persisted-only tier and never pruning it.
+ StateId target = CreateStateId(16);
+ StateId stale = CreateStateId(8);
+
+ PersistBase(Block0, stale);
+ PersistBase(Block0, target);
+
+ IPersistence.IWriteBatch writeBatch = Substitute.For();
+ _persistence.CreateWriteBatch(Arg.Any(), Arg.Any()).Returns(writeBatch);
+
+ StateId result = _persistenceManager.FlushToPersistence();
+
+ Assert.That(result, Is.EqualTo(target));
+ _persistence.Received().CreateWriteBatch(Block0, target);
+ Assert.That(_snapshotRepository.HasBaseSnapshot(stale), Is.False);
+ }
+
#endregion
+ private PersistenceManager.ConversionCandidate? InvokeTryFindSnapshotToConvert(StateId currentPersistedState)
+ {
+ // TryFindSnapshotToConvert is private; reach it via reflection so we can unit-test the
+ // priority logic without driving the full DetermineSnapshotAction → AddToPersistence loop.
+ System.Reflection.MethodInfo method = typeof(PersistenceManager).GetMethod(
+ "TryFindSnapshotToConvert",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
+ return (PersistenceManager.ConversionCandidate?)method.Invoke(_persistenceManager, [currentPersistedState]);
+ }
+
+ private void InvokeConvertCompactedRange(Snapshot compacted)
+ {
+ // ConvertCompactedRange is private; reach it via reflection to unit-test the in-memory
+ // removal logic directly without driving the full DetermineSnapshotAction → AddToPersistence loop.
+ System.Reflection.MethodInfo method = typeof(PersistenceManager).GetMethod(
+ "ConvertCompactedRange",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
+ method.Invoke(_persistenceManager, [compacted]);
+ }
+
#region Helper Classes
private class TestFinalizedStateProvider : IFinalizedStateProvider
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/ReadOnlySnapshotBundlePersistedTests.cs b/src/Nethermind/Nethermind.State.Flat.Test/ReadOnlySnapshotBundlePersistedTests.cs
new file mode 100644
index 000000000000..3cca0a96144c
--- /dev/null
+++ b/src/Nethermind/Nethermind.State.Flat.Test/ReadOnlySnapshotBundlePersistedTests.cs
@@ -0,0 +1,171 @@
+// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using System;
+using System.IO;
+using Nethermind.Core;
+using Nethermind.Core.Crypto;
+using Nethermind.Db;
+using Nethermind.State.Flat.Persistence;
+using Nethermind.State.Flat.PersistedSnapshots;
+using Nethermind.State.Flat.PersistedSnapshots.Storage;
+using Nethermind.Trie;
+using NSubstitute;
+using NUnit.Framework;
+
+namespace Nethermind.State.Flat.Test;
+
+[TestFixture]
+public class ReadOnlySnapshotBundlePersistedTests
+{
+ private ResourcePool _pool = null!;
+ private ArenaManager _memArena = null!;
+ private string _memArenaDir = null!;
+ private BlobArenaManager _blobs = null!;
+ private string _blobsDir = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _pool = new ResourcePool(new FlatDbConfig());
+ _memArenaDir = Path.Combine(Path.GetTempPath(), $"nm-robtest-arena-{Guid.NewGuid():N}");
+ _memArena = TestFixtureHelpers.CreateArenaManager(_memArenaDir);
+ _blobsDir = Path.Combine(Path.GetTempPath(), $"nm-robtest-blobs-{Guid.NewGuid():N}");
+ _blobs = new BlobArenaManager(_blobsDir, 4L * 1024 * 1024);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ _blobs.Dispose();
+ _memArena.Dispose();
+ try { Directory.Delete(_blobsDir, recursive: true); } catch { /* best-effort */ }
+ try { Directory.Delete(_memArenaDir, recursive: true); } catch { /* best-effort */ }
+ }
+
+ [Test]
+ public void TryLoadStateRlp_ReturnsFromPersistedSnapshot_BeforePersistence()
+ {
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+
+ TreePath path = new(Keccak.Compute("path"), 4);
+ byte[] nodeRlp = [0xC2, 0x80, 0x80];
+
+ SnapshotContent content = new();
+ content.StateNodes[path] = new TrieNode(NodeType.Leaf, nodeRlp);
+ Snapshot snap = new(s0, s1, content, _pool, ResourcePool.Usage.MainBlockProcessing);
+ byte[] tableData = PersistedSnapshotBuilderTestExtensions.Build(snap, _blobs);
+
+ PersistedSnapshot persisted = CreatePersistedSnapshot(s0, s1, tableData);
+ PersistedSnapshotList list = new(1) { persisted };
+
+ IPersistence.IPersistenceReader reader = Substitute.For();
+
+ using ReadOnlySnapshotBundle bundle = new(
+ new SnapshotPooledList(0),
+ reader,
+ recordDetailedMetrics: false,
+ persistedSnapshots: AlwaysTrueStack(list));
+
+ byte[]? result = bundle.TryLoadStateRlp(path, Keccak.Compute("hash"), ReadFlags.None);
+
+ Assert.That(result, Is.EqualTo(nodeRlp));
+ reader.DidNotReceive().TryLoadStateRlp(Arg.Any(), Arg.Any());
+ }
+
+ [Test]
+ public void TryLoadStorageRlp_ReturnsFromPersistedSnapshot_BeforePersistence()
+ {
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+
+ Hash256 address = Keccak.Compute("address");
+ TreePath path = new(Keccak.Compute("path"), 6);
+ byte[] nodeRlp = [0xC1, 0x80];
+
+ SnapshotContent content = new();
+ content.StorageNodes[(address, path)] = new TrieNode(NodeType.Branch, nodeRlp);
+ Snapshot snap = new(s0, s1, content, _pool, ResourcePool.Usage.MainBlockProcessing);
+ byte[] tableData = PersistedSnapshotBuilderTestExtensions.Build(snap, _blobs);
+
+ PersistedSnapshot persisted = CreatePersistedSnapshot(s0, s1, tableData);
+ PersistedSnapshotList list = new(1) { persisted };
+
+ IPersistence.IPersistenceReader reader = Substitute.For();
+
+ using ReadOnlySnapshotBundle bundle = new(
+ new SnapshotPooledList(0),
+ reader,
+ recordDetailedMetrics: false,
+ persistedSnapshots: AlwaysTrueStack(list));
+
+ byte[]? result = bundle.TryLoadStorageRlp(address, path, Keccak.Compute("hash"), ReadFlags.None);
+
+ Assert.That(result, Is.EqualTo(nodeRlp));
+ reader.DidNotReceive().TryLoadStorageRlp(Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
+ [Test]
+ public void TryLoadStateRlp_FallsThrough_WhenNotInPersistedSnapshot()
+ {
+ StateId s0 = new(0, Keccak.EmptyTreeHash);
+ StateId s1 = new(1, Keccak.Compute("1"));
+
+ TreePath storedPath = new(Keccak.Compute("stored"), 4);
+ TreePath missingPath = new(Keccak.Compute("missing"), 3);
+ byte[] nodeRlp = [0xC0];
+ byte[] dbRlp = [0xC1, 0x80, 0x80];
+
+ SnapshotContent content = new();
+ content.StateNodes[storedPath] = new TrieNode(NodeType.Leaf, nodeRlp);
+ Snapshot snap = new(s0, s1, content, _pool, ResourcePool.Usage.MainBlockProcessing);
+ byte[] tableData = PersistedSnapshotBuilderTestExtensions.Build(snap, _blobs);
+
+ PersistedSnapshot persisted = CreatePersistedSnapshot(s0, s1, tableData);
+ PersistedSnapshotList list = new(1) { persisted };
+
+ IPersistence.IPersistenceReader reader = Substitute.For();
+ reader.TryLoadStateRlp(Arg.Any(), Arg.Any()).Returns(dbRlp);
+
+ using ReadOnlySnapshotBundle bundle = new(
+ new SnapshotPooledList(0),
+ reader,
+ recordDetailedMetrics: false,
+ persistedSnapshots: AlwaysTrueStack(list));
+
+ byte[]? result = bundle.TryLoadStateRlp(missingPath, Keccak.Compute("hash"), ReadFlags.None);
+
+ Assert.That(result, Is.EqualTo(dbRlp));
+ reader.Received(1).TryLoadStateRlp(Arg.Any(), Arg.Any());
+ }
+
+ [Test]
+ public void TryLoadStateRlp_WithoutPersistedSnapshots_GoesDirectlyToPersistence()
+ {
+ byte[] dbRlp = [0xC0];
+ TreePath path = new(Keccak.Compute("path"), 4);
+
+ IPersistence.IPersistenceReader reader = Substitute.For();
+ reader.TryLoadStateRlp(Arg.Any(), Arg.Any()).Returns(dbRlp);
+
+ using ReadOnlySnapshotBundle bundle = new(
+ new SnapshotPooledList(0),
+ reader,
+ recordDetailedMetrics: false,
+ persistedSnapshots: PersistedSnapshotStack.Empty());
+
+ byte[]? result = bundle.TryLoadStateRlp(path, Keccak.Compute("hash"), ReadFlags.None);
+
+ Assert.That(result, Is.EqualTo(dbRlp));
+ reader.Received(1).TryLoadStateRlp(Arg.Any(), Arg.Any());
+ }
+
+ // Each test snapshot is constructed without a bloom, so it carries the AlwaysTrue
+ // placeholder — the stack probes every snapshot unfiltered, which is what these tests want.
+ private static PersistedSnapshotStack AlwaysTrueStack(PersistedSnapshotList list) =>
+ new(list, recordDetailedMetrics: false);
+
+ private PersistedSnapshot CreatePersistedSnapshot(StateId from, StateId to, byte[] data) =>
+ TestFixtureHelpers.CreatePersistedSnapshot(_memArena, _blobs, from, to, data);
+}
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/ReadOnlySnapshotBundleTests.cs b/src/Nethermind/Nethermind.State.Flat.Test/ReadOnlySnapshotBundleTests.cs
index bb9f720245ce..d0118b84999d 100644
--- a/src/Nethermind/Nethermind.State.Flat.Test/ReadOnlySnapshotBundleTests.cs
+++ b/src/Nethermind/Nethermind.State.Flat.Test/ReadOnlySnapshotBundleTests.cs
@@ -9,6 +9,7 @@
using Nethermind.Db;
using Nethermind.Int256;
using Nethermind.State.Flat.Persistence;
+using Nethermind.State.Flat.PersistedSnapshots;
using Nethermind.Trie;
using NSubstitute;
using NUnit.Framework;
@@ -27,7 +28,8 @@ private Snapshot MakeSnapshot(Action? populate = null) =>
FlatTestHelpers.MakeSnapshot(_pool, populate);
private static ReadOnlySnapshotBundle Bundle(SnapshotPooledList snapshots, IPersistence.IPersistenceReader? reader = null, bool recordDetailedMetrics = false) =>
- new(snapshots, reader ?? Substitute.For(), recordDetailedMetrics);
+ new(snapshots, reader ?? Substitute.For(), recordDetailedMetrics,
+ PersistedSnapshotStack.Empty(recordDetailedMetrics));
[TestCase(true)]
[TestCase(false)]
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/SnapshotCompactorTests.cs b/src/Nethermind/Nethermind.State.Flat.Test/SnapshotCompactorTests.cs
index 3b89ece9484b..5f62ef92f184 100644
--- a/src/Nethermind/Nethermind.State.Flat.Test/SnapshotCompactorTests.cs
+++ b/src/Nethermind/Nethermind.State.Flat.Test/SnapshotCompactorTests.cs
@@ -20,6 +20,7 @@ public class SnapshotCompactorTests
private SnapshotCompactor _compactor = null!;
private ResourcePool _resourcePool = null!;
private FlatDbConfig _config = null!;
+ private FlatTestContainer _tier = null!;
private SnapshotRepository _snapshotRepository;
[SetUp]
@@ -27,10 +28,14 @@ public void SetUp()
{
_config = new FlatDbConfig { CompactSize = 16 };
_resourcePool = new ResourcePool(_config);
- _snapshotRepository = new SnapshotRepository(LimboLogs.Instance);
+ _tier = new FlatTestContainer();
+ _snapshotRepository = _tier.Repository;
_compactor = new SnapshotCompactor(_config, ScheduleHelper.CreateWithOffset(_config, 0), _resourcePool, _snapshotRepository, LimboLogs.Instance);
}
+ [TearDown]
+ public void TearDown() => _tier.Dispose();
+
private static StateId CreateStateId(long blockNumber, byte rootByte = 0)
{
byte[] bytes = new byte[32];
@@ -46,7 +51,7 @@ private void BuildSnapshotChain(long startBlock, long endBlock)
StateId to = CreateStateId(i + 1);
Snapshot snapshot = _resourcePool.CreateSnapshot(from, to, ResourcePool.Usage.ReadOnlyProcessingEnv);
- bool added = _snapshotRepository.TryAddSnapshot(snapshot);
+ bool added = _snapshotRepository.TryAdd(snapshot, SnapshotTier.InMemoryBase);
Assert.That(added, Is.True, $"Failed to add snapshot {i}->{i + 1}");
_snapshotRepository.AddStateId(to);
}
@@ -274,9 +279,9 @@ public void CompactSnapshotBundle_SelfDestructedAddress_RemovesStorageAndNodes()
using Snapshot compacted = _compactor.CompactSnapshotBundle(snapshots);
// Self-destructed address should be tracked, and its storage cleared
+ // Storage nodes are not cleared — orphaned nodes are skipped during trie traversal
Assert.That(compacted.Content.SelfDestructedStorageAddresses.Count, Is.GreaterThan(0));
Assert.That(compacted.StoragesCount, Is.EqualTo(0));
- Assert.That(compacted.StorageNodesCount, Is.EqualTo(0));
}
[Test]
@@ -344,12 +349,12 @@ public void CompactSnapshotBundle_UsesMidCompactorUsageNonBoundary()
}
[Test]
- public void Debug_AssembleSnapshotsUntil_Works()
+ public void Debug_AssembleInMemorySnapshotsForCompaction_Works()
{
BuildSnapshotChain(0, 4);
StateId target = CreateStateId(4);
- SnapshotPooledList assembled = _snapshotRepository.AssembleSnapshotsUntil(target, 0, 10);
+ SnapshotPooledList assembled = _snapshotRepository.AssembleInMemorySnapshotsForCompaction(target, 0, 10);
Assert.That(assembled.Count, Is.EqualTo(4));
@@ -399,14 +404,12 @@ public void GetSnapshotsToCompact_NotCompactionBlock_ReturnsEmpty()
[Test]
public void GetSnapshotsToCompact_FullCompaction_ReturnsMultipleSnapshots()
{
- // Build chain of 15 snapshots (0->1, 1->2, ..., 14->15)
BuildSnapshotChain(0, 15);
- // Add the 16th snapshot (15->16) separately
StateId targetFrom = CreateStateId(15);
StateId targetTo = CreateStateId(16);
Snapshot targetSnapshot = _resourcePool.CreateSnapshot(targetFrom, targetTo, ResourcePool.Usage.ReadOnlyProcessingEnv);
- _snapshotRepository.TryAddSnapshot(targetSnapshot);
+ _snapshotRepository.TryAdd(targetSnapshot, SnapshotTier.InMemoryBase);
_snapshotRepository.AddStateId(targetTo);
using SnapshotPooledList snapshots = _compactor.GetSnapshotsToCompact(targetSnapshot);
@@ -422,7 +425,7 @@ public void GetSnapshotsToCompact_PowerOf2Compaction_ReturnsCorrectCount(long bl
BuildSnapshotChain(0, blockNumber);
StateId targetTo = CreateStateId(blockNumber);
- _snapshotRepository.TryLeaseState(targetTo, out Snapshot? targetSnapshot);
+ _snapshotRepository.TryLeaseInMemoryState(targetTo, SnapshotTier.InMemoryBase, out Snapshot? targetSnapshot);
using SnapshotPooledList snapshots = _compactor.GetSnapshotsToCompact(targetSnapshot!);
@@ -436,7 +439,7 @@ public void GetSnapshotsToCompact_SingleSnapshot_ReturnsEmpty()
StateId from = new(0, Keccak.Zero);
StateId to = new(16, Keccak.Zero);
Snapshot snapshot = _resourcePool.CreateSnapshot(from, to, ResourcePool.Usage.ReadOnlyProcessingEnv);
- _snapshotRepository.TryAddSnapshot(snapshot);
+ _snapshotRepository.TryAdd(snapshot, SnapshotTier.InMemoryBase);
_snapshotRepository.AddStateId(to);
using Snapshot targetSnapshot = _resourcePool.CreateSnapshot(from, to, ResourcePool.Usage.ReadOnlyProcessingEnv);
@@ -455,7 +458,7 @@ public void GetSnapshotsToCompact_IncompleteChain_ReturnsEmpty()
StateId from = new(i, Keccak.Zero);
StateId to = new(i + 1, Keccak.Zero);
Snapshot snapshot = _resourcePool.CreateSnapshot(from, to, ResourcePool.Usage.ReadOnlyProcessingEnv);
- _snapshotRepository.TryAddSnapshot(snapshot);
+ _snapshotRepository.TryAdd(snapshot, SnapshotTier.InMemoryBase);
_snapshotRepository.AddStateId(to);
}
@@ -471,15 +474,13 @@ public void GetSnapshotsToCompact_IncompleteChain_ReturnsEmpty()
[Test]
public void DoCompactSnapshot_ValidChain_CreatesCompactedSnapshot()
{
- // Build chain of 15 snapshots (0->1, 1->2, ..., 14->15)
BuildSnapshotChain(0, 15);
- // Add the 16th snapshot (15->16) separately
StateId targetFrom = CreateStateId(15);
StateId targetTo = CreateStateId(16);
Snapshot targetSnapshot = _resourcePool.CreateSnapshot(targetFrom, targetTo, ResourcePool.Usage.ReadOnlyProcessingEnv);
targetSnapshot.Content.Accounts[TestItem.AddressB] = new Account((UInt256)20, (UInt256)2000);
- _snapshotRepository.TryAddSnapshot(targetSnapshot);
+ _snapshotRepository.TryAdd(targetSnapshot, SnapshotTier.InMemoryBase);
_snapshotRepository.AddStateId(targetTo);
_compactor.DoCompactSnapshot(targetSnapshot.To);
@@ -496,7 +497,8 @@ public void Constructor_NonPowerOf2CompactSize_Throws() =>
public void GetSnapshotsToCompact_Size2Compaction_AllowedByDefault()
{
FlatDbConfig config = new() { CompactSize = 16 };
- SnapshotRepository repo = new(LimboLogs.Instance);
+ using FlatTestContainer tier = new();
+ SnapshotRepository repo = tier.Repository;
SnapshotCompactor compactor = new(config, ScheduleHelper.CreateWithOffset(config, 0), _resourcePool, repo, LimboLogs.Instance);
for (long i = 0; i < 2; i++)
@@ -504,12 +506,12 @@ public void GetSnapshotsToCompact_Size2Compaction_AllowedByDefault()
StateId from = CreateStateId(i);
StateId to = CreateStateId(i + 1);
Snapshot snapshot = _resourcePool.CreateSnapshot(from, to, ResourcePool.Usage.ReadOnlyProcessingEnv);
- repo.TryAddSnapshot(snapshot);
+ repo.TryAdd(snapshot, SnapshotTier.InMemoryBase);
repo.AddStateId(to);
}
StateId target = CreateStateId(2);
- repo.TryLeaseState(target, out Snapshot? targetSnapshot);
+ repo.TryLeaseInMemoryState(target, SnapshotTier.InMemoryBase, out Snapshot? targetSnapshot);
using SnapshotPooledList snapshots = compactor.GetSnapshotsToCompact(targetSnapshot!);
@@ -555,7 +557,8 @@ public void GetSnapshotsToCompact_WithOffset_FullCompactionShiftedFromBoundary()
// CompactSize=16, offset=3 -> full compaction triggers when (block+3) % 16 == 0,
// i.e. at blocks 13, 29, 45, ... Build a chain to block 29 (second full boundary).
FlatDbConfig config = new() { CompactSize = 16 };
- SnapshotRepository repo = new(LimboLogs.Instance);
+ using FlatTestContainer tier = new();
+ SnapshotRepository repo = tier.Repository;
SnapshotCompactor compactor = new(config, ScheduleHelper.CreateWithOffset(config, 3), _resourcePool, repo, LimboLogs.Instance);
for (long i = 0; i < 29; i++)
@@ -563,20 +566,20 @@ public void GetSnapshotsToCompact_WithOffset_FullCompactionShiftedFromBoundary()
StateId from = CreateStateId(i);
StateId to = CreateStateId(i + 1);
Snapshot s = _resourcePool.CreateSnapshot(from, to, ResourcePool.Usage.ReadOnlyProcessingEnv);
- repo.TryAddSnapshot(s);
+ repo.TryAdd(s, SnapshotTier.InMemoryBase);
repo.AddStateId(to);
}
// Block 29: (29+3) & -(29+3) = 32 & -32 = 32, capped at CompactSize=16 -> full compaction
StateId target29 = CreateStateId(29);
- repo.TryLeaseState(target29, out Snapshot? targetSnapshot);
+ repo.TryLeaseInMemoryState(target29, SnapshotTier.InMemoryBase, out Snapshot? targetSnapshot);
using SnapshotPooledList snapshots29 = compactor.GetSnapshotsToCompact(targetSnapshot!);
Assert.That(snapshots29.Count, Is.EqualTo(16), "Block 29 should trigger full compaction with offset=3");
targetSnapshot!.Dispose();
// Block 16: (16+3) & -(16+3) = 19 & -19 = 1 -> caller sees compactSize<=1, no compaction
StateId target16 = CreateStateId(16);
- repo.TryLeaseState(target16, out targetSnapshot);
+ repo.TryLeaseInMemoryState(target16, SnapshotTier.InMemoryBase, out targetSnapshot);
using SnapshotPooledList snapshots16 = compactor.GetSnapshotsToCompact(targetSnapshot!);
Assert.That(snapshots16.Count, Is.EqualTo(0), "Block 16 should NOT trigger compaction with offset=3");
targetSnapshot!.Dispose();
@@ -587,7 +590,8 @@ public void CompactSnapshotBundle_WithOffset_UsesCorrectUsageTier()
{
// CompactSize=16, offset=3. At block 13 the bit trick yields 16 -> Compact16 tier.
FlatDbConfig config = new() { CompactSize = 16 };
- SnapshotRepository repo = new(LimboLogs.Instance);
+ using FlatTestContainer tier = new();
+ SnapshotRepository repo = tier.Repository;
SnapshotCompactor compactor = new(config, ScheduleHelper.CreateWithOffset(config, 3), _resourcePool, repo, LimboLogs.Instance);
StateId from = new(0, Keccak.Zero);
diff --git a/src/Nethermind/Nethermind.State.Flat.Test/SnapshotRepositoryTests.cs b/src/Nethermind/Nethermind.State.Flat.Test/SnapshotRepositoryTests.cs
index 0a93d7319118..ebc32cd58f2f 100644
--- a/src/Nethermind/Nethermind.State.Flat.Test/SnapshotRepositoryTests.cs
+++ b/src/Nethermind/Nethermind.State.Flat.Test/SnapshotRepositoryTests.cs
@@ -7,7 +7,6 @@
using Nethermind.Core.Crypto;
using Nethermind.Core.Test.Builders;
using Nethermind.Db;
-using Nethermind.Logging;
using NUnit.Framework;
namespace Nethermind.State.Flat.Test;
@@ -15,6 +14,7 @@ namespace Nethermind.State.Flat.Test;
[TestFixture]
public class SnapshotRepositoryTests
{
+ private FlatTestContainer _tier = null!;
private SnapshotRepository _repository = null!;
private ResourcePool _resourcePool = null!;
private FlatDbConfig _config = null!;
@@ -24,9 +24,13 @@ public void SetUp()
{
_config = new FlatDbConfig { CompactSize = 16 };
_resourcePool = new ResourcePool(_config);
- _repository = new SnapshotRepository(LimboLogs.Instance);
+ _tier = new FlatTestContainer();
+ _repository = _tier.Repository;
}
+ [TearDown]
+ public void TearDown() => _tier.Dispose();
+
private StateId CreateStateId(long blockNumber, byte rootByte = 0)
{
byte[] bytes = new byte[32];
@@ -51,9 +55,7 @@ private Snapshot AddSnapshotToRepository(StateId from, StateId to, bool compacte
{
Snapshot snapshot = CreateSnapshot(from, to, withData);
- bool added = compacted
- ? _repository.TryAddCompactedSnapshot(snapshot)
- : _repository.TryAddSnapshot(snapshot);
+ bool added = _repository.TryAdd(snapshot, compacted ? SnapshotTier.InMemoryCompacted : SnapshotTier.InMemoryBase);
Assert.That(added, Is.True, $"Failed to add snapshot {from}->{to}");
@@ -66,9 +68,7 @@ private Snapshot AddSnapshotToRepository(StateId from, StateId to, bool compacte
}
private bool TryLease(StateId state, bool compacted, out Snapshot? snapshot)
- => compacted
- ? _repository.TryLeaseCompactedState(state, out snapshot)
- : _repository.TryLeaseState(state, out snapshot);
+ => _repository.TryLeaseInMemoryState(state, compacted ? SnapshotTier.InMemoryCompacted : SnapshotTier.InMemoryBase, out snapshot);
private List BuildSnapshotChain(long startBlock, long endBlock)
{
@@ -101,8 +101,9 @@ public void TryAddSnapshot_NewAndDuplicate_BehavesCorrectly([Values] bool compac
Snapshot snapshot1 = CreateSnapshot(from, to);
Snapshot snapshot2 = CreateSnapshot(from, to);
- bool added1 = compacted ? _repository.TryAddCompactedSnapshot(snapshot1) : _repository.TryAddSnapshot(snapshot1);
- bool added2 = compacted ? _repository.TryAddCompactedSnapshot(snapshot2) : _repository.TryAddSnapshot(snapshot2);
+ SnapshotTier tier = compacted ? SnapshotTier.InMemoryCompacted : SnapshotTier.InMemoryBase;
+ bool added1 = _repository.TryAdd(snapshot1, tier);
+ bool added2 = _repository.TryAdd(snapshot2, tier);
Assert.That(added1, Is.True);
Assert.That(added2, Is.False);
@@ -118,12 +119,12 @@ public void AddAndRemoveSnapshot_CannotLeaseAfterRemoval()
Snapshot snapshot = CreateSnapshot(from, to);
_repository.AddStateId(to);
- _repository.TryAddSnapshot(snapshot);
- bool leasedBefore = _repository.TryLeaseState(to, out Snapshot? leasedSnapshot);
+ _repository.TryAdd(snapshot, SnapshotTier.InMemoryBase);
+ bool leasedBefore = _repository.TryLeaseInMemoryState(to, SnapshotTier.InMemoryBase, out Snapshot? leasedSnapshot);
leasedSnapshot?.Dispose();
- _repository.RemoveAndReleaseKnownState(to);
- bool leasedAfter = _repository.TryLeaseState(to, out _);
+ _repository.RemoveAndReleaseInMemoryKnownState(to, SnapshotTier.InMemoryBase);
+ bool leasedAfter = _repository.TryLeaseInMemoryState(to, SnapshotTier.InMemoryBase, out _);
Assert.That(leasedBefore, Is.True);
Assert.That(leasedAfter, Is.False);
@@ -135,18 +136,18 @@ public void RemoveSnapshot_WithActiveLeases_DisposesWhenAllReleased()
AddSnapshotToRepository(0, 1);
StateId to = CreateStateId(1);
- bool leased1 = _repository.TryLeaseState(to, out Snapshot? snapshot1);
- bool leased2 = _repository.TryLeaseState(to, out Snapshot? snapshot2);
+ bool leased1 = _repository.TryLeaseInMemoryState(to, SnapshotTier.InMemoryBase, out Snapshot? snapshot1);
+ bool leased2 = _repository.TryLeaseInMemoryState(to, SnapshotTier.InMemoryBase, out Snapshot? snapshot2);
Assert.That(leased1, Is.True);
Assert.That(leased2, Is.True);
- _repository.RemoveAndReleaseKnownState(to);
+ _repository.RemoveAndReleaseInMemoryKnownState(to, SnapshotTier.InMemoryBase);
snapshot1!.Dispose();
snapshot2!.Dispose();
- bool leasedAfter = _repository.TryLeaseState(to, out _);
+ bool leasedAfter = _repository.TryLeaseInMemoryState(to, SnapshotTier.InMemoryBase, out _);
Assert.That(leasedAfter, Is.False);
}
@@ -210,31 +211,31 @@ public void HasState_ExistingAndNonExistent()
}
[Test]
- public void GetSnapshotBeforeStateId_EmptyRepository()
+ public void GetStatesUpToBlock_EmptyRepository()
{
StateId target = CreateStateId(10);
- ArrayPoolList states = _repository.GetSnapshotBeforeStateId(target);
+ ArrayPoolList states = _repository.GetStatesUpToBlock(target.BlockNumber);
Assert.That(states.Count, Is.EqualTo(0));
states.Dispose();
}
[Test]
- public void GetSnapshotBeforeStateId_NoStatesBeforeTarget()
+ public void GetStatesUpToBlock_NoStatesBeforeTarget()
{
StateId state10 = CreateStateId(10);
_repository.AddStateId(state10);
StateId target = CreateStateId(5);
- ArrayPoolList states = _repository.GetSnapshotBeforeStateId(target);
+ ArrayPoolList states = _repository.GetStatesUpToBlock(target.BlockNumber);
Assert.That(states.Count, Is.EqualTo(0));
states.Dispose();
}
[Test]
- public void GetSnapshotBeforeStateId_StatesBeforeTarget()
+ public void GetStatesUpToBlock_StatesBeforeTarget()
{
StateId state1 = CreateStateId(1);
StateId state3 = CreateStateId(3);
@@ -249,7 +250,7 @@ public void GetSnapshotBeforeStateId_StatesBeforeTarget()
_repository.AddStateId(state10);
StateId target = CreateStateId(6);
- ArrayPoolList states = _repository.GetSnapshotBeforeStateId(target);
+ ArrayPoolList