feat: add institutional governance and recovery contracts
This commit is contained in:
+182
-23
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user