Dentolize · X-Ray Integration Walkthrough
On this pageBusiness viewTechnical view

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 from POST /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 from POST /api/xray-acquisition/disconnect, and also invoked server-side by revokeByAccessKey() 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 a KEYS scan 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 what AcquirePopover polls 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 (xrayAcquisitionRequested subscription, filtered by request.isDesktopApp and a present leaseToken in packages/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:

  1. Timeout stale requests. Loads up to 100 requests in a non-terminal, timeout-eligible status (WAITING, ACCEPTED, IN_PROGRESS, PREVIEW) whose stateDeadlineAt has already passed, and flips each to TIMEOUT with a conditional update guarded by both id and the request's still-current status (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 from now - createdAt; it's now a per-status stateDeadlineAt column 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 reaches CAPTURED or 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-minute XrayUploadIntent expiry, handled in the next step).
  2. Expire stale upload intents. Loads up to 100 PENDING XrayUploadIntent rows past their expiresAt, calls expireXrayUploadIntent() for each — which flips the intent to EXPIRED, refunds the company's reserved storage quota, and rolls the parent acquisition back from UPLOADING to WAITING_UPLOAD inside a transaction — and publishes the updated acquisition.
  3. Delete orphaned S3 objects. cleanupCancelledXrayUploadObjects() finds up to 100 CANCELLED/EXPIRED intents whose object was never marked deleted and removes the underlying S3 object.
  4. Report stale WAITING_UPLOAD count. Counts acquisitions that have sat in WAITING_UPLOAD longer than XRAY_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.