mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:07:14 +01:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
154 lines
5.8 KiB
Python
154 lines
5.8 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` 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 _session_records, merge_device_texts
|
|
|
|
if TYPE_CHECKING:
|
|
import threading
|
|
|
|
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) -> list[tuple[str, dict[str, object], str]]:
|
|
"""Union 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())
|
|
),
|
|
_session_records,
|
|
"sessions",
|
|
)
|
|
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",
|
|
)
|
|
pending = self._pending()
|
|
if not pending:
|
|
return EventResult(
|
|
snapshot=full,
|
|
summary=f"{len(self._texts)} device log(s) cached, changed: {devices}; "
|
|
f"every session already handed to the ingest -- no-op, no file I/O "
|
|
f"({_ms(started)} ms)",
|
|
)
|
|
try:
|
|
with self._gate:
|
|
outcome = _poke_credit.credit_sessions(
|
|
[(rid, payload) for rid, payload, _hlc in pending]
|
|
)
|
|
except Exception:
|
|
# 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) failed; the next stream event "
|
|
"retries, and the 15-min workout-sync still covers them",
|
|
len(pending),
|
|
)
|
|
return EventResult(snapshot=full, summary="crediting failed (see above)")
|
|
self._seen.update((rid, hlc) for rid, _payload, hlc in pending)
|
|
return EventResult(
|
|
snapshot=full,
|
|
summary=f"{len(self._texts)} device log(s) cached, changed: {devices}; "
|
|
f"{len(pending)} session(s) checked, {len(outcome.ingested)} newly "
|
|
f"logged {list(outcome.ingested)}, {outcome.credited} credited "
|
|
f"({_ms(started)} ms)",
|
|
)
|
|
|
|
|
|
def _ms(started: float) -> int:
|
|
"""Milliseconds since ``started`` (a ``perf_counter`` reading)."""
|
|
return round((time.perf_counter() - started) * 1000)
|