From 7a1dabfa7f0f0a2919833777c96c3c27dfa51f30 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:58:56 +0800 Subject: [PATCH] fix: Persist the Azure digest deadline receipt Co-Authored-By: Codex --- docs/unified-daily-notification.md | 7 +++++ python/scripts/azure_timer_digest.py | 26 ++++++++++++++++++- python/tests/test_azure_timer_digest.py | 34 +++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/docs/unified-daily-notification.md b/docs/unified-daily-notification.md index 88da4e37..29e78f17 100644 --- a/docs/unified-daily-notification.md +++ b/docs/unified-daily-notification.md @@ -336,6 +336,13 @@ stored delivery receipt; it does not recollect late candidates or prove receiver receipt. Do not postpone the central read or widen the business window to hide late producer jobs. +The Azure deadline check also saves its first result under +`/deadline-checks/.json` within the delivery +prefix. The record is create-only. It can be read after the job's console +logs expire and records the check result, the original slot and check time. +A retry keeps the first recorded result. The check reads the delivery +record and never sends a notification or modifies that record. + Each expected producer target has an archive-only entrypoint, `python -m scripts.publish_digest_before_read`, in its platform repository. The proposed trigger is a dedicated Cloud Run Job at 06:10 Asia/Shanghai, diff --git a/python/scripts/azure_timer_digest.py b/python/scripts/azure_timer_digest.py index 98c59f1a..f2bf44f5 100644 --- a/python/scripts/azure_timer_digest.py +++ b/python/scripts/azure_timer_digest.py @@ -58,8 +58,32 @@ def execute_scheduled(scheduled, configuration, store, send, *, now=core.utc_now def _execute_validated(scheduled, configuration, store, send, *, now, platform_config, provenance): report_date, day, check = core.business_slot(scheduled) - return core.execute_slot( + status, result = core.execute_slot( report_date, day, check, {'scheduled_at': scheduled.isoformat(), 'trigger_provenance': provenance}, configuration, store, send, now=now, platform_config=platform_config, ) + if result.get('phase') != 'deadline_check': + return status, result + key = f'{configuration.recipient_scope}/deadline-checks/{report_date.isoformat()}.json' + receipt = { + 'schema_version': 'qsl.daily_digest_deadline_check.v1', + 'report_date': report_date.isoformat(), + 'business_day': day, + 'scheduled_at': scheduled.isoformat(), + 'checked_at': now().isoformat(), + 'delivery': result['delivery'], + 'trigger_provenance': provenance, + } + try: + created, existing = store.reserve(key, receipt) + if not created: + identity_fields = ('schema_version', 'report_date', 'business_day') + if (not isinstance(existing, dict) + or any(existing.get(field) != receipt[field] for field in identity_fields) + or existing.get('delivery') not in ('confirmed', 'receipt_unverified')): + raise core.StoreUnavailable() + result = {**result, 'delivery': existing['delivery']} + except core.StoreUnavailable: + return 503, {**result, 'deadline_receipt': 'unverified'} + return status, {**result, 'deadline_receipt': 'recorded' if created else 'already_recorded'} diff --git a/python/tests/test_azure_timer_digest.py b/python/tests/test_azure_timer_digest.py index 4e7b1ee5..17202438 100644 --- a/python/tests/test_azure_timer_digest.py +++ b/python/tests/test_azure_timer_digest.py @@ -73,3 +73,37 @@ def test_container_window_supplies_original_time_at_the_boundary(self): ValueError, 'unverified_sliding_window' ): window_slot(bad_start, bad_end, 86400, NOW) + + def test_deadline_receipt_is_separate_create_only_and_keeps_first_result(self): + store, send = Mock(), Mock() + store.delivery.return_value = { + 'report_date': '2026-11-10', 'business_day': '2026-11-09', + 'recipient_scope': 'synthetic', 'delivery': 'accepted', 'late': False, + } + configuration = Mock(recipient_scope='synthetic', cutover_date='2026-11-10', enabled=True) + slot = datetime.fromisoformat('2026-11-09T23:30:00Z') + store.reserve.return_value = (True, 1) + code, result = execute_scheduled(slot, configuration, store, send, now=lambda: NOW) + self.assertEqual((code, result['delivery'], result['deadline_receipt']), (200, 'confirmed', 'recorded')) + key, receipt = store.reserve.call_args.args + self.assertEqual(key, 'synthetic/deadline-checks/2026-11-10.json') + self.assertEqual(receipt['delivery'], 'confirmed') + store.delivery.assert_called_once_with('synthetic/2026-11-10.json') + + store.reserve.return_value = (False, {**receipt, 'delivery': 'receipt_unverified'}) + code, result = execute_scheduled(slot, configuration, store, send, now=lambda: NOW) + self.assertEqual((code, result['delivery'], result['deadline_receipt']), + (200, 'receipt_unverified', 'already_recorded')) + store.finish.assert_not_called() + send.assert_not_called() + + def test_deadline_receipt_storage_failure_retries_check_without_sending(self): + store, send = Mock(), Mock() + store.delivery.return_value = None + store.reserve.side_effect = core.StoreUnavailable() + configuration = Mock(recipient_scope='synthetic', cutover_date='2026-11-10', enabled=True) + slot = datetime.fromisoformat('2026-11-09T23:30:00Z') + code, result = execute_scheduled(slot, configuration, store, send, now=lambda: NOW) + self.assertEqual((code, result['delivery'], result['deadline_receipt']), + (503, 'receipt_unverified', 'unverified')) + send.assert_not_called()