Environment
- FASTER 2.6.5
FasterKV<SpanByte, SpanByte>
- Distinct-key iteration through
session.Iterate()
Problem
session.Iterate() can return two distinct live keys for one logical key when:
- The key is a serialized
SpanByte longer than the inline IntPtr payload field.
- The key has multiple log versions.
- An older version is processed through the non-tailmost-record path.
For example, after updating one logical key, iteration can produce:
Expected: 1 live key
Actual: 2 keys, including one corrupted/phantom key
Point reads using the original key still return the correct latest value.
Minimal reproduction
using var session =
fht.For(new SpanByteFunctions<Empty>())
.NewSession<SpanByteFunctions<Empty>>();
var key = MemoryMarshal.Cast<char, byte>(
"key-longer-than-eight-bytes".AsSpan());
var initialValue = MemoryMarshal.Cast<char, byte>(
"initial-value".AsSpan());
var latestValue = MemoryMarshal.Cast<char, byte>(
"latest-value".AsSpan());
session.Upsert(key, initialValue);
// Make the first version immutable so the next update is appended.
fht.Log.ShiftReadOnlyAddress(fht.Log.TailAddress, wait: true);
session.Upsert(key, latestValue);
var count = 0;
using var iterator = session.Iterate();
while (iterator.GetNext(out _))
{
++count;
}
Assert.AreEqual(1, count);
On FASTER 2.6.5, the assertion fails because the iterator returns two keys.
Root cause
In cs/src/core/Index/FASTER/FASTERIterator.cs, non-tailmost keys are passed by value to this helper:
private void ProcessNonTailmostMainKvRecord(
RecordInfo recordInfo,
Key key)
Both pull and push iteration paths call it similarly:
ProcessNonTailmostMainKvRecord(recordInfo, key);
A serialized SpanByte is self-relative: its payload begins at the memory address of its payload field and may continue beyond the fixed-size struct.
Passing it by value copies only the fixed-size SpanByte representation, not the complete trailing serialized payload. The copy retains the original length and serialized flag, so AsReadOnlySpan() interprets unrelated memory after the copied inline bytes as part of the key.
The resulting corrupted key no longer matches the real key in the iterator's temporary reconciliation store and can survive as an additional distinct key.
Tailmost records are returned directly from the main iterator. Therefore, this specifically affects serialized keys processed through the non-tailmost path.
Proposed fix
Pass the iterator-owned key by reference at both call sites:
- ProcessNonTailmostMainKvRecord(recordInfo, key);
+ ProcessNonTailmostMainKvRecord(recordInfo, ref key);
Change the helper signature accordingly:
private void ProcessNonTailmostMainKvRecord(
RecordInfo recordInfo,
- Key key)
+ ref Key key)
Using ref avoids copying the self-relative serialized SpanByte and preserves its original log-record address.
Validation
A local upstream regression test produced the following results:
- Before the
ref change: failed, expected 1 distinct live key but received 2.
- After the
ref change: passed, exactly 1 live key was returned.
- Existing
SpanByteLogScanTests: 2/2 passed.
Impact
Applications using distinct-key iteration with serialized SpanByte keys may observe:
- Phantom or corrupted keys.
- More than one result for a single logical key.
- Incorrect unique-key counts.
- Different behavior between point reads and iteration.
The underlying latest value remains readable through a normal point lookup; the problem occurs while reconciling historical records during distinct-key iteration.
Environment
FasterKV<SpanByte, SpanByte>session.Iterate()Problem
session.Iterate()can return two distinct live keys for one logical key when:SpanBytelonger than the inlineIntPtrpayload field.For example, after updating one logical key, iteration can produce:
Point reads using the original key still return the correct latest value.
Minimal reproduction
On FASTER 2.6.5, the assertion fails because the iterator returns two keys.
Root cause
In
cs/src/core/Index/FASTER/FASTERIterator.cs, non-tailmost keys are passed by value to this helper:Both pull and push iteration paths call it similarly:
A serialized
SpanByteis self-relative: its payload begins at the memory address of itspayloadfield and may continue beyond the fixed-size struct.Passing it by value copies only the fixed-size
SpanByterepresentation, not the complete trailing serialized payload. The copy retains the original length and serialized flag, soAsReadOnlySpan()interprets unrelated memory after the copied inline bytes as part of the key.The resulting corrupted key no longer matches the real key in the iterator's temporary reconciliation store and can survive as an additional distinct key.
Tailmost records are returned directly from the main iterator. Therefore, this specifically affects serialized keys processed through the non-tailmost path.
Proposed fix
Pass the iterator-owned key by reference at both call sites:
Change the helper signature accordingly:
private void ProcessNonTailmostMainKvRecord( RecordInfo recordInfo, - Key key) + ref Key key)Using
refavoids copying the self-relative serializedSpanByteand preserves its original log-record address.Validation
A local upstream regression test produced the following results:
refchange: failed, expected 1 distinct live key but received 2.refchange: passed, exactly 1 live key was returned.SpanByteLogScanTests: 2/2 passed.Impact
Applications using distinct-key iteration with serialized
SpanBytekeys may observe:The underlying latest value remains readable through a normal point lookup; the problem occurs while reconciling historical records during distinct-key iteration.