Files
渡口浪人 a2e8a204e8
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m30s
fix: defer automatic uploads while device synchronization is pending
2026-10-18 20:21:52 +08:00

165 lines
6.7 KiB
Python

"""Durable, read-only recovery progress for writes with uncertain outcomes."""
import json
from datetime import UTC, datetime
from uuid import uuid4
from pigway_frame_flow.errors import ErrorCode
from pigway_frame_flow.storage.database import encode, now_iso
class RecoveryMonitor:
def __init__(self, service):
self.service, self.db = service, service.db
def observe(self, op, reason):
timestamp = now_iso()
age = (
datetime.now(UTC) - datetime.fromisoformat(op["created_at"])
).total_seconds()
review = age >= self.service.settings.sync_timeout + 300
with self.db.transaction() as tx:
previous = tx.execute(
"SELECT * FROM operation_recovery WHERE operation_id=?", (op["id"],)
).fetchone()
# Once escalated, wall-clock corrections must not clear the alert.
review = review or bool(previous and previous["state"] == "manual_review")
state = "manual_review" if review else "checking"
tx.execute(
"INSERT INTO operation_recovery VALUES(?,?,?,?,?,1) ON CONFLICT(operation_id) DO UPDATE SET state=excluded.state,reason=excluded.reason,last_checked_at=excluded.last_checked_at,checks=operation_recovery.checks+1",
(op["id"], state, reason, timestamp, timestamp),
)
if review and (not previous or previous["state"] != "manual_review"):
tx.execute(
"INSERT INTO events VALUES(?,?,?,?)",
(
uuid4().hex,
"operation.blocked",
encode(
{
"operation_id": op["id"],
"device_id": op["device_id"],
"reason": reason,
"requires_manual_review": True,
}
),
timestamp,
),
)
tx.execute(
"INSERT INTO history(client_id,request_id,operation_id,device_id,action,result,timestamp) VALUES(?,?,?,?,?,?,?)",
(
op["client_id"],
op["request_id"],
op["id"],
op["device_id"],
op["action"],
"MANUAL_REVIEW_REQUIRED",
timestamp,
),
)
def resolve(self, operation_id):
self.db.execute(
"UPDATE operation_recovery SET state='resolved' WHERE operation_id=? AND state!='resolved'",
(operation_id,),
)
def info(self, operation_id):
row = self.db.one(
"SELECT state,reason,first_checked_at,last_checked_at,checks FROM operation_recovery WHERE operation_id=?",
(operation_id,),
)
if row:
row["requires_manual_review"] = row["state"] == "manual_review"
return row
def observe_device(self, op, *, device=None, error=None):
row = self.db.one("SELECT result FROM operations WHERE id=?", (op["id"],))
result = json.loads(row["result"] or "{}")
observation = {"checked_at": now_iso()}
if device is not None:
observation.update(
image_id=device.current_content.id,
synced=device.current_content.synced,
online=device.status == "online",
heartbeat=device.last_seen_at,
)
else:
observation["read_error"] = {
"code": error.code,
**{
k: v
for k, v in error.details.items()
if k
in ("transport_error", "http_status", "attempts", "retry_exhausted")
},
}
samples = result.get("sync_observations", [])
if samples:
old = samples[-1]
changed = {k: v for k, v in old.items() if k != "checked_at"} != {
k: v for k, v in observation.items() if k != "checked_at"
}
if (
not changed
and (
datetime.now(UTC) - datetime.fromisoformat(old["checked_at"])
).total_seconds()
< 60
):
return observation
samples.append(observation)
result["sync_observations"] = (
[samples[0], *samples[-11:]] if len(samples) > 12 else samples
)
# Evidence must not reset updated_at: that is the sync timeout's clock.
self.db.execute(
"UPDATE operations SET result=? WHERE id=?", (encode(result), op["id"])
)
return observation
def close_superseded_displays(self):
for op in self.db.all(
"SELECT * FROM operations WHERE action='display' AND write_started=1 "
"AND status IN ('SYNCING','UNKNOWN')"
):
result = json.loads(op["result"] or "{}")
if not result.get("image_id") or json.loads(op["payload"]).get(
"options", {}
).get("restore_previous"):
continue
newer = self.db.one(
"SELECT id,result FROM operations WHERE device_id=? AND action='display' "
"AND status='COMPLETED' AND write_started=1 AND created_at>? "
"AND COALESCE(json_extract(payload,'$.options.restore_previous'),0)=0 "
"AND json_extract(result,'$.image_id') IS NOT NULL "
"ORDER BY created_at DESC LIMIT 1",
(op["device_id"], op["created_at"]),
)
if (
not newer
or json.loads(newer["result"])["image_id"] == result["image_id"]
):
continue
# A later confirmed permanent display ends this attempt, but cannot
# prove whether the earlier image was ever shown. Never replay it.
self.service.update(
op["id"],
"FAILED",
error={
"code": ErrorCode.DISPLAY_NOT_CONFIRMED,
"message": "Superseded by a later confirmed display; earlier display unconfirmed",
"request_id": op["request_id"],
"details": {
"superseded_by_operation_id": newer["id"],
"previous_error": json.loads(op["error"] or "null"),
},
},
)
self.db.history(op, "DISPLAY_NOT_CONFIRMED")
self.db.event(
"operation.failed",
{"operation_id": op["id"], "code": ErrorCode.DISPLAY_NOT_CONFIRMED},
)