mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
R4-1: collecting superseded plan files runs after the configuration is already durable, but an error listing the directory propagated out of config/set. The client saw a failure for a revision the server had committed, and its retry came back as a conflict. collect_plans now reports 0 instead of raising, and config/set logs and proceeds — the event fires, the revision is returned. R4-2: the pending set was cleared when a batch went out, not when it came back, so every render during an in-flight content/sign queued another request: six calls where one was needed, and unbounded on a socket that is slow rather than busy. Queued and in-flight are separate states now; a failure backs off (2 s doubling to 60 s) instead of retrying on the next frame; an in-flight entry expires after 15 s so a promise that never settles cannot wedge retries; a late answer after dispose() no longer renders. Tests: test/signing.test.mjs — eight cases with hand-settled promises, verified against a v1.45.1 checkout where four of them fail (2 sign calls instead of 1, no backoff, a late answer rendering after teardown). Backend: a broken collector still yields a successful save whose revision the next CAS accepts. Pure collector: a disappearing directory returns 0. Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
98 lines
3.7 KiB
Python
98 lines
3.7 KiB
Python
"""Plan-file collection — pure, so it is unit-testable without Home Assistant.
|
|
|
|
The file system is not part of the configuration store's transaction, so who
|
|
may delete a plan file, and when, is a correctness question rather than a
|
|
housekeeping one. It lives here, apart from the WebSocket plumbing, precisely
|
|
because it is the part that has to be reasoned about and tested.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from .const import PLAN_ORPHAN_TTL_S
|
|
from .validation import PLAN_EXTENSIONS
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
def plan_basename(url: Any) -> str:
|
|
"""File name a stored plan_url points at ('' when there is none)."""
|
|
if not isinstance(url, str) or not url:
|
|
return ""
|
|
return url.split("?", 1)[0].rsplit("/", 1)[-1]
|
|
|
|
|
|
def plan_refs(cfg: dict[str, Any] | None) -> set[str]:
|
|
"""Plan file names a configuration references."""
|
|
out: set[str] = set()
|
|
for sp in (cfg or {}).get("spaces") or []:
|
|
name = plan_basename(sp.get("plan_url"))
|
|
if name:
|
|
out.add(name)
|
|
return out
|
|
|
|
|
|
def is_plan_file(name: str) -> bool:
|
|
"""Does this look like a plan we wrote: <space>.<ext> or <space>.<token>.<ext>?"""
|
|
parts = name.split(".")
|
|
return len(parts) in (2, 3) and parts[-1].lower() in PLAN_EXTENSIONS
|
|
|
|
|
|
def collect_plans(
|
|
plans_dir: Path,
|
|
old_cfg: dict[str, Any] | None,
|
|
new_cfg: dict[str, Any],
|
|
now: float | None = None,
|
|
) -> int:
|
|
"""Drop plan files the accepted configuration made obsolete (review R3-1).
|
|
|
|
Called inside the config write lock, right after the new revision is
|
|
stored, so it decides from the two configurations that actually bracket the
|
|
commit instead of trusting a client to say what may be deleted. The earlier
|
|
design — a `plan/cleanup` command carrying `keep` — could not be ordered
|
|
against another client's commit: a delayed call removed the file that
|
|
client had just saved, leaving the accepted configuration pointing at
|
|
nothing, which is the damage copy-on-write was introduced to prevent.
|
|
|
|
Two rules, both conservative:
|
|
* a file the OLD configuration referenced and the new one does not was
|
|
authoritative and has been superseded — remove it;
|
|
* any other unreferenced plan file is a rejected or abandoned upload, and
|
|
is removed only once PLAN_ORPHAN_TTL_S has passed: a fresh one may
|
|
belong to a transaction that has not committed yet.
|
|
|
|
Never raises: the configuration is already stored by the time this runs, so
|
|
a file-system problem must not turn a durable commit into a failed call.
|
|
"""
|
|
new_refs = plan_refs(new_cfg)
|
|
old_refs = plan_refs(old_cfg)
|
|
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
|
removed = 0
|
|
try:
|
|
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
|
|
except OSError as err:
|
|
# The directory can vanish or turn unreadable between the check and the
|
|
# walk. This is housekeeping running behind a commit that is already
|
|
# durable, so it reports "nothing collected" instead of failing (R4-1).
|
|
_LOGGER.warning("House Plan: could not list %s: %s", plans_dir, err)
|
|
return 0
|
|
for item in items:
|
|
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
|
|
continue
|
|
superseded = item.name in old_refs
|
|
try:
|
|
stale = item.stat().st_mtime < cutoff
|
|
except OSError:
|
|
stale = False
|
|
if not superseded and not stale:
|
|
continue
|
|
try:
|
|
item.unlink()
|
|
removed += 1
|
|
except OSError as err:
|
|
_LOGGER.warning("House Plan: could not remove the old plan %s: %s", item, err)
|
|
return removed
|