158 lines
6.4 KiB
Python
158 lines
6.4 KiB
Python
"""Resolve missing upload responses using read-only, exact-content evidence."""
|
|||
|
|
|
||
|
|
import hashlib
|
||
|
|
import json
|
||
|
|
from datetime import UTC, datetime, timedelta
|
||
|
|
|
||
|
|
from pigway_frame_flow.errors import ErrorCode
|
||
|
|
from pigway_frame_flow.storage.database import encode, now_iso
|
||
|
|
|
||
|
|
|
||
|
|
class UploadReconciliation:
|
||
|
|
def __init__(self, service):
|
||
|
|
self.service = service
|
||
|
|
|
||
|
|
def prepare(self, operation_id, driver, asset, previous):
|
||
|
|
path = self.service.assets.path(asset["asset_id"])
|
||
|
|
self.service.update(
|
||
|
|
operation_id,
|
||
|
|
"RENDERING",
|
||
|
|
{
|
||
|
|
"rendered_asset_id": asset["asset_id"],
|
||
|
|
"upload_intent": {
|
||
|
|
"name": asset["name"],
|
||
|
|
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||
|
|
"size": path.stat().st_size,
|
||
|
|
"before_ids": [image.image_id for image in driver.images()],
|
||
|
|
"previous_image_id": previous,
|
||
|
|
"started_at": now_iso(),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
def check(self, op, driver, device):
|
||
|
|
service = self.service
|
||
|
|
result = json.loads(op["result"] or "{}")
|
||
|
|
intent = result.get("upload_intent")
|
||
|
|
if not intent:
|
||
|
|
return None
|
||
|
|
expected = result.get("image_id")
|
||
|
|
if not expected:
|
||
|
|
start = datetime.fromisoformat(intent["started_at"])
|
||
|
|
candidates = []
|
||
|
|
for image in driver.images():
|
||
|
|
if (
|
||
|
|
image.image_id in intent["before_ids"]
|
||
|
|
or image.name != intent["name"]
|
||
|
|
):
|
||
|
|
continue
|
||
|
|
if image.size_bytes is not None and image.size_bytes != intent["size"]:
|
||
|
|
continue
|
||
|
|
if not image.created_at:
|
||
|
|
continue
|
||
|
|
try:
|
||
|
|
created = datetime.fromisoformat(image.created_at)
|
||
|
|
age = (created - start).total_seconds()
|
||
|
|
except (ValueError, TypeError):
|
||
|
|
continue
|
||
|
|
if 0 <= age <= service.settings.sync_timeout + 300:
|
||
|
|
candidates.append(image)
|
||
|
|
# Ambiguous matches never release a write lock.
|
||
|
|
if len(candidates) != 1:
|
||
|
|
return None
|
||
|
|
expected = candidates[0].image_id
|
||
|
|
data = driver.download(expected)
|
||
|
|
if (
|
||
|
|
len(data) != intent["size"]
|
||
|
|
or hashlib.sha256(data).hexdigest() != intent["sha256"]
|
||
|
|
):
|
||
|
|
return None
|
||
|
|
result["image_id"] = expected
|
||
|
|
result["upload_confirmed"] = True
|
||
|
|
service.update(op["id"], "UNKNOWN", result, json.loads(op["error"] or "{}"))
|
||
|
|
service.assets.promote(result["rendered_asset_id"])
|
||
|
|
source = service.db.one(
|
||
|
|
"SELECT payload FROM asset_sources WHERE asset_id=?",
|
||
|
|
(result["rendered_asset_id"],),
|
||
|
|
)
|
||
|
|
service.db.execute(
|
||
|
|
"INSERT OR REPLACE INTO image_sources VALUES(?,?,?,?,?)",
|
||
|
|
(
|
||
|
|
op["device_id"],
|
||
|
|
expected,
|
||
|
|
result["rendered_asset_id"],
|
||
|
|
op["id"],
|
||
|
|
source["payload"] if source else encode({"type": "image"}),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
if not service.db.one(
|
||
|
|
"SELECT image_id FROM managed_cloud_uploads WHERE operation_id=?",
|
||
|
|
(op["id"],),
|
||
|
|
):
|
||
|
|
service.cloud_library.register(
|
||
|
|
op["device_id"], expected, result["rendered_asset_id"], op["id"]
|
||
|
|
)
|
||
|
|
if device.current_content.id == expected and device.current_content.synced:
|
||
|
|
options = json.loads(op["payload"]).get("options", {})
|
||
|
|
service.db.execute(
|
||
|
|
"UPDATE device_state SET managed_image_id=? WHERE device_id=?",
|
||
|
|
(expected, op["device_id"]),
|
||
|
|
)
|
||
|
|
if options.get("restore_previous"):
|
||
|
|
expires = datetime.fromisoformat(intent["started_at"]) + timedelta(
|
||
|
|
seconds=options["duration"]
|
||
|
|
)
|
||
|
|
service.db.execute(
|
||
|
|
"INSERT OR IGNORE INTO overrides VALUES(?,?,?,?,?,?)",
|
||
|
|
(
|
||
|
|
op["id"],
|
||
|
|
op["device_id"],
|
||
|
|
expected,
|
||
|
|
options["priority"],
|
||
|
|
expires.isoformat(),
|
||
|
|
intent["started_at"],
|
||
|
|
),
|
||
|
|
)
|
||
|
|
return expected
|
||
|
|
elapsed = (
|
||
|
|
datetime.now(UTC) - datetime.fromisoformat(intent["started_at"])
|
||
|
|
).total_seconds()
|
||
|
|
if (
|
||
|
|
result.get("upload_confirmed")
|
||
|
|
and not json.loads(op["payload"]).get("options", {}).get("restore_previous")
|
||
|
|
and elapsed > service.settings.sync_timeout + 300
|
||
|
|
and device.current_content.id == intent["previous_image_id"]
|
||
|
|
and device.current_content.synced
|
||
|
|
):
|
||
|
|
# Upload is proven; display is not. Two separated stable observations
|
||
|
|
# close the failed display, without replaying either vendor write.
|
||
|
|
observed = result.get("unchanged_observed_at")
|
||
|
|
if not observed:
|
||
|
|
result["unchanged_observed_at"] = now_iso()
|
||
|
|
service.update(
|
||
|
|
op["id"], "UNKNOWN", result, json.loads(op["error"] or "{}")
|
||
|
|
)
|
||
|
|
elif (
|
||
|
|
datetime.now(UTC) - datetime.fromisoformat(observed)
|
||
|
|
).total_seconds() >= 60:
|
||
|
|
service.update(
|
||
|
|
op["id"],
|
||
|
|
"FAILED",
|
||
|
|
result,
|
||
|
|
{
|
||
|
|
"code": ErrorCode.DISPLAY_NOT_CONFIRMED,
|
||
|
|
"message": "Upload confirmed; frame remained on previous image",
|
||
|
|
"request_id": op["request_id"],
|
||
|
|
"details": {},
|
||
|
|
},
|
||
|
|
)
|
||
|
|
service.db.history(op, "DISPLAY_NOT_CONFIRMED")
|
||
|
|
service.db.event(
|
||
|
|
"operation.failed",
|
||
|
|
{"operation_id": op["id"], "code": ErrorCode.DISPLAY_NOT_CONFIRMED},
|
||
|
|
)
|
||
|
|
elif result.get("unchanged_observed_at"):
|
||
|
|
result.pop("unchanged_observed_at")
|
||
|
|
service.update(op["id"], "UNKNOWN", result, json.loads(op["error"] or "{}"))
|
||
|
|
return expected
|