From e3b3c7fbd318775da2b8ed129f4a970c178ba1f9 Mon Sep 17 00:00:00 2001 From: ogt Date: Wed, 15 Jul 2026 17:25:25 +0800 Subject: [PATCH] fix(growth): make active source canary idempotent --- config.py | 2 +- .../external_marketplace_canary_service.py | 85 +++++++++++++++++-- tests/test_external_market_offer_service.py | 21 +++++ 3 files changed, 101 insertions(+), 7 deletions(-) diff --git a/config.py b/config.py index a6badfe..0b15da6 100644 --- a/config.py +++ b/config.py @@ -414,7 +414,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.807" +SYSTEM_VERSION = "V10.808" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/services/external_marketplace_canary_service.py b/services/external_marketplace_canary_service.py index 96b6f4e..b08b367 100644 --- a/services/external_marketplace_canary_service.py +++ b/services/external_marketplace_canary_service.py @@ -287,14 +287,13 @@ def activate_external_market_source_after_canary( and bool(item["same_item_reconciliation"].get("independent_verifier_passed")) for item in candidates ) - can_activate = bool( + canary_evidence_verified = bool( independent_readback.get("success") - and len(candidates) >= threshold and readback_count == len(candidates) and guards_passed and independent_verifiers_passed ) - if not can_activate: + if not canary_evidence_verified: return { "success": True, "status": "canary_threshold_not_met", @@ -305,6 +304,72 @@ def activate_external_market_source_after_canary( "guards_passed": guards_passed, "independent_verifiers_passed": independent_verifiers_passed, "applied": False, + "state_changed": False, + "writes_database_count": 0, + } + + with engine.begin() as conn: + _ensure_external_market_source_seeds(conn) + current = ( + conn.execute( + text( + """ + SELECT status, enabled, write_enabled + FROM external_market_sources + WHERE code = :source_code + """ + ), + {"source_code": source_code}, + ) + .mappings() + .first() + ) + if not current: + return { + "success": False, + "status": "source_registry_missing", + "source_code": source_code, + "applied": False, + "state_changed": False, + "writes_database_count": 0, + } + + already_active = bool( + str(current.get("status") or "") == "active" + and bool(current.get("enabled")) + and bool(current.get("write_enabled")) + ) + if already_active: + return { + "success": True, + "status": "already_active_verified", + "source_code": source_code, + "candidate_count": len(candidates), + "readback_pass_count": readback_count, + "minimum_verified_count": threshold, + "guards_passed": guards_passed, + "independent_verifiers_passed": independent_verifiers_passed, + "applied": True, + "state_changed": False, + "previous_state": dict(current), + "observed_state": dict(current), + "identity": dict(identity or {}), + "writes_database_count": 0, + } + + if len(candidates) < threshold: + return { + "success": True, + "status": "canary_threshold_not_met", + "source_code": source_code, + "candidate_count": len(candidates), + "readback_pass_count": readback_count, + "minimum_verified_count": threshold, + "guards_passed": guards_passed, + "independent_verifiers_passed": independent_verifiers_passed, + "applied": False, + "state_changed": False, + "observed_state": dict(current), "writes_database_count": 0, } @@ -330,6 +395,7 @@ def activate_external_market_source_after_canary( "status": "source_registry_missing", "source_code": source_code, "applied": False, + "state_changed": False, "writes_database_count": 0, } conn.execute( @@ -382,6 +448,7 @@ def activate_external_market_source_after_canary( "observed_state": dict(observed or {}), "identity": dict(identity or {}), "writes_database_count": 1 if applied else 0, + "state_changed": applied, } @@ -452,7 +519,13 @@ def rollback_external_market_source_canary( {"offer_id": offer_id}, ) - if activation_receipt.get("applied") and previous: + state_changed = bool( + activation_receipt.get( + "state_changed", + activation_receipt.get("applied"), + ) + ) + if state_changed and previous: conn.execute( text( """ @@ -478,10 +551,10 @@ def rollback_external_market_source_canary( "status": "rolled_back_and_quarantined", "source_code": source_code, "quarantined_offer_count": len(quarantined_ids), - "source_state_restored": bool(activation_receipt.get("applied") and previous), + "source_state_restored": bool(state_changed and previous), "identity": dict(identity or {}), "writes_database_count": len(quarantined_ids) - + (1 if activation_receipt.get("applied") and previous else 0), + + (1 if state_changed and previous else 0), } diff --git a/tests/test_external_market_offer_service.py b/tests/test_external_market_offer_service.py index 3274158..b5229b8 100644 --- a/tests/test_external_market_offer_service.py +++ b/tests/test_external_market_offer_service.py @@ -797,6 +797,27 @@ def test_targeted_marketplace_canary_activates_and_rollback_quarantines(monkeypa assert yahoo["has_runtime_data"] is True assert yahoo["write_enabled"] is True + one_candidate_readback = readback_external_offer_candidates( + engine, + candidates[:1], + source_code="yahoo_shopping", + ingestion_method="public_product_jsonld", + ) + idempotent_activation = ( + canary_service.activate_external_market_source_after_canary( + engine, + source_code="yahoo_shopping", + candidates=candidates[:1], + independent_readback=one_candidate_readback, + identity=identity, + minimum_verified_count=2, + ) + ) + assert idempotent_activation["status"] == "already_active_verified" + assert idempotent_activation["applied"] is True + assert idempotent_activation["state_changed"] is False + assert idempotent_activation["writes_database_count"] == 0 + rollback = canary_service.rollback_external_market_source_canary( engine, source_code="yahoo_shopping",