From 9da05140a55dd424815d33e8c030fb18561b0abf Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sun, 21 Jun 2026 07:22:53 +0800 Subject: [PATCH 1/2] Fix snapshot point read cache options --- .../LevelDBStore/Plugins/Storage/Snapshot.cs | 19 ++-- .../RocksDBStore/Plugins/Storage/Snapshot.cs | 20 ++-- .../SnapshotReadOptionsTest.cs | 102 ++++++++++++++++++ 3 files changed, 125 insertions(+), 16 deletions(-) create mode 100644 tests/Neo.Plugins.Storage.Tests/SnapshotReadOptionsTest.cs diff --git a/plugins/LevelDBStore/Plugins/Storage/Snapshot.cs b/plugins/LevelDBStore/Plugins/Storage/Snapshot.cs index 6d31165ba..5edff4002 100644 --- a/plugins/LevelDBStore/Plugins/Storage/Snapshot.cs +++ b/plugins/LevelDBStore/Plugins/Storage/Snapshot.cs @@ -25,7 +25,8 @@ internal class Snapshot : IStoreSnapshot, IEnumerable public IEnumerable<(byte[] Key, byte[] Value)> Find(byte[]? keyOrPrefix, SeekDirection direction = SeekDirection.Forward) { - return _db.Seek(_readOptions, keyOrPrefix, direction); + return _db.Seek(_scanReadOptions, keyOrPrefix, direction); } public bool Contains(byte[] key) { - return _db.Contains(_readOptions, key); + return _db.Contains(_pointReadOptions, key); } public byte[]? TryGet(byte[] key) { - return _db.Get(_readOptions, key); + return _db.Get(_pointReadOptions, key); } public bool TryGet(byte[] key, [NotNullWhen(true)] out byte[]? value) { - value = _db.Get(_readOptions, key); + value = _db.Get(_pointReadOptions, key); return value != null; } public IEnumerator> GetEnumerator() { - using var iterator = _db.CreateIterator(_readOptions); + using var iterator = _db.CreateIterator(_scanReadOptions); for (iterator.SeekToFirst(); iterator.Valid(); iterator.Next()) yield return new KeyValuePair(iterator.Key()!, iterator.Value()!); } diff --git a/plugins/RocksDBStore/Plugins/Storage/Snapshot.cs b/plugins/RocksDBStore/Plugins/Storage/Snapshot.cs index 9c3fc170e..cdfa5deb5 100644 --- a/plugins/RocksDBStore/Plugins/Storage/Snapshot.cs +++ b/plugins/RocksDBStore/Plugins/Storage/Snapshot.cs @@ -23,7 +23,8 @@ internal class Snapshot : IStoreSnapshot private readonly RocksDb _db; private readonly RocksDbSharp.Snapshot _snapshot; private readonly WriteBatch _batch; - private readonly ReadOptions _options; + private readonly ReadOptions _scanOptions; + private readonly ReadOptions _pointOptions; private readonly Lock _lock = new(); public IStore Store { get; } @@ -35,9 +36,12 @@ internal Snapshot(Store store, RocksDb db) _snapshot = db.CreateSnapshot(); _batch = new WriteBatch(); - _options = new ReadOptions(); - _options.SetFillCache(false); - _options.SetSnapshot(_snapshot); + _scanOptions = new ReadOptions(); + _scanOptions.SetFillCache(false); + _scanOptions.SetSnapshot(_snapshot); + + _pointOptions = new ReadOptions(); + _pointOptions.SetSnapshot(_snapshot); } public void Commit() @@ -63,7 +67,7 @@ public void Put(byte[] key, byte[] value) { keyOrPrefix ??= []; - using var it = _db.NewIterator(readOptions: _options); + using var it = _db.NewIterator(readOptions: _scanOptions); if (direction == SeekDirection.Forward) for (it.Seek(keyOrPrefix); it.Valid(); it.Next()) @@ -75,17 +79,17 @@ public void Put(byte[] key, byte[] value) public bool Contains(byte[] key) { - return _db.Get(key, Array.Empty(), 0, 0, readOptions: _options) >= 0; + return _db.Get(key, Array.Empty(), 0, 0, readOptions: _pointOptions) >= 0; } public byte[]? TryGet(byte[] key) { - return _db.Get(key, readOptions: _options); + return _db.Get(key, readOptions: _pointOptions); } public bool TryGet(byte[] key, [NotNullWhen(true)] out byte[]? value) { - value = _db.Get(key, readOptions: _options); + value = _db.Get(key, readOptions: _pointOptions); return value != null; } diff --git a/tests/Neo.Plugins.Storage.Tests/SnapshotReadOptionsTest.cs b/tests/Neo.Plugins.Storage.Tests/SnapshotReadOptionsTest.cs new file mode 100644 index 000000000..564c9031c --- /dev/null +++ b/tests/Neo.Plugins.Storage.Tests/SnapshotReadOptionsTest.cs @@ -0,0 +1,102 @@ +// Copyright (C) 2015-2026 The Neo Project. +// +// SnapshotReadOptionsTest.cs file belongs to the neo project and is free +// software distributed under the MIT software license, see the +// accompanying file LICENSE in the main directory of the +// repository or http://www.opensource.org/licenses/mit-license.php +// for more details. +// +// Redistribution and use in source and binary forms with or without +// modifications are permitted. + +using System.Reflection; + +namespace Neo.Plugins.Storage.Tests; + +[TestClass] +public class SnapshotReadOptionsTest +{ + [TestMethod] + public void LevelDbSnapshotUsesDedicatedReadOptionsForScanAndPointReads() + { + var snapshotType = GetSnapshotType(typeof(LevelDBStore)); + var scanReadOptions = GetField(snapshotType, "_scanReadOptions"); + var pointReadOptions = GetField(snapshotType, "_pointReadOptions"); + + AssertMethodUsesOnly(snapshotType, "Find", scanReadOptions, pointReadOptions); + AssertMethodUsesOnly(snapshotType, "GetEnumerator", scanReadOptions, pointReadOptions); + AssertMethodUsesOnly(snapshotType, "Contains", pointReadOptions, scanReadOptions); + AssertMethodUsesOnly(snapshotType, "TryGet", pointReadOptions, scanReadOptions); + } + + [TestMethod] + public void RocksDbSnapshotUsesDedicatedReadOptionsForScanAndPointReads() + { + var snapshotType = GetSnapshotType(typeof(RocksDBStore)); + var scanOptions = GetField(snapshotType, "_scanOptions"); + var pointOptions = GetField(snapshotType, "_pointOptions"); + + AssertMethodUsesOnly(snapshotType, "Find", scanOptions, pointOptions); + AssertMethodUsesOnly(snapshotType, "Contains", pointOptions, scanOptions); + AssertMethodUsesOnly(snapshotType, "TryGet", pointOptions, scanOptions); + } + + private static Type GetSnapshotType(Type storeProviderType) + { + return storeProviderType.Assembly.GetType("Neo.Plugins.Storage.Snapshot", throwOnError: true)!; + } + + private static FieldInfo GetField(Type type, string fieldName) + { + var field = type.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(field, $"{type.FullName} should declare {fieldName}."); + return field; + } + + private static void AssertMethodUsesOnly(Type type, string methodName, FieldInfo expectedField, FieldInfo unexpectedField) + { + var implementations = GetImplementations(type, methodName).ToArray(); + Assert.IsNotEmpty(implementations, $"{type.FullName}.{methodName} should exist."); + Assert.IsTrue( + implementations.Any(method => UsesField(method, expectedField)), + $"{type.FullName}.{methodName} should use {expectedField.Name}."); + Assert.IsFalse( + implementations.Any(method => UsesField(method, unexpectedField)), + $"{type.FullName}.{methodName} should not use {unexpectedField.Name}."); + } + + private static IEnumerable GetImplementations(Type type, string methodName) + { + const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + foreach (var method in type.GetMethods(flags).Where(method => method.Name == methodName)) + yield return method; + + foreach (var nestedType in type.GetNestedTypes(BindingFlags.NonPublic)) + { + if (!nestedType.Name.Contains($"<{methodName}>")) continue; + + var moveNext = nestedType.GetMethod("MoveNext", flags); + if (moveNext != null) + yield return moveNext; + } + } + + private static bool UsesField(MethodBase method, FieldInfo field) + { + var body = method.GetMethodBody()?.GetILAsByteArray(); + if (body is null) return false; + + var token = BitConverter.GetBytes(field.MetadataToken); + for (var i = 0; i <= body.Length - token.Length; i++) + { + if (body[i] == token[0] + && body[i + 1] == token[1] + && body[i + 2] == token[2] + && body[i + 3] == token[3]) + return true; + } + + return false; + } +} From 5f04ce6a1bbfb9c50e80993267d13502ba495514 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Thu, 10 Sep 2026 19:38:16 +0800 Subject: [PATCH 2/2] Verify snapshot cache settings and every point-read overload --- .../LevelDBStore/Plugins/Storage/Snapshot.cs | 2 +- .../RocksDBStore/Plugins/Storage/Snapshot.cs | 1 + .../SnapshotReadOptionsTest.cs | 170 +++++++++++++++--- 3 files changed, 146 insertions(+), 27 deletions(-) diff --git a/plugins/LevelDBStore/Plugins/Storage/Snapshot.cs b/plugins/LevelDBStore/Plugins/Storage/Snapshot.cs index 5edff4002..eda7f375e 100644 --- a/plugins/LevelDBStore/Plugins/Storage/Snapshot.cs +++ b/plugins/LevelDBStore/Plugins/Storage/Snapshot.cs @@ -38,7 +38,7 @@ internal Snapshot(Store store, DB db) _db = db; _snapshot = db.CreateSnapshot(); _scanReadOptions = new ReadOptions { FillCache = false, Snapshot = _snapshot }; - _pointReadOptions = new ReadOptions { Snapshot = _snapshot }; + _pointReadOptions = new ReadOptions { FillCache = true, Snapshot = _snapshot }; _batch = new WriteBatch(); } diff --git a/plugins/RocksDBStore/Plugins/Storage/Snapshot.cs b/plugins/RocksDBStore/Plugins/Storage/Snapshot.cs index cdfa5deb5..b9453da84 100644 --- a/plugins/RocksDBStore/Plugins/Storage/Snapshot.cs +++ b/plugins/RocksDBStore/Plugins/Storage/Snapshot.cs @@ -41,6 +41,7 @@ internal Snapshot(Store store, RocksDb db) _scanOptions.SetSnapshot(_snapshot); _pointOptions = new ReadOptions(); + _pointOptions.SetFillCache(true); _pointOptions.SetSnapshot(_snapshot); } diff --git a/tests/Neo.Plugins.Storage.Tests/SnapshotReadOptionsTest.cs b/tests/Neo.Plugins.Storage.Tests/SnapshotReadOptionsTest.cs index 564c9031c..d0a4f6315 100644 --- a/tests/Neo.Plugins.Storage.Tests/SnapshotReadOptionsTest.cs +++ b/tests/Neo.Plugins.Storage.Tests/SnapshotReadOptionsTest.cs @@ -9,13 +9,22 @@ // Redistribution and use in source and binary forms with or without // modifications are permitted. +using Neo.Persistence; using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.CompilerServices; namespace Neo.Plugins.Storage.Tests; [TestClass] public class SnapshotReadOptionsTest { + private static readonly Dictionary OpCodesByValue = typeof(OpCodes) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(field => field.FieldType == typeof(OpCode)) + .Select(field => (OpCode)field.GetValue(null)!) + .ToDictionary(opcode => opcode.Value); + [TestMethod] public void LevelDbSnapshotUsesDedicatedReadOptionsForScanAndPointReads() { @@ -27,6 +36,8 @@ public void LevelDbSnapshotUsesDedicatedReadOptionsForScanAndPointReads() AssertMethodUsesOnly(snapshotType, "GetEnumerator", scanReadOptions, pointReadOptions); AssertMethodUsesOnly(snapshotType, "Contains", pointReadOptions, scanReadOptions); AssertMethodUsesOnly(snapshotType, "TryGet", pointReadOptions, scanReadOptions); + AssertLevelDbFillCache(snapshotType, scanReadOptions, false); + AssertLevelDbFillCache(snapshotType, pointReadOptions, true); } [TestMethod] @@ -41,6 +52,99 @@ public void RocksDbSnapshotUsesDedicatedReadOptionsForScanAndPointReads() AssertMethodUsesOnly(snapshotType, "TryGet", pointOptions, scanOptions); } +#pragma warning disable CS0618 // Exercise both supported TryGet overloads. + [TestMethod] + [DataRow("LevelDBStore")] + [DataRow("RocksDBStore")] + public void PointAndScanReadsKeepTheSameSnapshot(string providerName) + { + string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + try + { + var provider = StoreFactory.GetStoreProvider(providerName); + Assert.IsNotNull(provider); + using var store = provider.GetStore(path); + store.Put([1], [10]); + store.Put([2], []); + using var snapshot = store.GetSnapshot(); + + if (providerName == "RocksDBStore") + { + AssertRocksDbFillCache(snapshot, "_scanOptions", false); + AssertRocksDbFillCache(snapshot, "_pointOptions", true); + } + + store.Put([1], [20]); + store.Delete([2]); + store.Put([3], [30]); + + AssertSnapshotValue(snapshot, [1], [10]); + AssertSnapshotValue(snapshot, [2], []); + Assert.IsFalse(snapshot.Contains([3])); + Assert.IsNull(snapshot.TryGet([3])); + Assert.IsFalse(snapshot.TryGet([3], out var missing)); + Assert.IsNull(missing); + + var forward = snapshot.Find(null, SeekDirection.Forward).ToArray(); + Assert.HasCount(2, forward); + CollectionAssert.AreEqual(new byte[] { 1 }, forward[0].Key); + CollectionAssert.AreEqual(new byte[] { 10 }, forward[0].Value); + CollectionAssert.AreEqual(new byte[] { 2 }, forward[1].Key); + Assert.IsEmpty(forward[1].Value); + var backward = snapshot.Find([2], SeekDirection.Backward).ToArray(); + Assert.HasCount(2, backward); + CollectionAssert.AreEqual(forward[1].Key, backward[0].Key); + CollectionAssert.AreEqual(forward[1].Value, backward[0].Value); + CollectionAssert.AreEqual(forward[0].Key, backward[1].Key); + CollectionAssert.AreEqual(forward[0].Value, backward[1].Value); + + CollectionAssert.AreEqual(new byte[] { 20 }, store.TryGet([1])); + Assert.IsFalse(store.Contains([2])); + Assert.IsTrue(store.Contains([3])); + } + finally + { + if (Directory.Exists(path)) Directory.Delete(path, true); + } + } + + private static void AssertSnapshotValue(IStoreSnapshot snapshot, byte[] key, byte[] expected) + { + Assert.IsTrue(snapshot.Contains(key)); + CollectionAssert.AreEqual(expected, snapshot.TryGet(key)); + Assert.IsTrue(snapshot.TryGet(key, out var value)); + CollectionAssert.AreEqual(expected, value); + } +#pragma warning restore CS0618 + + private static void AssertRocksDbFillCache(IStoreSnapshot snapshot, string fieldName, bool expected) + { + var options = (RocksDbSharp.ReadOptions)GetField(snapshot.GetType(), fieldName).GetValue(snapshot)!; + Assert.AreEqual(expected ? (byte)1 : (byte)0, + RocksDbSharp.Native.Instance.rocksdb_readoptions_get_fill_cache(options.Handle), fieldName); + GC.KeepAlive(options); + } + + private static void AssertLevelDbFillCache(Type type, FieldInfo field, bool expected) + { + // LevelDB exposes no native getter. Inspect the exact initializer assigned + // to this field, including the setter's boolean argument. + var constructor = type.GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic).Single(); + var instructions = ReadInstructions(constructor).ToArray(); + int storeIndex = Array.FindIndex(instructions, instruction => + instruction.Code == OpCodes.Stfld && instruction.Token == field.MetadataToken); + Assert.IsTrue(storeIndex >= 0, $"Missing initialization for {field.Name}."); + int createIndex = Array.FindLastIndex(instructions, storeIndex, instruction => instruction.Code == OpCodes.Newobj); + Assert.IsTrue(createIndex >= 0); + var setter = typeof(Neo.IO.Data.LevelDB.ReadOptions).GetProperty("FillCache")!.SetMethod; + var calls = Enumerable.Range(createIndex + 1, storeIndex - createIndex - 1) + .Where(index => (instructions[index].Code == OpCodes.Call || instructions[index].Code == OpCodes.Callvirt) + && constructor.Module.ResolveMethod(instructions[index].Token) == setter).ToArray(); + Assert.HasCount(1, calls, $"{field.Name} must explicitly configure FillCache."); + Assert.AreEqual(expected ? OpCodes.Ldc_I4_1 : OpCodes.Ldc_I4_0, + instructions[calls[0] - 1].Code, $"Incorrect FillCache for {field.Name}."); + } + private static Type GetSnapshotType(Type storeProviderType) { return storeProviderType.Assembly.GetType("Neo.Plugins.Storage.Snapshot", throwOnError: true)!; @@ -57,12 +161,17 @@ private static void AssertMethodUsesOnly(Type type, string methodName, FieldInfo { var implementations = GetImplementations(type, methodName).ToArray(); Assert.IsNotEmpty(implementations, $"{type.FullName}.{methodName} should exist."); - Assert.IsTrue( - implementations.Any(method => UsesField(method, expectedField)), - $"{type.FullName}.{methodName} should use {expectedField.Name}."); - Assert.IsFalse( - implementations.Any(method => UsesField(method, unexpectedField)), - $"{type.FullName}.{methodName} should not use {unexpectedField.Name}."); + foreach (var implementation in implementations) + { + var instructions = ReadInstructions(implementation).ToArray(); + Assert.IsTrue(instructions.Any(instruction => instruction.Code == OpCodes.Ldfld + && instruction.Token == expectedField.MetadataToken), + $"{implementation} should load {expectedField.Name}."); + Assert.IsFalse(instructions.Any(instruction => + (instruction.Code == OpCodes.Ldfld || instruction.Code == OpCodes.Ldflda) + && instruction.Token == unexpectedField.MetadataToken), + $"{implementation} should not load {unexpectedField.Name}."); + } } private static IEnumerable GetImplementations(Type type, string methodName) @@ -70,33 +179,42 @@ private static IEnumerable GetImplementations(Type type, string meth const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; foreach (var method in type.GetMethods(flags).Where(method => method.Name == methodName)) - yield return method; - - foreach (var nestedType in type.GetNestedTypes(BindingFlags.NonPublic)) { - if (!nestedType.Name.Contains($"<{methodName}>")) continue; - - var moveNext = nestedType.GetMethod("MoveNext", flags); - if (moveNext != null) - yield return moveNext; + var iterator = method.GetCustomAttribute(); + yield return iterator is null ? method : iterator.StateMachineType.GetMethod("MoveNext", flags)!; } } - private static bool UsesField(MethodBase method, FieldInfo field) + private static IEnumerable<(OpCode Code, int Token)> ReadInstructions(MethodBase method) { var body = method.GetMethodBody()?.GetILAsByteArray(); - if (body is null) return false; - - var token = BitConverter.GetBytes(field.MetadataToken); - for (var i = 0; i <= body.Length - token.Length; i++) + Assert.IsNotNull(body); + using var reader = new BinaryReader(new MemoryStream(body)); + while (reader.BaseStream.Position < body.Length) { - if (body[i] == token[0] - && body[i + 1] == token[1] - && body[i + 2] == token[2] - && body[i + 3] == token[3]) - return true; + byte first = reader.ReadByte(); + short value = first == 0xfe ? unchecked((short)(0xfe00 | reader.ReadByte())) : first; + var code = OpCodesByValue[value]; + int token = 0; + if (code.OperandType is OperandType.InlineField or OperandType.InlineMethod) + token = reader.ReadInt32(); + else + { + int size = code.OperandType switch + { + OperandType.InlineNone => 0, + OperandType.ShortInlineBrTarget or OperandType.ShortInlineI or OperandType.ShortInlineVar => 1, + OperandType.InlineVar => 2, + OperandType.InlineI or OperandType.InlineBrTarget or OperandType.InlineSig + or OperandType.InlineString or OperandType.InlineTok or OperandType.InlineType + or OperandType.ShortInlineR => 4, + OperandType.InlineI8 or OperandType.InlineR => 8, + OperandType.InlineSwitch => reader.ReadInt32() * 4, + _ => throw new InvalidOperationException($"Unexpected IL operand: {code.OperandType}") + }; + reader.BaseStream.Position += size; + } + yield return (code, token); } - - return false; } }