Skip to content

fix: stop the screen client serving stale or blank content after a degraded pull - #12

Closed
tuj wants to merge 10 commits into
fix/issue-507-problem-med-visning-af-spillelister-paa-skaermefrom
fix/issue-507-extra-fixes
Closed

fix: stop the screen client serving stale or blank content after a degraded pull#12
tuj wants to merge 10 commits into
fix/issue-507-problem-med-visning-af-spillelister-paa-skaermefrom
fix/issue-507-extra-fixes

Conversation

@tuj

@tuj tuj commented Sep 2, 2026

Copy link
Copy Markdown

Link to issue

os2display#507 — Problem med visning af spillelister på skærme.

Stacked on fix/issue-507-problem-med-visning-af-spillelister-paa-skaerme (nginx rate-limit + retry/fan-out); this is the follow-up work from reviewing those.

Description

A screen could go stale — or blank — and stay that way until somebody edited the content in Admin. Two root causes: the client cached pulls that had partly failed, so "the next pull will retry it" was never true, and it relied on React object identity to deliver content it had fetched successfully.

Seven commits, each carrying its full reasoning in the commit message:

Commit Fix
b0ebacd Don't cache a pull that partly failed. Track which relation groups failed and withhold their checksums so the next pull refetches them. Also stops overlapping pulls, times out the client config request, and jitters a server-sent Retry-After.
f788541 Deliver region content on every pull. Delivery hung off useEffect([region]), and a pull serving the layout from cache hands back the same region objects every time — so edited slides never reached the region.
617cac2 Keep the last known good layout when its request fails, rather than unmounting every region and restarting playback from the first slide.
f4fe5c9 Count only renderable slides when deciding a screen is empty, so the fallback image appears instead of a black screen. (The template fallback in its subject line is removed again by f5e0e97.)
2448a41 Keep the last known good slides when a playlist's /slides request fails.
f575782 Contain a slide's template error in the slide's own error boundary. It used to escape and replace the whole region with the error fallback until the client was reloaded.
f5e0e97 Stop fetching templates altogether. They are bundled into the client, and the only thing used from the API's template resource was the id — already carried by the slide. One fewer request per template per pull, and a throttled template request can no longer empty a region.

No backend change: /v2/templates stays for the Admin template picker, so there is no /v2 BC question.

Checklist

  • My code is covered by test cases.
  • My code passes our test (all our tests).
  • My code passes our static analysis suite.
  • My code passes our continuous integration process.

Additional comments or questions

Reading the commits in order tells one story — stop caching what failed, stop depending on object identity to deliver what succeeded, stop letting one failed request blank a region, then stop making the request at all — but they are independently reviewable.

One note if you read commit messages: 2448a41 justifies matching on @id via the template fallback from f4fe5c9, which f5e0e97 then removes. At HEAD the reason is the media cache.

Review of the rate-limit fix pointed out that the retry layer cannot help with
the case that actually freezes a screen: getScreen promoted every pull to the
cache together with the server's relationsChecksum, including pulls that had
fallen back to stale data. The next pull compared the checksums, found them
equal, took the cache branch and served the degraded data - so a region, layout,
template or media item stayed stale until somebody edited the content in Admin.
That is what made "the next pull will retry it" untrue.

- Track which relation groups a pull failed to load and store only the checksums
  it is entitled to. The checksums move to their own field: ContentService
  hashes the screen object to decide whether it changed, so rewriting
  relationsChecksum on the screen itself would re-emit it on every pull.
- Treat a missing relationsChecksum as "must refetch" rather than defaulting it
  to an empty collection. Two empty maps compare equal on every key, and the API
  really can send nothing - the DTO getter answers null for an empty map.
- Refetch a slide's template or media when the cached value is null, rather than
  when the checksum changed. The checksum cannot carry that signal: with the
  regions branch served from cache, slide and previousSlide are clones of the
  same cached object, so their checksums always agree. Clear slide.invalid on
  recovery too - Region filters invalid slides out, so refetching the template
  achieves nothing while the flag stands.

Also from review:

- Wait for a pull to finish before scheduling the next one. setInterval fired
  regardless, so a slow pull got a second one stacked on top of it, doubling the
  fan-out exactly when the backend was already struggling. Scheduling happens in
  a finally, and a chain id keeps a restart from leaving two chains running.
  This also makes stop() effective during the first pull, which start()'s finally
  used to ignore - a DataSync that ContentService had already discarded kept
  polling and dispatching content events.
- Give the client config request a timeout. Every request through ApiHelper was
  already capped, but loadConfig awaited a raw fetch, which was the one
  unbounded await in a pull. Extracted the AbortController pattern out of
  ApiHelper into fetchWithTimeout and used it in both places. loadConfig also
  cleared its in-flight promise only on the fetch path, so a single stalled
  request pinned every later call to it and stalled every pull behind it.
- Jitter a Retry-After the server sent. Every client rejected in the same second
  gets the same value, so honouring it verbatim re-synchronises the burst the
  header exists to spread. Added rather than subtracted: RFC 9110 makes it a
  minimum.

Tests: 161 pass. New coverage for recovery after a failed region, layout,
template and media request, for a screen that advertises no checksums, for the
clean-pull path still using the cache, for the poll not overlapping and not
outliving stop() or a restart, and for the request timeout and the config loader.

Refs issue 507 (os2display/display-api-service)
@tuj
tuj requested a review from turegjorup September 2, 2026 11:07
@tuj tuj self-assigned this Sep 2, 2026
@tuj tuj added the bug Something isn't working label Sep 2, 2026
…hanges

Looking into why the ContentService screen hash forced PullStrategy to keep its
checksums in a separate field turned up something worse: the hash was hiding a
content-staleness bug, and it is the os2display#507 symptom.

contentHandler had two mutually exclusive branches. Screen hash changed - emit
the screen and leave delivery to each Region's useEffect([region]) firing
regionReady. Screen hash unchanged - push updateRegion directly. Neither covers
the common case. A pull whose layout checksum is unchanged reuses
lastestScreenData.layoutData by reference, so layoutData.regions[i] are the same
objects pull after pull. Edit a slide and the server bumps
relationsChecksum.regions, which is inside the hashed screen, so the emit branch
is taken - but [region] is Object.is-equal, the effect never runs, regionReady
never fires, and updateRegion is never called with the fresh regionData.
checkScheduling does not rescue it either; it re-reads the stale cached region.
The screen keeps showing the old content until something forces a layout
refetch.

- Drop the ContentService hash and always emit. Re-emitting is cheap: Screen
  keys its regions by id, so React reconciles them in place and playback state
  survives, and Region defers any slide-list swap to the playlist wrap-around.
- Push updateRegion for every region on every content event, before the emit, so
  delivery no longer depends on a component noticing a prop changed.
- Add ScheduleService.regionReady, an unconditional replay of the cached slides.
  The push happens before React has mounted a newly added region, so that
  dispatch is lost; updateRegion's hash gate would then report no change on
  every later pull and the region would stay blank.
- Make the regionReady effect in Region and TouchRegion mount-only. Hanging it
  off region object identity is what produced the bug.

The ScheduleService hash stays. Unlike the ContentService one it gates state
churn into the region components rather than a React re-render, and it is the
only thing that notices a feed changed - feedData is refetched every pull and
carries no checksum. Its input drops the region, though: findScheduledSlides
derives the slides from it, so hashing both serialised the full region payload
twice per pull for nothing.

Also fixed while wiring this up:

- checkScheduling wrote this.regions[regionId].slide, singular. Harmless before,
  but regionReady reads cached.slides, so a region mounting after a schedule
  change would have been handed the pre-change set.
- App and Screen re-ran their useEffect([screen]) on every emit, costing a
  /v2/tenants/{id} request per pull - the fan-out os2display#507 is about - and a
  colour-scheme teardown that strips color-scheme-* off the html element before
  an async config load puts it back. Both now key on the value they use.
- Corrected the lastestScreenChecksums comment, which justified the separate
  field with a re-emit that would not have happened on every pull anyway. The
  field is right for a different reason: relationsChecksum reaches the client as
  the server sent it, not as what a degraded pull is entitled to compare
  against.

ContentService and ScheduleService had no test coverage. The new suites cover
the regression directly; 10 of their 14 cases fail against the previous
behaviour.
@tuj
tuj removed the request for review from turegjorup September 3, 2026 05:29
getScreen stored a null layoutData when the layout request failed and emitted it
to the client. Screen builds its regions from layoutData.regions, so a null
unmounts every region: regionRemoved clears each region's scheduling interval
and cached content, and the next successful pull mounts them again from scratch,
restarting playback from the first slide. The screen is blank in the meantime.

