diff --git a/src/Nethermind/Nethermind.Core.Test/Modules/PseudoNethermindModule.cs b/src/Nethermind/Nethermind.Core.Test/Modules/PseudoNethermindModule.cs index 4294f43a58e0..a84f144e7b01 100644 --- a/src/Nethermind/Nethermind.Core.Test/Modules/PseudoNethermindModule.cs +++ b/src/Nethermind/Nethermind.Core.Test/Modules/PseudoNethermindModule.cs @@ -76,8 +76,8 @@ protected override void Load(ContainerBuilder builder) .AddSingleton() .AddSingleton(Substitute.For()) - // Flatdb (if used) need a more complete memcolumndb implementation with snapshots and sorted view. - .AddSingleton>((_) => new TestMemColumnsDb()) + // FlatDb uses SnapshotableMemColumnsDb for fast O(1) MVCC snapshots instead of slow O(n) full copies + .AddSingleton>((_) => new SnapshotableMemColumnsDb(neverPrune: true)) .AddDecorator() .Intercept((flatDbConfig) => { diff --git a/src/Nethermind/Nethermind.Db.Test/SnapshotableMemColumnsDbTests.cs b/src/Nethermind/Nethermind.Db.Test/SnapshotableMemColumnsDbTests.cs new file mode 100644 index 000000000000..10b5362dba51 --- /dev/null +++ b/src/Nethermind/Nethermind.Db.Test/SnapshotableMemColumnsDbTests.cs @@ -0,0 +1,218 @@ +// SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using FluentAssertions; +using Nethermind.Core; +using Nethermind.Core.Test.Builders; +using NUnit.Framework; + +namespace Nethermind.Db.Test +{ + [TestFixture] + [Parallelizable(ParallelScope.All)] + public class SnapshotableMemColumnsDbTests + { + private enum TestColumns + { + Column1, + Column2, + Column3 + } + + private readonly byte[] _sampleValue = { 1, 2, 3 }; + private readonly byte[] _sampleValue2 = { 4, 5, 6 }; + + [Test] + public void Can_create_and_get_columns() + { + SnapshotableMemColumnsDb columnsDb = new(); + + IDb column1 = columnsDb.GetColumnDb(TestColumns.Column1); + IDb column2 = columnsDb.GetColumnDb(TestColumns.Column2); + + column1.Should().NotBeNull(); + column2.Should().NotBeNull(); + column1.Should().NotBeSameAs(column2); + } + + [Test] + public void Can_write_and_read_from_columns() + { + SnapshotableMemColumnsDb columnsDb = new(); + + IDb column1 = columnsDb.GetColumnDb(TestColumns.Column1); + IDb column2 = columnsDb.GetColumnDb(TestColumns.Column2); + + column1.Set(TestItem.KeccakA, _sampleValue); + column2.Set(TestItem.KeccakA, _sampleValue2); + + column1.Get(TestItem.KeccakA).Should().BeEquivalentTo(_sampleValue); + column2.Get(TestItem.KeccakA).Should().BeEquivalentTo(_sampleValue2); + } + + [Test] + public void Can_create_snapshot() + { + SnapshotableMemColumnsDb columnsDb = new(); + + IDb column1 = columnsDb.GetColumnDb(TestColumns.Column1); + column1.Set(TestItem.KeccakA, _sampleValue); + + IColumnDbSnapshot snapshot = columnsDb.CreateSnapshot(); + snapshot.Should().NotBeNull(); + + IReadOnlyKeyValueStore snapshotColumn = snapshot.GetColumn(TestColumns.Column1); + snapshotColumn.Get(TestItem.KeccakA).Should().BeEquivalentTo(_sampleValue); + + snapshot.Dispose(); + } + + [Test] + public void Snapshot_is_isolated_from_subsequent_writes() + { + SnapshotableMemColumnsDb columnsDb = new(); + + IDb column1 = columnsDb.GetColumnDb(TestColumns.Column1); + column1.Set(TestItem.KeccakA, _sampleValue); + + IColumnDbSnapshot snapshot = columnsDb.CreateSnapshot(); + + // Modify after snapshot + column1.Set(TestItem.KeccakA, _sampleValue2); + column1.Set(TestItem.KeccakB, _sampleValue2); + + // Snapshot should see old values + IReadOnlyKeyValueStore snapshotColumn = snapshot.GetColumn(TestColumns.Column1); + snapshotColumn.Get(TestItem.KeccakA).Should().BeEquivalentTo(_sampleValue); + snapshotColumn.Get(TestItem.KeccakB).Should().BeNull(); + + // Main db should see new values + column1.Get(TestItem.KeccakA).Should().BeEquivalentTo(_sampleValue2); + column1.Get(TestItem.KeccakB).Should().BeEquivalentTo(_sampleValue2); + + snapshot.Dispose(); + } + + [Test] + public void Snapshot_captures_all_columns_atomically() + { + SnapshotableMemColumnsDb columnsDb = new(); + + IDb column1 = columnsDb.GetColumnDb(TestColumns.Column1); + IDb column2 = columnsDb.GetColumnDb(TestColumns.Column2); + + column1.Set(TestItem.KeccakA, new byte[] { 1 }); + column2.Set(TestItem.KeccakA, new byte[] { 2 }); + + IColumnDbSnapshot snapshot = columnsDb.CreateSnapshot(); + + // Modify both columns + column1.Set(TestItem.KeccakA, new byte[] { 10 }); + column2.Set(TestItem.KeccakA, new byte[] { 20 }); + + // Snapshot should see old values in both columns + IReadOnlyKeyValueStore snapshotColumn1 = snapshot.GetColumn(TestColumns.Column1); + IReadOnlyKeyValueStore snapshotColumn2 = snapshot.GetColumn(TestColumns.Column2); + + snapshotColumn1.Get(TestItem.KeccakA).Should().BeEquivalentTo(new byte[] { 1 }); + snapshotColumn2.Get(TestItem.KeccakA).Should().BeEquivalentTo(new byte[] { 2 }); + + snapshot.Dispose(); + } + + [Test] + public void Multiple_snapshots_are_independent() + { + SnapshotableMemColumnsDb columnsDb = new(); + + IDb column1 = columnsDb.GetColumnDb(TestColumns.Column1); + + column1.Set(TestItem.KeccakA, new byte[] { 1 }); + IColumnDbSnapshot snapshot1 = columnsDb.CreateSnapshot(); + + column1.Set(TestItem.KeccakA, new byte[] { 2 }); + IColumnDbSnapshot snapshot2 = columnsDb.CreateSnapshot(); + + column1.Set(TestItem.KeccakA, new byte[] { 3 }); + + // Each snapshot sees its version + snapshot1.GetColumn(TestColumns.Column1).Get(TestItem.KeccakA).Should().BeEquivalentTo(new byte[] { 1 }); + snapshot2.GetColumn(TestColumns.Column1).Get(TestItem.KeccakA).Should().BeEquivalentTo(new byte[] { 2 }); + + snapshot1.Dispose(); + snapshot2.Dispose(); + } + + [Test] + public void Can_use_write_batch() + { + SnapshotableMemColumnsDb columnsDb = new(); + + using (IColumnsWriteBatch batch = columnsDb.StartWriteBatch()) + { + batch.GetColumnBatch(TestColumns.Column1).Set(TestItem.KeccakA, _sampleValue); + } + + IDb column1 = columnsDb.GetColumnDb(TestColumns.Column1); + column1.Get(TestItem.KeccakA).Should().BeEquivalentTo(_sampleValue); + } + + [Test] + public void Flush_does_not_cause_trouble() + { + SnapshotableMemColumnsDb columnsDb = new(); + columnsDb.Flush(); + } + + [Test] + public void Dispose_does_not_cause_trouble() + { + SnapshotableMemColumnsDb columnsDb = new(); + columnsDb.Dispose(); + } + + [Test] + public void ColumnKeys_returns_all_columns() + { + SnapshotableMemColumnsDb columnsDb = new(); + + columnsDb.GetColumnDb(TestColumns.Column1); + columnsDb.GetColumnDb(TestColumns.Column2); + + columnsDb.ColumnKeys.Should().Contain(TestColumns.Column1); + columnsDb.ColumnKeys.Should().Contain(TestColumns.Column2); + } + + [Test] + public void Snapshot_column_supports_ISortedKeyValueStore() + { + SnapshotableMemColumnsDb columnsDb = new(); + byte[] keyA = new byte[] { 0x01 }; + byte[] keyB = new byte[] { 0x02 }; + byte[] keyC = new byte[] { 0x03 }; + + IDb column1 = columnsDb.GetColumnDb(TestColumns.Column1); + column1.Set(keyA, new byte[] { 1 }); + column1.Set(keyB, new byte[] { 2 }); + column1.Set(keyC, new byte[] { 3 }); + + IColumnDbSnapshot snapshot = columnsDb.CreateSnapshot(); + IReadOnlyKeyValueStore snapshotColumn = snapshot.GetColumn(TestColumns.Column1); + + // Check if snapshot column is ISortedKeyValueStore + snapshotColumn.Should().BeAssignableTo(); + + ISortedKeyValueStore sortedColumn = (ISortedKeyValueStore)snapshotColumn; + byte[]? firstKey = sortedColumn.FirstKey; + byte[]? lastKey = sortedColumn.LastKey; + + firstKey.Should().NotBeNull(); + lastKey.Should().NotBeNull(); + + firstKey.Should().BeEquivalentTo(keyA); // 0x01 + lastKey.Should().BeEquivalentTo(keyC); // 0x03 + + snapshot.Dispose(); + } + } +} diff --git a/src/Nethermind/Nethermind.Db.Test/SnapshotableMemDbTests.cs b/src/Nethermind/Nethermind.Db.Test/SnapshotableMemDbTests.cs new file mode 100644 index 000000000000..74900111c699 --- /dev/null +++ b/src/Nethermind/Nethermind.Db.Test/SnapshotableMemDbTests.cs @@ -0,0 +1,452 @@ +// SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Nethermind.Core; +using Nethermind.Core.Extensions; +using Nethermind.Core.Test.Builders; +using NUnit.Framework; +using Bytes = Nethermind.Core.Extensions.Bytes; + +namespace Nethermind.Db.Test +{ + [TestFixture] + [Parallelizable(ParallelScope.All)] + public class SnapshotableMemDbTests + { + private readonly byte[] _sampleValue = { 1, 2, 3 }; + private readonly byte[] _sampleValue2 = { 4, 5, 6 }; + + [Test] + public void Simple_set_get_is_fine() + { + SnapshotableMemDb memDb = new(); + memDb.Set(TestItem.KeccakA, _sampleValue); + byte[] retrievedBytes = memDb.Get(TestItem.KeccakA); + retrievedBytes.Should().BeEquivalentTo(_sampleValue); + } + + [Test] + public void Can_create_with_name() + { + SnapshotableMemDb memDb = new("test_db"); + memDb.Set(TestItem.KeccakA, _sampleValue); + memDb.Get(TestItem.KeccakA); + memDb.Name.Should().Be("test_db"); + } + + [Test] + public void Can_create_snapshot() + { + SnapshotableMemDb memDb = new(); + memDb.Set(TestItem.KeccakA, _sampleValue); + + IKeyValueStoreSnapshot snapshot = memDb.CreateSnapshot(); + snapshot.Should().NotBeNull(); + + byte[] value = snapshot.Get(TestItem.KeccakA); + value.Should().BeEquivalentTo(_sampleValue); + + snapshot.Dispose(); + } + + [Test] + public void Snapshot_is_isolated_from_subsequent_writes() + { + SnapshotableMemDb memDb = new(); + memDb.Set(TestItem.KeccakA, _sampleValue); + + IKeyValueStoreSnapshot snapshot = memDb.CreateSnapshot(); + + // Modify after snapshot + memDb.Set(TestItem.KeccakA, _sampleValue2); + memDb.Set(TestItem.KeccakB, _sampleValue2); + + // Snapshot should see old values + byte[] valueA = snapshot.Get(TestItem.KeccakA); + valueA.Should().BeEquivalentTo(_sampleValue); + + byte[] valueB = snapshot.Get(TestItem.KeccakB); + valueB.Should().BeNull(); + + // Main db should see new values + memDb.Get(TestItem.KeccakA).Should().BeEquivalentTo(_sampleValue2); + memDb.Get(TestItem.KeccakB).Should().BeEquivalentTo(_sampleValue2); + + snapshot.Dispose(); + } + + [Test] + public void Multiple_snapshots_see_correct_versions() + { + SnapshotableMemDb memDb = new(); + + // Version 1 + memDb.Set(TestItem.KeccakA, new byte[] { 1 }); + IKeyValueStoreSnapshot snapshot1 = memDb.CreateSnapshot(); + + // Version 2 + memDb.Set(TestItem.KeccakA, new byte[] { 2 }); + IKeyValueStoreSnapshot snapshot2 = memDb.CreateSnapshot(); + + // Version 3 + memDb.Set(TestItem.KeccakA, new byte[] { 3 }); + IKeyValueStoreSnapshot snapshot3 = memDb.CreateSnapshot(); + + // Check each snapshot sees its version + byte[]? value1 = snapshot1.Get(TestItem.KeccakA); + byte[]? value2 = snapshot2.Get(TestItem.KeccakA); + byte[]? value3 = snapshot3.Get(TestItem.KeccakA); + + value1.Should().NotBeNull(); + value2.Should().NotBeNull(); + value3.Should().NotBeNull(); + + value1.Should().BeEquivalentTo(new byte[] { 1 }); + value2.Should().BeEquivalentTo(new byte[] { 2 }); + value3.Should().BeEquivalentTo(new byte[] { 3 }); + + snapshot1.Dispose(); + snapshot2.Dispose(); + snapshot3.Dispose(); + } + + [Test] + public void Disposing_all_snapshots_clears_old_versions() + { + SnapshotableMemDb memDb = new(); + memDb.Set(TestItem.KeccakA, new byte[] { 1 }); + + IKeyValueStoreSnapshot snapshot1 = memDb.CreateSnapshot(); + memDb.Set(TestItem.KeccakA, new byte[] { 2 }); + + IKeyValueStoreSnapshot snapshot2 = memDb.CreateSnapshot(); + memDb.Set(TestItem.KeccakA, new byte[] { 3 }); + + // Dispose all snapshots + snapshot1.Dispose(); + snapshot2.Dispose(); + + // After disposal, old versions should be pruned + // Main db should still work + memDb.Get(TestItem.KeccakA).Should().BeEquivalentTo(new byte[] { 3 }); + memDb.Set(TestItem.KeccakA, new byte[] { 4 }); + memDb.Get(TestItem.KeccakA).Should().BeEquivalentTo(new byte[] { 4 }); + } + + [Test] + public void Can_remove_key() + { + SnapshotableMemDb memDb = new(); + memDb.Set(TestItem.KeccakA, _sampleValue); + memDb.Remove(TestItem.KeccakA.Bytes); + memDb.KeyExists(TestItem.KeccakA).Should().BeFalse(); + } + + [Test] + public void Snapshot_sees_removed_key_as_existing_if_removed_after_snapshot() + { + SnapshotableMemDb memDb = new(); + memDb.Set(TestItem.KeccakA, _sampleValue); + + IKeyValueStoreSnapshot snapshot = memDb.CreateSnapshot(); + + memDb.Remove(TestItem.KeccakA.Bytes); + + // Main db should not see key + memDb.KeyExists(TestItem.KeccakA).Should().BeFalse(); + + // Snapshot should still see key + snapshot.KeyExists(TestItem.KeccakA).Should().BeTrue(); + snapshot.Get(TestItem.KeccakA).Should().BeEquivalentTo(_sampleValue); + + snapshot.Dispose(); + } + + [Test] + public void Can_get_keys() + { + SnapshotableMemDb memDb = new(); + memDb.Set(TestItem.KeccakA, _sampleValue); + memDb.Set(TestItem.KeccakB, _sampleValue); + memDb.Keys.Should().HaveCount(2); + } + + [Test] + public void Can_get_all() + { + SnapshotableMemDb memDb = new(); + memDb.Set(TestItem.KeccakA, _sampleValue); + memDb.Set(TestItem.KeccakB, _sampleValue); + memDb.GetAllValues().Should().HaveCount(2); + } + + [Test] + public void Can_get_values() + { + SnapshotableMemDb memDb = new(); + memDb.Set(TestItem.KeccakA, _sampleValue); + memDb.Set(TestItem.KeccakB, _sampleValue); + memDb.Values.Should().HaveCount(2); + } + + [Test] + public void Dispose_does_not_cause_trouble() + { + SnapshotableMemDb memDb = new(); + memDb.Dispose(); + } + + [Test] + public void Flush_does_not_cause_trouble() + { + SnapshotableMemDb memDb = new(); + memDb.Flush(); + } + + [Test] + public void Can_clear() + { + SnapshotableMemDb memDb = new(); + memDb.Set(TestItem.KeccakA, _sampleValue); + memDb.Set(TestItem.KeccakB, _sampleValue); + memDb.Clear(); + memDb.Keys.Should().HaveCount(0); + } + + [Test] + public void FirstKey_returns_sorted_first_key() + { + SnapshotableMemDb memDb = new(); + byte[] keyA = new byte[] { 0x01 }; + byte[] keyB = new byte[] { 0x02 }; + byte[] keyC = new byte[] { 0x03 }; + + memDb.Set(keyC, _sampleValue); // Insert out of order + memDb.Set(keyA, _sampleValue); + memDb.Set(keyB, _sampleValue); + + byte[]? firstKey = memDb.FirstKey; + firstKey.Should().NotBeNull(); + firstKey.Should().BeEquivalentTo(keyA); // 0x01 is smallest + } + + [Test] + public void LastKey_returns_sorted_last_key() + { + SnapshotableMemDb memDb = new(); + byte[] keyA = new byte[] { 0x01 }; + byte[] keyB = new byte[] { 0x02 }; + byte[] keyC = new byte[] { 0x03 }; + + memDb.Set(keyA, _sampleValue); // Insert out of order + memDb.Set(keyC, _sampleValue); + memDb.Set(keyB, _sampleValue); + + byte[]? lastKey = memDb.LastKey; + lastKey.Should().NotBeNull(); + lastKey.Should().BeEquivalentTo(keyC); // 0x03 is largest + } + + [Test] + public void GetViewBetween_returns_keys_in_range() + { + SnapshotableMemDb memDb = new(); + byte[] keyA = new byte[] { 0x01 }; + byte[] keyB = new byte[] { 0x02 }; + byte[] keyC = new byte[] { 0x03 }; + byte[] keyD = new byte[] { 0x04 }; + byte[] keyE = new byte[] { 0x05 }; + + memDb.Set(keyA, new byte[] { 1 }); + memDb.Set(keyB, new byte[] { 2 }); + memDb.Set(keyC, new byte[] { 3 }); + memDb.Set(keyD, new byte[] { 4 }); + memDb.Set(keyE, new byte[] { 5 }); + + // Get keys between B (inclusive) and E (exclusive) + ISortedView view = memDb.GetViewBetween(keyB, keyE); + + List keys = new(); + List values = new(); + while (view.MoveNext()) + { + keys.Add(view.CurrentKey.ToArray()); + values.Add(view.CurrentValue.ToArray()); + } + + keys.Should().HaveCount(3); + keys[0].Should().BeEquivalentTo(keyB); + keys[1].Should().BeEquivalentTo(keyC); + keys[2].Should().BeEquivalentTo(keyD); + + values[0].Should().BeEquivalentTo(new byte[] { 2 }); + values[1].Should().BeEquivalentTo(new byte[] { 3 }); + values[2].Should().BeEquivalentTo(new byte[] { 4 }); + + view.Dispose(); + } + + [Test] + public void Snapshot_GetViewBetween_sees_correct_version() + { + SnapshotableMemDb memDb = new(); + byte[] keyA = new byte[] { 0x01 }; + byte[] keyB = new byte[] { 0x02 }; + byte[] keyC = new byte[] { 0x03 }; + byte[] keyD = new byte[] { 0x04 }; + byte[] keyE = new byte[] { 0x05 }; + + memDb.Set(keyA, new byte[] { 1 }); + memDb.Set(keyB, new byte[] { 2 }); + memDb.Set(keyC, new byte[] { 3 }); + + IKeyValueStoreSnapshot snapshot = memDb.CreateSnapshot(); + + // Modify after snapshot + memDb.Set(keyB, new byte[] { 99 }); + memDb.Set(keyD, new byte[] { 4 }); + + // Snapshot view should see old version + ISortedKeyValueStore sortedSnapshot = (ISortedKeyValueStore)snapshot; + ISortedView view = sortedSnapshot.GetViewBetween(keyA, keyE); + + List values = new(); + while (view.MoveNext()) + { + values.Add(view.CurrentValue.ToArray()); + } + + values.Should().HaveCount(3); + values[0].Should().BeEquivalentTo(new byte[] { 1 }); + values[1].Should().BeEquivalentTo(new byte[] { 2 }); // Old version + values[2].Should().BeEquivalentTo(new byte[] { 3 }); + + view.Dispose(); + snapshot.Dispose(); + } + + [Test] + public void Can_use_batches() + { + SnapshotableMemDb memDb = new(); + using (IWriteBatch batch = memDb.StartWriteBatch()) + { + batch.Set(TestItem.KeccakA, _sampleValue); + } + + byte[] retrieved = memDb.Get(TestItem.KeccakA); + retrieved.Should().BeEquivalentTo(_sampleValue); + } + + [Test] + public void Can_get_all_ordered() + { + SnapshotableMemDb memDb = new(); + + memDb.Set(TestItem.KeccakE, _sampleValue); + memDb.Set(TestItem.KeccakC, _sampleValue); + memDb.Set(TestItem.KeccakA, _sampleValue); + memDb.Set(TestItem.KeccakB, _sampleValue); + memDb.Set(TestItem.KeccakD, _sampleValue); + + IEnumerable> orderedItems = memDb.GetAll(true); + + orderedItems.Should().HaveCount(5); + + byte[][] keys = orderedItems.Select(kvp => kvp.Key).ToArray(); + for (int i = 0; i < keys.Length - 1; i++) + { + Bytes.BytesComparer.Compare(keys[i], keys[i + 1]).Should().BeLessThan(0, + $"Keys should be in ascending order at position {i}"); + } + } + + [Test] + public void Snapshot_FirstKey_and_LastKey_work() + { + SnapshotableMemDb memDb = new(); + byte[] keyA = new byte[] { 0x01 }; + byte[] keyB = new byte[] { 0x02 }; + byte[] keyC = new byte[] { 0x03 }; + + memDb.Set(keyA, _sampleValue); + memDb.Set(keyB, _sampleValue); + + IKeyValueStoreSnapshot snapshot = memDb.CreateSnapshot(); + + memDb.Set(keyC, _sampleValue); // Add after snapshot + + ISortedKeyValueStore sortedSnapshot = (ISortedKeyValueStore)snapshot; + byte[]? firstKey = sortedSnapshot.FirstKey; + byte[]? lastKey = sortedSnapshot.LastKey; + + firstKey.Should().NotBeNull(); + lastKey.Should().NotBeNull(); + + firstKey.Should().BeEquivalentTo(keyA); // 0x01 + lastKey.Should().BeEquivalentTo(keyB); // 0x02 (not keyC which was added after) + + snapshot.Dispose(); + } + + [Test] + public void Snapshot_survives_pruning_when_newer_snapshot_disposed() + { + // Use default (pruning enabled) to verify the fix + SnapshotableMemDb memDb = new(); + + // Write key A at version 1 + memDb.Set(TestItem.KeccakA, new byte[] { 1 }); + + // Write key B at version 2 + memDb.Set(TestItem.KeccakB, new byte[] { 2 }); + + // Create snapshot1 at version 2 (sees both keys) + IKeyValueStoreSnapshot snapshot1 = memDb.CreateSnapshot(); + + // Update key A at version 3 + memDb.Set(TestItem.KeccakA, new byte[] { 3 }); + + // Create snapshot2 at version 3 + IKeyValueStoreSnapshot snapshot2 = memDb.CreateSnapshot(); + + // Dispose snapshot2 - triggers PruneVersionsOlderThan(2) + snapshot2.Dispose(); + + // snapshot1 should still see the original value for key A + byte[]? valueA = snapshot1.Get(TestItem.KeccakA); + valueA.Should().NotBeNull("snapshot1 at version 2 should still see key A written at version 1"); + valueA.Should().BeEquivalentTo(new byte[] { 1 }); + + // Key B should still work + byte[]? valueB = snapshot1.Get(TestItem.KeccakB); + valueB.Should().BeEquivalentTo(new byte[] { 2 }); + + snapshot1.Dispose(); + } + + [Test] + public void NeverPrune_option_disables_pruning() + { + SnapshotableMemDb memDb = new(neverPrune: true); + + memDb.Set(TestItem.KeccakA, new byte[] { 1 }); + IKeyValueStoreSnapshot snapshot1 = memDb.CreateSnapshot(); + + memDb.Set(TestItem.KeccakA, new byte[] { 2 }); + IKeyValueStoreSnapshot snapshot2 = memDb.CreateSnapshot(); + + // Dispose in any order - no pruning should occur + snapshot2.Dispose(); + + // snapshot1 should still work + byte[]? value = snapshot1.Get(TestItem.KeccakA); + value.Should().BeEquivalentTo(new byte[] { 1 }); + + snapshot1.Dispose(); + } + } +} diff --git a/src/Nethermind/Nethermind.Db/SnapshotableMemColumnsDb.cs b/src/Nethermind/Nethermind.Db/SnapshotableMemColumnsDb.cs new file mode 100644 index 000000000000..aba3025ea027 --- /dev/null +++ b/src/Nethermind/Nethermind.Db/SnapshotableMemColumnsDb.cs @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Collections.Generic; +using Nethermind.Core; + +namespace Nethermind.Db +{ + /// + /// In-memory column database with snapshot support. + /// Each column is a separate SnapshotableMemDb instance. + /// + public class SnapshotableMemColumnsDb : IColumnsDb where TKey : struct, Enum + { + private readonly Dictionary _columnDbs = new(); + private readonly bool _neverPrune; + + private SnapshotableMemColumnsDb(TKey[] keys, bool neverPrune) + { + _neverPrune = neverPrune; + foreach (TKey key in keys) + { + GetColumnDb(key); + } + } + + public SnapshotableMemColumnsDb(params TKey[] keys) : this(keys, false) + { + } + + public SnapshotableMemColumnsDb() : this(Enum.GetValues(), false) + { + } + + public SnapshotableMemColumnsDb(string _) : this(Enum.GetValues(), false) + { + } + + public SnapshotableMemColumnsDb(bool neverPrune) : this(Enum.GetValues(), neverPrune) + { + } + + public IDb GetColumnDb(TKey key) + { + if (!_columnDbs.TryGetValue(key, out SnapshotableMemDb? db)) + { + db = new SnapshotableMemDb($"Column_{key}", _neverPrune); + _columnDbs[key] = db; + } + return db; + } + + public IEnumerable ColumnKeys => _columnDbs.Keys; + + public IReadOnlyColumnDb CreateReadOnly(bool createInMemWriteStore) + { + return new ReadOnlyColumnsDb(this, createInMemWriteStore); + } + + public IColumnsWriteBatch StartWriteBatch() + { + return new InMemoryColumnWriteBatch(this); + } + + public IColumnDbSnapshot CreateSnapshot() + { + Dictionary snapshots = new(); + foreach (KeyValuePair kvp in _columnDbs) + { + snapshots[kvp.Key] = kvp.Value.CreateSnapshot(); + } + return new ColumnSnapshot(snapshots); + } + + public void Dispose() + { + foreach (SnapshotableMemDb db in _columnDbs.Values) + { + db.Dispose(); + } + } + + public void Flush(bool onlyWal = false) + { + foreach (SnapshotableMemDb db in _columnDbs.Values) + { + db.Flush(onlyWal); + } + } + + /// + /// Snapshot of column database at a specific point in time. + /// + private sealed class ColumnSnapshot(Dictionary snapshots) : IColumnDbSnapshot + { + private readonly Dictionary _snapshots = snapshots; + + public IReadOnlyKeyValueStore GetColumn(TKey key) + { + return _snapshots[key]; + } + + public void Dispose() + { + foreach (IKeyValueStoreSnapshot snapshot in _snapshots.Values) + { + snapshot.Dispose(); + } + } + } + } +} diff --git a/src/Nethermind/Nethermind.Db/SnapshotableMemDb.cs b/src/Nethermind/Nethermind.Db/SnapshotableMemDb.cs new file mode 100644 index 000000000000..e77fe9b54d0a --- /dev/null +++ b/src/Nethermind/Nethermind.Db/SnapshotableMemDb.cs @@ -0,0 +1,716 @@ +// SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Nethermind.Core; +using Nethermind.Core.Collections; +using Nethermind.Core.Extensions; + +namespace Nethermind.Db +{ + /// + /// In-memory database with MVCC-based snapshot support. + /// Uses Multi-Version Concurrency Control to enable O(1) snapshot creation. + /// + public class SnapshotableMemDb(string name = nameof(SnapshotableMemDb), bool neverPrune = false) : IFullDb, ISortedKeyValueStore, IKeyValueStoreWithSnapshot + { + private readonly SortedSet<(byte[] Key, int Version, byte[]? Value)> _db = new(new EntryComparer()); + private readonly EntryComparer _entryComparer = new(); + private int _currentVersion = 0; + private readonly HashSet _activeSnapshotVersions = new(); + private readonly Lock _versionLock = new(); + private readonly bool _neverPrune = neverPrune; + + public long ReadsCount { get; private set; } + public long WritesCount { get; private set; } + + public string Name { get; } = name; + + public byte[]? this[ReadOnlySpan key] + { + get => Get(key); + set => Set(key, value); + } + + public KeyValuePair[] this[byte[][] keys] + { + get + { + KeyValuePair[] result = new KeyValuePair[keys.Length]; + lock (_versionLock) + { + ReadsCount += keys.Length; + for (int i = 0; i < keys.Length; i++) + { + byte[] key = keys[i]; + result[i] = new KeyValuePair(key, GetValueAtVersion(key, _currentVersion)); + } + } + return result; + } + } + + public byte[]? Get(ReadOnlySpan key, ReadFlags flags = ReadFlags.None) + { + ReadsCount++; + byte[] keyArray = key.ToArray(); + lock (_versionLock) + { + return GetValueAtVersion(keyArray, _currentVersion); + } + } + + public unsafe Span GetSpan(scoped ReadOnlySpan key, ReadFlags flags = ReadFlags.None) + => Get(key, flags).AsSpan(); + + public void Set(ReadOnlySpan key, byte[]? value, WriteFlags flags = WriteFlags.None) + { + WritesCount++; + byte[] keyArray = key.ToArray(); + + lock (_versionLock) + { + _currentVersion++; + AddOrReplace((keyArray, _currentVersion, value)); + + if (!_neverPrune && _activeSnapshotVersions.Count == 0) + { + RemovePreviousVersions(keyArray, _currentVersion); + } + } + } + + public void Remove(ReadOnlySpan key) + { + Set(key, null); + } + + public bool KeyExists(ReadOnlySpan key) + { + byte[] keyArray = key.ToArray(); + lock (_versionLock) + { + return GetValueAtVersion(keyArray, _currentVersion) is not null; + } + } + + public IWriteBatch StartWriteBatch() => new MemDbWriteBatch(this); + + public void Flush(bool onlyWal = false) { } + + public void Clear() + { + lock (_versionLock) + { + _db.Clear(); + _currentVersion = 0; + _activeSnapshotVersions.Clear(); + } + } + + public IEnumerable> GetAll(bool ordered = false) + { + List> result; + lock (_versionLock) + { + result = new List>(); + foreach (byte[] key in GetAllUniqueKeys()) + { + byte[]? value = GetValueAtVersion(key, _currentVersion); + if (value is not null) + { + result.Add(new KeyValuePair(key, value)); + } + } + } + return result; + } + + public IEnumerable GetAllKeys(bool ordered = false) + { + List result; + lock (_versionLock) + { + result = new List(); + foreach (byte[] key in GetAllUniqueKeys()) + { + if (GetValueAtVersion(key, _currentVersion) is not null) + { + result.Add(key); + } + } + } + return result; + } + + public IEnumerable GetAllValues(bool ordered = false) + { + List result; + lock (_versionLock) + { + result = new List(); + foreach (byte[] key in GetAllUniqueKeys()) + { + byte[]? value = GetValueAtVersion(key, _currentVersion); + if (value is not null) + { + result.Add(value); + } + } + } + return result; + } + + public ICollection Keys + { + get + { + lock (_versionLock) + { + return GetAllUniqueKeys() + .Where(k => GetValueAtVersion(k, _currentVersion) is not null) + .ToArray(); + } + } + } + + public ICollection Values + { + get + { + lock (_versionLock) + { + return GetAllUniqueKeys() + .Select(k => GetValueAtVersion(k, _currentVersion)) + .Where(v => v is not null) + .ToArray()!; + } + } + } + + public int Count + { + get + { + lock (_versionLock) + { + return GetAllUniqueKeys() + .Count(k => GetValueAtVersion(k, _currentVersion) is not null); + } + } + } + + public void Dispose() { } + + public bool PreferWriteByArray => true; + + public unsafe void DangerousReleaseMemory(in ReadOnlySpan span) { } + + public IDbMeta.DbMetric GatherMetric() => new() { Size = Count }; + + // ISortedKeyValueStore implementation + public byte[]? FirstKey + { + get + { + lock (_versionLock) + { + return FindFirstKeyAtVersion(_currentVersion); + } + } + } + + public byte[]? LastKey + { + get + { + lock (_versionLock) + { + return FindLastKeyAtVersion(_currentVersion); + } + } + } + + public ISortedView GetViewBetween(ReadOnlySpan firstKeyInclusive, ReadOnlySpan lastKeyExclusive) + { + int version; + lock (_versionLock) + { + version = _currentVersion; + } + return new MemDbSortedView(this, version, firstKeyInclusive.ToArray(), lastKeyExclusive.ToArray()); + } + + // IKeyValueStoreWithSnapshot implementation + public IKeyValueStoreSnapshot CreateSnapshot() + { + lock (_versionLock) + { + int snapshotVersion = _currentVersion; + _activeSnapshotVersions.Add(snapshotVersion); + return new MemDbSnapshot(this, snapshotVersion); + } + } + + internal void OnSnapshotDisposed(int version) + { + lock (_versionLock) + { + _activeSnapshotVersions.Remove(version); + + // Skip pruning if disabled + if (_neverPrune) + { + return; + } + + // Fast path: no active snapshots - keep only latest version per key + if (_activeSnapshotVersions.Count == 0) + { + KeepOnlyLatestVersions(); + return; + } + + // Slow path: prune versions older than oldest active snapshot + int minVersion = _activeSnapshotVersions.Min(); + PruneVersionsOlderThan(minVersion); + } + } + + internal byte[]? GetAtVersion(ReadOnlySpan key, int version) + { + byte[] keyArray = key.ToArray(); + lock (_versionLock) + { + return GetValueAtVersion(keyArray, version); + } + } + + internal IEnumerable> GetAllAtVersion(int version) + { + List> result; + lock (_versionLock) + { + result = new List>(); + foreach (byte[] key in GetAllUniqueKeys()) + { + byte[]? value = GetValueAtVersion(key, version); + if (value is not null) + { + result.Add(new KeyValuePair(key, value)); + } + } + } + return result; + } + + /// + /// Gets the value for a key at a specific version (latest version <= requested version). + /// Returns null if the key doesn't exist or was deleted (tombstone) at that version. + /// Uses GetViewBetween for O(log n) lookup. + /// + private byte[]? GetValueAtVersion(byte[] key, int version) + { + var lower = (key, 0, (byte[]?)null); + var upper = (key, version, (byte[]?)null); + + if (_entryComparer.Compare(lower, upper) > 0) + return null; + + var view = _db.GetViewBetween(lower, upper); + var max = view.Max; + return max.Key is not null ? max.Value : null; + } + + /// + /// Returns all unique keys in sorted order. + /// + private IEnumerable GetAllUniqueKeys() + { + byte[]? lastKey = null; + foreach (var entry in _db) + { + if (lastKey == null || lastKey.AsSpan().SequenceCompareTo(entry.Key) != 0) + { + lastKey = entry.Key; + yield return entry.Key; + } + } + } + + private byte[]? FindFirstKeyAtVersion(int version) + { + foreach (byte[] key in GetAllUniqueKeys()) + { + if (GetValueAtVersion(key, version) is not null) + return key; + } + return null; + } + + private byte[]? FindLastKeyAtVersion(int version) + { + byte[]? lastKey = null; + foreach (var entry in _db.Reverse()) + { + if (lastKey is null || lastKey.AsSpan().SequenceCompareTo(entry.Key) != 0) + { + lastKey = entry.Key; + if (GetValueAtVersion(entry.Key, version) is not null) + return entry.Key; + } + } + return null; + } + + /// + /// Removes all versions except the latest for each key. + /// Called when there are no active snapshots. + /// + private void KeepOnlyLatestVersions() + { + using ArrayPoolList<(byte[] Key, int Version, byte[]? Value)> toRemove = new(_db.Count); + (byte[] Key, int Version, byte[]? Value) prev = default; + + foreach (var entry in _db) + { + if (prev.Key is not null && prev.Key.AsSpan().SequenceCompareTo(entry.Key) == 0) + { + toRemove.Add(prev); + } + + prev = entry; + } + + foreach (var entry in toRemove) + { + _db.Remove(entry); + } + } + + /// + /// Removes old versions per key, keeping the latest version below minVersion + /// so that snapshots at or after that version can still resolve the key. + /// + private void PruneVersionsOlderThan(int minVersion) + { + using ArrayPoolList<(byte[] Key, int Version, byte[]? Value)> toRemove = new(_db.Count); + byte[]? prevKey = null; + int prevVersion = 0; + byte[]? prevValue = null; + + foreach (var entry in _db) + { + if (entry.Version < minVersion && + prevKey is not null && + prevKey.AsSpan().SequenceCompareTo(entry.Key) == 0) + { + // Same key, older entry — the current entry supersedes the previous one + toRemove.Add((prevKey, prevVersion, prevValue)); + } + + prevKey = entry.Key; + prevVersion = entry.Version; + prevValue = entry.Value; + } + + foreach (var entry in toRemove) + { + _db.Remove(entry); + } + } + + /// + /// Adds an entry to the set, replacing any existing entry with the same (Key, Version). + /// Since the comparer ignores Value, we must remove-then-add to update values. + /// + private void AddOrReplace((byte[] Key, int Version, byte[]? Value) entry) + { + if (!_db.Add(entry)) + { + _db.Remove(entry); + _db.Add(entry); + } + } + + /// + /// Removes all versions of a key older than the specified version. + /// Used during writes when no snapshots are active to prevent unbounded memory growth. + /// + private void RemovePreviousVersions(byte[] key, int currentVersion) + { + var lower = (key, 0, (byte[]?)null); + var upper = (key, currentVersion - 1, (byte[]?)null); + + if (_entryComparer.Compare(lower, upper) > 0) + return; + + var view = _db.GetViewBetween(lower, upper); + // Materialize before removing to avoid modifying during enumeration + var toRemove = new List<(byte[] Key, int Version, byte[]? Value)>(view); + foreach (var entry in toRemove) + { + _db.Remove(entry); + } + } + + /// + /// Comparer for (byte[] Key, int Version, byte[]? Value) tuples. + /// Compares by key first using byte array comparer, then by version. Ignores Value. + /// + private sealed class EntryComparer : IComparer<(byte[] Key, int Version, byte[]? Value)> + { + public int Compare((byte[] Key, int Version, byte[]? Value) x, (byte[] Key, int Version, byte[]? Value) y) + { + int keyComparison = x.Key.AsSpan().SequenceCompareTo(y.Key); + if (keyComparison != 0) + { + return keyComparison; + } + return x.Version.CompareTo(y.Version); + } + } + + /// + /// Read-only snapshot of SnapshotableMemDb at a specific version. + /// + private sealed class MemDbSnapshot(SnapshotableMemDb db, int snapshotVersion) : IKeyValueStoreSnapshot, ISortedKeyValueStore + { + private readonly SnapshotableMemDb _db = db; + private readonly int _snapshotVersion = snapshotVersion; + + public byte[]? Get(ReadOnlySpan key, ReadFlags flags = ReadFlags.None) + { + return _db.GetAtVersion(key, _snapshotVersion); + } + + public unsafe Span GetSpan(scoped ReadOnlySpan key, ReadFlags flags = ReadFlags.None) + => Get(key, flags).AsSpan(); + + public bool KeyExists(ReadOnlySpan key) + { + return Get(key) is not null; + } + + public unsafe void DangerousReleaseMemory(in ReadOnlySpan span) { } + + public bool PreferWriteByArray => true; + + public byte[]? FirstKey + { + get + { + lock (_db._versionLock) + { + return _db.FindFirstKeyAtVersion(_snapshotVersion); + } + } + } + + public byte[]? LastKey + { + get + { + lock (_db._versionLock) + { + return _db.FindLastKeyAtVersion(_snapshotVersion); + } + } + } + + public ISortedView GetViewBetween(ReadOnlySpan firstKeyInclusive, ReadOnlySpan lastKeyExclusive) + { + return new MemDbSortedView(_db, _snapshotVersion, firstKeyInclusive.ToArray(), lastKeyExclusive.ToArray()); + } + + public void Dispose() + { + _db.OnSnapshotDisposed(_snapshotVersion); + } + } + + /// + /// Sorted view iterator for a range of keys. + /// Uses GetViewBetween for efficient range queries instead of scanning from the beginning. + /// + private sealed class MemDbSortedView(SnapshotableMemDb db, int version, byte[] firstKey, byte[] lastKey) : ISortedView + { + private readonly SnapshotableMemDb _db = db; + private readonly int _version = version; + private readonly byte[] _firstKey = firstKey; + private readonly byte[] _lastKey = lastKey; + private byte[]? _currentKey; + private byte[]? _currentValue; + + public bool StartBefore(ReadOnlySpan key) + { + byte[] keyArray = key.ToArray(); + lock (_db._versionLock) + { + var lower = (_firstKey, 0, (byte[]?)null); + var upper = (keyArray, 0, (byte[]?)null); + + if (_db._entryComparer.Compare(lower, upper) > 0) + { + // key is before _firstKey: position before start so MoveNext yields first element + _currentKey = null; + _currentValue = null; + return true; + } + + var view = _db._db.GetViewBetween(lower, upper); + + byte[]? bestKey = null; + byte[]? bestValue = null; + byte[]? candidateKey = null; + byte[]? candidateValue = null; + + foreach (var entry in view) + { + if (candidateKey is not null && candidateKey.AsSpan().SequenceCompareTo(entry.Key) != 0) + { + if (candidateValue is not null) + { + bestKey = candidateKey; + bestValue = candidateValue; + } + candidateKey = null; + candidateValue = null; + } + + if (entry.Version <= _version) + { + candidateKey = entry.Key; + candidateValue = entry.Value; + } + } + + if (candidateValue is not null) + { + bestKey = candidateKey; + bestValue = candidateValue; + } + + _currentKey = bestKey; + _currentValue = bestValue; + return bestKey is not null; + } + } + + public bool MoveNext() + { + lock (_db._versionLock) + { + var lower = _currentKey is not null + ? (_currentKey, int.MaxValue, (byte[]?)null) + : (_firstKey, 0, (byte[]?)null); + var upper = (_lastKey, 0, (byte[]?)null); + + if (_db._entryComparer.Compare(lower, upper) > 0) + return false; + + var view = _db._db.GetViewBetween(lower, upper); + + byte[]? candidateKey = null; + byte[]? candidateValue = null; + + foreach (var entry in view) + { + if (candidateKey is not null && candidateKey.AsSpan().SequenceCompareTo(entry.Key) != 0) + { + // Finished a key group + if (candidateValue is not null) + { + _currentKey = candidateKey; + _currentValue = candidateValue; + return true; + } + candidateKey = null; + candidateValue = null; + } + + if (entry.Version <= _version) + { + candidateKey = entry.Key; + candidateValue = entry.Value; + } + } + + if (candidateValue is not null) + { + _currentKey = candidateKey; + _currentValue = candidateValue; + return true; + } + + return false; + } + } + + public ReadOnlySpan CurrentKey => _currentKey ?? ReadOnlySpan.Empty; + public ReadOnlySpan CurrentValue => _currentValue ?? ReadOnlySpan.Empty; + + public void Dispose() + { + _currentKey = null; + _currentValue = null; + } + } + + /// + /// Write batch that collects all operations and commits them atomically with a single lock. + /// + private sealed class MemDbWriteBatch(SnapshotableMemDb db) : IWriteBatch + { + private readonly SnapshotableMemDb _db = db; + private readonly ArrayPoolList<(byte[] Key, byte[]? Value, WriteFlags Flags)> _operations = new(16); + private bool _disposed; + + public void Set(ReadOnlySpan key, byte[]? value, WriteFlags flags = WriteFlags.None) + { + if (_disposed) throw new ObjectDisposedException(nameof(MemDbWriteBatch)); + _operations.Add((key.ToArray(), value, flags)); + } + + public void Merge(ReadOnlySpan key, ReadOnlySpan value, WriteFlags flags = WriteFlags.None) + { + // For in-memory database, merge is the same as set + Set(key, value.ToArray(), flags); + } + + public void Clear() + { + if (_disposed) throw new ObjectDisposedException(nameof(MemDbWriteBatch)); + _operations.Clear(); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + if (_operations.Count > 0) + { + lock (_db._versionLock) + { + // Increment version once for the entire batch + _db._currentVersion++; + int batchVersion = _db._currentVersion; + + foreach ((byte[] key, byte[]? value, WriteFlags _) in _operations) + { + _db.AddOrReplace((key, batchVersion, value)); + } + + _db.WritesCount += _operations.Count; + } + } + + _operations.Dispose(); + } + } + } +} diff --git a/src/Nethermind/Nethermind.State.Flat.Test/FlatTrieVerifierTests.cs b/src/Nethermind/Nethermind.State.Flat.Test/FlatTrieVerifierTests.cs index b40c7a390a87..cbb045d12846 100644 --- a/src/Nethermind/Nethermind.State.Flat.Test/FlatTrieVerifierTests.cs +++ b/src/Nethermind/Nethermind.State.Flat.Test/FlatTrieVerifierTests.cs @@ -33,7 +33,7 @@ public class FlatTrieVerifierTests(FlatLayout layout) private RawScopedTrieStore _trieStore = null!; private StateTree _stateTree = null!; private ILogManager _logManager = null!; - private TestMemColumnsDb _columnsDb = null!; + private SnapshotableMemColumnsDb _columnsDb = null!; private IPersistence _persistence = null!; [SetUp] @@ -44,7 +44,7 @@ public void SetUp() _stateTree = new StateTree(_trieStore, LimboLogs.Instance); _logManager = LimboLogs.Instance; - _columnsDb = new TestMemColumnsDb(); + _columnsDb = new SnapshotableMemColumnsDb(); _persistence = layout == FlatLayout.PreimageFlat ? new PreimageRocksdbPersistence(_columnsDb) : new RocksDbPersistence(_columnsDb); @@ -82,7 +82,7 @@ private void WriteAccountsToFlat((Address address, Account account)[] accounts, private void WriteStorageDirectToDb(Address address, UInt256 slot, byte[] value) { - TestMemDb storageDb = (TestMemDb)_columnsDb.GetColumnDb(FlatDbColumns.Storage); + IDb storageDb = _columnsDb.GetColumnDb(FlatDbColumns.Storage); ValueHash256 addrHash; ValueHash256 slotHash; @@ -111,7 +111,7 @@ private void WriteStorageDirectToDb(Address address, UInt256 slot, byte[] value) private void CorruptAccountInFlat(Address address, Account corruptedAccount) { - TestMemDb accountDb = (TestMemDb)_columnsDb.GetColumnDb(FlatDbColumns.Account); + IDb accountDb = _columnsDb.GetColumnDb(FlatDbColumns.Account); ValueHash256 addrKey = layout == FlatLayout.PreimageFlat ? CreatePreimageAddressKey(address) : ValueKeccak.Compute(address.Bytes);