diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 18b4d8f..033fed6 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,7 +1,7 @@ # pigway-frame-flow v0.1.23 -- 自动调度和自动重试在同一设备已有图片等待同步时排队,避免 /upload 自动激活新图片而替换尚未确认的目标。等待沿用现有同步超时;保留用户手动切换与优先级行为。 +- 自动调度、自动重试和后台清理在同一设备已有图片等待同步时排队,避免 /upload 激活新图或删除操作更新云端计数,干扰尚未确认的目标。等待沿用现有同步超时;保留用户手动切换与优先级行为。 - 在操作回执中保留最多 12 条设备读回状态及受限错误字段,记录实际图片、同步、在线和心跳,供定位未确认原因;不保存原始异常、URL 或凭据。采样不延长同步超时,原始上传证据保留。 - 沿用安全基础镜像、完整构建和固定镜像 ID 的预构建验收流程;不重发不确定的设备写入。 -验证:600 项测试(全量 599 项及新增超时边界用例)、Ruff、格式和 mypy 通过。历史设备物理刷新状态无法从缺失的原始采样追溯,不能将此版本描述为已修复所有网络或设备故障。 +验证:601 项测试、Ruff、格式和 mypy 通过。历史设备物理刷新状态无法从缺失的原始采样追溯,不能将此版本描述为已修复所有网络或设备故障。 diff --git a/SHA256SUMS b/SHA256SUMS index 1df0cb2..42421fa 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -10,7 +10,7 @@ c6fc984a1ca46cbd3e06de4591e41d21e4eb592416385d4000ade8fd570aad0c Dockerfile 250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md 26ad0d9063c0ee843f0f532604b8fd66e8b39fd72d129595ed078677c0d6d13c README.md ee00cb267b38485f1e574d610f0a57b51d4ae575731a18e57d06a7c43dc0a8b0 README.zh-CN.md -668d9245385b20a1d7052b96c982a4d4654e40b0e2e978522d4bef513b85f668 RELEASE_NOTES.md +5ec5770a6e0202d981c5719de8cb09fefbd5d0a79b537d949b91ec67c8bce72b RELEASE_NOTES.md 6a0c85c8d52a2d4a3b779dce306a93c4efc4dfe3ab3b23e4a6a3bb7c55079910 SECURITY.md e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 config/.gitkeep @@ -49,7 +49,7 @@ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_f 81effa34d6c452579cfa65781909f7c5a9e8ddad136e4c47ee0704939851879a pigway_frame_flow/display/cloud_library.py 5e7e272e671a6d01d9b677416c77967ca488b4e2aa559f4211cf0de4ba1e88bc pigway_frame_flow/display/recovery.py e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py -faff3c264ef1ffe496d2989da0b7ee0add60bda1b7ff92cf47e4fc7690b95242 pigway_frame_flow/display/service.py +6b09243081d53fb6c8a780d77e064f0685267c61e05f514262c8514ee3a36fda pigway_frame_flow/display/service.py 0975b24488be19f24e768c4c76d9f3c0348417c1351c4e410897da00f7be6656 pigway_frame_flow/display/upload_reconciliation.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/drivers/__init__.py 9a8386ef368a6df7fd8cb23bcc442c28e784729a5ce4a78911ad86245d5c5794 pigway_frame_flow/drivers/base.py @@ -207,8 +207,8 @@ f14bad45534041ff0b7dead21d2952ec9d6c60e9f718ab5393bdf5a47b2a6d3e tests/test_pho ab773ca6868ec007786e7142d5425663a5c8da35b9aa90837ac8e46b51e51507 tests/test_production_bootstrap.py fee152c5f6eaeda712d38cf88a79213f5e885ba323eb15dfa5a12cf4c88028c3 tests/test_production_deploy.py f0601f5ed89e8a14a4584a9683611aa7510a964f50a642348b8ab6c62df72278 tests/test_publication_security.py -1f6ca49673eae895dacfb5dd74cecf72b2136c218078ab99e24ae766a1340ed8 tests/test_recovery.py -072e8ca2fa68ca73fb078b107ced96ea683652410b286017db883c97608d78f6 tests/test_retention.py +915992a2ef9aeb8ae5c688d5dd50c6acd37ba726eb5aff1a0df991ffe374d94e tests/test_recovery.py +e0a426eed6f1b6f59207b4de7d8286b6852550bfa28ad30e20f7e911ad9865ee tests/test_retention.py ab2cd30163453fe571b55c30cb26dc047a104a536ec01d2c64aa2bc91996ba11 tests/test_scheduler.py 0cccc08adf35a06dbf704eb5c35966bc1c3de0ce39d3014efd7ca751253ce79f tests/test_server_release.py a7ae1b4a64bfcbcf2b7ff795b30ba91db24ee2ad7544073dca2bf43d021cd266 tests/test_smoke_dependencies.py diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py index 03bccc2..68538f5 100644 --- a/pigway_frame_flow/display/service.py +++ b/pigway_frame_flow/display/service.py @@ -518,11 +518,11 @@ class DisplayService: ) and self.blocked(op["device_id"]): continue payload = json.loads(op["payload"]) - if op["action"] == "display": - if op["client_id"] in ( - "scheduler", - "scheduler-retry", - ) and self.db.one( + if ( + op["action"] in ("display", "delete", "restore") + and op["client_id"] + in ("scheduler", "scheduler-retry", "cloud-library", "retention") + and self.db.one( "SELECT id FROM operations WHERE device_id=? AND action='display' " "AND status='SYNCING' AND write_started=1 AND updated_at>? LIMIT 1", ( @@ -532,10 +532,12 @@ class DisplayService: - timedelta(seconds=self.settings.sync_timeout) ).isoformat(), ), - ): - # Upload activates a new image upstream. Give the prior - # image its existing confirmation window before replacing it. - continue + ) + ): + # Both upload and deletion update the cloud frame counter. + # Automatic writes must not invalidate a pending display ack. + continue + if op["action"] == "display": priority = payload["options"]["priority"] top = self.top(op["device_id"]) if top and priority < top["priority"]: diff --git a/tests/test_recovery.py b/tests/test_recovery.py index c74496f..4a3fd1a 100644 --- a/tests/test_recovery.py +++ b/tests/test_recovery.py @@ -270,3 +270,24 @@ def test_automatic_wait_does_not_extend_past_sync_timeout(service): ) service.process_one() assert service.operation(second)["status"] == "SYNCING" + + +def test_automatic_cleanup_waits_for_display_confirmation(service, monkeypatch): + first = submit(service) + service.process_one() + monkeypatch.setattr(service.cloud_library, "eligible", lambda *args: True) + cleanup = service.submit( + "delete", + {"image_id": 1, "cloud_cleanup": True}, + "living-room", + "cloud-library", + "fixture-cleanup", + ) + service.process_one() + assert service.operation(cleanup)["status"] == "QUEUED" + assert 1 in [x.image_id for x in service.registry.get("living-room").images()] + service.sync() + service.process_one() + assert service.operation(first)["status"] == "COMPLETED" + assert service.operation(cleanup)["status"] == "COMPLETED" + assert 1 not in [x.image_id for x in service.registry.get("living-room").images()] diff --git a/tests/test_retention.py b/tests/test_retention.py index e88e1ae..1515284 100644 --- a/tests/test_retention.py +++ b/tests/test_retention.py @@ -37,6 +37,8 @@ def due(service, image_id): "UPDATE image_retention SET expires_at=? WHERE image_id=?", ((datetime.now(UTC) - timedelta(days=1)).isoformat(), image_id), ) + # Complete the upload before simulating expiry days later and switching away. + service.sync() # The test driver switches to a different image, not a real device. driver = service.registry.get("living-room") driver.display(1)