mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 22:47:13 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
The Firebase stream only ingested StrongLifts sessions, so a manual workout logged on the phone waited for the 15-min workout-sync tick. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWVTJPDdbeF71W9Wd3q
188 lines
7.0 KiB
Python
188 lines
7.0 KiB
Python
"""What the Firebase stream does with one ``put``/``patch``: cache, merge, credit.
|
|
|
|
Holds every device's last-seen log text, so each event recomputes the
|
|
cross-device union with the sync's OWN decoder and tombstone rule
|
|
(:func:`screen_locker._sync_records.merge_device_texts`) without a single
|
|
extra Firebase read. The PC's own log stays in the union -- it carries the
|
|
tombstones ``_manual_push`` re-publishes every tick, and dropping it would
|
|
let a retracted session be credited again -- but an event that touched only
|
|
this PC's log never triggers a pass.
|
|
|
|
Crediting is :func:`screen_locker._poke_credit.credit_sessions` (and
|
|
``credit_manual_workouts`` for phone-logged manual workouts) under the
|
|
same in-process lock as the LAN poke handler. ``write_signed_entry`` already
|
|
dedups by workout id; the ``seen`` set here only spares the file I/O for
|
|
records this daemon has already handed to the ingest.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import logging
|
|
import time
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
from screen_locker import _poke_credit
|
|
from screen_locker._rtdb_stream import LOG_LEAF, device_updates
|
|
from screen_locker._sync_records import (
|
|
_manual_records,
|
|
_session_records,
|
|
merge_device_texts,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
import threading
|
|
|
|
from crdt_sync import Hlc
|
|
|
|
from screen_locker._poke_credit import BatchOutcome
|
|
from screen_locker._rtdb_stream import SseEvent
|
|
|
|
__all__ = ["DeviceLogCache", "EventResult"]
|
|
|
|
_logger: Final = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EventResult:
|
|
"""What one event did, as a sentence for the journal."""
|
|
|
|
snapshot: bool
|
|
summary: str
|
|
|
|
|
|
class DeviceLogCache:
|
|
"""Every device's last-streamed log text, plus records already ingested."""
|
|
|
|
def __init__(
|
|
self, prefix: str, own_ids: frozenset[str], gate: threading.Lock
|
|
) -> None:
|
|
"""Cache logs under ``prefix``; ``own_ids`` are this PC's device dirs."""
|
|
self._prefix = prefix
|
|
self._own_ids = own_ids
|
|
self._gate = gate
|
|
self._texts: dict[str, str] = {}
|
|
self._seen: set[tuple[str, str]] = set()
|
|
|
|
def _apply(self, updates: dict[str, str | None], *, full: bool) -> set[str]:
|
|
"""Fold ``updates`` into the cache; return the devices that changed."""
|
|
if full:
|
|
fresh = {dev: text for dev, text in updates.items() if text is not None}
|
|
changed = {
|
|
dev
|
|
for dev in fresh.keys() | self._texts.keys()
|
|
if fresh.get(dev) != self._texts.get(dev)
|
|
}
|
|
self._texts = fresh
|
|
return changed
|
|
changed = set()
|
|
for dev, text in updates.items():
|
|
if text == self._texts.get(dev):
|
|
continue # byte-identical rewrite: nothing to read or credit
|
|
changed.add(dev)
|
|
if text is None:
|
|
self._texts.pop(dev, None)
|
|
else:
|
|
self._texts[dev] = text
|
|
return changed
|
|
|
|
def _pending(
|
|
self, extract: Callable[[str], dict[str, tuple[dict, Hlc]]], what: str
|
|
) -> list[tuple[str, dict[str, object], str]]:
|
|
"""Union ``extract`` records this daemon has not yet handed to the ingest."""
|
|
union = merge_device_texts(
|
|
(
|
|
(f"{self._prefix}/{dev}/{LOG_LEAF}", text)
|
|
for dev, text in sorted(self._texts.items())
|
|
),
|
|
extract,
|
|
what,
|
|
)
|
|
return [
|
|
(rid, payload, str(hlc))
|
|
for rid, (payload, hlc) in sorted(union.items())
|
|
if (rid, str(hlc)) not in self._seen
|
|
]
|
|
|
|
def handle(self, event: SseEvent) -> EventResult:
|
|
"""Apply one ``put``/``patch`` and credit whatever it newly brought.
|
|
|
|
Raises:
|
|
ValueError: The event's data is not JSON.
|
|
TypeError: The JSON is not the documented ``{path, data}`` shape.
|
|
"""
|
|
started = time.perf_counter()
|
|
full, updates = device_updates(event)
|
|
changed = self._apply(updates, full=full)
|
|
devices = ", ".join(sorted(changed)) or "none"
|
|
if not full and not changed:
|
|
return EventResult(
|
|
snapshot=False, summary="byte-identical rewrite; nothing to do"
|
|
)
|
|
foreign = changed - self._own_ids
|
|
if not full and not foreign:
|
|
return EventResult(
|
|
snapshot=False,
|
|
summary=f"only this PC's own log changed ({devices}); no credit",
|
|
)
|
|
sessions = self._pending(_session_records, "sessions")
|
|
manuals = self._pending(_manual_records, "manual workouts")
|
|
if not sessions and not manuals:
|
|
return EventResult(
|
|
snapshot=full,
|
|
summary=f"{len(self._texts)} device log(s) cached, changed: {devices}; "
|
|
f"every session and manual workout already handed to the ingest "
|
|
f"-- no-op, no file I/O ({_ms(started)} ms)",
|
|
)
|
|
try:
|
|
with self._gate:
|
|
done = [
|
|
_credit(pending, credit, what)
|
|
for pending, credit, what in (
|
|
(sessions, _poke_credit.credit_sessions, "session"),
|
|
(
|
|
manuals,
|
|
_poke_credit.credit_manual_workouts,
|
|
"manual workout",
|
|
),
|
|
)
|
|
if pending
|
|
]
|
|
except Exception: # pylint: disable=broad-exception-caught # one bad event must not drop the stream
|
|
# Not a stream problem: keep the connection (a reconnect would
|
|
# re-download the snapshot and fail the same way). Nothing is
|
|
# marked seen, so the next event retries.
|
|
_logger.exception(
|
|
"Crediting %d streamed session(s) and %d manual workout(s) failed; "
|
|
"the next stream event retries, and the 15-min workout-sync still "
|
|
"covers them",
|
|
len(sessions),
|
|
len(manuals),
|
|
)
|
|
return EventResult(snapshot=full, summary="crediting failed (see above)")
|
|
self._seen.update((rid, hlc) for rid, _payload, hlc in [*sessions, *manuals])
|
|
return EventResult(
|
|
snapshot=full,
|
|
summary=f"{len(self._texts)} device log(s) cached, changed: {devices}; "
|
|
f"{'; '.join(done)} ({_ms(started)} ms)",
|
|
)
|
|
|
|
|
|
def _credit(
|
|
pending: list[tuple[str, dict[str, object], str]],
|
|
credit: Callable[[list[tuple[str, dict[str, object]]]], BatchOutcome],
|
|
what: str,
|
|
) -> str:
|
|
"""Run one batch credit step; describe what it did."""
|
|
outcome = credit([(rid, payload) for rid, payload, _hlc in pending])
|
|
return (
|
|
f"{len(pending)} {what}(s) checked, {len(outcome.ingested)} newly "
|
|
f"logged {list(outcome.ingested)}, {outcome.credited} credited"
|
|
)
|
|
|
|
|
|
def _ms(started: float) -> int:
|
|
"""Milliseconds since ``started`` (a ``perf_counter`` reading)."""
|
|
return round((time.perf_counter() - started) * 1000)
|