feat: add institutional governance and recovery contracts

This commit is contained in:
2026-08-01 17:46:54 +02:00
parent b65b48832b
commit 7192d32e65
61 changed files with 12539 additions and 168 deletions
+182 -23
View File
@@ -1,11 +1,20 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from importlib.metadata import PackageNotFoundError, version
import logging
from celery import Celery
from celery.signals import heartbeat_sent, worker_ready, worker_shutdown
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_OUTBOX, CalendarOutboxProvider
from govoplan_core.core.campaigns import (
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS,
CampaignDeliveryTaskProvider,
)
from govoplan_core.core.calendar import (
CAPABILITY_CALENDAR_OUTBOX,
CalendarOutboxProvider,
)
from govoplan_core.core.dataflows import (
CAPABILITY_DATAFLOW_RUN_WORKER,
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER,
@@ -23,7 +32,10 @@ from govoplan_core.core.idm import (
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
IdmAssignmentLifecycle,
)
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.module_management import (
load_startup_enabled_modules,
startup_candidate_module_ids,
)
from govoplan_core.core.mail import (
CAPABILITY_MAIL_BOUNCE_PROCESSING,
CAPABILITY_MAIL_DELIVERY_OUTBOX,
@@ -31,7 +43,10 @@ from govoplan_core.core.mail import (
MailDeliveryOutboxProvider,
)
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
from govoplan_core.core.notifications import (
CAPABILITY_NOTIFICATIONS_DISPATCH,
NotificationDispatchProvider,
)
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_ROUTING,
PostboxRoutingProvider,
@@ -42,9 +57,19 @@ from govoplan_core.core.workflows import (
)
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import configure_runtime
from govoplan_core.core.runtime_coordination import (
RuntimeIdentity,
heartbeat_runtime_node,
register_runtime_node,
runtime_identity,
stop_runtime_node,
)
from govoplan_core.settings import settings
from govoplan_core.db.session import configure_database
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.server.registry import (
available_module_manifests,
build_platform_registry,
)
configure_database(settings.database_url)
@@ -141,6 +166,119 @@ celery.conf.update(
},
)
_worker_identity: RuntimeIdentity | None = None
_worker_draining = False
_worker_consumer: object | None = None
logger = logging.getLogger("govoplan.worker.runtime")
def _core_version() -> str:
try:
return version("govoplan-core")
except PackageNotFoundError:
return "development"
def _worker_runtime_identity(sender: object | None = None) -> RuntimeIdentity:
global _worker_identity
if _worker_identity is None:
hostname = str(getattr(sender, "hostname", "") or "").strip() or None
module_ids = tuple(load_startup_enabled_modules(settings.enabled_modules))
_worker_identity = runtime_identity(
settings,
software_version=_core_version(),
module_ids=module_ids,
role="worker",
node_id=hostname,
)
return _worker_identity
@worker_ready.connect
def _register_worker_runtime(sender=None, **_kwargs) -> None:
global _worker_consumer
_worker_consumer = sender
identity = _worker_runtime_identity(sender)
try:
with get_database().SessionLocal() as session:
node = register_runtime_node(
session,
identity,
metadata={"process": "celery-worker"},
)
session.commit()
except Exception:
_set_worker_consumers(identity, draining=True)
logger.exception(
"worker runtime registration failed; queues were disabled node_id=%s",
identity.node_id,
)
raise
_set_worker_consumers(identity, draining=node.state == "draining")
@heartbeat_sent.connect
def _heartbeat_worker_runtime(sender=None, **_kwargs) -> None:
identity = _worker_runtime_identity(sender)
try:
with get_database().SessionLocal() as session:
node = heartbeat_runtime_node(
session,
identity,
metadata={"process": "celery-worker"},
)
session.commit()
except Exception: # noqa: BLE001 - uncertain authority must fail closed
_set_worker_consumers(identity, draining=True)
logger.exception(
"worker runtime heartbeat failed; queues were disabled node_id=%s",
identity.node_id,
)
return
_set_worker_consumers(identity, draining=node.state == "draining")
def _set_worker_consumers(
identity: RuntimeIdentity,
*,
draining: bool,
) -> None:
global _worker_draining
if draining == _worker_draining:
return
consumer = _worker_consumer
if consumer is not None:
method_name = "cancel_task_queue" if draining else "add_task_queue"
method = getattr(consumer, method_name)
destination = None
else:
method = (
celery.control.cancel_consumer if draining else celery.control.add_consumer
)
destination = [identity.node_id]
for queue in identity.queues:
if destination is None:
method(queue)
else:
method(queue, destination=destination)
_worker_draining = draining
@worker_shutdown.connect
def _stop_worker_runtime(sender=None, **_kwargs) -> None:
identity = _worker_identity
if identity is None:
return
try:
with get_database().SessionLocal() as session:
stop_runtime_node(session, identity)
session.commit()
except Exception: # noqa: BLE001 - shutdown must continue
logger.exception(
"worker runtime stop marker failed node_id=%s",
identity.node_id,
)
@celery.task(name="govoplan.ping")
def ping():
@@ -149,9 +287,15 @@ def ping():
def _platform_registry() -> PlatformRegistry:
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
enabled_modules = load_startup_enabled_modules(settings.enabled_modules, available=available_modules)
candidate_modules = startup_candidate_module_ids(
settings.enabled_modules, raw_enabled_modules
)
available_modules = available_module_manifests(
enabled_modules=candidate_modules, ignore_load_errors=True
)
enabled_modules = load_startup_enabled_modules(
settings.enabled_modules, available=available_modules
)
registry = build_platform_registry(enabled_modules)
context = ModuleContext(registry=registry, settings=settings)
configure_runtime(context)
@@ -211,9 +355,7 @@ def _dataflow_trigger_dispatcher(
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER):
return None
capability = registry.require_capability(
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER
)
capability = registry.require_capability(CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER)
if not isinstance(capability, DataflowTriggerDispatcher):
raise RuntimeError("Dataflow trigger dispatcher capability is invalid")
return capability
@@ -237,9 +379,7 @@ def _workflow_runtime_worker(
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_WORKFLOW_RUNTIME_WORKER):
return None
capability = registry.require_capability(
CAPABILITY_WORKFLOW_RUNTIME_WORKER
)
capability = registry.require_capability(CAPABILITY_WORKFLOW_RUNTIME_WORKER)
if not isinstance(capability, WorkflowRuntimeWorker):
raise RuntimeError("Workflow runtime worker capability is invalid")
return capability
@@ -275,9 +415,7 @@ def _idm_assignment_lifecycle(
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE):
return None
capability = registry.require_capability(
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE
)
capability = registry.require_capability(CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE)
if not isinstance(capability, IdmAssignmentLifecycle):
raise RuntimeError("IDM assignment lifecycle capability is invalid")
return capability
@@ -295,7 +433,11 @@ def send_email(self, job_id: str):
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
return dict(_campaign_delivery_tasks().send_campaign_job(session, job_id=job_id, enqueue_imap_task=True))
return dict(
_campaign_delivery_tasks().send_campaign_job(
session, job_id=job_id, enqueue_imap_task=True
)
)
@celery.task(name="govoplan.campaigns.append_sent", bind=True, max_retries=None)
@@ -306,7 +448,9 @@ def append_sent(self, job_id: str):
with get_database().SessionLocal() as session:
try:
return dict(_campaign_delivery_tasks().append_sent_for_job(session, job_id=job_id))
return dict(
_campaign_delivery_tasks().append_sent_for_job(session, job_id=job_id)
)
except Exception as exc:
if getattr(exc, "temporary", None) is True:
raise self.retry(exc=exc, countdown=300)
@@ -318,7 +462,11 @@ def deliver_notification(self, notification_id: str):
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
result = dict(_notification_dispatch().deliver_notification(session, notification_id=notification_id))
result = dict(
_notification_dispatch().deliver_notification(
session, notification_id=notification_id
)
)
session.commit()
return result
@@ -328,7 +476,11 @@ def deliver_pending_notifications(self, tenant_id: str | None = None, limit: int
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
result = dict(_notification_dispatch().deliver_pending(session, tenant_id=tenant_id, limit=limit))
result = dict(
_notification_dispatch().deliver_pending(
session, tenant_id=tenant_id, limit=limit
)
)
session.commit()
return result
@@ -411,7 +563,13 @@ def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50
with get_database().SessionLocal() as session:
provider = _calendar_outbox()
if provider is None:
return {"processed": 0, "succeeded": 0, "retrying": 0, "failed": 0, "operations": []}
return {
"processed": 0,
"succeeded": 0,
"retrying": 0,
"failed": 0,
"operations": [],
}
result = dict(provider.dispatch_due(session, tenant_id=tenant_id, limit=limit))
session.commit()
return result
@@ -608,6 +766,7 @@ def dispatch_platform_events(self, limit: int = 100):
dataflow_dispatcher = _dataflow_trigger_dispatcher(registry)
consumers = ()
if dataflow_dispatcher is not None:
def deliver_to_dataflow(
event: PlatformEvent,
_delivery_key: str,