feat(calendar): make external mutations durable
This commit is contained in:
@@ -24,10 +24,88 @@ The first standalone module provides:
|
||||
- API endpoints for listing calendars, creating/updating/deleting events, importing iCalendar, and exporting event ICS
|
||||
- free/busy API primitive with recurrence expansion for scheduling and appointment conflict checks
|
||||
- calendar-owned CalDAV sync sources with credential references, scheduled due-sync metadata, full/incremental inbound sync, two-way PUT/DELETE writes, and ETag conflict handling
|
||||
- a Calendar-owned durable desired-state outbox for two-way CalDAV writes. Event
|
||||
state and the exact external resource snapshot commit atomically; workers use
|
||||
deterministic hrefs, conditional requests, expiring leases, bounded
|
||||
exponential retry, and semantic GET reconciliation after ambiguous outcomes
|
||||
- WebUI views: month, week, workweek, day, and continuous week-row scrolling
|
||||
|
||||
The first implementation is not yet a full CalDAV network server. It is the internal calendar storage, sync, availability, and UI foundation on which Open-Xchange integration, richer recurrence editing, scheduling inbox/outbox behavior, and CalDAV server endpoints can be built.
|
||||
|
||||
## Outbound delivery operations
|
||||
|
||||
No event API or cross-module capability performs CalDAV network I/O inside the
|
||||
caller's transaction. A local mutation instead creates a
|
||||
`calendar_outbox_operations` row containing the exact desired ICS resource,
|
||||
target href, expected ETag, conflict-policy snapshot, and idempotency key.
|
||||
|
||||
The registered `govoplan.calendar.dispatch_outbox` worker commits an expiring
|
||||
lease before doing network I/O and commits each outcome independently. If a
|
||||
worker loses the database connection after a successful remote write, the next
|
||||
attempt reads the remote object and compares semantic ICS fingerprints. A
|
||||
matching PUT or an already absent DELETE completes successfully without a blind
|
||||
duplicate write. Pending updates for one href supersede older never-attempted
|
||||
rows; attempted predecessors are reconciled first, and updates queued behind an
|
||||
in-flight write inherit the ETag produced by that write. Delivery and inbound
|
||||
REPORT application take the same source-row lock,
|
||||
and only one operation per source is leased at a time. This deliberately trades
|
||||
per-source throughput for a simple ordering guarantee: a stale inbound report
|
||||
cannot land after a newer outbound result, and queued leases do not expire while
|
||||
waiting behind another network request for the same source.
|
||||
|
||||
Operators can list, dispatch, retry, reconcile, and discard tenant operations
|
||||
through the `/calendar/caldav/outbox` administration endpoints. Terminal ETag
|
||||
conflicts and exhausted retries remain the current local desired state and
|
||||
shield that resource from inbound overwrite until explicitly resolved.
|
||||
Discarding is the administrator's accept-remote transition: it is allowed only
|
||||
for the latest generation, atomically cancels its unresolved predecessor chain,
|
||||
marks the event projection as discarded, clears the sync token, and schedules a
|
||||
full inbound reconciliation so an unchanged remote object is not missed.
|
||||
|
||||
Disabling outbound delivery or switching to inbound-only is rejected while
|
||||
unresolved desired state exists; retirement is the explicit exception and
|
||||
cancels unresolved work. Endpoint/calendar changes also require no active event
|
||||
bindings or unresolved delivery. Credential rotation does not discard committed
|
||||
desired state. Public event mutation cannot set sync-owned source hrefs, kinds,
|
||||
or ETags. Deleting a synchronized collection or retiring its source is a local
|
||||
unlink: it never deletes the remote collection or its remaining remote events.
|
||||
The current singular ownership model permits one active sync source per
|
||||
calendar; multi-source fan-in will require per-event source routing. Celery beat
|
||||
triggers recovery every minute, while root-transaction after-commit dispatch
|
||||
provides the normal low-latency path.
|
||||
|
||||
### Collection retirement and bulk event moves
|
||||
|
||||
Collection deletion accepts two explicit, non-destructive external actions for
|
||||
`event_action="move"`:
|
||||
|
||||
- `detach_keep_remote` applies only when the source collection is synchronized
|
||||
and the target is local. It moves the local event projections, clears their
|
||||
sync-owned source kind, href, ETag, and CalDAV projection metadata, retires
|
||||
the source locally, and cancels undelivered outbox state. An active delivery
|
||||
lease blocks the transaction. The action never enqueues a remote DELETE, so
|
||||
the remote collection and remote events remain unchanged.
|
||||
- `copy_to_remote` applies only when the source is local and the target has an
|
||||
active, enabled, two-way CalDAV source. It moves the local events and commits
|
||||
destination PUT desired states in the durable outbox in the same database
|
||||
transaction. Remote failures therefore remain visible and retryable without
|
||||
rolling back or hiding the local move.
|
||||
|
||||
Local-to-local moves retain their existing behavior and do not accept an
|
||||
`external_action`. Implicit or mismatched actions, inbound-only destinations,
|
||||
and synchronized-to-synchronized moves are rejected. `remote_move` is a known
|
||||
but deliberately unsupported action: implementing it requires a separately
|
||||
approved saga for destination reconciliation, conditional source deletion,
|
||||
collision handling, and concurrent-edit policy. The WebUI does not offer that
|
||||
destructive mode.
|
||||
|
||||
Resolved terminal rows (`succeeded`, `superseded`, and `cancelled`) are removed
|
||||
in bounded batches after `CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS` (90 days by
|
||||
default; `0` disables cleanup). `conflict` and `dead` rows are never removed by
|
||||
this cleanup because they still carry unresolved local desired state. Each
|
||||
periodic or after-commit dispatch also performs one bounded cleanup batch for
|
||||
the same tenant scope.
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Scheduling
|
||||
|
||||
Reference in New Issue
Block a user