Distinct-key iteration returns phantom keys for serialized SpanByte keys
Nobody has claimed this yet.
- Dominant language
- C#
- Stars
- 6.6k
- Forks
- 595
- PR merge metrics
- No merged PRs in 30d
Description
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
SpanBytelonger than the inlineIntPtrpayload 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
refchange: failed, expected 1 distinct live key but received 2. - After the
refchange: 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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in cs/src/core/Index/FASTER/FASTERIterator.cs and inspect both calls to ProcessNonTailmostMainKvRecord, along with the helper signature. Reproduce the issue with the supplied SpanByte update and distinct-key iteration example, then verify the ref-based change with the regression test and existing SpanByteLogScanTests; done means one live key is returned.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- databases
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 85/100