Skip to content

Distinct-key iteration returns phantom keys for serialized SpanByte keys #962

Description

@daz2d

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:

  1. The key is a serialized SpanByte longer than the inline IntPtr payload field.
  2. The key has multiple log versions.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions