Skip to content

Commit a9aa8b7

Browse files
badrishcCopilot
andauthored
Avoid tempKv-driven scan iteration in Garnet callers (#1797)
* Avoid tempKv-driven scan iteration in Garnet callers Replace the four Garnet/Cluster callsites of `Session.Iterate(...)` (which allocates a parallel `tempKv` proportional to the keyspace and copies every in-range record into it) with the lookup-based `Session.IterateLookupSnapshot`, a new Tsavorite ClientSession method that captures `Log.TailAddress` once and pins both `untilAddress` and `maxAddress` to it. This preserves the prior snapshot semantics (each unique live key emitted exactly once based on its latest in-range version, with the in-flight RCU race window correctly handled) without the O(N) `tempKv` memory cost. Converted callsites: - `StoreWrapper.HasKeysInSlots`: collapses two pull-iterations (string + object) into a single push scan over the unified context with a cached early-exit callback (`hasKeysInSlotsFuncs` field on StoreWrapper). - `StorageSession.DBKeys` (KEYS command): switched the existing cached `unifiedStoreDbKeysFuncs` callback from `Session.Iterate(...)` to `IterateLookupSnapshot`. - `ClusterManagerSlotState.DeleteKeysInSlots`: refactored into a new `StorageSession.DeleteSlotKeys(slots)` method on the unified context with a cached `deleteSlotKeysFuncs` callback. Cluster static helper now one-line delegate. Preserves prior behavior — deletes every matched live key including expired-but-not-yet-tombstoned ones (no expiry filter). - `VectorManager.Cleanup`: switched from push `Session.Iterate(ref callbacks)` to `IterateLookupSnapshot`. API changes: - New `ClientSession.IterateLookupSnapshot<TScanFunctions>(ref scanFns, bool includeTombstones = false)` on Tsavorite. Wraps `ScanCursor` with the snapshot pattern `untilAddress = maxAddress = capturedTail`. - Removed parameterless pull `IGarnetApi.IterateStore()` overload (the only Garnet API method that exposed the tempKv pull iterator). The push overload `IterateStore<TScanFunctions>(...)` is unchanged. **Breaking change** for out-of-tree extensions that bind to `IGarnetApi`. - Removed deprecated `LogCompactionType.ShiftForced` enum value plus its back-compat shim. `ShiftForced` was deprecated in PR #482 (June 2024, almost two years ago); long-deprecated values are removed in v2. Migration: `--compaction-type Shift --compaction-force-delete true`. **Breaking change** in config surface — `CommandLineParser` produces a clear startup error listing the now-valid values `None | Shift | Lookup | Scan`. - Reordered `LogCompactionType` enum members + help-text to `None | Shift | Lookup | Scan` so users see the recommended `Lookup` before the not-recommended `Scan`. Added a startup `LogWarning` when `CompactionType = Scan` is selected to flag the temporary-memory-spike cost. `Scan` itself still works as before — no behavior change. All callbacks follow the existing cached-field pattern (`xxxFuncs ??= new(); xxxFuncs.Initialize(...)`) used by `unifiedStoreDbKeysFuncs`, `unifiedStoreDbScanFuncs`, `unifiedStoreDbSizeFuncs`, `expiredKeyDeletionScanFuncs` — steady-state per-call callback allocations are zero. Net per-call cost on the four hot paths drops from `{1-2 sessions + 1-2 TsavoriteKV instances + 2 iterators + O(N) record copies}` to `{1 session, 0 callback alloc}`. Tests added: - `SpanByteIterateLookupSnapshotBasicCorrectness` (libs/storage/Tsavorite/cs/test/SpanByteIterationTests.cs): inserts 200 keys, RCUs all of them, asserts `IterateLookupSnapshot` emits each unique live key exactly once with the post-RCU value; asserts the snapshot is stable across calls. - `ClusterDelKeysInSlotRemovesStringAndObjectKeys` (test/Garnet.test.cluster/ClusterMigrateTests.cs): exercises `CLUSTER DELKEYSINSLOT` over both raw-string and collection-object keys via the unified scan; asserts both are deleted. - `SeKeysNoDuplicatesUnderRcu` (test/Garnet.test/RespScanCommandsTests.cs): runs concurrent SET RCUs while issuing KEYS in a loop, asserts each key is returned exactly once across multiple rounds. Documentation: - `libs/host/defaults.conf` and `website/docs/getting-started/configuration.md` updated to use the new compaction-type ordering and call out `Scan` as NOT RECOMMENDED. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback - ClusterDelKeysInSlotRemovesStringAndObjectKeys: change [Order(22)] to [Order(28)] to avoid collision with the existing #if DEBUG-gated Order(22) test in the same fixture (ordered, non-parallelizable execution would otherwise be ambiguous). - SeKeysNoDuplicatesUnderRcu: assert that the background writer actually stops within the wait window (raised from 5s to 30s) and surface any exception the writer threw, so a hung/faulted writer can't leak a connection or silently mask test failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Strengthen ClusterDelKeysInSlotRemovesStringAndObjectKeys: add control slot The previous version of the test only verified that DELKEYSINSLOT removed the targeted keys; it did not verify that keys in OTHER slots are preserved. A bug in the slot-set filter inside DeleteSlotKeysScan.Reader (e.g., a wrong `slots.Contains(...)` check or a hash-slot computation regression) would have collateral-deleted unrelated keys without this test catching it. Now the test: - Picks two distinct slots owned by node 0 (delSlot, keepSlot). - Inserts one string key + one object key in EACH slot (4 keys total). - Runs CLUSTER DELKEYSINSLOT on delSlot only. - Asserts delSlot is empty and both delSlot keys are gone via GET/EXISTS. - Asserts keepSlot still has both keys with original values (string GET returns 'raw-keep'; SCARD on the object key returns 3). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7758c68 commit a9aa8b7

15 files changed

Lines changed: 407 additions & 75 deletions

File tree

libs/cluster/Server/ClusterManagerSlotState.cs

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@
55
using System.Collections.Generic;
66
using System.Text;
77
using System.Threading;
8-
using Garnet.common;
98
using Microsoft.Extensions.Logging;
10-
using Tsavorite.core;
119

1210
namespace Garnet.cluster
1311
{
@@ -467,15 +465,6 @@ public void TryResetSlotState(HashSet<int> slots)
467465
/// <param name="basicGarnetApi"></param>
468466
/// <param name="slots">Slot list</param>
469467
public static void DeleteKeysInSlots(BasicGarnetApi basicGarnetApi, HashSet<int> slots)
470-
{
471-
using var iter = basicGarnetApi.IterateStore();
472-
while (iter.GetNext())
473-
{
474-
var key = iter.Key;
475-
var s = HashSlotUtils.HashSlot(key);
476-
if (slots.Contains(s))
477-
_ = basicGarnetApi.DELETE(PinnedSpanByte.FromPinnedSpan(key));
478-
}
479-
}
468+
=> basicGarnetApi.DeleteSlotKeys(slots);
480469
}
481470
}

