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

99 lines
5.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 0005: Playback is absolute instants reconciled forward, never a countdown
- Status: Accepted
- Date: 2026-08-27
- Related: [0004](0004-channel-groups-and-one-clock.md),
[0006](0006-display-rendering-and-offline-continuation.md)
## 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.