Mailglass.Webhook.Reconciler (Mailglass v0.1.0)

Copy Markdown View Source

Oban cron worker that closes the orphan-webhook race window (CONTEXT D-17, D-18).

An orphan webhook event is one inserted by Mailglass.Webhook.Ingest with delivery_id: nil + needs_reconciliation: true because the matching mailglass_deliveries row had not yet committed when the webhook arrived (a real race in production for low-latency providers).

This worker runs on a */5 * * * * cron schedule (adopters wire the cron in their own Oban config; see guides/webhooks.md — lands with Plan 04-09). For each orphan older than 60 seconds (grace window for late commits):

  1. Mailglass.Events.Reconciler.find_orphans/1 returns the candidate batch (tenant-scoped, age-bounded, newest max_age_minutes only)
  2. Mailglass.Events.Reconciler.attempt_link/1 looks up the Delivery via (provider, provider_message_id) from the orphan's metadata
  3. On match: append a NEW :reconciled event (D-18 — append, never UPDATE the orphan row; preserves the SQLSTATE 45A01 append-only invariant) + call Projector.update_projections/2 for the matched Delivery + post-commit broadcast on the Projector PubSub topic (Phase 3 D-04)
  4. On no-match: leave the orphan row untouched; next tick retries

After 7 days (max_age_minutes: 7 * 24 * 60), find_orphans/1 filters the row out of the scan (admin LiveView shows it as "older than 7 days — unlikely to reconcile" per D-19).

Optional-dep gating

The entire module is conditionally compiled at file top level behind if Code.ensure_loaded?(Oban.Worker). When Oban is absent, a stub module is defined that exposes available?/0 → false; Mailglass.Application emits a consolidated Logger.warning at boot (D-20) directing operators to run mix mailglass.reconcile and mix mailglass.webhooks.prune from their own cron infrastructure.

Concurrency

unique: [period: 60] dedupes overlapping cron runs — if a previous tick is still processing when the next fires, the second is a no-op at the Oban layer. concurrency: 1 is the implicit default for :mailglass_reconcile queue; adopters who raise it accept the reconciliation race (Ecto optimistic locking on the Delivery row still makes the final write correct, but duplicate :reconciled events become possible if two workers see the same orphan — the partial UNIQUE index on idempotency_key ("reconciled:#{orphan.id}") structurally prevents this anyway).

Summary

Functions

Returns true when the Reconciler module is fully compiled (Oban available). Callers use this in mix mailglass.reconcile and in Mailglass.Application to decide whether to invoke the worker or emit the Oban-missing warning.

Run the reconciliation sweep for the given tenant (or all tenants when tenant_id is nil).

Functions

available?()

(since 0.1.0)
@spec available?() :: boolean()

Returns true when the Reconciler module is fully compiled (Oban available). Callers use this in mix mailglass.reconcile and in Mailglass.Application to decide whether to invoke the worker or emit the Oban-missing warning.

reconcile(tenant_id \\ nil, limit \\ 1000)

@spec reconcile(String.t() | nil, pos_integer()) ::
  {:ok, %{scanned: non_neg_integer(), linked: non_neg_integer()}}

Run the reconciliation sweep for the given tenant (or all tenants when tenant_id is nil).

Returns {:ok, %{scanned: n, linked: m}} on success.

Exposed as a public function so mix mailglass.reconcile can invoke the same code path; also useful in tests and for ops engineers who want to run a sweep out-of-band without waiting for the next cron tick.