Add worker delivery acceptance probe

This commit is contained in:
2026-08-03 00:20:51 +02:00
parent 435b924fd9
commit 21c1fa49b6
4 changed files with 113 additions and 2 deletions
+8
View File
@@ -158,6 +158,7 @@ release evidence.
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. | | `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. | | `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
| `CELERY_QUEUES` | `send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default` | Queue list expected by worker/process manager definitions. Keep this aligned with every enabled module task route; Ops reports missing worker queue consumers. | | `CELERY_QUEUES` | `send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default` | Queue list expected by worker/process manager definitions. Keep this aligned with every enabled module task route; Ops reports missing worker queue consumers. |
| `CELERY_VISIBILITY_TIMEOUT_SECONDS` | `3600` | Maximum time before Redis may redeliver work left unacknowledged by a lost worker. Set this above the longest supported task duration; changing it requires a worker-loss acceptance drill. |
| `PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS` | `8` | Failed durable consumer deliveries are quarantined after this many attempts. | | `PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS` | `8` | Failed durable consumer deliveries are quarantined after this many attempts. |
| `PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS` | `90` | Successful event envelopes older than this are removed by the daily retention task. Quarantined evidence is retained. | | `PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS` | `90` | Successful event envelopes older than this are removed by the daily retention task. Quarantined evidence is retained. |
@@ -177,6 +178,13 @@ crashes, and expired worker leases:
python -m celery -A govoplan_core.celery_app:celery beat --loglevel INFO python -m celery -A govoplan_core.celery_app:celery beat --loglevel INFO
``` ```
Before promoting a worker composition, run the repository worker-runtime drill
against the same Redis and Core build. It uses the bounded
`govoplan.worker.acceptance` task and records publish/consume, retry, warm
SIGTERM, and worker-loss redelivery evidence without accessing tenant data.
Production evidence must use the deployed queue configuration and a visibility
timeout that is longer than every supported business task.
### Storage ### Storage
| Setting | Default | Notes | | Setting | Default | Notes |
+72
View File
@@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone
from importlib.metadata import PackageNotFoundError, version from importlib.metadata import PackageNotFoundError, version
import logging import logging
import os import os
import time
from celery import Celery from celery import Celery
from celery.signals import ( from celery.signals import (
@@ -110,6 +111,14 @@ celery.conf.update(
worker_prefetch_multiplier=1, worker_prefetch_multiplier=1,
task_acks_late=True, task_acks_late=True,
task_reject_on_worker_lost=True, task_reject_on_worker_lost=True,
task_track_started=True,
broker_transport_options={
"visibility_timeout": settings.celery_visibility_timeout_seconds,
},
result_backend_transport_options={
"visibility_timeout": settings.celery_visibility_timeout_seconds,
},
visibility_timeout=settings.celery_visibility_timeout_seconds,
beat_schedule={ beat_schedule={
"calendar-outbox-every-minute": { "calendar-outbox-every-minute": {
"task": "govoplan.calendar.dispatch_outbox", "task": "govoplan.calendar.dispatch_outbox",
@@ -308,6 +317,69 @@ def ping():
return "pong" return "pong"
@celery.task(
bind=True,
name="govoplan.worker.acceptance",
max_retries=1,
acks_late=True,
reject_on_worker_lost=True,
track_started=True,
)
def worker_acceptance_probe(
task,
probe_id: str,
*,
mode: str = "complete",
delay_seconds: float = 0.0,
track_delivery: bool = False,
):
"""Exercise broker delivery semantics without touching business data."""
if mode not in {"complete", "retry_once"}:
raise ValueError(f"Unsupported worker acceptance mode: {mode}")
normalized_probe_id = str(probe_id).strip()
if not normalized_probe_id or len(normalized_probe_id) > 120:
raise ValueError("Worker acceptance probe_id must contain 1-120 characters")
bounded_delay = max(0.0, min(float(delay_seconds), 120.0))
delivery_count = _worker_acceptance_delivery_count(
task,
normalized_probe_id,
) if track_delivery else None
task.update_state(
state="PROGRESS",
meta={
"phase": "started",
"probe_id": normalized_probe_id,
"retries": int(task.request.retries or 0),
"delivery_count": delivery_count,
},
)
if mode == "retry_once" and int(task.request.retries or 0) == 0:
raise task.retry(countdown=0.2)
if bounded_delay:
time.sleep(bounded_delay)
delivery_info = task.request.delivery_info or {}
return {
"probe_id": normalized_probe_id,
"mode": mode,
"retries": int(task.request.retries or 0),
"redelivered": bool(delivery_info.get("redelivered")),
"delivery_count": delivery_count,
}
def _worker_acceptance_delivery_count(task, probe_id: str) -> int:
client = getattr(task.backend, "client", None)
if client is None:
raise RuntimeError(
"Worker acceptance delivery tracking requires a Redis result backend"
)
key = f"govoplan:worker-acceptance:{probe_id}"
count = int(client.incr(key))
client.expire(key, 60 * 60)
return count
def _platform_registry() -> PlatformRegistry: def _platform_registry() -> PlatformRegistry:
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules) raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
candidate_modules = startup_candidate_module_ids( candidate_modules = startup_candidate_module_ids(
+6
View File
@@ -115,6 +115,12 @@ class Settings(BaseSettings):
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK") migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL") redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED") celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
celery_visibility_timeout_seconds: int = Field(
default=3600,
ge=30,
le=7 * 24 * 60 * 60,
alias="CELERY_VISIBILITY_TIMEOUT_SECONDS",
)
s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL") s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL")
s3_region: str = Field(default="garage", alias="S3_REGION") s3_region: str = Field(default="garage", alias="S3_REGION")
+27 -2
View File
@@ -1,8 +1,9 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from unittest.mock import patch
from govoplan_core.celery_app import celery from govoplan_core.celery_app import celery, worker_acceptance_probe
from govoplan_core.settings import settings from govoplan_core.settings import settings
@@ -26,9 +27,33 @@ class CeleryQueueContractTests(unittest.TestCase):
def test_delivery_tasks_keep_worker_loss_protection(self) -> None: def test_delivery_tasks_keep_worker_loss_protection(self) -> None:
self.assertTrue(celery.conf.task_acks_late) self.assertTrue(celery.conf.task_acks_late)
self.assertTrue(celery.conf.task_reject_on_worker_lost) self.assertTrue(celery.conf.task_reject_on_worker_lost)
self.assertTrue(celery.conf.task_track_started)
self.assertEqual(1, celery.conf.worker_prefetch_multiplier) self.assertEqual(1, celery.conf.worker_prefetch_multiplier)
self.assertEqual(
settings.celery_visibility_timeout_seconds,
celery.conf.broker_transport_options["visibility_timeout"],
)
def test_worker_acceptance_probe_is_bounded_and_side_effect_free(self) -> None:
with patch.object(worker_acceptance_probe, "update_state") as update_state:
result = worker_acceptance_probe.run(
"probe-1",
mode="complete",
delay_seconds=0,
)
self.assertEqual(
{
"probe_id": "probe-1",
"mode": "complete",
"retries": 0,
"redelivered": False,
"delivery_count": None,
},
result,
)
update_state.assert_called_once()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()