Real-Time Status, Presence & Timeouts
Business view
Two things need to feel instantaneous for this feature to be usable: knowing whether a scanner is actually online before asking it to do anything, and knowing the moment a capture's status changes without refreshing the page.
Presence. When staff open the "Acquire from Device" popover, they only ever see rooms where a desktop app currently holds an active lease — no one can send a request into the void and wonder why nothing happens. A desktop app that goes offline (crashes, loses network, is closed without disconnecting cleanly) drops off the list within 90 seconds even if it never says goodbye. A graceful shutdown or an explicit "disconnect" releases the room immediately instead of waiting out that window.
Live status. Once a request is sent, its status updates in real time in both the floating widget and the Logs table — no polling, no manual refresh, for anyone viewing a single branch. (Viewing "All Branches" in the Logs table falls back to refreshing every 10 seconds, since live updates need a specific branch.)
No request left behind. If a desktop app accepts a request and then hangs — crashes mid-capture, loses its connection, whatever — the request would otherwise sit "In Progress" forever. A background job on the server checks every active request against its own per-state deadline and force-fails any that have run too long, so staff always get a definitive answer instead of an infinite spinner. The same job also cleans up abandoned in-progress uploads and orphaned storage objects.
Technical view
Presence tracking is now the same thing as the lease token
The earlier draft of this documentation described presence as a separate Redis-key mechanism from acquisition auth, registered independently over both REST and a raw WebSocket connectionParams payload. That's been unified: presence, room occupancy, and desktop authentication are now all facets of a single lease, issued by desktopConnectionManager.acquire() (packages/server/src/utils/desktopConnectionManager.js:24) when a desktop app calls POST /api/xray-acquisition/connect. See Access Keys & Security Model for how a lease is minted from an Access Key.
- Leases live in Redis with a 90-second TTL (
TTL_SECONDS = 90, line 7), keyed by SHA-256 digests of(companyId, branchId, room)for the room key and of the lease token itself for the token key — the actual company/branch/room values are never stored as plaintext Redis key names. heartbeat(leaseToken)(line 92) — called fromPOST /api/xray-acquisition/heartbeat— extends the TTL on both the room key and the token key via another atomic Lua script, and only succeeds if the token still matches the current room holder (protecting against a stale heartbeat from an instance that already lost the room).release(leaseToken)(line 127) — called fromPOST /api/xray-acquisition/disconnect, and also invoked server-side byrevokeByAccessKey()when an admin revokes the underlying Access Key — deletes both keys immediately rather than waiting for the TTL.isConnected()/getConnectedRooms()(lines 195-206) check for the room key's existence directly (EXISTS, not aKEYSscan like the earlier draft described) — this was also fixed as part of the same rewrite.connectedDesktopRooms(GraphQL query,packages/server/src/resolvers/queries/patientQueries.js:1007) is whatAcquirePopoverpolls every 10 seconds (AcquirePopover.js:21) to build the room picker.
One registration path, used for both REST and the subscription
A desktop app authenticates its GraphQL WebSocket connection with the same lease token it uses for REST calls, passed via connectionParams: { leaseToken } (packages/server/src/server.js:69). The useServer context function calls the same validateDesktopPrincipal used by the REST endpoints, and on disconnect (onDisconnect, line 86) unbinds the local context-to-token mapping — but does not proactively release the Redis lease. In practice this means a desktop app that loses its WebSocket connection (network blip, tab-equivalent crash) keeps holding the room until the lease's 90-second TTL lapses or it calls the REST disconnect endpoint explicitly, whichever comes first; the companion desktop app (see The Desktop App & Scanner Integration) calls disconnect on graceful shutdown specifically to avoid the TTL wait.
Live status delivery
Two Redis pub/sub channels drive everything (packages/server/src/utils/redisSubscriptionConstants.js:28-29):
XRAY_ACQUISITION_REQUEST_CHANNEL— new requests and cancellations, consumed only by the desktop app (xrayAcquisitionRequestedsubscription, filtered byrequest.isDesktopAppand a presentleaseTokeninpackages/server/src/resolvers/Subscription.js:161).XRAY_ACQUISITION_STATUS_CHANNEL— every status transition, consumed by staff browsers (xrayAcquisitionStatusUpdated,Subscription.js:166, filtered by company + branch + optional room).
Both are published together by a single publishAcquisition() helper (packages/server/src/services/xrayAcquisitionTransitions.js:70) every time a request's state changes, and both are registered in SUBSCRIPTION_CHANNELS so RedisPubSub's triggerTransform shares them correctly across server instances.
Server-side timeout and cleanup cron
xrayAcquisitionTimeoutCron (packages/server/src/cronJobs/xrayAcquisitions/xrayAcquisitionTimeoutCron.js:12) runs under a Redlock distributed lock (lock:${name}, 55-second lease, extended via an assertLock() check between batches) so overlapping runs across multiple cron workers don't double-process. Each run does four things, in order:
- Timeout stale requests. Loads up to 100 requests in a non-terminal, timeout-eligible status (
WAITING,ACCEPTED,IN_PROGRESS,PREVIEW) whosestateDeadlineAthas already passed, and flips each toTIMEOUTwith a conditional update guarded by bothidand the request's still-currentstatus(so a request that raced to a different terminal status between the read and the write isn't incorrectly overwritten). This is a real change from the earlier draft, which described the deadline as computed fromnow - createdAt; it's now a per-statusstateDeadlineAtcolumn set explicitly on every transition (see X-Ray Acquisition Workflow), so a request doesn't inherit a stale deadline from an earlier phase of its own lifecycle — and note that once a request reachesCAPTUREDor later,deadlineFor()deliberately stops setting a new deadline, so this cron no longer times out requests that are purely waiting on an upload (that's instead governed by the 10-minuteXrayUploadIntentexpiry, handled in the next step). - Expire stale upload intents. Loads up to 100
PENDINGXrayUploadIntentrows past theirexpiresAt, callsexpireXrayUploadIntent()for each — which flips the intent toEXPIRED, refunds the company's reserved storage quota, and rolls the parent acquisition back fromUPLOADINGtoWAITING_UPLOADinside a transaction — and publishes the updated acquisition. - Delete orphaned S3 objects.
cleanupCancelledXrayUploadObjects()finds up to 100CANCELLED/EXPIREDintents whose object was never marked deleted and removes the underlying S3 object. - Report stale
WAITING_UPLOADcount. Counts acquisitions that have sat inWAITING_UPLOADlonger thanXRAY_STALE_WAITING_UPLOAD_HOURS(default 24h) and reports it as a gauge metric — see Rollout, Feature Flag & Observability.
A lock-acquisition failure (another worker already running the job) is swallowed silently; any error after the lock was acquired is re-thrown so it reaches the cron/Sentry error-reporting wrapper rather than being lost.