Skip to main content

ADR 0038 — schedule-events Topic: Partitioning, Retention, and the Outbox vs Best-Effort Split

Status: Accepted Context date: 2026-08-06 Builds on: ADR 0024 (outbox pattern, platform-events), ADR 0025 (status projection), ADR 0030 (entitlement projection), ADR 0032 (service boundary)

Context

The scheduler emits lifecycle and run events for three consumers: the notification service (tenant subscription webhooks, email/in-app alerts), Analytics (long-horizon run history in ClickHouse), and the tenants themselves (via notification's fan-out). docs/06 sets the conventions — envelope, partition-key-per-ordering-scope, and outbox-for-business-critical — and this ADR extends them to a new topic. The design question with teeth: which events must survive a crash between DB commit and Kafka publish, and which may be lost?

Decision

1. Two new topics

TopicPartition keyRetentionProducers
schedule-eventstenant_id14 dScheduler
schedule-dlq14 dScheduler (exhausted deliveries)

Partitioning by tenant_id gives per-tenant ordering, matching platform-events (ADR 0024) — the ordering scope consumers actually need (a run.succeeded never overtakes its run.started for one tenant). The envelope is pkg/events.Envelope unchanged; room_id is omitted. Schema: contracts/events/schedule-events.json.

2. Outbox vs best-effort, per event type

TypePathWhy
schedule.created / updated / deletedoutboxLifecycle facts; consumers (notification, analytics) must never miss a mutation.
schedule.paused / resumedoutboxSame — and actor=system pauses are the audit trail of platform actions.
schedule.triggeredoutbox (product-critical)For action=event schedules, this event is the product delivery — losing it between commit and publish loses the tenant's job. The same argument docs/06 makes for room.closed.
schedule.run.failedoutboxDrives tenant alerting through the ADR 0024/0025 machinery.
schedule.disabled_autooutboxBreaker trips must reach tenant admins; carries admin_emails per the IDs-plus-data stance (ADR 0024/0025).
schedule.run.started / succeeded / skippedbest-effortHigh-volume observational telemetry; authoritative state is queryable via the run-history API, so a lost copy costs nothing but a dashboard blip.

The outbox is the scheduler's own outbox table in scheduler_db, same shape and RelayOutbox (FOR UPDATE SKIP LOCKED) pattern as auth's (ADR 0024). Unlike the auth outbox (which ADR 0025 made a deliberate two-writer contract), the scheduler is the only writer of its outbox.

3. Consumed topics

TopicGroupPurpose
platform-eventsscheduler-tenant-statustenant.suspended / activated / provisionedtenant_status projection, occurred_at-guarded (ADR 0025 §3 verbatim).
platform-eventsscheduler-entitlementsADR 0030 entitlement snapshot + quota-verdict projection (entitlement_version guard).

Notification requires no code change to consume schedule-events — its dispatcher already matches on envelope type per tenant subscription; only its topic list grows.

Alternatives Considered

  • Reusing platform-events. Rejected: wrong audience and volume profile — platform-events is low-volume internal lifecycle with PII-driven short retention (ADR 0024); run events are high-volume and tenant-consumable.
  • Reusing room-events. Rejected: wrong ordering scope (room_id) for events that have no room, and it would entangle two unrelated bounded contexts' retention and consumer groups.
  • Everything through the outbox. Rejected: run.started/succeeded/skipped at full volume would make the outbox table and relay the service's hottest path for events whose loss is harmless — the run-history API is the authoritative record.
  • Everything best-effort. Rejected: loses schedule.triggered for action=event schedules on a crash — i.e., loses the product itself — and breaks the alerting chain for run.failed/disabled_auto.

Consequences

  • Positive: docs/06's conventions hold unmodified: one envelope, ordering scoped to what consumers need, outbox reserved for business-critical facts.
  • Positive: Tenant alerting for failing/disabled schedules rides the proven ADR 0024/0025 notification machinery with zero new mechanism.
  • Negative / accepted: Best-effort run events mean Analytics' ClickHouse history can have gaps that the run-history API does not; long-horizon analytics is explicitly a lossy mirror of scheduler_db, not a second source of truth.
  • Negative / accepted: admin_emails on schedule.disabled_auto continues the accepted PII-on-Kafka stance (ADR 0024/0025) — bounded here by the 14 d retention.
  • Follow-up: Declare both topics explicitly (retention, produce ACL restricted to the scheduler relay) rather than relying on broker auto-creation — the same standing follow-up ADR 0024/0025 carry for platform-events.