mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:27:15 +01:00
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:
parent
426b9372be
commit
590f606856
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user