from __future__ import annotations import uuid from datetime import timedelta from typing import Any from syncgames.errors import LeaseHeldError from syncgames.store import Meta, ObjectStore, parse_expires, utc_now def lease_active(lease: dict[str, Any] | None) -> bool: if not lease: return False exp = parse_expires(lease.get("expires_at")) if exp is None: return False return exp > utc_now() def acquire_lease( store: ObjectStore, game_id: str, device_id: str, *, ttl_hours: int, versions_to_keep: int, break_other: bool = False, ) -> Meta: meta = store.get_meta(game_id) if meta is None: from syncgames.store import empty_meta meta = empty_meta(game_id, versions_to_keep) lease = meta.lease if lease_active(lease) and lease and lease.get("holder") != device_id: if not break_other: raise LeaseHeldError( f"Lease held by {lease.get('holder')} until {lease.get('expires_at')}" ) session_id = str(uuid.uuid4()) expires = utc_now() + timedelta(hours=ttl_hours) meta.raw["lease"] = { "holder": device_id, "expires_at": expires.strftime("%Y-%m-%dT%H:%M:%SZ"), "session_id": session_id, } meta.raw["versions_to_keep"] = versions_to_keep store.put_meta(game_id, meta) # Re-read to reduce lost-update window (optimistic) again = store.get_meta(game_id) if again is None or not again.lease or again.lease.get("holder") != device_id: raise LeaseHeldError("Lost lease race — another device acquired the lease") return again def release_lease(store: ObjectStore, game_id: str, device_id: str, meta: Meta | None = None) -> Meta: meta = meta or store.get_meta(game_id) if meta is None: from syncgames.store import empty_meta meta = empty_meta(game_id) lease = meta.lease if lease and lease.get("holder") not in (None, device_id): if lease_active(lease): raise LeaseHeldError(f"Cannot release lease owned by {lease.get('holder')}") meta.raw["lease"] = None store.put_meta(game_id, meta) return meta def break_lease(store: ObjectStore, game_id: str) -> Meta: from syncgames.store import empty_meta meta = store.get_meta(game_id) or empty_meta(game_id) meta.raw["lease"] = None store.put_meta(game_id, meta) return meta