Skip to content

GetChangesSince's millisecond cursor comparison can miss a same-millisecond write #43

Description

@Yaraslaut

Problem

A model that answers a "changes since" query by filtering on a millisecond-resolution timestamp column with a strict updatedAtMs > since comparison can silently miss a write that lands in the same millisecond as the cursor value from the previous poll. This is plausible whenever poll → write → poll executes within one clock tick (a fast machine, or a loaded CI runner). The write genuinely happened after the cursor was captured in wall-clock terms, but > treats "equal" as "not new" and excludes it.

Reproducer

// asOf captured before the query runs, so a racing write is lost across
// two polls rather than duplicated -- deliberate and correct on its own.
auto asOf = nowMs();
auto rows = query.Where("updatedAtMs", ">", since).All();
  1. Poll once with an empty since → capture asOf1 as the next cursor.
  2. Perform a write whose timestamp is nowMs() at that instant.
  3. Poll again with since = asOf1.

If the write's timestamp equals asOf1 (same millisecond), step 3's strict > excludes it from the result, even though the write happened strictly after step 1 captured its cursor. This is a boundary/granularity bug, not a logic error in the surrounding design — capturing the cursor before running the query is the right call to avoid losing a genuinely-concurrent write; the bug is specifically that the comparison operator can't distinguish "same instant" from "not yet happened" at millisecond resolution.

Desired behavior

A "since" cursor comparison should never silently drop a write that happened after the cursor was captured, even when the write's timestamp collides with the cursor at the timestamp's own resolution.

Suggested fix direction

Neither > (under-inclusive, this bug) nor >= (over-inclusive: a same-millisecond write from a different concurrent actor would then show up twice, once on each of two consecutive polls using it as their cursor) is unconditionally correct at millisecond granularity. The real fix needs either higher-resolution/monotonic ordering (microsecond timestamps, or a per-write sequence number) or an explicit (timestamp, sequence) cursor pair so two writes in the same millisecond stay strictly orderable. Cursoring on a strictly-increasing, collision-free key (e.g. an auto-increment id) instead of a wall-clock timestamp sidesteps the problem entirely for any "changes since" shape that can be modeled as an append-only log.

Source: docs/findings/036-getchangessince-millisecond-cursor-boundary-race.md

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions