fix(ha): share quick connect state between instances #34
Reference in New Issue
Block a user
Delete Branch "benvin/quickconnect-shared-state"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Quick connect state lives in each pod's memory, so with two replicas and no sticky sessions the three legs of a flow land on different pods and it 404s (#18).
IQuickConnectStore; in-memory stays the single-instance default.Exchange keeps upstream's non-consuming read, idempotent for the full 10 minutes.
Emby.Server.Implementations/QuickConnect/RedisQuickConnectStore.cs:117-120,140— the in-memory fallback defeats the single-use guarantee the PR is built on: aRedisTimeoutExceptionon aSETthe server did apply leaves the authorization in both Redis and that pod's_fallback, andTryConsumeAuthorizationAsyncfalls through to_fallbackon a plain Redis miss (not only on an exception), so one secret can be exchanged once against Redis and again on the pod that fell back -> consult_fallbackonly when the Redis call itself threw, and do not mirror authorizations into it at all.Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs:136-161—Authenticatedis now a cross-pod read-modify-write with two Redis round trips and a DB write inside the window: two concurrentPOST /QuickConnect/Authorizeon different pods both pass the guard at :142 and both callAuthenticateDirect, minting two device access tokens; the secondSetAuthorizationAsyncoverwrites the first, leaving a live orphaned token in the database -> make authorize one atomic store operation, the wayRedisScanLeaderLeaseandRedisTranscodeSessionStorealready do it with Lua.Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs:174— externally visible change not required by the state-sharing fix: onmainGetAuthorizedRequestwas a non-consumingTryGetValue, soPOST /Users/AuthenticateWithQuickConnectwas idempotent for the ten minute window; it is now one-shot, and a client that retries the exchange after a timeout gets 404 -> split single-use consumption into its own PR, or state in the body which clients were checked for exchange retries.tests/Jellyfin.Server.Tests/QuickConnect/QuickConnectReplicaTests.cs— nothing exercises the degradation the body andJellyfin.Server/Extensions/QuickConnectStoreServiceCollectionExtensions.cs:55-70claim: Redis unreachable at construction, and Redis failing mid-flow. There is also noAddQuickConnectStorewiring test, althoughTranscodeStoreWiringTestsestablishes one for the sibling extension -> add both.tests/Jellyfin.Server.Tests/QuickConnect/QuickConnectReplicaTests.cs:135-139— a single un-repeated attempt makes this a timing-dependent race test that a non-atomic get-then-delete can pass -> loop it over ~50 secrets.Emby.Server.Implementations/QuickConnect/RedisQuickConnectStore.cs:52,71,96,117,135—catch (Exception)also swallowsJsonException, so a payload written by a different build silently degrades to per-pod state -> narrow toRedisException.MediaBrowser.Controller/QuickConnect/IQuickConnect.cs:24,31,46— sync to async is a breaking change to a public contract out-of-tree plugins bind against -> unavoidable here, but call it out in the body so it is not found at plugin-load time.Emby.Server.Implementations/QuickConnect/RedisQuickConnectStore.cs:68-72— a transport failure on the poll leg is swallowed and answered from_fallback, which is empty for any request created while Redis was up, soCheckRequestStatus(QuickConnectManager.cs:101-104) turns a valkey blip intoResourceNotFoundException->ExceptionMiddleware404 "Unknown secret". Every in-flight client is told its secret is invalid and stops polling, where a propagated error would have let it retry -> propagate on the read paths too, or return a distinct "could not look up" rather than a miss.Emby.Server.Implementations/QuickConnect/RedisQuickConnectStore.cs:43,56+MediaBrowser.Controller/QuickConnect/InMemoryQuickConnectStore.cs:11-15—TryClaimAuthorizationAsync,SetAuthorizationAsyncandTryConsumeAuthorizationAsyncnever touch_fallback, so its_authorizationsand_authorizationClaimsare permanently empty and no flow can be authorized or exchanged on any instance while Redis is down. TheInMemoryQuickConnectStoresummary ("quick connect keeps working for clients whose three legs happen to land on one instance") and the class remarks at:20-25assert the opposite -> drop the fallback, or make the doc say an outage stops quick connect.Jellyfin.Server/Extensions/QuickConnectStoreServiceCollectionExtensions.cs:59-69— the construction-time path degrades toInMemoryQuickConnectStore, where quick connect keeps working instance-locally; the runtime path 500s every authorize and exchange. One valkey outage therefore behaves in two opposite ways depending on whether a pod happened to restart during it, and a restart looks like a fix -> pick one degradation and make both paths and the comment agree.Emby.Server.Implementations/QuickConnect/RedisQuickConnectStore.cs:163—RedisServerExceptionderives fromRedisException(verified against StackExchange.Redis 2.13.17), soOOM command not allowed,MISCONF, andREADONLY You can't write against a read only replicaafter a valkey failover all count as transport failures and silently drop the whole deployment to per-instance state behind one warning ->exception is RedisConnectionException or TimeoutException.Emby.Server.Implementations/QuickConnect/RedisQuickConnectStore.cs:111-112— the request and its code index are two unrelated round trips; a failure between them leaves the request resolvable by secret but not by code, and the catch then mirrors the whole thing into_fallback. Nothing repairs it: polling keeps returning 200 while the code the user is reading 404s for the full TTL -> write both keys in oneITransactionor in the Lua.Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs:151-154— whenAuthenticateDirectthrows, the claim stays for the remaining ~11 minutes and every retry, on every instance, throwsInvalidOperationException("Request is already authorized"), which is untrue and whichExceptionMiddleware.GetStatusCodedoes not map, so the dashboard gets a 500 -> release the claim when the mint fails before a token exists, and give the genuinely-claimed case its own message and a mapped status.tests/Jellyfin.Server.Tests/HighAvailability/RedisFaultProxy.cs:134-137—ForwardAsyncregisters in_liveafter awaiting the upstream connect and never rechecks_cut, so a connection completing betweenCut()setting the flag andDropLiveConnections()running survives the outage and the operation under test succeeds -> recheck_cutafter the connect and drop before registering.Emby.Server.Implementations/QuickConnect/RedisQuickConnectStore.cs:103-106— an already-elapsedexpiresUtcsilently no-ops, whileInMemoryQuickConnectStore.SetRequestAsyncstores it regardless; two implementations of one interface disagree on the same input -> make them match.tests/Jellyfin.Server.Tests/QuickConnect/RedisQuickConnectStoreDegradedTests.cs:146-166— the test re-issues the authorization by hand at:162and then assertsGETDELsingle-use, whichExchange_DuringAnOutage_SurfacesTheFailureAndLeavesTheTokenUnspentalready covers; nothing here depends on the earlier failed write -> assert something the failed write actually changed, or fold it into the other test.Jellyfin.Server/Extensions/QuickConnectStoreServiceCollectionExtensions.cs:53-55— "a set-but-unreachable connection string fails startup" does not hold. Nothing resolvesIQuickConnectStoreduring startup (ApplicationHost.cs:622registersIQuickConnectas a singleton only controllers pull, andInitializeServicesnever touches it), andTranscodeStoreConnectivityProbe.cs:45-51deliberately swallows the connect failure, so the pod starts. With valkey down at pod start theRedisConnectionExceptioninstead comes out ofUserController's constructor (UserController.cs:70) on every request;ExceptionMiddleware.GetStatusCodehas no case for it, soPOST /Users/AuthenticateByNamereturns 500 and password login is down, not just quick connect. The failed singleton is not cached either, so every request re-paysconnectTimeout-> resolve the store during startup so the pod genuinely fails to start, or catch in the factory and let the store fail per call asServiceUnavailableException.Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs:167-168— the authorization and theAuthenticated=truerequest are two unrelated round trips: exactly the defectSetRequestScriptwas added to remove one leg earlier. A failure between them leaves a minted access token in valkey and in the database that the poll never reveals (Authenticatedstays false for the whole TTL) and that the never-released claim then makes unreachable on every retry -> write the auth key and the two request keys in one Lua call.Jellyfin.Api/Controllers/QuickConnectController.cs:120-127— the 409's "Start quick connect again for a new code" never reaches a client:ExceptionMiddleware.cs:95-97replaces the body with "Error processing request." outside Development, andQuickConnectStatusCodeTestsasserts that message on the exception rather than on the response -> catchConflictExceptionandreturn Conflict(ex.Message), as the 404 leg already does withNotFound("Unknown secret").Jellyfin.Api/Controllers/QuickConnectController.cs:105-115— 409 and 503 were declared, but the 404 this action throws for an unknown or expired code was not, and that 404 is the documented way out of the burned-claim 409 -> add<response code="404">andProducesResponseType(404).MediaBrowser.Controller/QuickConnect/IQuickConnectStore.cs:27,35,45,57,67,76— every method takes aCancellationTokenno implementation can honour (StackExchange.Redis has no token overloads, the in-memory store ignores it) andQuickConnectManagernever passes one -> drop the parameter.