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):
Mailglass.Events.Reconciler.find_orphans/1returns the candidate batch (tenant-scoped, age-bounded, newestmax_age_minutesonly)Mailglass.Events.Reconciler.attempt_link/1looks up the Delivery via(provider, provider_message_id)from the orphan's metadata- On match: append a NEW
:reconciledevent (D-18 — append, never UPDATE the orphan row; preserves the SQLSTATE 45A01 append-only invariant) + callProjector.update_projections/2for the matched Delivery + post-commit broadcast on the Projector PubSub topic (Phase 3 D-04) - 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
@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.
@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.