Regions and campaigns already degrade by keeping what the previous pull had -
content one pull out of date beats a black region, and a rejected request says
nothing about what should be shown. Layout now makes the same trade.

The fallback is deliberately narrow. It is skipped when the previous pull was in
campaign mode, which holds a synthetic full-screen layout with one hardcoded
region that no playlist in a normal pull is scheduled for, and when the screen's
layout IRI has changed since, where the cached regions would not match the
regionData this pull just fetched. In both cases layoutData stays null, as
before.

report.layout is unaffected, so the checksum is still withheld and the next pull
retries the layout.
@tuj tuj changed the title fix: recover from a degraded pull instead of caching it fix: stop the screen client serving stale or blank content after a degraded pull Sep 4, 2026
tuj added 2 commits September 4, 2026 05:14
Nulling templateData marks the slide invalid, and Region drops invalid
slides, so a few seconds of rate limiting during the template phase
emptied a region as soon as its playlist wrapped. Keep the template the
previous pull loaded instead, the same trade getRegions and the layout
branch already make, and only mark a slide invalid when no pull has ever
loaded its template.

Failed template requests are now cached for the rest of the pull too.
Slides share a handful of templates, so without that every slide using an
unreachable template paid the full retry budget again, stretching a short
outage across the whole pull.

The region emptied with the fallback image already suppressed, because
checkForEmptyContent counted slides the regions had dropped. Count with
the test Region applies when it renders, shared between the two so they
cannot drift apart, and re-check when a region is removed - losing the
last region with content otherwise left contentEmpty stuck at false.
getRegions hands back playlists mapped straight off the API, which have
never carried slidesData, so the cloneDeep in getSlidesForRegions had
nothing to preserve: a region that loaded fine but whose /slides request
failed lost that playlist's content for a whole pull. Look the playlist
up in the previous pull and keep its slides, the same trade getRegions
and the layout branch already make.

Both lookups match on @id rather than on position. Playlists and slides
get added, removed and reordered between pulls, and since the template
fallback added in f4fe5c9 reads previousSlide.templateData, a positional
pairing could hand a slide another slide's template after an editor
reordered a playlist - rendering the wrong template rather than none.
@tuj
tuj requested a review from turegjorup September 4, 2026 05:39
tuj added 2 commits September 4, 2026 08:45
renderSlide was called as an argument in Slide's render, so a template
this build cannot resolve threw before Slide's ErrorBoundary mounted.
The throw escaped past Slide entirely and hit the region's boundary,
which has no error handler and never resets - so one unrenderable slide
replaced its whole region with the error fallback until the client was
reloaded.

Rendering the template from a child component puts the throw inside the
boundary meant to contain it: that slide shows the fallback, slideError
fires, and the region moves on to the next slide.
Every template's render code and config is bundled into the client, and
the only thing rendering wants from templateData is the id it looks the
bundled module up by. That id is the last segment of the template IRI the
slide already carries, and the API's template resource exposes nothing
else a screen can use - the resources column that carried asset paths in
2.7 is deprecated and unexposed. So the request read back a value the
pull already had, at the cost of one round trip per template per pull.