libs/host/Configuration/Options.cs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ internal sealed class Options : ICloneable
236236
[Option("expired-object-collection-freq", Required = false, HelpText = "Frequency in seconds for the background task to perform object collection which removes expired members within object from memory. 0 = disabled. Use the HCOLLECT and ZCOLLECT API to collect on-demand.")]
237237
public int ExpiredObjectCollectionFrequencySecs { get; set; }
238238

239-
[Option("compaction-type", Required = false, HelpText = "Hybrid log compaction type. Value options: None - no compaction, Shift - shift begin address without compaction (data loss), Scan - scan old pages and move live records to tail (no data loss), Lookup - lookup each record in compaction range, for record liveness checking using hash chain (no data loss)")]
239+
[Option("compaction-type", Required = false, HelpText = "Hybrid log compaction type. Value options: None - no compaction, Shift - shift begin address without compaction (data loss), Lookup - lookup each record in compaction range, for record liveness checking using hash chain (no data loss; recommended for production use), Scan - scan old pages and move live records to tail (no data loss; NOT RECOMMENDED - builds a temporary parallel KV index proportional to the keyspace, causing significant transient memory use; prefer Lookup)")]
240240
public LogCompactionType CompactionType { get; set; }
241241

242242
[OptionValidation]
@@ -773,12 +773,11 @@ endpoint is IPEndPoint listenEp && clusterAnnounceEndpoint[0] is IPEndPoint anno
773773
throw new Exception("Revivification cannot specify RevivifiableFraction without specifying bins.");
774774
}
775775

