5.6 KiB
5.6 KiB
03 — Domain Events, Outbox & Jobs
Every mutation in SoloPM emits a domain event. One mechanism feeds five consumers: audit log, notifications, SSE, Meilisearch sync, outgoing webhooks. The design is a hand-rolled transactional outbox in Postgres with a single in-process dispatcher — no external queue, no River. Rationale in 17-decisions.md; the explicit assumption is one app instance (FOR UPDATE SKIP LOCKED makes even accidental multi-instance mostly safe, but the SSE hub is per-process).
Emit contract
- Implement
events.Emit(ctx, tx, Event{Type, ActorID, ProjectID, ResourceType, ResourceID, Payload, ViaWebhook})which, inside the caller's transaction:- Inserts the
audit_logrow (exactly-once, atomic with the mutation — an audit entry can never be lost or orphaned) - Inserts the
eventsoutbox row
- Inserts the
Payloadincludes: changed-field map{field: {prev, new}}, denormalized display data (issue title, project title,#N), and server-parsed@mentionusernames where applicable (08-collaboration.md)- After commit, the service fires
pg_notify('solopm_events', '')— best-effort wake-up; polling is the safety net - Every service mutation calls
Emit; a mutation without an event is a spec violation
Event taxonomy
Format resource.action, past tense. action_type in audit_log uses the same strings.
project.created/project.updated/project.status_changed/project.deletedproject.member_added/project.member_removed/project.role_changed/project.ownership_transferredproject.dependency_added/project.dependency_removedproject.exported(audit-only; no notification/SSE/search/webhook fan-out)label.created/label.updated/label.deletedepic.created/epic.updated/epic.status_changed/epic.deletedissue.created/issue.updated/issue.status_changed/issue.assigned/issue.unassigned/issue.moved(rank/epic/parent changes)issue.blocker_added/issue.blocker_removedissue.label_added/issue.label_removedcomment.created/comment.updated/comment.resolved/comment.unresolved/comment.deletedattachment.uploaded/attachment.deletedlink.added/link.removedwiki.created/wiki.updated/wiki.deleteduser.created/user.updated/user.deletedauth.login_succeeded/auth.login_failed/auth.token_created/auth.token_revokedwebhook.received/webhook.processed/webhook.failed(incoming-webhook lifecycle)
Dispatcher
- One goroutine started in
main, stopped on shutdown:LISTEN solopm_events+ 2-second poll fallback - Claims batches:
SELECT … FROM events WHERE processed_at IS NULL ORDER BY id LIMIT 50 FOR UPDATE SKIP LOCKED - For each event, run the fan-out router (below), then
UPDATE events SET processed_at = now() - Crash mid-fan-out → event re-processed on restart; all consumers must be idempotent (see guarantees table)
attemptsincremented per processing try; events failing 8 times are marked processed with an error log (never wedge the queue)
Consumers & guarantees
| Consumer | Mechanism | Guarantee |
|---|---|---|
| Audit log | synchronous inside Emit (same tx) |
exactly-once |
| Notifications | inline in dispatcher → notifications inserts + send_email jobs |
at-least-once, deduped |
| SSE | inline publish to in-memory hub | best-effort (clients refetch on reconnect) |
| Meilisearch | search_sync job |
at-least-once, idempotent upsert/delete by doc id |
| Outgoing webhooks | delivery row + webhook_delivery job |
at-least-once; UNIQUE(webhook_id, event_id) dedupes |
- Notifications consumer: compute recipients per the rules in 09-notifications-realtime.md; skip the actor themself; insert
notificationsrows (dedupe: skip if an identical(user_id, type, resource, event)row was already created for this event id); for each recipient withemail_mode = instant, enqueue asend_emailjob;daily_digestusers are handled by the nightly digest job - SSE consumer: build the envelope (09-notifications-realtime.md) and publish to the hub; hub delivers
dataframes only to connected users who are members ofevent.project_id - Search consumer: map event →
{index, doc_id, op: upsert|delete}per 10-search.md; enqueuesearch_syncjob; skip event types with no search impact - Webhook consumer: for each active
outgoing_webhooksrow of the project whoseevent_typesis empty or contains the event type: insertoutgoing_webhook_deliveries(pending) +webhook_deliveryjob
Job queue
- Worker pool: 3 goroutines polling
jobsevery 1s:SELECT … WHERE status = 'pending' AND run_at <= now() ORDER BY run_at LIMIT 10 FOR UPDATE SKIP LOCKED, setrunning, execute, setdone - On failure:
attempts++,last_errorrecorded,run_at = now() + 30s × 2^attempts(capped at 1h), back topending - After
max_attempts(8): statusdead; dead jobs visible in admin (and webhook deliveries surface asfailedin the delivery log) - Job kinds:
send_email,webhook_delivery,search_sync,daily_digest(scheduled nightly),cull(scheduled daily; see 12-retention.md) - Scheduled jobs: on startup and every hour, ensure the next
daily_digestandculljobs exist (idempotent upsert by kind + date) - Email and webhook delivery never block an HTTP request; search sync typically lands < 2s after the mutation