Scheduling: an unhandled PlaybackOrder fails silently at five of six dispatch sites #403
Closed
opened 2026-07-17 12:59:29 +02:00 by timothy
·
3 comments
No Branch/Tag Specified
main
renovate/lucene.net
renovate/dotnet-monorepo
renovate/sqlitepclraw.bundle_e_sqlite3-3.x
renovate/meziantou.analyzer-3.x
renovate/cliwrap-3.x
913-scripted-playout-401
release/v26.15.0-notes
issue-806-guard-populations
scratch/767b-poisoned
scratch/767b-control
release/v26.14.0-notes
release/v26.14.0
docs/510-skill-logo-bug-policy
fix/510-watermark-resolution-policy
fix/629-verdict-classifier-falseopens
fix/609-decisions-edit-token-scope
issue-135-clear-to-none
release/v26.12.0-notes
fix/409b-lastscan-api-parity
fix/401-updatechannel-mirror-422
fix/327-playlist-rename-validation
fix/410-scancancel-log-level
fix/409-447-librariesscreen-neverscanned
fix/338-zap-exit-code
fix/367-plex-budget-message
fix/310-debom-legacy-cs
ci/604-lane-rebalance
feat/388-design-mirror
feat/247-test-ownership
feat/247-primary-action
feat/357-player-owned-playback
feat/357-jellyfin-plugin-poc
fix/289-mcp-hardening
issue58-mcp
feat/244-channels-extract
ci/auto-bump-prod-compose
feat/multi-rerun-collections-api
feat/collections-api
feat/quick-wins
feat/185-docs-part2
feat/140-collections-screen
feat/146-channel-edit
feat/147-classic-ui-link
issue22-renovate-dashboard
feat/91-cutover
feat/63-composite-create
feat/65-library-browse
feat/85-epg
feat/86-schedule-editor
feat/109-dashboard-data
feat/99-session-tracking
fix/dockerfile-node-tag
feat/59-spa-foundation
docs/59-ui-redesign-brief
feat/102-json-guide
feat/111-schedule-durations
feat/104-artwork-upload
feat/103-media-sources-api
feat/playouts-read-api
feat/108-health-api
feat/105-picker-list-endpoints
issue-97-channel-state-api
issue42-jellyfin-musicvideos
issue46-rest-api-error-contract
dependabot/nuget/ErsatzTV.FFmpeg.Tests/multi-d307a2e06f
qsv-improvements
hdr-vulkan-cuda-test
v26.16.0
v26.15.0
v26.14.0
v26.13.0
v26.12.0
v26.11.0
v26.10.0
v26.9.0
v26.8.0
v26.7.0
blazor-final
v26.6.0
v26.5.0
v26.4.0
v26.3.1
v26.3.0
v26.2.0
v26.1.1
v26.1.0
v25.9.0
v25.8.0
v25.7.1
v25.7.0
v25.6.0
v25.5.0
v25.4.0
v25.3.1
v25.3.0
v25.2.0
v25.1.0
v0.8.8-beta
v0.8.7-beta
v0.8.6-beta
v0.8.5-beta
v0.8.4-beta
v0.8.3-beta
v0.8.2-beta
v0.8.1-beta
v0.8.0-beta
v0.7.9-beta
v0.7.8-beta
v0.7.7-beta
v0.7.6-beta
v0.7.5-beta
v0.7.4-beta
v0.7.3-beta
v0.7.2-beta
v0.7.1-beta
v0.7.0-beta
v0.6.9-beta
v0.6.8-beta
v0.6.7-beta
v0.6.6-beta
v0.6.5-beta
v0.6.4-beta
v0.6.3-beta
v0.6.2-beta
v0.6.1-beta
v0.6.0-beta
v0.5.8-beta
v0.5.7-beta
v0.5.6-beta
v0.5.5-beta
v0.5.4-beta
v0.5.3-beta
v0.5.2-beta
v0.5.1-beta
v0.5.0-beta
v0.4.5-alpha
v0.4.4-alpha
v0.4.3-alpha
v0.4.2-alpha
v0.4.1-alpha
v0.4.0-alpha
v0.3.8-alpha
v0.3.7-alpha
develop
v0.3.6-alpha
v0.3.5-alpha
v0.3.4-alpha
v0.3.3-alpha
v0.3.2-alpha
v0.3.1-alpha
v0.3.0-alpha
v0.2.5-alpha
v0.2.4-alpha
v0.2.3-alpha
v0.2.2-alpha
v0.2.1-alpha
v0.2.0-alpha
v0.1.5-alpha
v0.1.4-alpha
v0.1.3-alpha
v0.1.2-alpha
v0.1.1-alpha
v0.1.0-alpha
v0.0.62-alpha
v0.0.61-alpha
v0.0.60-alpha
v0.0.59-alpha
v0.0.58-alpha
v0.0.57-alpha
v0.0.56-alpha
v0.0.55-alpha
v0.0.54-alpha
v0.0.53-alpha
v0.0.52-alpha
v0.0.51-alpha
v0.0.50-alpha
v0.0.49-prealpha
v0.0.48-prealpha
v0.0.47-prealpha
v0.0.46-prealpha
v0.0.45-prealpha
v0.0.44-prealpha
v0.0.43-prealpha
v0.0.42-prealpha
v0.0.41-prealpha
v0.0.40-prealpha
v0.0.39-prealpha
v0.0.38-prealpha
v0.0.37-prealpha
v0.0.36-prealpha
v0.0.35-prealpha
v0.0.34-prealpha
v0.0.33-prealpha
v0.0.32-prealpha
v0.0.31-prealpha
v0.0.30-prealpha
v0.0.29-prealpha
v0.0.28-prealpha
v0.0.27-prealpha
v0.0.26-prealpha
v0.0.25-prealpha
v0.0.24-prealpha
v0.0.23-prealpha
v0.0.22-prealpha
v0.0.21-prealpha
v0.0.20-prealpha
v0.0.19-prealpha
v0.0.18-prealpha
v0.0.17-prealpha
v0.0.16-prealpha
v0.0.15-prealpha
v0.0.14-prealpha
v0.0.13-prealpha
v0.0.12-prealpha
v0.0.11-prealpha
v0.0.10-prealpha
v0.0.9-prealpha
v0.0.8-prealpha
v0.0.7-prealpha
v0.0.6-prealpha
v0.0.5-prealpha
v0.0.4-prealpha
v0.0.3-prealpha
v0.0.2-prealpha
v0.0.1-prealpha
Labels
Clear labels
ad-hoc
api
bug
ci-cd
content
dependencies
enhancement
frontend
in-progress
jellyfin
parked
priority: high
priority: low
priority: medium
review
security
One-off / ad-hoc work not tracked by a dedicated issue
REST API / HTTP endpoints
Something isn't working
Build, test, deploy pipeline
Channel content / schedules / playlists
Dependency updates (Renovate)
New feature or improvement
ChicoryTV React SPA frontend
Claimed by an active session — do not pick up
Jellyfin tuner / IPTV integration
Excluded from automatic queue pickup; work only when explicitly selected
Adversarial review finding
Security / vulnerability fix
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: timothy/ersatztv#403
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Split out of #70 as an explicit non-goal of PR #402, and confirmed independently by that PR's adversarial review.
Problem
Adding a new
PlaybackOrdervalue is unsafe by construction: five of the six sites that turn the enum into an enumerator mishandle an unknown value silently, and two of them substitute a different working order rather than failing.PlayoutBuilder.csdefault:armRandomizedMediaCollectionEnumerator. Carries the comment// TODO: handle this error case differently?PlaylistEnumerator.cs:166-207— nodefault:arm at all:210 if (enumerator is not null)→ the item is silently dropped from the playlist. Aggravating factor: null is a legitimate state at:201(SeasonEpisode withCount == 0), so nothing can flag itBlockPlayoutBuilder.cs:54-61+:166-169allow-list filtercontinues — the block item never airs, before the:367switch is even reachedEnumeratorCache.cs→Option.NoneNoneas "no content"SchedulingEngine.EnumeratorForContent→Option.None(5foreachcall sites)MultiCollectionGroup.cs:41-43throw new NotSupportedException)Also: three reverse-mappings (enumerator type →
PlaybackOrder) fall back to_ => PlaybackOrder.None(SchedulingEngine.cs,BlockPlayoutEnumerator.cs,YamlPlayoutApplyHistoryHandler.cs), so a new enumerator class silently reverse-maps toNonewhen writingPlayoutHistory.Latent fragility spotted while surveying:
Randomis in Block's allow-list but has no arm in the:367switch — it works only by coincidence, via the_ =>Random fallback.Why it matters
A distribution order degrading to unweighted random is undetectable by inspection — the output is supposed to look arbitrary. #70 worked around this by refusing
WeightedShuffleat the write path for engines that can't handle it (PR #402), which closes that feature's exposure but leaves the trap armed for the next order added. #176 and any future ordering work walk into the same hole.Scope
Make the unsupported-order path loud rather than silent. Options to weigh (design call):
PlaybackOrderSupportpredicate per engine family, enforced at the write path centrally instead of per-handler (PR #402 had to patch handlers one at a time — and missed one,CreateChannelFromLineup, which its review caught).Changing shipped fallback behavior is a real behavior change in the riskiest subsystem — hence its own issue, its own review, and it should land on the #163/#380 golden net.
Notes
WeightedShuffleand the write-path workaround), #380 (theShuffleSourceBuilderseam), #176.decisions.md2026-07-17 (#70 entry) documents the full site-by-site table.Done-when
Random-in-allow-list-but-not-in-switch coincidence is resolved_ => PlaybackOrder.Nonefallbacks revieweddecisions.mdupdatedClaiming (Claude Code orchestrator session, 2026-07-18).
Selected as the next eligible
priority: mediumbacklog issue after #350: #350 is already claimed by a parallel session (instrumentation shipped via PR #446, remaining optimization deferred/data-gated), so it is excluded. #403 is the lowest-numbered unclaimed tier-5 candidate; #404 is next.Plan: make the unsupported-
PlaybackOrderpath loud rather than silent across the six dispatch sites, guarded by the scheduler golden net. Will decide the exact mechanism (shared per-engine support predicate at the write path vs. loud throws/logs at each site) after reading the current sites + docs, and report the design call here before implementing widely.Design call (before implementing widely)
Grounded in
docs/decisions.md(the #70 entry, lines ~1447-1459) which explicitly deferred this to #403: #70 closed the persistence hole (write-path rejectsWeightedShufflewhere unhandled) and made YAML + Scripted loud (LogWarning);MultiCollectionGroupalready throws. The still-silent build-time sites are the three this issue targets:PlayoutBuilder.cs:1460default:-> silently substitutesRandomizedMediaCollectionEnumerator(the// TODOarm).PlaylistEnumerator.cs-> nodefault:arm; enumerator stays null -> item silently dropped. (Createisstaticwith no logger -> that's why it can't report; I'll add an optionalOption<ILogger>param, matching the existingGetStartTimeAfter(..., Option<ILogger>.None)pattern.)BlockPlayoutBuilder.cs:166-169allow-listcontinue-> silent skip; plus the:367_ =>silent-Random, andRandomis allow-listed but has no explicit arm (works only by coincidence).Approach
Randomarm to kill the coincidence.ErsatzTV.Core/Scheduling/PlaybackOrderSupport.cs(per engine family) + a tripwire test assertingsupported ∪ unsupported == Enum.GetValues<PlaybackOrder>()for each engine. Adding a newPlaybackOrdervalue then fails the test until it is consciously classified — that is the by-construction guarantee the issue asks for._ => PlaybackOrder.NoneinSchedulingEngine,BlockPlayoutEnumerator,YamlPlayoutApplyHistoryHandler): made loud where a logger is in reach.Deliberately OUT of scope
Not broadening the write-path rejection beyond #70's per-engine guards. The decisions doc records that this exact 'which order reaches which engine' perimeter has been wrong three times, always by enumerating from a list instead of grepping every writer — and #70 already closed the real persistence exposure. Generalizing it now risks newly-400ing configs that currently save-and-fall-back, for no safety gain. So write-path validation is unchanged; this PR makes the build-time fallbacks loud + adds the tripwire.
Touches the scheduler, so: golden net stays green + mandatory independent (cold/cross-model) review before merge. Will report progress here.
Done — merged via PR #457
What was done
Made the unsupported-
PlaybackOrderpath loud but non-fatal at the three still-silent build-time dispatch sites, and added a by-construction net so the next order can't slip through:PlayoutBuilderdefault:arm) — was// TODOsilently returning Random; now logs a Warning (and correctly distinguishes a supported order that fell through viagoto default, e.g. a Marathon that couldn't build, from a genuinely-unsupported order). Keeps the Random fallback.PlaylistEnumerator.Create) — had nodefault:arm → null enumerator → item silently dropped.Createwasstaticwith no logger (root cause of the un-reportable drop); added an optionalOption<ILogger>, loggered callers pass it, newdefault:arm warns.BlockPlayoutBuilder) — silent allow-listcontinuenow warns; added an explicitRandomarm (the coincidence this issue flagged) + a loud defensive fallback.PlaybackOrderSupportmatrix (perSchedulingEngineKind) + tripwirePlaybackOrderSupportTests:Supported ∪ Unsupportedmust partition the enum for every engine, and every engine kind must have a matrix entry — so a new order (or engine) fails the test until consciously classified. Block consumes the matrix at runtime for its allow-list (removing a duplicated inline list).Done-when
All six boxes ticked: loud at every site ✅, Block
Randomcoincidence resolved ✅, reverse-mappings reviewed ✅ (deferred, see below), golden/characterization no-drift ✅, adversarial review passed ✅,decisions.mdupdated ✅.Files changed
ErsatzTV.Core/Scheduling/:PlaybackOrderSupport.cs(new),SchedulingEngineKind.cs(new),PlayoutBuilder.cs,PlaylistEnumerator.cs,BlockScheduling/BlockPlayoutBuilder.cs,YamlScheduling/EnumeratorCache.cs,Engine/SchedulingEngine.cs. Tests:PlaybackOrderSupportTests.cs(new tripwire),PlaylistEnumeratorTests.cs(behavioral drop+warn test). Docs:docs/decisions.md.Deliberately deferred (not follow-up issues — documented non-goals)
decisions.mdrecords this perimeter has been wrong three times, so broadening it here is pure regression risk for no safety gain._ => PlaybackOrder.Nonemappings (SchedulingEngine,BlockPlayoutEnumerator,YamlPlayoutApplyHistoryHandler) reviewed and left alone: that path is the reverse axis (enumerator-class → order for history) and is hit by legitimately-supported enumerator types (ShuffleInOrder/SeasonEpisode) that simply aren't reverse-mapped, so making it loud would emit false-positive warnings. Completing that reverse map is a separate concern.Verification
Full
ErsatzTV.Core.Tests(586+) incl. scheduler goldens green (no behavior drift); infra + app scheduling tests green. Independent cold adversarial review (+ focused re-review of the fix commit): MERGEABLE @1d95cbfe— matrix accuracy, Block allow-list parity, byte-identical fallbacks, and non-vacuous tripwire all verified. CI green.Docs updated
docs/decisions.md— appended the #403 design entry (loud build-time sites + support matrix/tripwire; write-path and reverse-map scope calls).