Files
Signage/docs/architecture/decisions/0005-authoritative-playback.md
2026-08-28 16:41:54 -05:00

5.7 KiB
Raw Permalink Blame History

0005: Playback is absolute instants reconciled forward, never a countdown

  • Status: Accepted
  • Date: 2026-08-27
  • Related: 0004, 0006

Context

The proven behaviour this preserves comes from the legacy Announcements v2 playback engine, which ran unattended church screens through outages, restarts, and mid-service edits. Its three hard-won properties are worth restating, because they are the ones a naive implementation loses:

  1. Absolute instants, not remaining time. A slide has a startedAt and a deadlineAt; a timer is only ever a hint about when to look again.
  2. Reconcile forward from the previous authoritative deadline, never from "now". Otherwise a delayed callback shortens nothing and lengthens everything, and drift accumulates all day.
  3. Catch up arithmetically. A screen that was off for a week must not replay a week of slides.

Decision

One serialized service per channel group

Each group has exactly one playback service owning its persisted state and its timers. Every mutation — a timer firing, an operator command, a provider invalidation — runs serialized against that group, inside one explicit transaction, and produces at most one revision increment. Two concurrent commands cannot interleave into a state neither asked for.

revision increases only on semantic change. An exact no-op — resuming something already running, activating the timeline already active — commits nothing and broadcasts nothing.

Reconciliation, not simulation

reconcile(group, at) is the single entry point. It reads persisted state and walks it forward to at:

  • If the current slot's deadline is still in the future, nothing happens. This is what makes restart recovery show the same item it was showing, rather than choosing a new one.
  • Otherwise the boundary is the previous deadline, not at. The next slot starts exactly when the last one ended, so a callback that fired 40 ms late costs 40 ms once instead of 40 ms per slide forever.
  • Eligibility and expiration boundaries are honoured at their exact instants, and a currently showing slot can only be cut short by its own window, never by another slot's.

Catch-up is bounded by boundaries, not by missed slides

When the gap is long, Signage advances the slot cursor arithmetically: it computes the effective duration of each enabled, eligible slot from what it already knows — the slot override, or the provider's registered default, or the native-image default — sums one pass of the pattern, and skips whole passes with a division. Only the slot it actually lands on is resolved.

That is the property that keeps a provider's rotation sane: catching up a week does not call resolveNext ten thousand times and does not consume a provider's no-repeat pool. Fast-forwarding stops at the first eligibility boundary in the interval, exactly as v2 did, so a schedule change inside the outage is still honoured.

Transport

Control Meaning
Pause Freeze the current slot. Persist the remainder; clear start and deadline.
Resume deadline = now + remainder; start = deadline − duration, so the reconstructed timeline stays consistent.
Static hold Show one native image while preserving the normal cursor and remainder underneath. Releasing it resumes from that remainder.
Next / Previous Move over actually resolved playback: previous re-shows the previous resolved selection from history, not "the slot before this one".
Blackout Cover the output with black. The hidden timeline keeps running, keeps its deadlines, and keeps resolving.
Reload Refresh provider availability and re-resolve upcoming selections without disturbing the current frame or its deadline.
Activate now / at next boundary Immediate, or queued as pending and applied at the next safe slot boundary.

A provider's internal React behaviour may change anything it likes during a slot; it may not extend that slot's authoritative deadline. The deadline was set by Signage before the renderer mounted.

Realtime carries revisions, not content

Signage publishes compact authoritative state — group id, revision, slot identity, absolute start/deadline, transport flags — and nothing large. Timelines, histories, and renderer payloads are fetched over authorized HTTP, keyed by revision, so a client that sees a revision it does not have refetches rather than waiting. That closes the GET-then-subscribe race without a second transport, and keeps every publication far inside the platform's payload ceiling.

Signage introduces no socket, no broker, and no second realtime transport. It publishes through the platform's module realtime contract and nothing else.

Consequences

  • Server authority is unambiguous: on reconnect, the server's state wins immediately, and a client's local cursor is discarded rather than reconciled.
  • Timers are never load-bearing. Losing one costs a late transition, not a wrong one.
  • Two displays that connected minutes apart agree, because they both read the same absolute instants.
  • A long outage costs one reconciliation, not one per missed slide.

Alternatives

  • A countdown per slide. Rejected: it drifts, and it cannot be restored after a restart.
  • Reconcile from now. Rejected: every late callback permanently lengthens the loop.
  • Replay every missed slide to catch up. Rejected: it burns a provider's rotation and can take longer than the outage.
  • Publish full state over realtime. Rejected: a large timeline exceeds the payload ceiling, and a screen that missed one message would be wrong until the next one.