776-
// For backwards compatibility
777-
if (CompactionType == LogCompactionType.ShiftForced)
776+
// Warn users who explicitly opt into Scan compaction about the memory-spike cost.
777+
// Scan builds a temporary parallel KV index proportional to the keyspace; Lookup is the recommended alternative.
778+
if (CompactionType == LogCompactionType.Scan)
778779
{
779-
logger?.LogWarning("Compaction type ShiftForced is deprecated. Use Shift instead along with CompactionForceDelete.");
780-
CompactionType = LogCompactionType.Shift;
781-
CompactionForceDelete = true;
780+
logger?.LogWarning("Compaction type Scan builds a temporary parallel KV index proportional to the keyspace, causing significant transient memory use. Use Lookup instead unless you have a specific reason for Scan.");
782781
}
783782

784783
if (SlowLogThreshold > 0)

libs/host/defaults.conf

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,8 @@
162162
/* Hybrid log compaction type. Value options: */
163163
/* None - no compaction */
164164
/* Shift - shift begin address without compaction (data loss) */
165-
/* Scan - scan old pages and move live records to tail (no data loss) */
166-
/* Lookup - lookup each record in compaction range, for record liveness checking using hash chain (no data loss) */
165+
/* Lookup - lookup each record in compaction range, for record liveness checking using hash chain (no data loss). Recommended for production use. */
166+
/* Scan - scan old pages and move live records to tail (no data loss). NOT RECOMMENDED: this strategy builds a temporary parallel KV index proportional to the keyspace, causing significant transient memory use. Prefer Lookup. */
167167
"CompactionType" : "None",
168168

169169
/* Forcefully delete the inactive segments immediately after the compaction strategy (type) is applied. */

