Honour crdt-sync tombstones when ingesting synced workouts

_records_matching read every record's payload and never checked
record.deleted, so a tombstoned workout was ingested like a live one.
Deleting a workout in the phone app therefore looked like it worked and
came back on the next 15-minute sync, silently re-crediting it.

crdt-sync deletion is monotonic (a tombstone cannot be resurrected by
merging an older, non-deleted copy), so this is the only durable way to
retract a synced workout across devices. Skip tombstoned records and say
so at info, since "this stopped counting" is exactly the kind of thing
that must not happen silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
This commit is contained in:
Krzysztof Rudnicki 2026-08-21 13:35:53 +02:00
parent 426b9372be
commit 590f606856
2 changed files with 45 additions and 0 deletions

View File

@ -60,6 +60,12 @@ def _records_matching(
) -> dict[str, tuple[dict, Hlc]]:
"""Return ``{record_id: (payload, hlc)}`` for payloads passing ``predicate``.
Tombstoned records are skipped. crdt-sync deletion is monotonic -- a
tombstone can never be resurrected by merging an older, non-deleted copy --
so ``deleted`` is the only way a workout removed on one device can stop
counting on another. Without this check, deleting a workout in the phone
app looked like it worked and then the next 15-minute sync re-ingested it.
Raises:
TypeError: If the top-level JSON isn't an object or a record's shape
doesn't match what :meth:`Record.from_dict` expects.
@ -73,6 +79,13 @@ def _records_matching(
result: dict[str, tuple[dict, Hlc]] = {}
for data in raw.values():
record = Record.from_dict(data)
if record.deleted:
_logger.info(
"Sync record %s is tombstoned — skipping it, so a workout "
"deleted on another device stops counting here too",
record.id,
)
continue
field = record.fields.get(_PAYLOAD_FIELD)
if field is None:
continue

View File

@ -11,6 +11,8 @@ from unittest.mock import MagicMock, patch
from crdt_sync import (
GitHubSyncError,
Hlc,
Record,
)
from screen_locker import _sync_client, _workout_sync
@ -53,6 +55,36 @@ class TestPullAllManualRecords:
result = _workout_sync.pull_all_manual_records()
assert sorted(rid for rid, _ in result) == ["manual:a", "manual:b"]
def test_skips_a_tombstoned_record(self) -> None:
"""A workout deleted on another device must stop counting here.
crdt-sync deletion is monotonic, so the tombstone is the only durable
way to retract a synced workout. Ingesting it anyway made a delete in
the phone app look like it worked and then silently come back on the
next 15-minute sync.
"""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")
hlc = Hlc(wall_time_ms=1000, counter=0, node_id="phone")
tombstoned = Record(
id="manual:gone",
fields={"payload": (_manual_payload(), hlc)},
deleted=True,
deleted_hlc=hlc,
).to_dict()
client = _multi_device_client(
{
"phone": json.dumps(
{
"a": _manual_record_dict("manual:kept", _manual_payload()),
"b": tombstoned,
}
),
}
)
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
result = _workout_sync.pull_all_manual_records()
assert [rid for rid, _ in result] == ["manual:kept"]
def test_skips_missing_and_corrupt_device_logs(self) -> None:
"""Skips missing and corrupt device logs."""
_workout_sync.SYNC_TOKEN_FILE.write_text("tok")