Skip to content

Commit b03997a

Browse files
committed
fix(client): reject at_timestamp=0 instead of silently unpinning the read
at_timestamp is a plain proto3 scalar with no field presence, so zero is not serialised and the server reads the field as absent. A caller asking to read as of the epoch therefore got a current read: the request was accepted, the pin was dropped on the wire, and live data came back for a time-travel query. The same package already asserts that half of it, in test_absent_by_default, which pins an unpinned request to timestamp zero. Validation now requires a positive integer and says why, and the public docstring records that zero is how the wire says "no pin" and so cannot also ask for one. Carries a regression test for the zero case; it fails against the previous validation, which accepted anything non-negative.
1 parent 501d338 commit b03997a

2 files changed

Lines changed: 24 additions & 4 deletions

File tree

coordinode/coordinode/client.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,8 @@ async def cypher(
296296
The timestamp has to fall inside the MVCC retention window; older snapshots are
297297
collected and the server answers UNAVAILABLE. It cannot be combined with a
298298
non-zero ``after_index``: waiting for a new write and reading a fixed past are
299-
opposite requests, and the pair is rejected.
299+
opposite requests, and the pair is rejected. Zero is rejected too: it is how the
300+
wire says "no pin", so it cannot also ask for one.
300301
"""
301302
from coordinode._proto.coordinode.v1.query.cypher_pb2 import ( # type: ignore[import]
302303
ExecuteCypherRequest,
@@ -1116,8 +1117,14 @@ def _make_read_concern(level: str | None, after_index: int | None, at_timestamp:
11161117
raise ValueError(f"after_index must be a non-negative integer, got {after_index!r}")
11171118
kwargs["after_index"] = after_index
11181119
if at_timestamp is not None:
1119-
if not isinstance(at_timestamp, int) or isinstance(at_timestamp, bool) or at_timestamp < 0:
1120-
raise ValueError(f"at_timestamp must be a non-negative integer, got {at_timestamp!r}")
1120+
# Positive, not merely non-negative: the field is a plain proto3 scalar
1121+
# with no presence, so zero is not put on the wire and the server reads
1122+
# it as "no pin". A request to read as of the epoch would come back as
1123+
# a current read, which is the one answer time travel must not give.
1124+
if not isinstance(at_timestamp, int) or isinstance(at_timestamp, bool) or at_timestamp < 1:
1125+
raise ValueError(
1126+
f"at_timestamp must be a positive integer (microseconds since the epoch), got {at_timestamp!r}"
1127+
)
11211128
# A fence waits for the log to reach an index; a pin reads a fixed
11221129
# point in the past. The server calls the pair mutually exclusive and
11231130
# answers INVALID_ARGUMENT, so say so here rather than a round trip

tests/unit/test_consistency_helpers.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,22 @@ def test_allows_a_zero_fence_with_a_pin(self) -> None:
7676

7777
@pytest.mark.parametrize("bad", [-1, True, "42", 1.5])
7878
def test_rejects_non_negative_integers(self, bad: object) -> None:
79-
with pytest.raises(ValueError, match="at_timestamp must be a non-negative integer"):
79+
with pytest.raises(ValueError, match="at_timestamp must be a positive integer"):
8080
_make_read_concern(None, None, bad) # type: ignore[arg-type]
8181

82+
def test_rejects_zero_because_the_wire_cannot_carry_it(self) -> None:
83+
"""Zero is how the wire says "no pin", so it cannot also mean a pin.
84+
85+
`at_timestamp` is a plain proto3 scalar with no field presence, so
86+
zero is not serialised and the server reads the field as absent. A
87+
caller asking to read as of the epoch would silently get a current
88+
read instead, which is the one answer a time-travel query must never
89+
return. `test_absent_by_default` pins the other half of this: an
90+
unpinned request is exactly the one whose timestamp is zero.
91+
"""
92+
with pytest.raises(ValueError, match="at_timestamp must be a positive integer"):
93+
_make_read_concern(None, None, 0)
94+
8295
def test_defaults_the_level_to_snapshot(self) -> None:
8396
"""A pinned read is a snapshot read, and the server enforces that.
8497

0 commit comments

Comments
 (0)