cultmesh-py: a peer's raw put with no stamp is refused, nothing minted (raw-put-refuse-py)
Some checks failed
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Some checks failed
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Settles variants:ruling:raw-put-without-stamp-refused for Python: a missing, empty or unparseable storedAt is message_invalid (field document) at the server gate and a ValueError at the node, so the single-file store is never written an empty stamp. parse_stored_at is public. Fixes finding cut-update-with-retry-py.s3.empty-peer-stamp-poisons-store. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c77d734da5
commit
9f052251fd
7 changed files with 54 additions and 16 deletions
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- `parse_stored_at` (was `_parse_stored_at`) is public: cultmesh-py judges a peer's `storedAt` by it. A peer's `document_put_raw` whose `storedAt` is missing, empty or not a stamp is refused `message_invalid` (field `document`) and nothing is minted or stored (variants:ruling:raw-put-without-stamp-refused).
|
||||
|
||||
### Added
|
||||
|
||||
- `CultCache.update` and `update_global` return `UpdateResult(outcome, document)`; `UpdateOutcome` is `UPDATED`, `DECLINED` or `LOST_RACE`. The single-file store gains `read(key)` (the record and its content version, the SHA-256 C# computes) and `commit_if(envelope, expected)`, the version is the SHA-256 of the record re-encoded as `[key, schemaId, storedAt, payload]` with minimal MessagePack encodings, which is the SHA-256 of the record's stored bytes exactly when the writer encoded it minimally (every writer in this library does; a record another writer encoded with a wider-than-needed length or integer has the version of its minimal form, not of its bytes). Both looked up by the cache and not `BackingStore` protocol members (variants:cut_spec:cut-update-with-retry-py).
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from typing import Any, Callable, Generic, Iterable, TypeVar
|
|||
|
||||
from .backing_store import BackingStore, CultCacheEnvelope
|
||||
from .documents import DocumentDefinition, UnreadSlots, extract_value
|
||||
from .stores import SchemaConflictError, _parse_stored_at, retyped, stored_at_over, written_owns
|
||||
from .stores import SchemaConflictError, parse_stored_at, retyped, stored_at_over, written_owns
|
||||
|
||||
T = TypeVar("T")
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
|
@ -650,7 +650,7 @@ class CultCache:
|
|||
self._drop(other, key)
|
||||
for envelope, value, keys in admitted:
|
||||
stamp = stamps.get(envelope.key)
|
||||
if isinstance(stamp, str) and _parse_stored_at(stamp) is not None:
|
||||
if isinstance(stamp, str) and parse_stored_at(stamp) is not None:
|
||||
envelope = replace(envelope, stored_at=stamp)
|
||||
self._remove_lookups(type, envelope.key)
|
||||
values[envelope.key] = value
|
||||
|
|
|
|||
|
|
@ -780,7 +780,7 @@ _STORED_AT_SHAPE = re.compile(
|
|||
)
|
||||
|
||||
|
||||
def _parse_stored_at(text: Any) -> datetime | None:
|
||||
def parse_stored_at(text: Any) -> datetime | None:
|
||||
"""The UTC instant a stored_at text names, or None. Only the shape the runtimes write is a stamp: an RFC 3339 date-time with T,
|
||||
an optional fraction of 1 to 9 digits (cut to the microsecond) and Z or a numeric offset of hours 00-23 and minutes 00-59. Checked here, not left to
|
||||
fromisoformat, whose accepted set differs by interpreter (3.10 and 3.13) and from Rust and TypeScript. An instant outside
|
||||
|
|
@ -808,7 +808,7 @@ def mint_stored_at(previous: str | None, now: datetime) -> str:
|
|||
A previous that does not parse leaves `now`. Raises ValueError, nothing minted, when `previous` is the last instant a stamp
|
||||
can name, so a write over it is refused and writes nothing."""
|
||||
now = now.astimezone(timezone.utc)
|
||||
last = _parse_stored_at(previous)
|
||||
last = parse_stored_at(previous)
|
||||
if last is not None and now <= last:
|
||||
try:
|
||||
now = last + timedelta(microseconds=1)
|
||||
|
|
@ -820,7 +820,7 @@ def mint_stored_at(previous: str | None, now: datetime) -> str:
|
|||
def stored_at_over(staged: str, replaced: str) -> str:
|
||||
"""The stored_at a record replacing `replaced` is written with (C# StoredAtOver): `staged` when both parse and it is strictly
|
||||
later, else one minted over `replaced`. No content is compared: every replacing write is stamped later."""
|
||||
later, earlier = _parse_stored_at(staged), _parse_stored_at(replaced)
|
||||
later, earlier = parse_stored_at(staged), parse_stored_at(replaced)
|
||||
if later is not None and earlier is not None and later > earlier:
|
||||
return staged
|
||||
return mint_stored_at(replaced, datetime.now(timezone.utc))
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ from cultcache_py import (
|
|||
leaves as t,
|
||||
)
|
||||
from cultcache_py.cache import GLOBAL_KEY, CultCacheError
|
||||
from cultcache_py.stores import _parse_stored_at, mint_stored_at, record_version, stored_at_over
|
||||
from cultcache_py.stores import parse_stored_at, mint_stored_at, record_version, stored_at_over
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -578,7 +578,7 @@ class StoreRestampTests(Fixture):
|
|||
return next(envelope.stored_at for envelope in store.pull_all() if envelope.key == key)
|
||||
|
||||
def later_than_alpha(self, text: str) -> bool:
|
||||
return _parse_stored_at(text) > _parse_stored_at(ALPHA)
|
||||
return parse_stored_at(text) > parse_stored_at(ALPHA)
|
||||
|
||||
def test_commit_if_and_the_restamp_at_the_store(self) -> None:
|
||||
store = SingleFileMessagePackBackingStore(self.path)
|
||||
|
|
@ -655,7 +655,7 @@ class StoreRestampTests(Fixture):
|
|||
}
|
||||
for text, instant in accepted.items():
|
||||
with self.subTest(text=text):
|
||||
self.assertEqual(_parse_stored_at(text), instant)
|
||||
self.assertEqual(parse_stored_at(text), instant)
|
||||
for text in (
|
||||
"2026-01-01", "20260101T000000Z", "2026-W01-1T00:00:00Z", "2026-01-01 00:00:00Z", "2026-01-01T00:00:00,5Z",
|
||||
"2026-01-01T00Z", "2026-01-01T00:00:00", "2026-01-01T00:00:00.Z", "2026-01-01T00:00:00.1234567890Z",
|
||||
|
|
@ -665,12 +665,12 @@ class StoreRestampTests(Fixture):
|
|||
"", "garbage",
|
||||
):
|
||||
with self.subTest(text=text):
|
||||
self.assertIsNone(_parse_stored_at(text))
|
||||
self.assertIsNone(parse_stored_at(text))
|
||||
|
||||
def test_a_stamp_outside_the_range_is_unparseable_and_one_at_the_edge_refuses_a_later_stamp(self) -> None:
|
||||
for text in ("9999-12-31T23:30:00-05:00", "0001-01-01T00:00:00+05:00"):
|
||||
with self.subTest(text=text):
|
||||
self.assertIsNone(_parse_stored_at(text))
|
||||
self.assertIsNone(parse_stored_at(text))
|
||||
now = datetime(2030, 1, 1, tzinfo=timezone.utc)
|
||||
self.assertEqual(mint_stored_at("9999-12-31T23:30:00-05:00", now), "2030-01-01T00:00:00.000000+00:00")
|
||||
edge = "9999-12-31T23:59:59.9999999Z"
|
||||
|
|
@ -699,10 +699,10 @@ class StoreRestampTests(Fixture):
|
|||
def test_stamp_parsing_and_install(self) -> None:
|
||||
for text in ("2026-01-01T00:00:00.1234567+00:00", "2026-01-01T00:00:00Z", "2026-01-01T00:00:00.123456+00:00"):
|
||||
with self.subTest(text=text):
|
||||
self.assertIsNotNone(_parse_stored_at(text))
|
||||
self.assertEqual(_parse_stored_at(text).tzinfo, timezone.utc)
|
||||
self.assertIsNone(_parse_stored_at("garbage"))
|
||||
self.assertIsNone(_parse_stored_at(None))
|
||||
self.assertIsNotNone(parse_stored_at(text))
|
||||
self.assertEqual(parse_stored_at(text).tzinfo, timezone.utc)
|
||||
self.assertIsNone(parse_stored_at("garbage"))
|
||||
self.assertIsNone(parse_stored_at(None))
|
||||
now = datetime(2030, 1, 1, tzinfo=timezone.utc)
|
||||
self.assertEqual(mint_stored_at("garbage", now), "2030-01-01T00:00:00.000000+00:00")
|
||||
self.assertEqual(stored_at_over("2000-01-01T00:00:00Z", "2000-01-01T00:00:00Z")[:4], str(datetime.now(timezone.utc).year)) # no later: minted at now
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from typing import Any, Callable
|
|||
from cultcache_py import CultCache, CultCacheEnvelope, SingleFileMessagePackBackingStore
|
||||
from cultcache_py.cache import CultCacheError, index_text
|
||||
from cultcache_py.documents import DocumentDefinition, extract_value
|
||||
from cultcache_py.stores import parse_stored_at
|
||||
from cultnet_py import (
|
||||
CultNetAppliedRecord,
|
||||
CultNetFileShardMutationLogStore,
|
||||
|
|
@ -712,6 +713,9 @@ class CultMeshDatabase:
|
|||
document_record = message.get("document")
|
||||
if not isinstance(document_record, dict):
|
||||
return None
|
||||
# A peer's put is stored under the stamp it carries, so one that is no stamp is refused: nothing is minted for it.
|
||||
if parse_stored_at(document_record.get("storedAt")) is None:
|
||||
raise ValueError("A raw put must carry a storedAt stamp.")
|
||||
schema_id = str(document_record.get("schemaId") or "")
|
||||
documents_by_schema_id = schema_document_map(self.documents)
|
||||
try:
|
||||
|
|
@ -724,7 +728,7 @@ class CultMeshDatabase:
|
|||
type=document.type,
|
||||
schema_id=document.catalog_entry().schema_id,
|
||||
payload=bytes(document_record["payload"]),
|
||||
stored_at=str(document_record.get("storedAt") or ""),
|
||||
stored_at=document_record["storedAt"],
|
||||
catalog_entry=document.catalog_entry(),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from typing import Any
|
|||
|
||||
import msgpack
|
||||
|
||||
from cultcache_py.stores import parse_stored_at
|
||||
from cultnet_py import (
|
||||
error_message,
|
||||
message_invalid_error,
|
||||
|
|
@ -969,7 +970,7 @@ class CultMeshLocalServer:
|
|||
as a snapshot would serve it under the shortest message id, must fit max_snapshot_bytes;
|
||||
a put arriving over RUDP must also fit, as fragments of rudp_max_fragment_bytes, in one
|
||||
message and the peer's reliable queue. A put whose document has no string recordKey or
|
||||
binary payload could never be stored or served, and is refused as malformed."""
|
||||
binary payload, or a storedAt that is no stamp, could never be stored or served, and is refused as malformed."""
|
||||
document_record = message.get("document")
|
||||
if not (
|
||||
isinstance(document_record, dict)
|
||||
|
|
@ -977,6 +978,8 @@ class CultMeshLocalServer:
|
|||
and isinstance(document_record.get("payload"), (bytes, bytearray))
|
||||
):
|
||||
return self._message_invalid(message, "document", "Raw put documents must carry a string recordKey and a binary payload.")
|
||||
if parse_stored_at(document_record.get("storedAt")) is None:
|
||||
return self._message_invalid(message, "document", "Raw put documents must carry a storedAt stamp.")
|
||||
if self.max_snapshot_bytes is None and not over_rudp:
|
||||
return None
|
||||
alone = self.node.database.raw_put_served_alone(message, message_id=_SHORTEST_SERVED_MESSAGE_ID)
|
||||
|
|
|
|||
|
|
@ -94,6 +94,33 @@ class NotificationStampTests(unittest.TestCase):
|
|||
self.assertEqual(node.database._shard_logs[SHARD_ID][-1]["put"]["document"]["storedAt"], held)
|
||||
|
||||
|
||||
class StamplessPutTests(unittest.TestCase):
|
||||
"""A peer's put with no storedAt, or one that is no stamp, is refused message_invalid; nothing is minted or stored."""
|
||||
|
||||
def test_a_put_without_a_stamp_is_refused_and_the_store_stays_writable(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
node = create_node(Path(directory) / "node.cc", runtime_id="stampless-server")
|
||||
node.database.register_document(NOTE)
|
||||
server = CultMeshLocalServer(node=node, port=0, write_authority=CultMeshGrantAllWrites())
|
||||
for label, stamp in (("missing", None), ("empty", ""), ("garbage", "yesterday"), ("no zone", "2026-11-07T00:00:00")):
|
||||
with self.subTest(label):
|
||||
message = put_of(KEY_A, "peer")
|
||||
del message["document"]["storedAt"]
|
||||
if stamp is not None:
|
||||
message["document"]["storedAt"] = stamp
|
||||
[refusal] = server._handle_connection_message(message, {}, peer=PEER)
|
||||
self.assertEqual(refusal["code"], "message_invalid")
|
||||
self.assertEqual(refusal["details"]["field"], "document")
|
||||
with self.assertRaises(ValueError):
|
||||
node.database.apply_raw_put_message(message)
|
||||
self.assertIsNone(node.cache.get(NOTE, KEY_A))
|
||||
self.assertNotIn(SHARD_ID, node.database._shard_logs)
|
||||
# The refused puts left the file readable: the next put of any key is stored.
|
||||
replies = server._handle_connection_message(put_of(KEY_B, "stamped"), {}, peer=PEER)
|
||||
self.assertEqual(replies, [])
|
||||
self.assertEqual(node.cache.get(NOTE, KEY_B)["text"], "stamped")
|
||||
|
||||
|
||||
class RemoteWriteAuthorityTests(unittest.TestCase):
|
||||
def build(self, authority: Any = None) -> CultMeshLocalServer:
|
||||
node = create_node(runtime_id="authority-server")
|
||||
|
|
|
|||
Loading…
Reference in a new issue