libs/server/API/GarnetApi.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -354,8 +354,8 @@ public readonly bool IterateStore<TScanFunctions>(ref TScanFunctions scanFunctio
354354
=> storageSession.IterateStore(ref scanFunctions, ref cursor, untilAddress, maxAddress: maxAddress, includeTombstones: includeTombstones);
355355

356356
/// <inheritdoc />
357-
public readonly ITsavoriteScanIterator IterateStore()
358-
=> storageSession.IterateStore();
357+
public readonly void DeleteSlotKeys(HashSet<int> slots)
358+
=> storageSession.DeleteSlotKeys(slots);
359359

360360
#endregion
361361

libs/server/API/GarnetWatchApi.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -602,8 +602,8 @@ public bool IterateStore<TScanFunctions>(ref TScanFunctions scanFunctions, ref l
602602
=> garnetApi.IterateStore(ref scanFunctions, ref cursor, untilAddress, maxAddress: maxAddress, includeTombstones: includeTombstones);
603603

604604
/// <inheritdoc />
605-
public ITsavoriteScanIterator IterateStore()
606-
=> garnetApi.IterateStore();
605+
public void DeleteSlotKeys(HashSet<int> slots)
606+
=> garnetApi.DeleteSlotKeys(slots);
607607

608608
#endregion
609609

libs/server/API/IGarnetApi.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2077,10 +2077,12 @@ public bool IterateStore<TScanFunctions>(ref TScanFunctions scanFunctions, ref l
20772077
where TScanFunctions : IScanIteratorFunctions;
20782078

20792079
/// <summary>
2080-
/// Iterate the contents of the store (pull based)
2080+
/// Delete every live key whose hash slot is in <paramref name="slots"/>.
2081+
/// Uses lookup-based iteration (no <c>tempKv</c>); preserves pull-iterator semantics —
2082+
/// every matched live key is deleted, including expired-but-not-yet-tombstoned records.
20812083
/// </summary>
2082-
/// <returns></returns>
2083-
public ITsavoriteScanIterator IterateStore();
2084+
/// <param name="slots">Hash slot set to delete.</param>
2085+
public void DeleteSlotKeys(HashSet<int> slots);
20842086

20852087
#endregion
20862088

libs/server/LogCompactionType.cs

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,18 @@ public enum LogCompactionType
2020
Shift,
2121

2222
/// <summary>
23-
/// Shift the begin address without compacting active records (data loss)
24-
/// Immediately deletes files - do not use if you plan to recover after failure.
23+
/// Lookup each record in compaction range, for record liveness checking using hash chain - no data loss
24+
/// (to delete actual data files from disk, take a checkpoint after compaction).
25+
/// Recommended for production use.
2526
/// </summary>
26-
ShiftForced,
27+
Lookup,
2728

2829
/// <summary>
2930
/// Scan from untilAddress to read-only address to check for record liveness checking - no data loss
30-
/// (to delete actual data files from disk, take a checkpoint after compaction)
31+
/// (to delete actual data files from disk, take a checkpoint after compaction).
32+
/// NOT RECOMMENDED: this strategy builds a temporary parallel KV index proportional to the keyspace,
33+
/// causing significant transient memory use. Prefer Lookup.
3134
/// </summary>
3235
Scan,
33-
34-
/// <summary>
35-
/// Lookup each record in compaction range, for record liveness checking using hash chain - no data loss
36-
/// (to delete actual data files from disk, take a checkpoint after compaction)
37-
/// </summary>
38-
Lookup,
3936
}
4037
}

libs/server/Resp/Vector/VectorManager.Cleanup.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,12 @@ private async Task RunCleanupTaskAsync()
143143
ref var scanCtx = ref cleanupSession.storageSession.stringBasicContext;
144144
ref var delCtx = ref cleanupSession.storageSession.vectorBasicContext;
145145

146-
// Scan whole keyspace (sigh) and remove any associated data
147-
//
148-
// We don't really have a choice here, just do it
149-
_ = scanCtx.Session.Iterate(ref callbacks);
146+
// Scan whole keyspace and remove any associated data using a snapshot
147+
// lookup-based push iterator. This avoids building a parallel tempKv (which
148+
// would cost memory proportional to the keyspace) — IterateLookupSnapshot
149+
// walks the log and uses hash-chain liveness checks bounded to the snapshot's
150+
// TailAddress, so concurrent RCUs don't drop records.
151+
_ = scanCtx.Session.IterateLookupSnapshot(ref callbacks);
150152

151153
// Key is mostly ignored when deleting from InProgressDeletes
152154
// So we just need a non-empty one to use with the context

libs/server/Storage/Session/Common/ArrayKeyIterationFunctions.cs

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System;
55
using System.Collections.Generic;
6+
using Garnet.common;
67
using Tsavorite.core;
78

89
namespace Garnet.server
@@ -25,6 +26,9 @@ sealed partial class StorageSession : IDisposable
2526
// Iterator for KEYS command
2627
private ArrayKeyIterationFunctions.UnifiedStoreGetDBKeys unifiedStoreDbKeysFuncs;
2728

29+
// Iterator for cluster slot deletion (DeleteSlotKeys)
30+
private ArrayKeyIterationFunctions.DeleteSlotKeysScan deleteSlotKeysFuncs;
31+
2832
long lastScanCursor;
2933
List<byte[]> Keys;
3034

@@ -119,10 +123,22 @@ internal bool IterateStore<TScanFunctions>(ref TScanFunctions scanFunctions, ref
119123
=> stringBasicContext.Session.IterateLookup(ref scanFunctions, ref cursor, untilAddress, validateCursor: validateCursor, maxAddress: maxAddress, resetCursor: false, includeTombstones: includeTombstones);
120124

121125
/// <summary>
122-
/// Iterate the contents of the store (pull based)
126+
/// Delete every live key whose hash slot is in <paramref name="slots"/>.
127+
/// Uses lookup-based push iteration over the unified context (no <c>tempKv</c>) with
128+
/// snapshot semantics: every key live at scan-start whose slot matches is deleted.
129+
/// Preserves the previous pull-iterator semantics — every matched live key is deleted,
130+
/// including expired-but-not-yet-tombstoned records (no expiry filter).
123131
/// </summary>
124-
internal ITsavoriteScanIterator IterateStore()
125-
=> stringBasicContext.Session.Iterate();
132+
/// <param name="slots">Hash slot set to delete.</param>
133+
internal void DeleteSlotKeys(HashSet<int> slots)
134+
{
135+
deleteSlotKeysFuncs ??= new();
136+
deleteSlotKeysFuncs.Initialize(this, slots);
137+
138+
// Snapshot semantics: ensures records RCU'd above TailAddress during the scan are
139+
// not silently suppressed (which would leave keys behind).
140+
_ = unifiedBasicContext.Session.IterateLookupSnapshot(ref deleteSlotKeysFuncs);
141+
}
126142

127143
/// <summary>
128144
/// Get a list of the keys in the store and object store when using pattern
@@ -137,7 +153,11 @@ internal unsafe List<byte[]> DBKeys(PinnedSpanByte pattern)
137153

138154
unifiedStoreDbKeysFuncs ??= IsConsistentReadSession ? new ConsistentUnifiedStoreGetDBKeys(readSessionState) : new UnifiedStoreGetDBKeys();
139155
unifiedStoreDbKeysFuncs.Initialize(Keys, allKeys ? null : pattern.ToPointer(), pattern.Length);
140-
unifiedBasicContext.Session.Iterate(ref unifiedStoreDbKeysFuncs);
156+
157+
// Snapshot semantics: emit each unique live key exactly once based on its latest in-range
158+
// version at scan-start, even if a concurrent RCU moves the key's tail above the captured
159+
// TailAddress during the scan. Equivalent to the legacy tempKv-backed Iterate(...).
160+
_ = unifiedBasicContext.Session.IterateLookupSnapshot(ref unifiedStoreDbKeysFuncs);
141161

142162
return Keys;
143163
}
@@ -321,6 +341,75 @@ public bool Reader<TSourceLogRecord>(in TSourceLogRecord logRecord, RecordMetada
321341
public void OnStop(bool completed, long numberOfRecords) { }
322342
public void OnException(Exception exception, long numberOfRecords) { }
323343
}
344+
345+
/// <summary>
346+
/// Lookup-based push iterator callback that deletes every live key whose hash slot is
347+
/// in the supplied set. Cached on <see cref="StorageSession"/> via the
348+
/// <c>deleteSlotKeysFuncs</c> field; re-initialised per call.
349+
/// IMPORTANT: matches the previous pull-iterator semantics — every matched live key is
350+
/// deleted, including expired-but-not-yet-tombstoned records (no expiry filter).
351+
/// </summary>
352+
internal sealed class DeleteSlotKeysScan : IScanIteratorFunctions
353+
{
354+
private StorageSession storageSession;
355+
private HashSet<int> slots;
356+
357+
internal void Initialize(StorageSession storageSession, HashSet<int> slots)
358+
{
359+
this.storageSession = storageSession;
360+
this.slots = slots;
361+
}
362+
363+
public bool Reader<TSourceLogRecord>(in TSourceLogRecord logRecord, RecordMetadata recordMetadata, long numberOfRecords, out CursorRecordResult cursorRecordResult)
364+
where TSourceLogRecord : ISourceLogRecord
365+
{
366+
cursorRecordResult = CursorRecordResult.Skip;
367+
if (slots.Contains(HashSlotUtils.HashSlot(logRecord.Key)))
368+
{
369+
_ = storageSession.DELETE(PinnedSpanByte.FromPinnedSpan(logRecord.Key), ref storageSession.unifiedBasicContext);
370+
cursorRecordResult = CursorRecordResult.Accept;
371+
}
372+
return true;
373+
}
374+
375+
public bool OnStart(long beginAddress, long endAddress) => true;
376+
public void OnStop(bool completed, long numberOfRecords) { }
377+
public void OnException(Exception exception, long numberOfRecords) { }
378+
}
379+
380+
/// <summary>
381+
/// Lookup-based push iterator callback that returns true from <see cref="Found"/> if
382+
/// any live record's key hashes to a slot in the supplied set. Stops scanning on the
383+
/// first match by returning <c>false</c> from <see cref="Reader"/>. Cached on
384+
/// <see cref="StoreWrapper"/> via the <c>hasKeysInSlotsFuncs</c> field; re-initialised per call.
385+
/// </summary>
386+
internal sealed class HasKeysInSlotsScan : IScanIteratorFunctions
387+
{
388+
private List<int> slots;
389+
internal bool Found;
390+
391+
internal void Initialize(List<int> slots)
392+
{
393+
this.slots = slots;
394+
Found = false;
395+
}
396+
397+
public bool Reader<TSourceLogRecord>(in TSourceLogRecord logRecord, RecordMetadata recordMetadata, long numberOfRecords, out CursorRecordResult cursorRecordResult)
398+
where TSourceLogRecord : ISourceLogRecord
399+
{
400+
cursorRecordResult = CursorRecordResult.Skip;
401+
if (slots.Contains(HashSlotUtils.HashSlot(logRecord.Key)))
402+
{
403+
Found = true;
404+
return false; // early exit
405+
}
406+
return true;
407+
}
408+
409+
public bool OnStart(long beginAddress, long endAddress) => true;
410+
public void OnStop(bool completed, long numberOfRecords) { }
411+
public void OnException(Exception exception, long numberOfRecords) { }
412+
}
324413
}
325414
}
326415
}

