diff --git a/plugins/LevelDBStore/Plugins/Storage/Snapshot.cs b/plugins/LevelDBStore/Plugins/Storage/Snapshot.cs
index 6d31165ba..eda7f375e 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..b9453da84 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,13 @@ 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.SetFillCache(true);
+ _pointOptions.SetSnapshot(_snapshot);
}
public void Commit()
@@ -63,7 +68,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 +80,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..d0a4f6315
--- /dev/null
+++ b/tests/Neo.Plugins.Storage.Tests/SnapshotReadOptionsTest.cs
@@ -0,0 +1,220 @@
+// 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 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()
+ {
+ 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);
+ AssertLevelDbFillCache(snapshotType, scanReadOptions, false);
+ AssertLevelDbFillCache(snapshotType, pointReadOptions, true);
+ }
+
+ [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);
+ }
+
+#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)!;
+ }
+
+ 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.");
+ 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)
+ {
+ const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
+
+ foreach (var method in type.GetMethods(flags).Where(method => method.Name == methodName))
+ {
+ var iterator = method.GetCustomAttribute();
+ yield return iterator is null ? method : iterator.StateMachineType.GetMethod("MoveNext", flags)!;
+ }
+ }
+
+ private static IEnumerable<(OpCode Code, int Token)> ReadInstructions(MethodBase method)
+ {
+ var body = method.GetMethodBody()?.GetILAsByteArray();
+ Assert.IsNotNull(body);
+ using var reader = new BinaryReader(new MemoryStream(body));
+ while (reader.BaseStream.Position < body.Length)
+ {
+ 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);
+ }
+ }
+}