Files
govoplan-calendar/src/govoplan_calendar/backend/outbox.py

1642 lines
54 KiB
Python

from __future__ import annotations
import hashlib
import json
import uuid
from collections.abc import Callable, Mapping
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import and_, event, or_
from sqlalchemy.orm import Session
from govoplan_calendar.backend.caldav import (
CalDAVNotFound,
CalDAVObject,
CalDAVPreconditionFailed,
)
from govoplan_calendar.backend.db.models import (
CalendarEvent,
CalendarOutboxOperation,
CalendarSyncSource,
)
from govoplan_calendar.backend.ical import events_to_ics, parse_vevents
from govoplan_core.db.base import utcnow
OUTBOX_PENDING_STATUSES = {"pending", "retry"}
OUTBOX_ACTIVE_STATUSES = OUTBOX_PENDING_STATUSES | {"in_progress"}
OUTBOX_TERMINAL_STATUSES = {"succeeded", "superseded", "cancelled", "conflict", "dead"}
OUTBOX_UNRESOLVED_DESIRED_STATUSES = OUTBOX_ACTIVE_STATUSES | {"conflict", "dead"}
OUTBOX_PURGEABLE_STATUSES = OUTBOX_TERMINAL_STATUSES - OUTBOX_UNRESOLVED_DESIRED_STATUSES
OUTBOX_DEFAULT_MAX_ATTEMPTS = 8
OUTBOX_DEFAULT_TERMINAL_RETENTION_DAYS = 90
OUTBOX_LEASE_SECONDS = 300
OUTBOX_BACKOFF_BASE_SECONDS = 5
OUTBOX_BACKOFF_MAX_SECONDS = 3600
_AFTER_COMMIT_TENANTS = "govoplan_calendar_after_commit_outbox_tenants"
def _source_configuration_snapshot(source: CalendarSyncSource) -> dict[str, str]:
return {
"calendar_id": source.calendar_id,
"collection_url": source.collection_url,
}
def _operation_source_unavailable_reason(
operation: CalendarOutboxOperation,
source: CalendarSyncSource | None,
) -> str | None:
if source is None:
return "CalDAV source no longer exists"
if source.tenant_id != operation.tenant_id:
return "Calendar outbox source tenant does not match the operation tenant"
if source.deleted_at is not None:
return "CalDAV source was retired before this operation was delivered"
if not source.sync_enabled:
return "CalDAV source was disabled before this operation was delivered"
if source.sync_direction != "two_way":
return "CalDAV source was made inbound-only before this operation was delivered"
metadata = operation.metadata_ or {}
frozen_configuration = (
metadata.get("source_configuration") if isinstance(metadata, dict) else None
)
if (
isinstance(frozen_configuration, dict)
and frozen_configuration != _source_configuration_snapshot(source)
):
return "CalDAV source endpoint or calendar changed before this operation was delivered"
return None
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
@event.listens_for(Session, "after_commit")
def _dispatch_committed_calendar_outbox(session: Session) -> None:
# ``after_commit`` also fires when a SAVEPOINT is released. The durable
# row is not visible outside the outer transaction at that point, so keep
# the wake-up registered until the root transaction commits.
if session.in_nested_transaction():
return
tenant_ids = tuple(session.info.pop(_AFTER_COMMIT_TENANTS, ()))
for tenant_id in tenant_ids:
_enqueue_celery_dispatch(tenant_id)
@event.listens_for(Session, "after_rollback")
def _discard_rolled_back_calendar_outbox(session: Session) -> None:
# A nested rollback must not discard wake-ups registered by work in the
# still-live outer transaction. A possibly superfluous wake-up after an
# outer commit is harmless; the dispatcher simply finds no due row.
if session.in_nested_transaction():
return
session.info.pop(_AFTER_COMMIT_TENANTS, None)
def _schedule_dispatch_after_commit(session: Session, tenant_id: str) -> None:
tenants = session.info.setdefault(_AFTER_COMMIT_TENANTS, [])
if tenant_id not in tenants:
tenants.append(tenant_id)
def _enqueue_celery_dispatch(tenant_id: str) -> None:
try:
from govoplan_core.celery_app import celery
from govoplan_core.settings import settings
if not getattr(settings, "celery_enabled", False):
return
celery.send_task(
"govoplan.calendar.dispatch_outbox",
args=[tenant_id, 50],
queue="calendar",
)
except Exception:
# The committed outbox row is the source of truth. Broker/import
# failures are recovered by the periodic dispatcher.
return
def calendar_payload_fingerprint(ics: str) -> str:
"""Return a semantic fingerprint resilient to harmless ICS formatting."""
try:
components: list[dict[str, Any]] = []
for parsed in parse_vevents(ics):
normalized = {key: value for key, value in parsed.items() if key != "raw_ics"}
components.append(normalized)
material = json.dumps(components, sort_keys=True, separators=(",", ":"), default=str)
except Exception:
material = "\n".join(line.rstrip() for line in ics.replace("\r\n", "\n").splitlines()).strip()
return hashlib.sha256(material.encode("utf-8")).hexdigest()
def _operation_idempotency_key(
*,
source: CalendarSyncSource,
href: str,
operation_kind: str,
payload_fingerprint: str | None,
expected_etag: str | None,
generation: str,
) -> str:
material = "\0".join(
(
source.id,
href,
operation_kind,
payload_fingerprint or "",
expected_etag or "",
generation,
)
)
return hashlib.sha256(material.encode("utf-8")).hexdigest()
def _event_generation(events: list[CalendarEvent], *, trigger_event: CalendarEvent | None) -> str:
parts = [f"{item.id}:{item.sequence}" for item in sorted(events, key=lambda item: item.id)]
if trigger_event is not None and trigger_event not in events:
parts.append(f"trigger:{trigger_event.id}:{trigger_event.sequence}")
return "|".join(parts)
def _supersede_pending_resource_operations(
session: Session,
*,
source_id: str,
href: str,
now: datetime,
) -> None:
for operation in (
session.query(CalendarOutboxOperation)
.filter(
CalendarOutboxOperation.source_id == source_id,
CalendarOutboxOperation.resource_href == href,
or_(
and_(
CalendarOutboxOperation.status == "pending",
CalendarOutboxOperation.attempt_count == 0,
CalendarOutboxOperation.last_attempt_at.is_(None),
),
CalendarOutboxOperation.status.in_(("conflict", "dead")),
),
)
.all()
):
was_unattempted = operation.status == "pending"
operation.status = "superseded"
operation.completed_at = operation.completed_at or now
if was_unattempted:
operation.last_error = (
"Superseded by a newer desired state for the same CalDAV resource"
)
def _set_event_outbox_state(
event_model: CalendarEvent,
*,
source: CalendarSyncSource,
href: str,
operation: CalendarOutboxOperation,
state: str,
raw_ics: str | None,
) -> None:
event_model.source_kind = "caldav"
event_model.source_href = href
if raw_ics is not None:
event_model.raw_ics = raw_ics
metadata = dict(event_model.metadata_ or {})
caldav_metadata = dict(metadata.get("caldav") or {})
caldav_metadata.update(
{
"source_id": source.id,
"collection_url": source.collection_url,
"href": href,
"etag": event_model.etag,
"external_state": state,
"outbox_operation_id": operation.id,
}
)
metadata["caldav"] = caldav_metadata
event_model.metadata_ = metadata
def enqueue_caldav_put(
session: Session,
*,
source: CalendarSyncSource,
event_model: CalendarEvent,
) -> CalendarOutboxOperation:
"""Persist the exact desired remote resource in the caller transaction."""
from govoplan_calendar.backend.service import (
caldav_resource_events,
caldav_resource_sort_key,
generated_caldav_href,
normalize_caldav_href,
resource_etag,
)
href = normalize_caldav_href(
source.collection_url,
event_model.source_href or generated_caldav_href(session, source=source, event=event_model),
)
resource_events = caldav_resource_events(session, source=source, href=href)
if event_model not in resource_events:
resource_events.append(event_model)
resource_events = sorted(resource_events, key=caldav_resource_sort_key)
payload_ics = events_to_ics(resource_events)
return enqueue_caldav_desired_state(
session,
source=source,
trigger_event=event_model,
href=href,
resource_events=resource_events,
payload_ics=payload_ics,
expected_etag=resource_etag(resource_events),
)
def enqueue_caldav_delete(
session: Session,
*,
source: CalendarSyncSource,
event_model: CalendarEvent,
) -> CalendarOutboxOperation | None:
"""Persist the desired state after deleting one VEVENT component."""
if not event_model.source_href:
return None
from govoplan_calendar.backend.service import (
caldav_resource_events,
caldav_resource_sort_key,
normalize_caldav_href,
resource_etag,
)
href = normalize_caldav_href(source.collection_url, event_model.source_href)
remaining = [
item
for item in caldav_resource_events(session, source=source, href=href)
if item.id != event_model.id
]
remaining = sorted(remaining, key=caldav_resource_sort_key)
payload_ics = events_to_ics(remaining) if remaining else None
expected_etag = event_model.etag or resource_etag(remaining)
return enqueue_caldav_desired_state(
session,
source=source,
trigger_event=event_model,
href=href,
resource_events=remaining,
payload_ics=payload_ics,
expected_etag=expected_etag,
)
def enqueue_caldav_desired_state(
session: Session,
*,
source: CalendarSyncSource,
trigger_event: CalendarEvent | None,
href: str,
resource_events: list[CalendarEvent],
payload_ics: str | None,
expected_etag: str | None,
) -> CalendarOutboxOperation:
now = utcnow()
operation_kind = "put" if payload_ics is not None else "delete"
fingerprint = calendar_payload_fingerprint(payload_ics) if payload_ics is not None else None
idempotency_key = _operation_idempotency_key(
source=source,
href=href,
operation_kind=operation_kind,
payload_fingerprint=fingerprint,
expected_etag=expected_etag,
generation=_event_generation(resource_events, trigger_event=trigger_event),
)
existing = (
session.query(CalendarOutboxOperation)
.filter(CalendarOutboxOperation.idempotency_key == idempotency_key)
.first()
)
if existing is not None:
return existing
_supersede_pending_resource_operations(session, source_id=source.id, href=href, now=now)
operation = CalendarOutboxOperation(
tenant_id=source.tenant_id,
source_id=source.id,
event_id=trigger_event.id if trigger_event is not None else None,
operation_kind=operation_kind,
resource_href=href,
payload_ics=payload_ics,
payload_fingerprint=fingerprint,
expected_etag=expected_etag,
idempotency_key=idempotency_key,
status="pending",
attempt_count=0,
max_attempts=OUTBOX_DEFAULT_MAX_ATTEMPTS,
available_at=now,
metadata_={
"event_ids": [item.id for item in resource_events],
"overwrite": source.conflict_policy == "overwrite",
"source_configuration": _source_configuration_snapshot(source),
},
)
session.add(operation)
session.flush()
for item in resource_events:
_set_event_outbox_state(
item,
source=source,
href=href,
operation=operation,
state="queued",
raw_ics=payload_ics,
)
if trigger_event is not None and trigger_event not in resource_events:
_set_event_outbox_state(
trigger_event,
source=source,
href=href,
operation=operation,
state="queued_delete",
raw_ics=None,
)
session.flush()
_schedule_dispatch_after_commit(session, source.tenant_id)
return operation
def list_calendar_outbox_operations(
session: Session,
*,
tenant_id: str,
status: str | None = None,
source_id: str | None = None,
limit: int = 100,
) -> list[CalendarOutboxOperation]:
query = session.query(CalendarOutboxOperation).filter(
CalendarOutboxOperation.tenant_id == tenant_id
)
if status:
query = query.filter(CalendarOutboxOperation.status == status)
if source_id:
query = query.filter(CalendarOutboxOperation.source_id == source_id)
return query.order_by(
CalendarOutboxOperation.created_at.desc(),
CalendarOutboxOperation.id.desc(),
).limit(max(1, min(limit, 500))).all()
def get_calendar_outbox_operation(
session: Session,
*,
tenant_id: str,
operation_id: str,
) -> CalendarOutboxOperation:
operation = (
session.query(CalendarOutboxOperation)
.filter(
CalendarOutboxOperation.tenant_id == tenant_id,
CalendarOutboxOperation.id == operation_id,
)
.first()
)
if operation is None:
raise ValueError("Calendar outbox operation not found")
return operation
def _lock_operation_source(
session: Session,
*,
tenant_id: str,
operation_id: str,
) -> tuple[CalendarOutboxOperation, CalendarSyncSource | None]:
"""Lock source then operation, matching the outbound worker lock order."""
peek = (
session.query(CalendarOutboxOperation.source_id)
.filter(
CalendarOutboxOperation.tenant_id == tenant_id,
CalendarOutboxOperation.id == operation_id,
)
.first()
)
if peek is None:
raise ValueError("Calendar outbox operation not found")
source = (
session.query(CalendarSyncSource)
.filter(CalendarSyncSource.id == peek[0])
.with_for_update()
.one_or_none()
)
operation = (
session.query(CalendarOutboxOperation)
.filter(
CalendarOutboxOperation.tenant_id == tenant_id,
CalendarOutboxOperation.id == operation_id,
)
.with_for_update()
.one()
)
if source is not None and source.id != operation.source_id:
raise ValueError("Calendar outbox source changed while acquiring the operation lock")
return operation, source
def _newer_resource_generation_exists(
session: Session,
operation: CalendarOutboxOperation,
) -> bool:
return (
session.query(CalendarOutboxOperation.id)
.filter(
CalendarOutboxOperation.source_id == operation.source_id,
CalendarOutboxOperation.resource_href == operation.resource_href,
or_(
CalendarOutboxOperation.created_at > operation.created_at,
and_(
CalendarOutboxOperation.created_at == operation.created_at,
CalendarOutboxOperation.id > operation.id,
),
),
)
.first()
is not None
)
def _assert_latest_resource_generation(
session: Session,
operation: CalendarOutboxOperation,
) -> None:
if _newer_resource_generation_exists(session, operation):
raise ValueError(
"This Calendar outbox operation is stale because a newer desired state exists; "
"use the latest operation or make a new local edit"
)
def _reclaim_expired_leases(
session: Session,
*,
now: datetime,
tenant_id: str | None = None,
) -> int:
query = session.query(CalendarOutboxOperation).filter(
CalendarOutboxOperation.status == "in_progress",
CalendarOutboxOperation.lease_expires_at.is_not(None),
CalendarOutboxOperation.lease_expires_at <= now,
)
if tenant_id:
query = query.filter(CalendarOutboxOperation.tenant_id == tenant_id)
expired = query.with_for_update(skip_locked=True).all()
for operation in expired:
operation.status = "retry"
operation.available_at = now
operation.lease_token = None
operation.lease_expires_at = None
operation.last_error = "Worker lease expired; remote outcome will be reconciled before retry"
return len(expired)
def claim_next_calendar_outbox_operation(
session: Session,
*,
tenant_id: str | None = None,
now: datetime | None = None,
lease_seconds: int = OUTBOX_LEASE_SECONDS,
) -> CalendarOutboxOperation | None:
now = now or utcnow()
_reclaim_expired_leases(session, now=now, tenant_id=tenant_id)
query = session.query(CalendarOutboxOperation).filter(
CalendarOutboxOperation.status.in_(sorted(OUTBOX_PENDING_STATUSES)),
CalendarOutboxOperation.available_at <= now,
)
if tenant_id:
query = query.filter(CalendarOutboxOperation.tenant_id == tenant_id)
candidates = query.order_by(
CalendarOutboxOperation.available_at.asc(),
CalendarOutboxOperation.created_at.asc(),
CalendarOutboxOperation.id.asc(),
).with_for_update(skip_locked=True).limit(50).all()
for operation in candidates:
source_delivery_in_progress = (
session.query(CalendarOutboxOperation.id)
.filter(
CalendarOutboxOperation.source_id == operation.source_id,
CalendarOutboxOperation.status == "in_progress",
)
.first()
)
if source_delivery_in_progress is not None:
# Delivery holds the source row lock across remote I/O. Leasing a
# second href for the same source would only make its lease age
# while it waits behind that lock.
continue
active_predecessor = (
session.query(CalendarOutboxOperation.id)
.filter(
CalendarOutboxOperation.source_id == operation.source_id,
CalendarOutboxOperation.resource_href == operation.resource_href,
CalendarOutboxOperation.status.in_(sorted(OUTBOX_ACTIVE_STATUSES)),
or_(
CalendarOutboxOperation.created_at < operation.created_at,
and_(
CalendarOutboxOperation.created_at == operation.created_at,
CalendarOutboxOperation.id < operation.id,
),
),
)
.first()
)
if active_predecessor is not None:
continue
succeeded_predecessor = (
session.query(CalendarOutboxOperation)
.filter(
CalendarOutboxOperation.source_id == operation.source_id,
CalendarOutboxOperation.resource_href == operation.resource_href,
CalendarOutboxOperation.status == "succeeded",
CalendarOutboxOperation.completed_at.is_not(None),
# Only repair the causal window where the newer row was
# created while its predecessor was still completing. A
# later inbound sync may legitimately have supplied a newer
# expected ETag and must not be overwritten here.
CalendarOutboxOperation.completed_at >= operation.created_at,
or_(
CalendarOutboxOperation.created_at < operation.created_at,
and_(
CalendarOutboxOperation.created_at == operation.created_at,
CalendarOutboxOperation.id < operation.id,
),
),
)
.order_by(
CalendarOutboxOperation.created_at.desc(),
CalendarOutboxOperation.id.desc(),
)
.first()
)
if succeeded_predecessor is not None:
operation.expected_etag = (
succeeded_predecessor.remote_etag
if succeeded_predecessor.operation_kind == "put"
else None
)
operation.status = "in_progress"
operation.attempt_count += 1
operation.last_attempt_at = now
operation.lease_token = str(uuid.uuid4())
operation.lease_expires_at = now + timedelta(seconds=max(30, lease_seconds))
session.flush()
return operation
session.flush()
return None
def _fetch_remote_object(client: object, href: str) -> CalDAVObject:
fetch_state = getattr(client, "fetch_object_state", None)
if callable(fetch_state):
result = fetch_state(href)
if isinstance(result, CalDAVObject):
return result
payload = client.fetch_object(href)
return CalDAVObject(href=href, calendar_data=payload)
def _remote_matches_operation(client: object, operation: CalendarOutboxOperation) -> tuple[bool, str | None]:
try:
remote = _fetch_remote_object(client, operation.resource_href)
except CalDAVNotFound:
return operation.operation_kind == "delete", None
if operation.operation_kind == "delete":
return False, remote.etag
if not remote.calendar_data or not operation.payload_fingerprint:
return False, remote.etag
return calendar_payload_fingerprint(remote.calendar_data) == operation.payload_fingerprint, remote.etag
def _operation_event_ids(operation: CalendarOutboxOperation) -> list[str]:
metadata = operation.metadata_ or {}
values = metadata.get("event_ids") if isinstance(metadata, dict) else None
return [str(value) for value in values or []]
def _event_points_to_operation(event_model: CalendarEvent, operation: CalendarOutboxOperation) -> bool:
metadata = event_model.metadata_ or {}
caldav = metadata.get("caldav") if isinstance(metadata, dict) else None
return bool(
isinstance(caldav, dict)
and caldav.get("outbox_operation_id") == operation.id
and caldav.get("source_id") == operation.source_id
and caldav.get("href") == operation.resource_href
and event_model.source_kind == "caldav"
and event_model.source_href == operation.resource_href
)
def _pointed_operation_events(
session: Session,
operation: CalendarOutboxOperation,
) -> list[CalendarEvent]:
event_ids = set(_operation_event_ids(operation))
if operation.event_id:
event_ids.add(operation.event_id)
if not event_ids:
return []
return [
event_model
for event_model in (
session.query(CalendarEvent)
.filter(
CalendarEvent.tenant_id == operation.tenant_id,
CalendarEvent.id.in_(sorted(event_ids)),
)
.all()
)
if _event_points_to_operation(event_model, operation)
]
def _record_event_projection_change(
session: Session,
*,
event_model: CalendarEvent,
previous: dict[str, Any],
) -> None:
from govoplan_calendar.backend.service import record_calendar_event_change
record_calendar_event_change(
session,
event=event_model,
operation="deleted" if event_model.deleted_at is not None else "updated",
user_id=None,
previous=previous,
)
def _set_pointed_event_outbox_state(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
state: str,
raw_ics: str | None,
remote_etag: str | None = None,
) -> None:
from govoplan_calendar.backend.service import calendar_event_change_payload
for event_model in _pointed_operation_events(session, operation):
previous = calendar_event_change_payload(event_model, prefix="previous_")
if remote_etag:
event_model.etag = remote_etag
_set_event_outbox_state(
event_model,
source=source,
href=operation.resource_href,
operation=operation,
state=state,
raw_ics=raw_ics,
)
_record_event_projection_change(
session,
event_model=event_model,
previous=previous,
)
def _complete_operation(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
remote_etag: str | None,
reconciled: bool,
) -> None:
now = utcnow()
operation.status = "succeeded"
operation.completed_at = now
operation.reconciled_at = now if reconciled else operation.reconciled_at
operation.remote_etag = remote_etag
operation.last_error = None
operation.lease_token = None
operation.lease_expires_at = None
source.last_status = "outbound_ok"
source.last_error = None
for stale_operation in (
session.query(CalendarOutboxOperation)
.filter(
CalendarOutboxOperation.source_id == operation.source_id,
CalendarOutboxOperation.resource_href == operation.resource_href,
CalendarOutboxOperation.status.in_(("conflict", "dead")),
or_(
CalendarOutboxOperation.created_at < operation.created_at,
and_(
CalendarOutboxOperation.created_at == operation.created_at,
CalendarOutboxOperation.id < operation.id,
),
),
)
.all()
):
# A successfully delivered newer desired state makes older failed
# snapshots audit history, not unresolved intent.
stale_operation.status = "superseded"
if operation.operation_kind == "put" and remote_etag:
# A newer desired state may have been queued while this operation was
# already in progress. Its snapshot can only know the predecessor
# ETag, not the ETag produced by this PUT. Carry the causal ETag forward
# so the newer PUT/DELETE is conditional on the state we just wrote.
later_operations = (
session.query(CalendarOutboxOperation)
.filter(
CalendarOutboxOperation.source_id == operation.source_id,
CalendarOutboxOperation.resource_href == operation.resource_href,
CalendarOutboxOperation.id != operation.id,
CalendarOutboxOperation.status.in_(sorted(OUTBOX_ACTIVE_STATUSES)),
or_(
CalendarOutboxOperation.created_at > operation.created_at,
and_(
CalendarOutboxOperation.created_at == operation.created_at,
CalendarOutboxOperation.id > operation.id,
),
),
)
.all()
)
later_ids = {item.id for item in later_operations}
for later_operation in later_operations:
later_operation.expected_etag = remote_etag
if later_ids:
from govoplan_calendar.backend.service import calendar_event_change_payload
for event_model in (
session.query(CalendarEvent)
.filter(
CalendarEvent.tenant_id == operation.tenant_id,
CalendarEvent.source_href == operation.resource_href,
)
.all()
):
metadata = event_model.metadata_ or {}
caldav = metadata.get("caldav") if isinstance(metadata, dict) else None
if isinstance(caldav, dict) and caldav.get("outbox_operation_id") in later_ids:
previous = calendar_event_change_payload(event_model, prefix="previous_")
event_model.etag = remote_etag
_record_event_projection_change(
session,
event_model=event_model,
previous=previous,
)
_set_pointed_event_outbox_state(
session,
operation=operation,
source=source,
state="synced",
raw_ics=operation.payload_ics,
remote_etag=remote_etag,
)
session.flush()
def _retry_delay_seconds(attempt_count: int) -> int:
exponent = max(0, min(attempt_count - 1, 20))
return min(OUTBOX_BACKOFF_MAX_SECONDS, OUTBOX_BACKOFF_BASE_SECONDS * (2**exponent))
def _fail_operation(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
error: BaseException | str,
terminal_status: str | None = None,
) -> None:
now = utcnow()
message = str(error)[:4000]
if terminal_status:
operation.status = terminal_status
operation.completed_at = now
elif operation.attempt_count >= operation.max_attempts:
operation.status = "dead"
operation.completed_at = now
else:
operation.status = "retry"
operation.available_at = now + timedelta(seconds=_retry_delay_seconds(operation.attempt_count))
operation.last_error = message
operation.lease_token = None
operation.lease_expires_at = None
if operation.status in {"dead", "conflict"}:
source.last_status = "outbound_error"
elif operation.status == "cancelled":
source.last_status = "outbound_cancelled"
else:
source.last_status = "outbound_retry"
source.last_error = message
event_state = (
"failed"
if operation.status in {"dead", "conflict"}
else "cancelled"
if operation.status == "cancelled"
else "retry"
)
_set_pointed_event_outbox_state(
session,
operation=operation,
source=source,
state=event_state,
raw_ics=operation.payload_ics,
)
session.flush()
def _lock_leased_outbox_operation(
session: Session,
*,
operation_id: str,
lease_token: str,
) -> tuple[CalendarOutboxOperation, CalendarSyncSource | None]:
operation = session.get(CalendarOutboxOperation, operation_id)
if (
operation is None
or operation.status != "in_progress"
or operation.lease_token != lease_token
):
raise ValueError("Calendar outbox lease is no longer valid")
source = (
session.query(CalendarSyncSource)
.filter(CalendarSyncSource.id == operation.source_id)
.with_for_update()
.one_or_none()
)
operation = (
session.query(CalendarOutboxOperation)
.filter(CalendarOutboxOperation.id == operation_id)
.with_for_update()
.one()
)
if operation.status != "in_progress" or operation.lease_token != lease_token:
raise ValueError("Calendar outbox lease is no longer valid")
return operation, source
def _cancel_undeliverable_operation(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource | None,
reason: str,
) -> None:
if source is not None and source.tenant_id == operation.tenant_id:
_fail_operation(
session,
operation=operation,
source=source,
error=reason,
terminal_status="cancelled",
)
return
operation.status = "cancelled"
operation.completed_at = utcnow()
operation.last_error = reason
operation.lease_token = None
operation.lease_expires_at = None
session.flush()
def _calendar_outbox_client(
session: Session,
*,
source: CalendarSyncSource,
client_factory: Callable[[Session, CalendarSyncSource], object] | None,
) -> object:
if client_factory is not None:
return client_factory(session, source)
from govoplan_calendar.backend.service import caldav_client_for_source
return caldav_client_for_source(session, source)
def _try_remote_match(
client: object,
operation: CalendarOutboxOperation,
) -> tuple[bool, str | None] | None:
try:
return _remote_matches_operation(client, operation)
except Exception:
return None
def _complete_if_remote_match_is_usable(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
remote_match: tuple[bool, str | None] | None,
) -> bool:
if remote_match is None:
return False
matched, remote_etag = remote_match
if not matched or (operation.operation_kind != "delete" and not remote_etag):
return False
_complete_operation(
session,
operation=operation,
source=source,
remote_etag=remote_etag,
reconciled=True,
)
return True
def _execute_calendar_outbox_put(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
client: object,
overwrite: bool,
) -> None:
result = client.put_object(
operation.resource_href,
operation.payload_ics or "",
etag=operation.expected_etag,
create=not bool(operation.expected_etag),
overwrite=overwrite,
)
remote_etag = result.etag
if remote_etag:
_complete_operation(
session,
operation=operation,
source=source,
remote_etag=remote_etag,
reconciled=False,
)
return
remote_match = _try_remote_match(client, operation)
if _complete_if_remote_match_is_usable(
session,
operation=operation,
source=source,
remote_match=remote_match,
):
return
_fail_operation(
session,
operation=operation,
source=source,
error="CalDAV PUT succeeded without an ETag and could not be reconciled safely",
)
def _execute_calendar_outbox_delete(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
client: object,
overwrite: bool,
) -> None:
if not operation.expected_etag and not overwrite:
remote_match = _remote_matches_operation(client, operation)
if _complete_if_remote_match_is_usable(
session,
operation=operation,
source=source,
remote_match=remote_match,
):
return
_fail_operation(
session,
operation=operation,
source=source,
error=(
"CalDAV DELETE has no known ETag and the remote resource exists; "
"refusing to delete an unknown remote version"
),
terminal_status="conflict",
)
return
result = client.delete_object(
operation.resource_href,
etag=operation.expected_etag,
overwrite=overwrite,
)
_complete_operation(
session,
operation=operation,
source=source,
remote_etag=result.etag,
reconciled=result.status == 404,
)
def _handle_outbox_precondition_failure(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
client: object | None,
error: CalDAVPreconditionFailed,
) -> None:
remote_match = _try_remote_match(client, operation) if client is not None else None
if _complete_if_remote_match_is_usable(
session,
operation=operation,
source=source,
remote_match=remote_match,
):
return
if remote_match is None:
_fail_operation(session, operation=operation, source=source, error=error)
return
matched, _remote_etag = remote_match
_fail_operation(
session,
operation=operation,
source=source,
error=(
"Matching CalDAV resource has no usable ETag; retrying reconciliation"
if matched
else "CalDAV resource changed remotely; reconcile or sync before retrying"
),
terminal_status=None if matched else "conflict",
)
def _handle_outbox_delivery_failure(
session: Session,
*,
operation: CalendarOutboxOperation,
source: CalendarSyncSource,
client: object | None,
error: Exception,
) -> None:
remote_match = _try_remote_match(client, operation) if client is not None else None
if _complete_if_remote_match_is_usable(
session,
operation=operation,
source=source,
remote_match=remote_match,
):
return
_fail_operation(session, operation=operation, source=source, error=error)
def execute_calendar_outbox_operation(
session: Session,
*,
operation_id: str,
lease_token: str,
client_factory: Callable[[Session, CalendarSyncSource], object] | None = None,
) -> CalendarOutboxOperation:
operation, source = _lock_leased_outbox_operation(
session,
operation_id=operation_id,
lease_token=lease_token,
)
unavailable_reason = _operation_source_unavailable_reason(operation, source)
if unavailable_reason:
_cancel_undeliverable_operation(
session,
operation=operation,
source=source,
reason=unavailable_reason,
)
return operation
if source is None:
raise ValueError("Calendar outbox source is no longer available")
operation_metadata = operation.metadata_ or {}
overwrite = (
bool(operation_metadata.get("overwrite"))
if isinstance(operation_metadata, dict)
else False
)
client: object | None = None
try:
client = _calendar_outbox_client(
session,
source=source,
client_factory=client_factory,
)
if operation.operation_kind == "put":
_execute_calendar_outbox_put(
session,
operation=operation,
source=source,
client=client,
overwrite=overwrite,
)
else:
_execute_calendar_outbox_delete(
session,
operation=operation,
source=source,
client=client,
overwrite=overwrite,
)
except CalDAVPreconditionFailed as exc:
_handle_outbox_precondition_failure(
session,
operation=operation,
source=source,
client=client,
error=exc,
)
except Exception as exc:
_handle_outbox_delivery_failure(
session,
operation=operation,
source=source,
client=client,
error=exc,
)
return operation
def dispatch_calendar_outbox(
session: Session,
*,
tenant_id: str | None = None,
limit: int = 50,
client_factory: Callable[[Session, CalendarSyncSource], object] | None = None,
now: datetime | None = None,
) -> dict[str, object]:
"""Lease, deliver, and durably record up to ``limit`` operations."""
results: list[dict[str, object]] = []
for _index in range(max(1, min(limit, 500))):
operation = claim_next_calendar_outbox_operation(
session,
tenant_id=tenant_id,
now=now,
)
if operation is None:
session.commit()
break
operation_id = operation.id
lease_token = operation.lease_token or ""
# The lease must be visible before external I/O.
session.commit()
operation = execute_calendar_outbox_operation(
session,
operation_id=operation_id,
lease_token=lease_token,
client_factory=client_factory,
)
results.append(
{
"id": operation.id,
"status": operation.status,
"attempt_count": operation.attempt_count,
"last_error": operation.last_error,
}
)
session.commit()
return {
"processed": len(results),
"succeeded": sum(item["status"] == "succeeded" for item in results),
"retrying": sum(item["status"] == "retry" for item in results),
"failed": sum(item["status"] in {"conflict", "dead", "cancelled"} for item in results),
"operations": results,
}
def purge_terminal_calendar_outbox_operations(
session: Session,
*,
retention_days: int,
tenant_id: str | None = None,
now: datetime | None = None,
limit: int = 1000,
) -> int:
"""Delete bounded, resolved outbox history older than the configured window.
``conflict`` and ``dead`` are terminal delivery outcomes but still carry
unresolved local desired state. They are intentionally excluded until an
administrator retries, reconciles, or discards them. A retention of zero
disables automatic cleanup.
"""
if retention_days < 0:
raise ValueError("Calendar outbox terminal retention days must be zero or greater")
if retention_days == 0:
return 0
cutoff = (now or utcnow()) - timedelta(days=retention_days)
query = session.query(CalendarOutboxOperation.id).filter(
CalendarOutboxOperation.status.in_(sorted(OUTBOX_PURGEABLE_STATUSES)),
CalendarOutboxOperation.completed_at.is_not(None),
CalendarOutboxOperation.completed_at <= cutoff,
)
if tenant_id:
query = query.filter(CalendarOutboxOperation.tenant_id == tenant_id)
operation_ids = [
operation_id
for (operation_id,) in (
query.order_by(
CalendarOutboxOperation.completed_at.asc(),
CalendarOutboxOperation.id.asc(),
)
.limit(max(1, min(limit, 10_000)))
.all()
)
]
if not operation_ids:
return 0
deleted = (
session.query(CalendarOutboxOperation)
.filter(CalendarOutboxOperation.id.in_(operation_ids))
.delete(synchronize_session=False)
)
session.flush()
return int(deleted)
def retry_calendar_outbox_operation(
session: Session,
*,
tenant_id: str,
operation_id: str,
) -> CalendarOutboxOperation:
operation, source = _lock_operation_source(
session,
tenant_id=tenant_id,
operation_id=operation_id,
)
if operation.status in {"succeeded", "in_progress", "superseded", "cancelled"}:
raise ValueError(f"A {operation.status} Calendar outbox operation cannot be retried")
_assert_latest_resource_generation(session, operation)
unavailable_reason = _operation_source_unavailable_reason(operation, source)
if unavailable_reason:
raise ValueError(unavailable_reason)
if source is None:
raise RuntimeError("Calendar outbox source availability invariant was violated")
operation.status = "pending"
operation.attempt_count = 0
operation.available_at = utcnow()
operation.completed_at = None
operation.last_error = None
operation.lease_token = None
operation.lease_expires_at = None
_set_pointed_event_outbox_state(
session,
operation=operation,
source=source,
state="queued",
raw_ics=operation.payload_ics,
)
session.flush()
_schedule_dispatch_after_commit(session, tenant_id)
return operation
def reconcile_calendar_outbox_operation(
session: Session,
*,
tenant_id: str,
operation_id: str,
client_factory: Callable[[Session, CalendarSyncSource], object] | None = None,
) -> CalendarOutboxOperation:
operation, source = _lock_operation_source(
session,
tenant_id=tenant_id,
operation_id=operation_id,
)
if operation.status in {"succeeded", "superseded", "cancelled"}:
raise ValueError(f"A {operation.status} Calendar outbox operation cannot be reconciled")
if (
operation.status == "in_progress"
and operation.lease_expires_at is not None
and _as_utc(operation.lease_expires_at) > utcnow()
):
raise ValueError("An actively leased Calendar outbox operation cannot be reconciled")
_assert_latest_resource_generation(session, operation)
unavailable_reason = _operation_source_unavailable_reason(operation, source)
if unavailable_reason:
raise ValueError(unavailable_reason)
if source is None:
raise RuntimeError("Calendar outbox source availability invariant was violated")
if client_factory is None:
from govoplan_calendar.backend.service import caldav_client_for_source
client = caldav_client_for_source(session, source)
else:
client = client_factory(session, source)
matched, remote_etag = _remote_matches_operation(client, operation)
if matched and (operation.operation_kind == "delete" or remote_etag):
_complete_operation(
session,
operation=operation,
source=source,
remote_etag=remote_etag,
reconciled=True,
)
elif matched:
_fail_operation(
session,
operation=operation,
source=source,
error="Matching CalDAV resource has no usable ETag; retrying reconciliation",
)
else:
operation.reconciled_at = utcnow()
_fail_operation(
session,
operation=operation,
source=source,
error="Remote CalDAV resource does not match the queued desired state",
terminal_status="conflict",
)
return operation
def discard_calendar_outbox_operation(
session: Session,
*,
tenant_id: str,
operation_id: str,
) -> CalendarOutboxOperation:
"""Abandon local desired state so a later inbound sync may accept remote."""
operation, source = _lock_operation_source(
session,
tenant_id=tenant_id,
operation_id=operation_id,
)
if operation.status in {"succeeded", "superseded", "cancelled"}:
raise ValueError(f"A {operation.status} Calendar outbox operation cannot be discarded")
if (
operation.status == "in_progress"
and operation.lease_expires_at is not None
and _as_utc(operation.lease_expires_at) > utcnow()
):
raise ValueError("An actively leased Calendar outbox operation cannot be discarded")
_assert_latest_resource_generation(session, operation)
unavailable_reason = _operation_source_unavailable_reason(operation, source)
if unavailable_reason and (
source is None or source.tenant_id != operation.tenant_id
):
raise ValueError(unavailable_reason)
if source is None:
raise RuntimeError("Calendar outbox source availability invariant was violated")
now = utcnow()
unresolved_chain = (
session.query(CalendarOutboxOperation)
.filter(
CalendarOutboxOperation.source_id == operation.source_id,
CalendarOutboxOperation.resource_href == operation.resource_href,
CalendarOutboxOperation.status.in_(sorted(OUTBOX_UNRESOLVED_DESIRED_STATUSES)),
or_(
CalendarOutboxOperation.created_at < operation.created_at,
and_(
CalendarOutboxOperation.created_at == operation.created_at,
CalendarOutboxOperation.id <= operation.id,
),
),
)
.order_by(
CalendarOutboxOperation.created_at.asc(),
CalendarOutboxOperation.id.asc(),
)
.with_for_update()
.all()
)
if any(
item.status == "in_progress"
and item.lease_expires_at is not None
and _as_utc(item.lease_expires_at) > now
for item in unresolved_chain
):
raise ValueError(
"The Calendar resource has an actively leased predecessor and cannot be discarded"
)
message = (
"Local desired state was discarded by an administrator; accept remote on next sync"
)
for item in unresolved_chain:
item.status = "cancelled"
item.completed_at = now
item.reconciled_at = now
item.last_error = message
item.lease_token = None
item.lease_expires_at = None
_set_pointed_event_outbox_state(
session,
operation=item,
source=source,
state="discarded",
raw_ics=item.payload_ics,
)
source.last_status = "outbound_discarded"
source.last_error = message
# Incremental sync might not report an unchanged remote object. Force the
# next source run to perform a full collection reconciliation.
source.sync_token = None
source.next_sync_at = now
session.flush()
return operation
def cancel_calendar_outbox_for_source(
session: Session,
*,
source_id: str,
reason: str,
) -> int:
now = utcnow()
operations = (
session.query(CalendarOutboxOperation)
.filter(
CalendarOutboxOperation.source_id == source_id,
CalendarOutboxOperation.status.in_(sorted(OUTBOX_UNRESOLVED_DESIRED_STATUSES)),
)
.all()
)
source = session.get(CalendarSyncSource, source_id)
for operation in operations:
operation.status = "cancelled"
operation.completed_at = now
operation.last_error = reason
operation.lease_token = None
operation.lease_expires_at = None
if source is not None and source.tenant_id == operation.tenant_id:
_set_pointed_event_outbox_state(
session,
operation=operation,
source=source,
state="cancelled",
raw_ics=operation.payload_ics,
)
return len(operations)
def calendar_outbox_has_live_lease(
session: Session,
*,
source_id: str,
now: datetime | None = None,
) -> bool:
now = now or utcnow()
return (
session.query(CalendarOutboxOperation.id)
.filter(
CalendarOutboxOperation.source_id == source_id,
CalendarOutboxOperation.status == "in_progress",
CalendarOutboxOperation.lease_expires_at > now,
)
.first()
is not None
)
def calendar_outbox_has_active_desired_state(
session: Session,
*,
source_id: str,
href: str,
) -> bool:
latest_status = (
session.query(CalendarOutboxOperation.status)
.filter(
CalendarOutboxOperation.source_id == source_id,
CalendarOutboxOperation.resource_href == href,
)
.order_by(
CalendarOutboxOperation.created_at.desc(),
CalendarOutboxOperation.id.desc(),
)
.first()
)
return bool(
latest_status is not None
and latest_status[0] in OUTBOX_UNRESOLVED_DESIRED_STATUSES
)
def calendar_outbox_has_unresolved_desired_state(
session: Session,
*,
source_id: str,
) -> bool:
rows = (
session.query(
CalendarOutboxOperation.resource_href,
CalendarOutboxOperation.status,
)
.filter(
CalendarOutboxOperation.source_id == source_id,
)
.order_by(
CalendarOutboxOperation.resource_href.asc(),
CalendarOutboxOperation.created_at.desc(),
CalendarOutboxOperation.id.desc(),
)
.all()
)
seen_hrefs: set[str] = set()
for href, status in rows:
if href in seen_hrefs:
continue
seen_hrefs.add(href)
if status in OUTBOX_UNRESOLVED_DESIRED_STATUSES:
return True
return False
def calendar_outbox_operation_response(operation: CalendarOutboxOperation) -> dict[str, Any]:
return {
"id": operation.id,
"tenant_id": operation.tenant_id,
"source_id": operation.source_id,
"event_id": operation.event_id,
"operation_kind": operation.operation_kind,
"resource_href": operation.resource_href,
"payload_fingerprint": operation.payload_fingerprint,
"expected_etag": operation.expected_etag,
"idempotency_key": operation.idempotency_key,
"status": operation.status,
"attempt_count": operation.attempt_count,
"max_attempts": operation.max_attempts,
"available_at": operation.available_at,
"last_attempt_at": operation.last_attempt_at,
"lease_expires_at": operation.lease_expires_at,
"completed_at": operation.completed_at,
"reconciled_at": operation.reconciled_at,
"remote_etag": operation.remote_etag,
"last_error": operation.last_error,
"created_at": operation.created_at,
"updated_at": operation.updated_at,
"metadata": dict(operation.metadata_ or {}),
}
class SqlCalendarOutboxProvider:
def __init__(
self,
*,
terminal_retention_days: int = OUTBOX_DEFAULT_TERMINAL_RETENTION_DAYS,
) -> None:
if isinstance(terminal_retention_days, bool):
raise ValueError("Calendar outbox terminal retention days must be an integer")
try:
normalized_retention_days = int(terminal_retention_days)
except (TypeError, ValueError) as exc:
raise ValueError("Calendar outbox terminal retention days must be an integer") from exc
if normalized_retention_days < 0:
raise ValueError("Calendar outbox terminal retention days must be zero or greater")
self.terminal_retention_days = normalized_retention_days
def dispatch_due(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 50,
) -> Mapping[str, object]:
if not isinstance(session, Session):
raise TypeError("Calendar outbox requires a SQLAlchemy Session")
result = dispatch_calendar_outbox(session, tenant_id=tenant_id, limit=limit)
result["purged"] = purge_terminal_calendar_outbox_operations(
session,
retention_days=self.terminal_retention_days,
tenant_id=tenant_id,
)
return result
__all__ = [
"OUTBOX_ACTIVE_STATUSES",
"OUTBOX_DEFAULT_MAX_ATTEMPTS",
"OUTBOX_DEFAULT_TERMINAL_RETENTION_DAYS",
"OUTBOX_PURGEABLE_STATUSES",
"OUTBOX_TERMINAL_STATUSES",
"OUTBOX_UNRESOLVED_DESIRED_STATUSES",
"SqlCalendarOutboxProvider",
"calendar_outbox_has_active_desired_state",
"calendar_outbox_has_live_lease",
"calendar_outbox_has_unresolved_desired_state",
"calendar_outbox_operation_response",
"calendar_payload_fingerprint",
"cancel_calendar_outbox_for_source",
"claim_next_calendar_outbox_operation",
"dispatch_calendar_outbox",
"discard_calendar_outbox_operation",
"enqueue_caldav_delete",
"enqueue_caldav_put",
"get_calendar_outbox_operation",
"list_calendar_outbox_operations",
"purge_terminal_calendar_outbox_operations",
"reconcile_calendar_outbox_operation",
"retry_calendar_outbox_operation",
]