libs/server/StoreWrapper.cs

Lines changed: 19 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -805,40 +805,30 @@ internal void Start()
805805

806806
private void StartSizeTrackers() => databaseManager.StartSizeTrackers(ctsCommit.Token);
807807

808+
/// <summary>
809+
/// Cached callback for <see cref="HasKeysInSlots"/>. Allocated once per StoreWrapper, reused
810+
/// across calls (cluster control-plane operations are serialised by the cluster manager).
811+
/// </summary>
812+
private StorageSession.ArrayKeyIterationFunctions.HasKeysInSlotsScan hasKeysInSlotsFuncs;
813+
808814
public bool HasKeysInSlots(List<int> slots)
809815
{
810-
if (slots.Count > 0)
811-
{
812-
bool hasKeyInSlots = false;
813-
{
814-
using var iter = store.Iterate<IGarnetObject, IGarnetObject, Empty, SimpleGarnetObjectSessionFunctions>(new SimpleGarnetObjectSessionFunctions()); // TODO replace with Push iterator
815-
while (!hasKeyInSlots && iter.GetNext())
816-
{
817-
var key = iter.Key;
818-
ushort hashSlotForKey = HashSlotUtils.HashSlot(key);
819-
if (slots.Contains(hashSlotForKey))
820-
hasKeyInSlots = true;
821-
}
822-
}
816+
if (slots.Count == 0) return false;
823817

824-
if (!hasKeyInSlots && !serverOptions.DisableObjects)
825-
{
826-
var functionsState = databaseManager.CreateFunctionsState();
827-
var objStoreFunctions = new ObjectSessionFunctions(functionsState);
828-
using var objectStoreSession = store?.NewSession<FixedSpanByteKey, ObjectInput, ObjectOutput, long, ObjectSessionFunctions>(objStoreFunctions);
829-
using var iter = objectStoreSession.Iterate();
830-
while (!hasKeyInSlots && iter.GetNext())
831-
{
832-
ushort hashSlotForKey = HashSlotUtils.HashSlot(iter.Key);
833-
if (slots.Contains(hashSlotForKey))
834-
hasKeyInSlots = true;
835-
}
836-
}
818+
// Single lookup-based push scan over the unified context. Since the migration to a
819+
// single store, the unified context surfaces every record (string + object) so one
820+
// scan suffices. No tempKv is allocated. IterateLookupSnapshot pins both untilAddress
821+
// and maxAddress to a single captured TailAddress so the scan is a consistent
822+
// point-in-time view (records RCU'd above the snapshot don't suppress in-range ones).
823+
var functionsState = databaseManager.CreateFunctionsState();
824+
var unifiedFunctions = new UnifiedSessionFunctions(functionsState);
825+
using var unifiedSession = store.NewSession<FixedSpanByteKey, UnifiedInput, UnifiedOutput, long, UnifiedSessionFunctions>(unifiedFunctions);
837826

838-
return hasKeyInSlots;
839-
}
827+
hasKeysInSlotsFuncs ??= new StorageSession.ArrayKeyIterationFunctions.HasKeysInSlotsScan();
828+
hasKeysInSlotsFuncs.Initialize(slots);
840829

841-
return false;
830+
_ = unifiedSession.IterateLookupSnapshot(ref hasKeysInSlotsFuncs);
831+
return hasKeysInSlotsFuncs.Found;
842832
}
843833

844834
/// <summary>

0 commit comments

Comments
 (0)