It also cost a failure mode. A throttled template request nulled
templateData, Region drops slides marked invalid, and the region emptied
itself as soon as its playlist wrapped (os2display#507). The negative cache, the
last-known-good fallback and the invalid-slide bookkeeping added to
survive that all go with the request.

A slide can still be invalid, but only when its template IRI carries no
id at all. An id with no module in this build is the remaining way a
template can be missing, and that is the render-time throw the slide's
error boundary now contains.

@turegjorup turegjorup left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not blocking. Nothing in the diff is wrong, but it collides with os2display#546 in a way that needs a named resolution, and one new shared helper has a hole.

Verified rather than read off the description: task test:unit is 34 files / 193 tests green; prettier and markdownlint clean. I reverted SlideTemplate to an inline renderSlide(...) call and both slide-template-error.test.jsx tests fail (expected null not to be null), so the boundary fix and its tests are real. ScreenProvider.php:60-62 builds screen.regions from $layout->getRegions() — the same layout screen.layout names — which is what makes the mount-only regionReady effects and the unconditional emit safe.

I went looking for a phantom-interval leak in ScheduleService.regions/intervals (a regionData key that never mounts would register a setInterval nothing ever clears, and count as content) and disproved it — the two region sources cannot diverge for the reason above, and the layout fallback guards the one case that could with previous.layout === newScreen.layout. Worth recording since it is the kind of thing that looks reachable from the diff alone.

Items with no diff line to attach to:

  • ErrorBoundary has no reset path (error-boundary.jsx:11-14) — hasError is set and never cleared. Commit 6 recovers by unmount/remount as the region advances, which works; but the region-level boundary in region.jsx still has no handler and no reset, so anything throwing outside the Slide subtree kills that region until the client reloads. This PR narrows that exposure rather than causing it — follow-up, not a blocker.
  • The fallback renders componentStack and a hardcoded Danish heading on a public display (error-boundary.jsx:38-44, "Seneste log hændelser"). Commit 6 makes it appear more often (per slide rather than per region), so it is worth deciding whether a screen in a public space should show a stack trace at all.
  • screen.jsx:103-111<Fragment key={…}> wrapping children that each carry the same key; the inner ones are redundant. Pre-existing.
  • touch-region.jsx:132-140<div role="button" onKeyDown={slideDone}> fires on any key, not just Enter/Space, and "LUK" is hardcoded. Pre-existing; this PR only touched the effect deps.

No src/Dto/ or src/State/ change, so the api-resource reviewer does not apply, and no generated-api.ts change for the RTK cache reviewer. Commit 7 does change how templateData is derived, so the slide-template contract is the one place a second opinion would help — though templates.js:72 reading only slide?.templateData?.id is strong evidence { id } is sufficient.

Comment thread assets/client/components/slide.jsx Outdated
>
<ErrorBoundary errorHandler={handleError}>
{renderSlide(slide, run, slideDone)}
<SlideTemplate slide={slide} run={run} slideDone={slideDone} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Highest-severity item here — merge with os2display#546 has one line where either side alone is wrong.

Not a defect in this diff, but it silently disables a shipped fix. git merge-tree against fix/region-run-id-collision conflicts only in CHANGELOG.md and this file. Everything else auto-merges, including os2display#546's MIN_SLIDE_DWELL_MS and slideDoneAfterMinimumDwell, which land in the merged file alongside SlideTemplate:

<<<<<<< fix/issue-507-extra-fixes
        <SlideTemplate slide={slide} run={run} slideDone={slideDone} />
=======
        {renderSlide(slide, run, slideDoneAfterMinimumDwell)}
>>>>>>> fix/region-run-id-collision

Taking this side keeps the boundary fix and drops the dwell guard, reinstating the unbounded effect → slideDone → new run id → effect loop os2display#546 exists to stop.

Fix: resolve to <SlideTemplate slide={slide} run={run} slideDone={slideDoneAfterMinimumDwell} /> — neither side verbatim.

Test: os2display#546's four dwell tests in region.test.jsx render through Slide, so a wrong resolution fails the suite. The net exists; whoever merges second just needs to know this line is not a pick-one.

Same file, minor: SlideTemplate's new JSDoc says @param {string} props.run - Timestamp for when to run the slide., which is the wording os2display#546 is sweeping out. Post-merge the file carries {number} Run id… on Slide and {string} Timestamp… on SlideTemplate, five lines apart.

* @param {object} slide The slide to test.
* @returns {boolean} True if the slide should be rendered.
*/
export default function isRenderableSlide(slide) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should fix — an absent slide counts as renderable.

slide?.invalid !== true returns true for null and undefined. Probed directly:

isRenderableSlide: {"null":true,"undefined":true,"invalid":false,"valid":true}
region of absent slides counts as content: true

So a region whose slides array holds only absent entries reports content, checkForEmptyContent leaves contentEmpty false, the fallback image stays suppressed and the screen is black — the exact failure this helper was extracted to close. Region also passes the absent entry through its filter to setCurrentSlide(undefined), so it renders nothing while claiming content.

To be straight about the limit: the logic hole is proven, the reachability is not. playlist.slidesData = result.value.results.map((ps) => ps.slide) yields such an array only if the API omits slide on a PlaylistSlide, which I did not confirm it can. The optional chaining here shows absent input was anticipated; the return value just does not act on it.

Fix: return slide != null && slide.invalid !== true;

Test: null/undefined cases on this helper, plus one checkForEmptyContent case with slides: [null].

this.currentScreen.regionData[regionId],
);
}
this.scheduleService.regionReady(regionId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should fix — a region that remounts without an intervening pull shows nothing for a poll interval.

This used to read this.currentScreen.regionData[regionId]; it now delegates to ScheduleService.regionReady, which reads a cache regionRemoved has just deleted. On a component swap for the same region id (default ↔ touch-buttons, both keyed region["@id"] in screen.jsx:103-111) React runs the old cleanup before the new mount, so the order is delete-then-read and the region gets nothing until the next pull — up to interval (30s default) of blank region on a public screen.

I can see this is deliberate and pinned: schedule-service.test.js:233-245 asserts recorder.sends stays at 1 across regionRemoved + regionReady. Raising it anyway because ContentService still holds currentScreen, so closing the window is nearly free, and a blank region is the precise symptom os2display#507 is about. Low frequency (needs a layout region-type change) and self-healing, hence should-fix rather than blocker.

Fix: fall back to this.scheduleService.updateRegion(regionId, this.currentScreen?.regionData?.[regionId]) when regionReady finds no cache.

Test: extend the existing regionRemoved test to assert a remount after removal is served from the current screen.

Comment thread CHANGELOG.md Outdated
layout is kept instead, unless it belongs to a campaign or to a layout the screen has since been
moved away from.
- Fixed a failed template request dropping slides that were playing perfectly well, where a few
seconds of rate limiting emptied a region as soon as its playlist wrapped (#507). The last known

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should fix — this entry describes behaviour commit 7 removed.

"The last known good template is kept instead, and a template that cannot be reached is now requested once per pull rather than once per slide." At HEAD there is no template request, no last-known-good fallback and no negative cache — and this contradicts the entry a few lines above, "Removed the screen client's template requests."

The PR description flags the code-level overlap between commits 4 and 7; the changelog was not updated with it.

Fix: drop the template clause, keeping the empty-content half that is still live.

Comment thread CHANGELOG.md Outdated
- Fixed a failed slides request emptying a playlist whose region had loaded fine, which lost that
playlist's content for a whole pull (#507). The slides the previous pull loaded are kept instead.
- Fixed the screen client pairing slides and playlists with the previous pull by position rather
than by id, so reordering a playlist in the admin could pair a slide with another slide's

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same cause as the entry above: at HEAD the @id matching only protects media, since the template fallback it was justifying is gone. "another slide's template or media" should lose template or.

*/
async fetchConfig(nowTimestamp) {
try {
const response = await fetchWithTimeout(`/config/client`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit — fetchConfig does not check response.ok, so a 4xx/5xx that returns a JSON body is cached as config for 15 minutes and appStorage.setApiUrl(configData.apiEndpoint) then stores undefined. Pre-existing behaviour, but the function was rewritten here so it is cheap to add: if (!response.ok) throw new Error(response.status) falls straight into the existing last-known-good path.

}

if (report.regions) {
stored.regions = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit — clearing to null makes a group this pull failed indistinguishable from a group the server genuinely sent null for, so a failed pull's degraded data would take the cache branch on the next one. Only reachable when the screen has no regions at all, where there is nothing to serve — so a comment noting that rather than a change.

tuj added 3 commits September 4, 2026 10:32
…kaerme' into fix/issue-507-extra-fixes

# Conflicts:
#	CHANGELOG.md
#	assets/client/components/slide.jsx
…ontent

Addresses the review on PR #12, plus one fault found while tracing it.

- Drop playlist rows whose slide relation is absent. They mapped to
  undefined, and getScreen's relations loop then wrote templateData onto
  them - a throw that reached pull()'s catch and aborted the whole pull,
  so one broken row in one playlist froze the screen on its last content,
  every pull.
- Treat an absent slide as unrenderable. isRenderableSlide answered true
  for null, so a region holding only absent slides counted as having
  content, which suppressed the fallback image and left the screen black.
- Restore a region's content and scheduling interval when it remounts.
  Changing a region's type swaps the component behind an unchanged region
  id, and React runs the outgoing cleanup before the incoming effects, so
  regionReady arrived with the cache regionRemoved had just dropped.
- Reject an error response as client config rather than caching the error
  body for the whole config interval with apiEndpoint undefined.
- Withhold a failed group's checksum with a unique symbol rather than
  null, which compared equal to the null the API answers for an empty
  relation map and so took the cached branch it exists to prevent.
- Drop the changelog's claims about template requests and the template
  fallback, both removed in f5e0e97.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants