Skip to content

Persist invitation-related booking webhook events atomically #4

Open

Description

@KitKat31337

Booking state can commit successfully while its corresponding booking webhook is never persisted. This is a follow-up for the scheduling-invitations work on feat/scheduling-invitations-complete in this fork.

Invitation lifecycle transitions already use a transactional outbox. However, booking.created, booking.rescheduled, and booking.cancelled are enqueued from asynchronous post-commit side-effect handlers. A process exit between commit and enqueue, an expired side-effect context, or an enqueue error can permanently lose the event. Existing delivery retries only recover events that reached the delivery/jobs tables.

Affected paths:

  • internal/handler/booking_handler.go: confirmation and cancellation side effects enqueue booking events after database changes.
  • internal/handler/manage_handler.go: rescheduling side effects enqueue the event after calendar/email work; an earlier error can return before enqueue.
  • internal/webhook/invitation.go: durable invitation lifecycle outbox provides an existing pattern to reuse.

For an external ticketing integration, a lost rescheduling or cancellation event can leave the external ticket with stale appointment information even though Calnode committed the change. A durable invitation-booked event does not cover later booking changes.

Expected change:

  • Persist invitation-related booking event intent atomically with successful booking creation, rescheduling, and cancellation.
  • Dispatch through the existing signed webhook delivery/retry infrastructure, without network operations inside the booking transaction or a separate signing/envelope system.
  • Capture transition-time intervals, status, assigned hosts, invitation ID, and external correlation; define how meeting/location enrichment becomes available.
  • Use stable event IDs across recovery/retry and prevent duplicate publication from current side-effect handlers.
  • Preserve ordinary booking behavior and never include raw scheduling tokens or URLs.

Acceptance coverage:

  • Stop/restart after commit but before dispatch and recover each booking event.
  • A rolled-back change emits no event.
  • Repeated polling and failed delivery retries retain stable IDs and correlation.
  • Slow/failing calendar or email operations cannot prevent event intent from being persisted.
  • Rescheduling/cancellation preserve the invitation's external reference in recovered payloads.

This issue tracks the reliability gap; the current branch documents its best-effort booking-event enqueue boundary. No live M365 credentials should be required for these regression tests.

Activity

  1. KitKat31337 commented on Oct 2, 2026

    @KitKat31337
    OwnerAuthor

    I propose a transactional booking-event outbox for invitation-related bookings, reusing the existing webhook dispatcher and signing/retry infrastructure.

    Moving Enqueue earlier in the post-commit goroutine would reduce the failure window but would not close it. The event intent must commit with the booking change.

    1. Persist one record per booking transition. Add a normal additive Goose migration for booking_event_outbox with a stable event ID, booking ID, monotonically increasing booking revision, event name, occurrence timestamp, immutable payload snapshot, recipient-owner IDs, and dispatch timestamp. Enforce uniqueness on (booking_id, revision). Do not reuse the invitation outbox's UNIQUE(invitation_id, event): multiple reschedules are legitimate. Keep the invitation lifecycle outbox and its event names intact.

    2. Capture events inside the booking transaction. Add a small local persistence helper in the booking service, called only for bookings with scheduling_invitation_id:

      • Creation: after assigned hosts, organizer/intake rows, invitation booking relationship, and token consumption are written, before Commit.
      • Rescheduling: after the validated interval update, before Commit, retaining the previous interval as well as the new one.
      • Cancellation: wrap both Cancel and CancelByID in a transaction and capture the event with the successful status transition. This covers customer, staff, and archive/admin cancellation paths.

      An outbox insert failure rolls back the booking change. Repeated cancellation/no-op requests must not create another transition event. All queries use the same transaction and drain cursors before subsequent queries, respecting SQLite's single-connection architecture. No calendar, email, or HTTP calls occur in this transaction.

    3. Snapshot what the event actually describes. Store transition-time status, start/end, effective duration, assigned host IDs/primary marker, invitation/event-type IDs, external {system, reference, url}, and optional attendee/intake fields needed by the existing field-selection logic. Snapshot the primary-host and invitation-creator owner IDs for routing. Do not reconstruct an old event's interval/status/host assignment from the current booking when the worker eventually runs. Use the existing envelope, with its stable id; include the booking revision in correlated data so receivers can recognize out-of-order changes. Exclude bearer tokens, token hashes, scheduling URLs, and manage credentials.

    4. Drain atomically into existing delivery jobs. Extend the worker with a booking-outbox drain following ProcessSchedulingInvitations: materialize pending rows, resolve active subscriptions/field selections for the captured owner IDs, build each subscriber's payload from the snapshot, insert webhook_deliveries and webhook.deliver jobs, and mark the event dispatched in one local transaction. Preserve mandatory invitation correlation despite optional field selection. Document subscription resolution at dispatch, matching the existing lifecycle approach. Add a nullable event-ID link and a uniqueness invariant on (event_id, webhook_id) for delivery rows as a second duplicate guard. Once queued, the stored body, event ID, delivery ID, and signature remain unchanged through retries.

    5. Keep meeting generation independent. Do not wait indefinitely for calendar/email work before dispatching a booking event. At first dispatch, enrich meeting/location information only if it is available and the current booking revision still matches the captured revision; otherwise retain the transition snapshot. This prevents a later reschedule's meeting URL from being attached to an earlier event. Freeze enrichment when delivery rows are created. A missing generated URL is an honest pending/unavailable value; consumers can reconcile current booking details through the existing read APIs. A guaranteed later meeting-ready notification would be a separate follow-up, not an implicit promise of this fix.

    6. Remove the duplicate publication path. For invitation bookings, stop the current asynchronous side-effect handlers from also calling Enqueue for these three events. Continue running calendar/meeting/confirmation/reminder work after commit. Ordinary bookings keep their current publication behavior in this focused change; broader adoption can follow separately.

    The guarantee is durable event capture and crash-safe queueing, not exactly-once HTTP delivery or indefinite retries. Existing delivery attempt limits still apply. A crash after the receiver accepts an event but before local success recording can cause redelivery with the same IDs; receivers must deduplicate. Ordering remains unguaranteed, with booking revisions allowing stale events to be recognized.

    Focused regression tests should cover:

    • Restart after each booking transition commits but before outbox dispatch, recovering the event without replaying the booking operation.
    • Forced outbox failure/transaction rollback leaving neither a booking change nor an event.
    • Two reschedules and cancellation before the first poll, retaining every transition's own intervals, status, revision, and correlation.
    • Repeated polls, crash between queueing and delivery, and failed HTTP delivery preserving IDs and avoiding duplicate queued deliveries.
    • Slow/failing calendar or mail providers not preventing event capture/dispatch.
    • Both cancellation entry points, creator/primary-host subscription deduplication, restricted field selections, and absence of credentials in payloads/logs.
    • Meeting enrichment occurring only for the matching revision and staying frozen across retries.
    • Existing ordinary booking behavior remaining unchanged.

    These tests can use disposable SQLite databases, existing provider/mailer fakes, and a local webhook receiver; no live M365 credentials are needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions