feat(session): share the session directory between instances #33

Merged
benvin merged 11 commits from benvin/session-directory into main 2026-09-27 11:24:32 +10:00
Member

Without sticky sessions a device's websocket sits on one pod while its requests land on either, so the session list hid half the estate and remote control reached nothing.

  • Publishes a session -> owning-pod directory in valkey; ownership follows the live socket via an epoch valkey hands out, never a pod's clock.
  • Routes sessionId-addressed work to the owner: remote control, capabilities, additional users, now-viewing, playback start/progress/stop.
  • Delivery comes from the owner's ack, not a subscriber count: unacked is a 404, never a silent 204; an unreachable owner applies it locally, at least once.
  • Idle and inactive sweeps act only on sessions this pod owns, so a stale copy cannot stop live playback.
  • SyncPlay routing (#17) and fan-out broadcasts (#20) stay local and log the skip.
Without sticky sessions a device's websocket sits on one pod while its requests land on either, so the session list hid half the estate and remote control reached nothing. - Publishes a session -> owning-pod directory in valkey; ownership follows the live socket via an epoch valkey hands out, never a pod's clock. - Routes sessionId-addressed work to the owner: remote control, capabilities, additional users, now-viewing, playback start/progress/stop. - Delivery comes from the owner's ack, not a subscriber count: unacked is a 404, never a silent 204; an unreachable owner applies it locally, at least once. - Idle and inactive sweeps act only on sessions this pod owns, so a stale copy cannot stop live playback. - SyncPlay routing (#17) and fan-out broadcasts (#20) stay local and log the skip.
unkin-agent added 1 commit 2026-09-24 22:49:02 +10:00
feat(session): share the session directory between instances
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline failed
00d0765152
Publish every session to valkey with the instance holding it, and route a
command for a non-local session to its owner over a per-instance pub/sub
channel. Entries expire, so a dead instance leaves the directory.
unkin-agent added 1 commit 2026-09-24 23:08:55 +10:00
fix(session): satisfy StyleCop member ordering and indentation
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
6f362c33c9
Author
Member
  • Emby.Server.Implementations/Session/SessionManager.cs:319,:353 — session Id is MD5(client+deviceId+userId) (:637,:714), identical on every pod, and LogSessionActivity publishes OwnerPod = this pod on every authenticated request (RequestHelpers.GetSession, e.g. POST /Sessions/Playing/Progress). Without sticky sessions both pods hold the same session and both blind-SET the same key, so ownership flaps and half the time names the pod with no websocket -> only publish/own while this pod holds a live ISessionController, and write ownership with a compare-and-set like RedisTranscodeSessionStore's RenewScript.
  • Emby.Server.Implementations/Session/SessionManager.cs:1383 — GetSessionToRemoteControl prefers the local SessionInfo unconditionally. On a pod that holds the session but not the websocket (normal, per above) SendMessageToSession iterates zero controllers and the command is silently dropped — the exact failure this PR claims to close -> fall through to the directory when the local session has no active controller.
  • Emby.Server.Implementations/Session/SessionManager.cs:2231-2234 — local and remote lists are concatenated with no dedupe by Id; the same session appears twice (with contradictory SupportsRemoteControl/IsActive) whenever both pods hold it -> DistinctBy(i => i.Id), preferring the entry whose owner has a live controller.
  • Emby.Server.Implementations/Session/RedisSessionDirectory.cs:78, SessionManager.cs:370,:2443 — RemoveAsync deletes jellyfin:session:<id> unconditionally. A websocket closing on pod A, or pod A shutting down, erases the entry for a session pod B currently owns; it vanishes from every list and is unroutable until B's next refresh -> compare-and-delete on OwnerPod.
  • Emby.Server.Implementations/Session/RedisPodMessageBus.cs:51-54 — CommandFlags.FireAndForget discards the PUBLISH receiver count, so a command routed to a pod that died inside the 60s TTL returns 204 and reaches nothing -> publish without FireAndForget, treat 0 receivers as not-found and drop the stale entry.
  • Emby.Server.Implementations/Session/SessionManager.cs:405 — GetRemoteSession does a full SCAN jellyfin:session:* plus a GET per key to find one id, and SendGeneralCommand/SendPlaystateCommand call it twice per request -> add ISessionDirectory.TryGetAsync(sessionId) backed by a single StringGetAsync.
  • Emby.Server.Implementations/Session/SessionManager.cs:319 — the directory write is awaited on the hot path of every authenticated request, and the payload is the whole SessionInfoDto including NowPlayingItem (a full BaseItemDto). Multi-KB round trip per request with no timeout; a slow valkey stalls requests instead of degrading -> publish from the refresh timer and lifecycle transitions only, or fire-and-forget it.
  • Emby.Server.Implementations/Session/SessionManager.cs:1773,:1813,:1586,:1594 — AddAdditionalUser/RemoveAdditionalUser/SendSyncPlay* still use GetSession(sessionId), which 404s for a session this pod does not hold, while GET /Sessions now advertises that session from every pod -> route them too, or say in the body which sessionId-addressed endpoints stay local.
  • tests/Jellyfin.Server.Tests/HighAvailability/SessionDirectoryReplicaTests.cs:106,:129 — no test has both replicas holding the same session id, which is the steady state without sticky sessions, so the three findings above all pass; and the handshake is simulated with a second LogSessionActivity rather than OnSessionControllerConnected, leaving the fire-and-forget republish at SessionManager.cs:338 untested -> add a case where replica B has also logged activity for the device before the command is sent.
  • nit: Emby.Server.Implementations/Session/RedisSessionDirectory.cs:83-98 — one failing StringGetAsync faults Task.WhenAll and the catch returns an empty list, discarding every entry that did load; cancellationToken is not passed to the GETs.
  • nit: MediaBrowser.Controller/Session/PodIdentity.cs:13 — the same expression is already inline at RedisConfigurationInvalidationBus.cs:35, RedisScanLeaderLease.cs:56 and DynamicHlsController.cs:48 -> switch them over or drop the helper; also Current re-reads the env var on every access.
  • nit: Jellyfin.Server/Extensions/SessionDirectoryServiceCollectionExtensions.cs:64 — Microsoft.Extensions.Options.IOptions<...> fully qualified; add the using.
- `Emby.Server.Implementations/Session/SessionManager.cs:319`,`:353` — session `Id` is `MD5(client+deviceId+userId)` (`:637`,`:714`), identical on every pod, and `LogSessionActivity` publishes `OwnerPod = this pod` on every authenticated request (`RequestHelpers.GetSession`, e.g. `POST /Sessions/Playing/Progress`). Without sticky sessions both pods hold the same session and both blind-`SET` the same key, so ownership flaps and half the time names the pod with no websocket -> only publish/own while this pod holds a live `ISessionController`, and write ownership with a compare-and-set like `RedisTranscodeSessionStore`'s `RenewScript`. - `Emby.Server.Implementations/Session/SessionManager.cs:1383` — `GetSessionToRemoteControl` prefers the local `SessionInfo` unconditionally. On a pod that holds the session but not the websocket (normal, per above) `SendMessageToSession` iterates zero controllers and the command is silently dropped — the exact failure this PR claims to close -> fall through to the directory when the local session has no active controller. - `Emby.Server.Implementations/Session/SessionManager.cs:2231-2234` — local and remote lists are concatenated with no dedupe by `Id`; the same session appears twice (with contradictory `SupportsRemoteControl`/`IsActive`) whenever both pods hold it -> `DistinctBy(i => i.Id)`, preferring the entry whose owner has a live controller. - `Emby.Server.Implementations/Session/RedisSessionDirectory.cs:78`, `SessionManager.cs:370`,`:2443` — `RemoveAsync` deletes `jellyfin:session:<id>` unconditionally. A websocket closing on pod A, or pod A shutting down, erases the entry for a session pod B currently owns; it vanishes from every list and is unroutable until B's next refresh -> compare-and-delete on `OwnerPod`. - `Emby.Server.Implementations/Session/RedisPodMessageBus.cs:51-54` — `CommandFlags.FireAndForget` discards the PUBLISH receiver count, so a command routed to a pod that died inside the 60s TTL returns 204 and reaches nothing -> publish without FireAndForget, treat 0 receivers as not-found and drop the stale entry. - `Emby.Server.Implementations/Session/SessionManager.cs:405` — `GetRemoteSession` does a full `SCAN jellyfin:session:*` plus a GET per key to find one id, and `SendGeneralCommand`/`SendPlaystateCommand` call it twice per request -> add `ISessionDirectory.TryGetAsync(sessionId)` backed by a single `StringGetAsync`. - `Emby.Server.Implementations/Session/SessionManager.cs:319` — the directory write is awaited on the hot path of every authenticated request, and the payload is the whole `SessionInfoDto` including `NowPlayingItem` (a full `BaseItemDto`). Multi-KB round trip per request with no timeout; a slow valkey stalls requests instead of degrading -> publish from the refresh timer and lifecycle transitions only, or fire-and-forget it. - `Emby.Server.Implementations/Session/SessionManager.cs:1773`,`:1813`,`:1586`,`:1594` — `AddAdditionalUser`/`RemoveAdditionalUser`/`SendSyncPlay*` still use `GetSession(sessionId)`, which 404s for a session this pod does not hold, while `GET /Sessions` now advertises that session from every pod -> route them too, or say in the body which `sessionId`-addressed endpoints stay local. - `tests/Jellyfin.Server.Tests/HighAvailability/SessionDirectoryReplicaTests.cs:106`,`:129` — no test has both replicas holding the same session id, which is the steady state without sticky sessions, so the three findings above all pass; and the handshake is simulated with a second `LogSessionActivity` rather than `OnSessionControllerConnected`, leaving the fire-and-forget republish at `SessionManager.cs:338` untested -> add a case where replica B has also logged activity for the device before the command is sent. - nit: `Emby.Server.Implementations/Session/RedisSessionDirectory.cs:83-98` — one failing `StringGetAsync` faults `Task.WhenAll` and the catch returns an empty list, discarding every entry that did load; `cancellationToken` is not passed to the GETs. - nit: `MediaBrowser.Controller/Session/PodIdentity.cs:13` — the same expression is already inline at `RedisConfigurationInvalidationBus.cs:35`, `RedisScanLeaderLease.cs:56` and `DynamicHlsController.cs:48` -> switch them over or drop the helper; also `Current` re-reads the env var on every access. - nit: `Jellyfin.Server/Extensions/SessionDirectoryServiceCollectionExtensions.cs:64` — `Microsoft.Extensions.Options.IOptions<...>` fully qualified; add the using.
unkin-agent added 2 commits 2026-09-25 00:01:47 +10:00
Ownership is claimed with a Lua check-and-set keyed on the instance holding
the websocket, routing prefers a live controller over a local copy, the
session list deduplicates by owner, removal is ownership-checked, undelivered
routed messages surface, single-session lookups stop scanning the keyspace and
directory writes leave the request path bounded by a timeout.
test(session): sample ownership after each non-owner request
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/pr/ci Pipeline was canceled
51261b0128
unkin-agent added 1 commit 2026-09-25 00:04:57 +10:00
fix(session): parse the owner key without assuming the pod id
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
086fdb8257
Author
Member
  • Emby.Server.Implementations/Session/RemoteSessionController.cs:68, SessionManager.cs:482,:515 — HoldsConnection is a snapshot up to RefreshIntervalSeconds (20s) old, and the PUBLISH subscriber count only proves the owner's Redis connection is alive, not that it still holds the socket. Owner-side the message hits controllers.Count == 0, logs a warning and returns; the caller sees delivered == 1 and gets 204 — the exact silent drop this PR targets -> make the owner acknowledge (reply channel / correlation id) and 404 on no ack, rather than trusting the subscriber count.
  • Emby.Server.Implementations/Session/SessionManager.cs:1479,:1568 — GetRemoteSession(...) ?? local falls back to the request-serving pod's controller-less copy whenever the directory entry is absent, expired, or RedisSessionDirectory.GetAsync swallowed a valkey error to null; SendMessageToSession then iterates zero controllers and returns 204 having delivered nothing. The body claims the request fails when the owner is no longer listening -> when local has no active controller and the directory has no entry, throw instead of returning local.
  • Emby.Server.Implementations/Session/SessionManager.cs:676,:1030,:2402-2405 — OnPlaybackStart/OnPlaybackProgress mutate only the copy on the pod that served POST /Sessions/Playing*, the entry is published only by the owner from the owner's copy, and the non-owner's copy is now filtered out by ownedElsewhere. Without sticky sessions a playback start served by the non-owner is never reflected anywhere, so GET /Sessions reports the session as idle on every pod (and the non-owner runs the 1s StartAutomaticProgress timer for a session nobody reports) -> publish playback transitions to the owner over the bus, or fold the non-owner's newer LastActivityDate/PlayState into the listed entry.
  • Emby.Server.Implementations/Session/RedisSessionDirectory.cs:39 — the claim is refused only when the recorded connected > 0, so two pods that both hold no controller (connectedUtcTicks == 0) take ownership from each other on every publish. A device with no websocket in active playback has its listed PlayState/NowPlayingItem flip between the two pods' partial copies -> refuse a zero-epoch claim from a different pod while an entry exists, or tie-break on something stable.
  • Emby.Server.Implementations/Session/RedisSessionDirectory.cs:39, SessionManager.cs:403 — the epoch is DateTime.UtcNow.Ticks taken on the claiming pod and compared against ticks written by a different machine. A pod whose clock lags cannot claim a genuinely newer connection until the old owner publishes a zero epoch or the entry expires (60s default), and commands route to a pod with no socket for that whole window -> derive the epoch from a valkey-side counter (INCR) so the comparison is against one clock.
  • Emby.Server.Implementations/Session/SessionManager.cs:1685,:1700 — GetSession(sessionId, false) returns the non-owner's controller-less copy in the common case, not null, so the "not held by this instance" debug line never fires and SendMessageToSession drops the SyncPlay command with no log and no error at all. The skip path is dead where it matters -> branch on "no active controller", not on "no local copy".
  • nit: Jellyfin.Server/Extensions/SessionDirectoryServiceCollectionExtensions.cs:50,:57 — IPodMessageBus and ISessionDirectory fall back to their null implementations independently. A Redis directory paired with NullPodMessageBus leaves _directoryEnabled true with PublishAsync always returning 0, so every routed command 404s permanently -> resolve both from one attempt and fall back together.
  • nit: Emby.Server.Implementations/Session/SessionManager.cs:419 — RefreshSessionDirectory is an async void timer callback with no re-entrancy guard, awaiting each publish serially at up to OperationTimeoutSeconds each; it also republishes every non-owned copy purely to have the claim refused.
  • nit: Emby.Server.Implementations/Session/RedisSessionDirectory.cs:175 — one failing StringGetAsync faults Task.WhenAll and the catch discards every entry that did load, contrary to the "degrade" comment at :195.
  • nit: Emby.Server.Implementations/Session/RedisPodMessageBus.cs:19 — the 5s publish timeout is hard-coded and ignores OperationTimeoutSeconds; combined with the blanket catch a transient valkey blip is surfaced to the user as "session not found".
  • nit: tests/Jellyfin.Server.Tests/HighAvailability/SessionDirectoryReplicaTests.cs:400 — CreateBus mutates the process-wide JELLYFIN_INSTANCE_ID while other test classes may be reading PodIdentity.Current in parallel -> pass the pod id to RedisPodMessageBus instead of going through the environment.
  • nit: tests/Jellyfin.Server.Tests/HighAvailability/SessionDirectoryReplicaTests.cs:125 — no case has both replicas holding a live controller, and none reports playback to the non-owner, so the flap and split-playback-state findings above pass.
- `Emby.Server.Implementations/Session/RemoteSessionController.cs:68`, `SessionManager.cs:482`,`:515` — `HoldsConnection` is a snapshot up to `RefreshIntervalSeconds` (20s) old, and the PUBLISH subscriber count only proves the owner's Redis connection is alive, not that it still holds the socket. Owner-side the message hits `controllers.Count == 0`, logs a warning and returns; the caller sees `delivered == 1` and gets 204 — the exact silent drop this PR targets -> make the owner acknowledge (reply channel / correlation id) and 404 on no ack, rather than trusting the subscriber count. - `Emby.Server.Implementations/Session/SessionManager.cs:1479`,`:1568` — `GetRemoteSession(...) ?? local` falls back to the request-serving pod's controller-less copy whenever the directory entry is absent, expired, or `RedisSessionDirectory.GetAsync` swallowed a valkey error to `null`; `SendMessageToSession` then iterates zero controllers and returns 204 having delivered nothing. The body claims the request fails when the owner is no longer listening -> when `local` has no active controller and the directory has no entry, throw instead of returning `local`. - `Emby.Server.Implementations/Session/SessionManager.cs:676`,`:1030`,`:2402-2405` — `OnPlaybackStart`/`OnPlaybackProgress` mutate only the copy on the pod that served `POST /Sessions/Playing*`, the entry is published only by the owner from the owner's copy, and the non-owner's copy is now filtered out by `ownedElsewhere`. Without sticky sessions a playback start served by the non-owner is never reflected anywhere, so `GET /Sessions` reports the session as idle on every pod (and the non-owner runs the 1s `StartAutomaticProgress` timer for a session nobody reports) -> publish playback transitions to the owner over the bus, or fold the non-owner's newer `LastActivityDate`/`PlayState` into the listed entry. - `Emby.Server.Implementations/Session/RedisSessionDirectory.cs:39` — the claim is refused only when the recorded `connected > 0`, so two pods that both hold no controller (`connectedUtcTicks == 0`) take ownership from each other on every publish. A device with no websocket in active playback has its listed `PlayState`/`NowPlayingItem` flip between the two pods' partial copies -> refuse a zero-epoch claim from a different pod while an entry exists, or tie-break on something stable. - `Emby.Server.Implementations/Session/RedisSessionDirectory.cs:39`, `SessionManager.cs:403` — the epoch is `DateTime.UtcNow.Ticks` taken on the claiming pod and compared against ticks written by a different machine. A pod whose clock lags cannot claim a genuinely newer connection until the old owner publishes a zero epoch or the entry expires (60s default), and commands route to a pod with no socket for that whole window -> derive the epoch from a valkey-side counter (`INCR`) so the comparison is against one clock. - `Emby.Server.Implementations/Session/SessionManager.cs:1685`,`:1700` — `GetSession(sessionId, false)` returns the non-owner's controller-less copy in the common case, not `null`, so the "not held by this instance" debug line never fires and `SendMessageToSession` drops the SyncPlay command with no log and no error at all. The skip path is dead where it matters -> branch on "no active controller", not on "no local copy". - nit: `Jellyfin.Server/Extensions/SessionDirectoryServiceCollectionExtensions.cs:50`,`:57` — `IPodMessageBus` and `ISessionDirectory` fall back to their null implementations independently. A Redis directory paired with `NullPodMessageBus` leaves `_directoryEnabled` true with `PublishAsync` always returning 0, so every routed command 404s permanently -> resolve both from one attempt and fall back together. - nit: `Emby.Server.Implementations/Session/SessionManager.cs:419` — `RefreshSessionDirectory` is an `async void` timer callback with no re-entrancy guard, awaiting each publish serially at up to `OperationTimeoutSeconds` each; it also republishes every non-owned copy purely to have the claim refused. - nit: `Emby.Server.Implementations/Session/RedisSessionDirectory.cs:175` — one failing `StringGetAsync` faults `Task.WhenAll` and the catch discards every entry that did load, contrary to the "degrade" comment at `:195`. - nit: `Emby.Server.Implementations/Session/RedisPodMessageBus.cs:19` — the 5s publish timeout is hard-coded and ignores `OperationTimeoutSeconds`; combined with the blanket catch a transient valkey blip is surfaced to the user as "session not found". - nit: `tests/Jellyfin.Server.Tests/HighAvailability/SessionDirectoryReplicaTests.cs:400` — `CreateBus` mutates the process-wide `JELLYFIN_INSTANCE_ID` while other test classes may be reading `PodIdentity.Current` in parallel -> pass the pod id to `RedisPodMessageBus` instead of going through the environment. - nit: `tests/Jellyfin.Server.Tests/HighAvailability/SessionDirectoryReplicaTests.cs:125` — no case has both replicas holding a live controller, and none reports playback to the non-owner, so the flap and split-playback-state findings above pass.
unkin-agent added 2 commits 2026-09-26 15:12:08 +10:00
Author
Member
  • Emby.Server.Implementations/Session/SessionManager.cs:1040 — CheckForIdlePlayback sweeps _activeConnections, i.e. local copies, but OnPlaybackStopped now routes to the owner. A non-owner copy that ever applied a report locally (the failed-route fallback, or a second live socket on the other replica — the state BothReplicasHoldingAConnection_AgreeOnOneOwner establishes as normal) keeps NowPlayingItem with a frozen LastPlaybackCheckIn, so five minutes later it routes a stop into a live playback on the owner and saves user data at its stale LastPlaybackCheckInPositionTicks -> skip sessions this instance holds no live controller for, or call OnPlaybackStoppedCore directly from the sweep.
  • Emby.Server.Implementations/Session/SessionManager.cs:1074 — same shape: CheckForInactiveSteams decides from the local copy's PlayState.IsPaused/LastPausedDate and SendPlaystateCommand now routes, so a stale paused non-owner copy sends a real Stop to the owner's live socket -> gate the loop on GetConnectedSession(session.Id).
  • Emby.Server.Implementations/Session/SessionManager.cs:2403, Jellyfin.Server.Implementations/Devices/DeviceManager.cs:33 — ReportCapabilities is sessionId-addressed, still resolves with GetSession, is not routed, and _capabilitiesMap is per-process. A POST /Sessions/Capabilities/Full landing on the non-owner never reaches the owner, so the owner publishes SupportsRemoteControl = false and an empty SupportedCommands and GET /Sessions?controllableByUserId= hides the device from every replica — the symptom this PR targets, on roughly half of connections. ?id=<remotely-owned> also 404s here while AddAdditionalUser on the same id succeeds -> route it like the additional-user change, or name it in the local-only boundary.
  • nit: Emby.Server.Implementations/Session/SessionManager.cs:2526 — route failed and this instance holds no copy: ReportNowViewingItem returns 204 having done nothing, against "unacked is a 404, never a silent 204" -> throw when neither the owner nor a local copy took it.
  • nit: Emby.Server.Implementations/Session/RedisPodMessageBus.cs:57 — a failed Subscribe is swallowed, leaving a bus that publishes but can never receive an ack; every routed operation then burns the full OperationTimeoutSeconds before reporting undelivered, and the paired fallback in SharedSessionServices.Create cannot see it -> let it throw.
  • nit: Emby.Server.Implementations/Session/RedisPodMessageBus.cs:84,:91 — the publish and the ack wait each get their own _timeout, so one routed call on a request path can block for 2x OperationTimeoutSeconds -> one deadline for both.
  • nit: Emby.Server.Implementations/Session/SessionManager.cs:1478 — an ack lost after the owner applied the report, or a partial apply before OnRoutedPlaybackReport's catch, makes the origin apply it a second time; idempotent for progress, but the stop path re-fires PlaybackStopped and re-saves user data -> make the stop idempotent or say the reports are at-least-once.
  • nit: MediaBrowser.Controller/Session/SessionDirectoryEntry.cs:19 — HoldsConnection is written on every publish and asserted by the tests but never read by production code -> drop it or gate on it.
  • nit: tests/Jellyfin.Server.Tests/HighAvailability/SessionDirectoryReplicaTests.cs:485 — asserts absolute counter values (1, then 2) against the valkey CI shares across the whole class run; it passes only because nothing else touches that key -> assert the ordering instead.
- `Emby.Server.Implementations/Session/SessionManager.cs:1040` — `CheckForIdlePlayback` sweeps `_activeConnections`, i.e. local copies, but `OnPlaybackStopped` now routes to the owner. A non-owner copy that ever applied a report locally (the failed-route fallback, or a second live socket on the other replica — the state `BothReplicasHoldingAConnection_AgreeOnOneOwner` establishes as normal) keeps `NowPlayingItem` with a frozen `LastPlaybackCheckIn`, so five minutes later it routes a stop into a live playback on the owner and saves user data at its stale `LastPlaybackCheckInPositionTicks` -> skip sessions this instance holds no live controller for, or call `OnPlaybackStoppedCore` directly from the sweep. - `Emby.Server.Implementations/Session/SessionManager.cs:1074` — same shape: `CheckForInactiveSteams` decides from the local copy's `PlayState.IsPaused`/`LastPausedDate` and `SendPlaystateCommand` now routes, so a stale paused non-owner copy sends a real `Stop` to the owner's live socket -> gate the loop on `GetConnectedSession(session.Id)`. - `Emby.Server.Implementations/Session/SessionManager.cs:2403`, `Jellyfin.Server.Implementations/Devices/DeviceManager.cs:33` — `ReportCapabilities` is sessionId-addressed, still resolves with `GetSession`, is not routed, and `_capabilitiesMap` is per-process. A `POST /Sessions/Capabilities/Full` landing on the non-owner never reaches the owner, so the owner publishes `SupportsRemoteControl = false` and an empty `SupportedCommands` and `GET /Sessions?controllableByUserId=` hides the device from every replica — the symptom this PR targets, on roughly half of connections. `?id=<remotely-owned>` also 404s here while `AddAdditionalUser` on the same id succeeds -> route it like the additional-user change, or name it in the local-only boundary. - nit: `Emby.Server.Implementations/Session/SessionManager.cs:2526` — route failed and this instance holds no copy: `ReportNowViewingItem` returns 204 having done nothing, against "unacked is a 404, never a silent 204" -> throw when neither the owner nor a local copy took it. - nit: `Emby.Server.Implementations/Session/RedisPodMessageBus.cs:57` — a failed `Subscribe` is swallowed, leaving a bus that publishes but can never receive an ack; every routed operation then burns the full `OperationTimeoutSeconds` before reporting undelivered, and the paired fallback in `SharedSessionServices.Create` cannot see it -> let it throw. - nit: `Emby.Server.Implementations/Session/RedisPodMessageBus.cs:84`,`:91` — the publish and the ack wait each get their own `_timeout`, so one routed call on a request path can block for 2x `OperationTimeoutSeconds` -> one deadline for both. - nit: `Emby.Server.Implementations/Session/SessionManager.cs:1478` — an ack lost after the owner applied the report, or a partial apply before `OnRoutedPlaybackReport`'s catch, makes the origin apply it a second time; idempotent for progress, but the stop path re-fires `PlaybackStopped` and re-saves user data -> make the stop idempotent or say the reports are at-least-once. - nit: `MediaBrowser.Controller/Session/SessionDirectoryEntry.cs:19` — `HoldsConnection` is written on every publish and asserted by the tests but never read by production code -> drop it or gate on it. - nit: `tests/Jellyfin.Server.Tests/HighAvailability/SessionDirectoryReplicaTests.cs:485` — asserts absolute counter values (1, then 2) against the valkey CI shares across the whole class run; it passes only because nothing else touches that key -> assert the ordering instead.
unkin-agent added 4 commits 2026-09-26 22:25:22 +10:00
# Conflicts:
#	.woodpecker/ci.yaml
#	Jellyfin.Server/CoreAppHost.cs
#	tests/Jellyfin.Server.Tests/HighAvailability/RedisTestServer.cs
test(session): cover the maintenance sweeps and capabilities routing
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
9f4c857b23
Author
Member
  • Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs:58,:64 — the Sessions/SessionsStart push is the only session-list consumer left on _sessionManager.Sessions. On a non-owner it omits every remotely-owned session and pushes the local copy that SessionManager.cs:2706 deliberately filters out — playback now routes to the owner, so that copy has NowPlayingItem null. The dashboard's Active Devices panel therefore reports a playing device as idle while GET /Sessions on the same pod reports it playing, and the panel flips between the two -> feed both GetDataToSend overloads from GetSessions.
  • Emby.Server.Implementations/Session/SessionManager.cs:2619, :1761, :2706 — ReportTranscodingInfo is local-only and deviceId-addressed, but segment requests land on either pod, so TranscodingInfo is set on the copy ownedElsewhere now drops. It went from visible on one pod to invisible on both; ClearTranscodingInfo at :1235/:1386 also only ever clears the owner's (empty) copy -> route it to the owner, or name it in the local-only boundary in the body.
  • nit: Emby.Server.Implementations/Session/SessionManager.cs:2477-2488 — an unacked capabilities route with a local copy present applies the report only to the copy GetSessions filters out and answers 204; the owner's published entry keeps the stale SupportsRemoteControl/SupportedCommands until the entry TTL expires or the client re-reports. "An unreachable owner applies it locally, at least once" does not hold for capabilities, since locally is not visible anywhere.
  • nit: Emby.Server.Implementations/Session/RedisSessionDirectory.cs:184-207 — GetAllAsync runs a keyspace SCAN jellyfin:session:* plus a GET per key on every GET /Sessions, which clients poll and the cast picker calls with controllableByUserId; the single-session lookup was the only scan replaced -> keep a set of live session ids and read that.
  • nit: tests/Jellyfin.Server.Tests/HighAvailability/SessionDirectoryReplicaTests.cs:637,:685 — both sweeps' negative halves wait 5s and 3s for something not to happen, while the OwnsSessionAsync read they depend on is itself allowed OperationTimeoutSeconds (5s). A slow valkey passes the assertion for the wrong reason, and the non-vacuity control then fires on the non-owner's message -> assert the sweep skipped rather than on elapsed time.
- `Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs:58`,`:64` — the `Sessions`/`SessionsStart` push is the only session-list consumer left on `_sessionManager.Sessions`. On a non-owner it omits every remotely-owned session and pushes the local copy that `SessionManager.cs:2706` deliberately filters out — playback now routes to the owner, so that copy has `NowPlayingItem` null. The dashboard's Active Devices panel therefore reports a playing device as idle while `GET /Sessions` on the same pod reports it playing, and the panel flips between the two -> feed both `GetDataToSend` overloads from `GetSessions`. - `Emby.Server.Implementations/Session/SessionManager.cs:2619`, `:1761`, `:2706` — `ReportTranscodingInfo` is local-only and deviceId-addressed, but segment requests land on either pod, so `TranscodingInfo` is set on the copy `ownedElsewhere` now drops. It went from visible on one pod to invisible on both; `ClearTranscodingInfo` at `:1235`/`:1386` also only ever clears the owner's (empty) copy -> route it to the owner, or name it in the local-only boundary in the body. - nit: `Emby.Server.Implementations/Session/SessionManager.cs:2477-2488` — an unacked capabilities route with a local copy present applies the report only to the copy `GetSessions` filters out and answers 204; the owner's published entry keeps the stale `SupportsRemoteControl`/`SupportedCommands` until the entry TTL expires or the client re-reports. "An unreachable owner applies it locally, at least once" does not hold for capabilities, since locally is not visible anywhere. - nit: `Emby.Server.Implementations/Session/RedisSessionDirectory.cs:184-207` — `GetAllAsync` runs a keyspace `SCAN jellyfin:session:*` plus a GET per key on every `GET /Sessions`, which clients poll and the cast picker calls with `controllableByUserId`; the single-session lookup was the only scan replaced -> keep a set of live session ids and read that. - nit: `tests/Jellyfin.Server.Tests/HighAvailability/SessionDirectoryReplicaTests.cs:637`,`:685` — both sweeps' negative halves wait 5s and 3s for something not to happen, while the `OwnsSessionAsync` read they depend on is itself allowed `OperationTimeoutSeconds` (5s). A slow valkey passes the assertion for the wrong reason, and the non-vacuity control then fires on the non-owner's message -> assert the sweep skipped rather than on elapsed time.
benvin merged commit 5177d6fb72 into main 2026-09-27 11:24:32 +10:00
benvin deleted branch benvin/session-directory 2026-09-27 11:24:32 +10:00
Sign in to join this conversation.