Skip to content

SignalR Active Reviewer Tracking for Study Annotation Distribution

Executive Summary

This feature uses SignalR to track when reviewers are actively viewing or annotating studies on the stage study review page. The tracking state is persisted on study documents and consumed by the annotation distribution algorithm to prevent studies from being annotated beyond the target number of annotation sessions. It also prevents over-screening by accounting for reviewers who currently have a study open.

Objectives:

  1. Track which reviewers currently have a study open on the review page
  2. Record whether a reviewer has started annotating (filled in any form field)
  3. Persist active reviewer sessions on the study document
  4. Integrate with the study distribution algorithm so open/in-progress studies are accounted for when presenting studies to other users
  5. Detect and handle idle reviewers (open study but no interaction) and suspended sessions (connection lost)
  6. Provide real-time warnings and optional hard-lock enforcement when annotation targets are reached

Why This Matters:

  • Prevents over-annotation: Without awareness of concurrent reviewers, the algorithm may present the same study to multiple users simultaneously, resulting in more annotations than the target
  • Prevents over-screening: Same principle applied to the screening distribution
  • Improves distribution accuracy: Studies currently being reviewed should count toward the target when deciding what to present to other reviewers
  • Handles squatting: Idle session detection frees slots from reviewers who open studies but don't interact
  • Real-time feedback: SignalR provides instant state updates without polling

High-Level Design

SignalR Hub

A new hub (or extension of the existing NotificationHub) handles study review presence:

  • JoinStudyReview(projectId, stageId, studyId) — Called when a reviewer opens a study on the review page or on SignalR reconnection
  • LeaveStudyReview(projectId, stageId, studyId) — Called when a reviewer navigates away intentionally (next study, close)
  • StartedAnnotating(projectId, stageId, studyId) — Called when the annotation form becomes dirty
  • StoppedAnnotating(projectId, stageId, studyId) — Called when annotations are removed and the form returns to a clean state without a saved session
  • Heartbeat(studyId, stageId) — Called every 30 seconds to confirm connection liveness

ActiveReviewSession Entity (Study Document)

ActiveReviewSession extends Entity<Guid> (consistent with AnnotationSession and other domain entities). Lives on the Study aggregate root — presence tracking is not extraction data, and active review sessions also serve screening distribution (not just annotation).

ActiveReviewSession : Entity<Guid>
  Id: Guid                          (entity identity)
  InvestigatorId: Guid              (natural key part 1)
  StageId: Guid                     (natural key part 2)
  DateTimeCreated: DateTime         (inherited from Entity)
  HasStartedAnnotating: bool        (form dirty flag)
  SuspendedSince: DateTime?         (null = connected; non-null = connection lost since)
  IdleSince: DateTime?              (null = active; non-null = idle since)

No LastHeartbeatUtc — heartbeats are a connection-level concern tracked on ReviewSessionConnection. No ConnectionIds — connection tracking is in a separate collection.

Upsert semantics: AddOrRefreshActiveReviewSession(investigatorId, stageId, sessionCountTarget) returns the existing session if one already exists for this reviewer on this stage, or creates a new one. Handles multi-device scenarios — the same reviewer on multiple devices occupies one slot, not multiple.

All existing sessions count toward slot reservation. A session reserves its slot for as long as it exists on the study document, regardless of its idle or suspended state. Slots are only freed when the session is removed (intentional leave, idle timeout, or suspended grace period expiry).

ReviewSessionConnection Entity (Separate Collection)

A lightweight entity in its own MongoDB collection that maps SignalR connections to active review sessions. Kept separate from the study document to avoid write amplification from frequent heartbeats.

ReviewSessionConnection
  ConnectionId: string              (natural key, indexed unique)
  StudyId: Guid
  StageId: Guid
  InvestigatorId: Guid
  ConnectedAtUtc: DateTime
  LastHeartbeatUtc: DateTime        (per-connection heartbeat)
  PendingStaleCheckToken: Guid?     (MassTransit scheduled message token for heartbeat timeout)

Indexed on: ConnectionId (unique), (StudyId, StageId, InvestigatorId) (composite for remaining-connection checks).

Relationship Between ActiveReviewSession and AnnotationSession

These are separate entities within the same aggregate (the Study). They are NOT a transformation of each other — they have different identities, different data, and different purposes. They are connected by the natural key (InvestigatorId, StageId) and an aggregate rule that prevents double-counting.

  • ActiveReviewSession: Tracks that a reviewer is currently present on the study review page. Created when the study is assigned. Exists for as long as the reviewer has the study open. Removed when they leave, or when idle/suspended timeouts expire.
  • AnnotationSession: Tracks persisted annotation data. Created when the reviewer saves for the first time. Permanent — part of the study's data forever.

They coexist after the first save (the reviewer saves but is still on the page). Double-counting is prevented not by removing the ActiveReviewSession on save, but by computing reserved sessions as the subset of active review sessions that do NOT have a corresponding AnnotationSession:

reserved = activeReviewSessions.Where(ars =>
    !annotationSessions.Any(s => s.InvestigatorId == ars.InvestigatorId
                               && s.StageId == ars.StageId
                               && !s.Reconciliation))

By definition, reserved sessions and annotation sessions never overlap.

SessionTallies and Computed Properties

The existing SessionTallies computed getter on ExtractionInfo is extended. SessionTally gains:

Field Type Source Description
StageId Guid Direct Which stage
NumberOfCandidateSessions int Direct Non-reconciliation AnnotationSessions (Incomplete + Completed). Existing field.
NumberOfCompletedCandidateSessions int Direct Completed non-reconciliation AnnotationSessions. Existing field.
ReconciliationStarted bool Direct Any reconciliation session exists. Existing field.
ReconciliationCompleted bool Direct Any completed reconciliation session exists. Existing field.
NumberOfReservedSessions int Computed Active review sessions that do NOT have a corresponding AnnotationSession — these are reserving a slot but haven't saved yet
NumberOfAnnotatingReservedSessions int Computed Subset of reserved where HasStartedAnnotating == true — reviewer has started filling the form
TotalAllocatedSessionCount int Computed NumberOfCandidateSessions + NumberOfReservedSessions — total slots in use. Used by distribution algorithm. Getter-only, [BsonElement] for indexing.
TotalEngagedSessionCount int Computed NumberOfCandidateSessions + NumberOfAnnotatingReservedSessions — total sessions with substantive engagement. Used for warning/lock threshold. Getter-only, [BsonElement] for indexing.

The distribution algorithm filters on TotalAllocatedSessionCount < SessionCountTarget to exclude studies from assignment. SessionCountTarget is passed as a parameter from the Stage — not denormalized onto the study.

Atomic Study Assignment (FindOneAndUpdate Pipeline)

When the algorithm assigns a study to a reviewer, the operation must be atomic — filter for a study with capacity AND add the ActiveReviewSession in a single operation, preventing two reviewers from claiming the last slot simultaneously.

Three-stage pipeline executed as a single FindOneAndUpdate:

  1. Push ActiveReviewSession — typed .Set() expression (no computed properties on this type, LINQ3 handles it)
  2. Seed tally if missing — if no SessionTally exists for this stage, append a zero-valued one. Uses raw BsonDocument $cond stage (LINQ3 cannot translate ternary branches with different serializers)
  3. Increment matching tally$map over tallies, increment NumberOfReservedSessions for the matching stage, recompute stored TotalAllocatedSessionCount and TotalEngagedSessionCount. Uses raw BsonDocument $map stage.

Filter (both cases handled):

$or: [
  { SessionTallies: { $elemMatch: { StageId: X, TotalAllocatedSessionCount: { $lt: target } } } },
  { "SessionTallies.StageId": { $ne: X } }   // no tally = 0 < target
]

LINQ3 limitation: Typed .Set() expressions fail for nested objects with [BsonElement] computed properties due to serializer mismatch in $cond branches. Stages 2 and 3 use raw BsonDocument via .AppendStage(). Stage 1 (flat array push) uses typed .Set(). This hybrid is proven in integration tests (PipelineUpdateProofOfConceptTests).

Audit compatibility: The existing WithAuditCeremony extension uses Builders<T>.Update.Combine() to add audit fields (Audit.Version, Audit.LastModified, Audit.LastModifiedBy, Audit.LastAppVersion). This is incompatible with pipeline updatesCombine cannot merge classic update operators with pipeline stages. The fix: a new WithAuditCeremonyPipeline extension (or overload of PartialUpdateAndGetAsync) that appends audit as an additional $set stage in the pipeline:

// Appended as a 4th pipeline stage:
{ $set: {
    "Audit.Version": { $add: ["$Audit.Version", 1] },
    "Audit.LastModified": <DateTime.UtcNow>,
    "Audit.LastModifiedBy": <userId>,
    "Audit.LastAppVersion": <appVersion>
}}

This keeps audit tracking working identically to the classic update path, just expressed as a pipeline stage rather than a combined update operator.

When to use which update approach:

Operation Approach Why
Algorithm study assignment FindOneAndUpdate pipeline Race-sensitive, new reviewer always = new reserved session
Direct navigation UoW load-modify-save May or may not be reserved (depends on existing AnnotationSession)
First annotation save UoW load-modify-save Both arrays change, getter recomputes correctly
Session removal UoW load-modify-save Conditional tally change based on reserved status
State changes (dirty/idle/suspended) UoW load-modify-save Low frequency, getter recomputes
Heartbeats Atomic on ReviewSessionConnection No study document change

Idle Session Detection and Timeout

Idle detection protects against reviewers who open a study and occupy a slot without interacting with the annotation form ("squatting"). Uses MassTransit delayed messages — no polling job.

Stage settings:

  • IdleSessionTimeoutMinutes: int? (nullable, default 120). Time after a session is marked idle before it is removed. Null = idle tracking disabled.

Phase 1 — Mark as idle (fixed 5 minutes):

  1. Session created with clean form → schedule MarkSessionIdle in 5 minutes
  2. Form becomes dirty (HasStartedAnnotating = true) → cancel MarkSessionIdle. Session can never go idle while form is dirty.
  3. If MarkSessionIdle fires (no interaction after 5 min) → set IdleSince = now on the ActiveReviewSession. Schedule RemoveIdleSession for Stage.IdleSessionTimeoutMinutes in the future. UI shows "Idle" status to reviewer and admins.
  4. If reviewer interacts with form after being marked idle → cancel RemoveIdleSession, clear IdleSince. UI returns to "Active".
  5. If reviewer removes annotations (form returns to clean, no saved session) → schedule new MarkSessionIdle in 5 minutes. Cycle repeats.

Phase 2 — Remove idle session (configurable, default 2 hours):

  1. RemoveIdleSession fires → remove ActiveReviewSession from study document. Slot freed. Annotation form becomes unavailable. Create ExpiredReviewSession audit record.
  2. If EnforceAnnotationTarget is enabled on the stage and the study has now reached its target with other reviewers, the idle reviewer cannot resume.

UI for idle reviewers: A status indicator (active / idle) shown to the reviewer, with a hover tooltip or countdown showing time remaining before the session is released. Admins see the same status when viewing project member activity.

Suspended Session Detection and Grace Period

Suspension handles involuntary connection loss (laptop sleep, network drop, browser crash). Uses MassTransit delayed messages.

Phase 1 — Mark as suspended (immediate on involuntary connection loss):

The distinction between intentional and involuntary disconnect comes from SignalR's OnDisconnectedAsync(Exception? exception):

  • exception == nullclean disconnect (tab closed, browser closed). Reviewer chose to leave. Session is removed immediately.
  • exception != nullinvoluntary disconnect (network drop, laptop sleep, crash). Session is suspended.

When the last connection for a reviewer is lost involuntarily → set SuspendedSince = now. Schedule RemoveSuspendedSession for 2 hours.

Phase 2 — Remove suspended session (2-hour grace period):

  1. RemoveSuspendedSession fires → remove ActiveReviewSession from study document. Slot freed. Create ExpiredReviewSession audit record.
  2. If reviewer reconnects before it fires (SignalR auto-reconnect, JoinStudyReview called) → clear SuspendedSince, cancel RemoveSuspendedSession.

UI for suspended sessions: Admins see the session marked as "Suspended" when viewing project member activity. The reviewer, on reconnection, either resumes normally (if within grace period) or sees a notification explaining their session was released (if past grace period).

Disconnect Handling (OnDisconnectedAsync)

1. Find ReviewSessionConnection for this connectionId
2. If not found → LeaveStudyReview already handled cleanup, return
3. Remove the ReviewSessionConnection
4. Cancel any pending heartbeat stale-check for this connection
5. Check: any remaining connections for (studyId, stageId, investigatorId)?
6. If yes → other devices still connected, return
7. If no → this was the last connection:
   a. exception == null → clean close. Remove ActiveReviewSession entirely.
   b. exception != null → involuntary loss. Set SuspendedSince = now,
      schedule RemoveSuspendedSession for 2 hours.

LeaveStudyReview (called explicitly by Angular on intentional navigation):

1. Remove ReviewSessionConnection for this connectionId
2. Cancel pending heartbeat stale-check and idle timers for this connection
3. Check remaining connections for (studyId, stageId, investigatorId)
4. If none → remove ActiveReviewSession entirely (intentional leave)

Tab close edge case: When a tab is closed, Angular's ngOnDestroy attempts to call LeaveStudyReview, but the WebSocket may close before it completes. If LeaveStudyReview gets through → session removed directly. If not → OnDisconnectedAsync(null) fires (clean close, step 7a), session removed anyway. Both paths produce the same result.

Heartbeat and Connection Liveness

Each connected device sends heartbeats every 30 seconds via SignalR. Heartbeats are tracked per-connection on ReviewSessionConnection, not on the ActiveReviewSession.

Cancel/reschedule pattern: Each heartbeat cancels the previous scheduled CheckConnectionLiveness message (using PendingStaleCheckToken) and schedules a new one for 2 minutes in the future. At any point, exactly one pending message exists per connection. If heartbeats stop, the single pending message fires at precisely 2 minutes after the last heartbeat.

When CheckConnectionLiveness fires:

  1. Check if the connection's LastHeartbeatUtc is still stale
  2. If yes → remove the ReviewSessionConnection, trigger the same remaining-connection check as OnDisconnectedAsync (treating it as an involuntary disconnect)

Expired Review Session Audit Trail

When an ActiveReviewSession is removed due to idle timeout or suspended grace period expiry, an ExpiredReviewSession record is created in a separate collection:

ExpiredReviewSession
  OriginalSessionId: Guid
  StudyId: Guid
  StageId: Guid
  InvestigatorId: Guid
  CreatedAtUtc: DateTime            (when the original session was created)
  ExpiredAtUtc: DateTime            (when it was removed)
  Reason: enum (IdleTimeout | SuspendedTimeout)
  HasStartedAnnotating: bool
  DurationSeconds: int

Useful for admin dashboards and understanding reviewer behavior patterns.

Multi-Replica API Considerations

No SignalR backplane is needed. The existing notification architecture uses MongoDB change streams as the cross-replica coordination mechanism:

  1. A study document changes (ActiveReviewSession added/removed/modified)
  2. Every replica independently watches the same MongoDB change stream
  3. Each replica's StudyCollectionSubscriptionManager fires on the change
  4. Each replica sends the StudyReviewPresenceSnapshot to its own locally-connected clients

No replica needs to send messages to clients on another replica.

Connection tracking is in the ReviewSessionConnection collection (not in-memory), so any replica can handle disconnect events and heartbeat updates regardless of which replica the connection was originally established on.

Reviewer Notifications and Enforcement

When a reviewer has a study open and the TotalAllocatedSessionCount reaches the target (due to other reviewers saving or starting annotations), the system notifies them in real-time via the change stream → SignalR pipeline. The reviewer is "surplus" if they are not one of the sessions counted in TotalAllocatedSessionCount — i.e., they have neither a saved AnnotationSession nor a counted ActiveReviewSession (because theirs was removed by idle/suspended timeout and the slot was taken by someone else).

Warning Banner (always shown when surplus):

  • A persistent warning banner appears at the top of the annotation form
  • The banner remains visible for the duration of the reviewer's time on that study
  • If a reviewer is already on the study when the threshold is crossed, they receive a notification dialogue explaining the situation. When dismissed, the warning banner remains
  • Dialogue text: "Your unsaved review session for this study was released after a period of inactivity. This study has since reached its target number of reviewers. Your unsaved changes cannot be submitted. You will be redirected to the next available study."

Hard Lock Mode (configurable per stage):

  • Stage.EnforceAnnotationTarget (boolean, default false) controls whether the system merely warns or actively prevents further annotation
  • When enabled and the reviewer is surplus to the target:
  • The annotation form is hidden
  • Annotation-related buttons are disabled/hidden
  • The warning banner and notification dialogue remain visible so the reviewer understands why
  • No dirty form exception — if the reviewer has unsaved work but their session was removed and the slot was filled, they cannot save. The purpose of this feature is to prevent over-annotation, and allowing a surplus reviewer to save would defeat that purpose regardless of their form state.
  • Applies immediately when the condition is met (no grace period needed — if the reviewer's slot is gone, it's gone)
  • When disabled: warning-only mode (banner + dialogue, no blocking). Reviewer can still save even when surplus.
  • Also blocks direct URL navigation to a study that has reached its target (for reviewers who are not already an existing session)

Selector logic (Angular ngrx):

selectIsReviewerSurplus =
    TotalAllocatedSessionCount >= SessionCountTarget
    AND currentReviewer is NOT one of the counted sessions
        (no ActiveReviewSession AND no AnnotationSession for this stage)

selectShouldShowWarning = selectIsReviewerSurplus

selectShouldLockForm = selectIsReviewerSurplus AND stage.EnforceAnnotationTarget

Design Decisions

Resolved

  • ActiveReviewSession is an Entity (Entity<Guid>) — consistent with AnnotationSession and other domain entities
  • Lives on Study aggregate root, not ExtractionInfo — serves both screening and annotation distribution
  • Upsert semantics for multi-device: AddOrRefreshActiveReviewSession returns existing session rather than throwing on duplicate
  • SessionCountTarget passed as parameter, not denormalized onto study
  • Separate ReviewSessionConnection collection — avoids churning study document on heartbeats
  • No LastHeartbeatUtc on ActiveReviewSession — heartbeats are connection-level
  • No ConnectionIds on ActiveReviewSession — connection tracking is in separate collection
  • MassTransit delayed messages with cancel/reschedule for all timeouts — no polling jobs
  • No SignalR backplane needed — MongoDB change streams coordinate replicas
  • No handoff invariant — ActiveReviewSession and AnnotationSession coexist after save. Double-counting prevented by computing "reserved" as active sessions without a corresponding AnnotationSession.
  • SuspendedSince and IdleSince as nullable DateTimes — consistent pattern, null = state doesn't apply
  • Idle and suspended sessions still count toward slot reservation while they exist. Slot freed only on session removal.
  • Clean vs involuntary disconnect distinguished by OnDisconnectedAsync(exception) — clean = remove session, involuntary = suspend
  • Stage setting EnforceAnnotationTarget — boolean for hard lock mode
  • Stage setting IdleSessionTimeoutMinutes — nullable int, default 120. Time after idle mark before session is removed.
  • Computed properties use [BsonElement] — ensures serialization regardless of class map registration order
  • ExpiredReviewSession audit trail — records timed-out sessions for admin visibility
  • StageId references — follow existing pattern (Stage is a sub-entity of Project aggregate). Revisit Stage aggregate boundaries after PR 2461 (Question Management v2) extracts annotation questions.
  • Atomic assignment via pipeline update — three-stage FindOneAndUpdate pipeline: push session → seed tally if missing → increment tally. Hybrid typed/BsonDocument due to LINQ3 serializer limitation. Proven in integration tests.
  • Audit compatibilityWithAuditCeremony incompatible with pipeline updates (Combine doesn't work with PipelineUpdateDefinition). New WithAuditCeremonyPipeline appends audit fields as an additional $set stage. New PartialUpdateAndGetAsync overload accepts PipelineDefinition<T, T>.
  • Non-atomic operations use UoW — direct navigation, state changes, annotation saves all go through load-modify-save. SessionTallies getter recomputes correctly on save (self-healing).
  • Extend existing NotificationHub — not a separate hub. Single WebSocket connection per client, shared OnDisconnectedAsync for both subscription cleanup and study presence cleanup, avoids second connection in Angular SignalR service. The hub grows from 276 to ~360 lines — manageable. INotificationHubClient gains StudyPresenceUpdated(StudyReviewPresenceSnapshot).
  • Hard lock applies immediately, no grace period, no dirty form exception — if the reviewer is surplus to the target (their session was removed and the slot was filled), they cannot save regardless of form state. The purpose is to prevent over-annotation. The dirty form status is irrelevant to the lock decision when enforcement is enabled. When enforcement is disabled (warning-only mode), reviewers can still save even when surplus.

Scope

In Scope

  • ActiveReviewSession entity on Study aggregate
  • ReviewSessionConnection entity + collection
  • SignalR hub methods (join, leave, startedAnnotating, stoppedAnnotating, heartbeat)
  • Disconnect handling (clean vs involuntary)
  • Idle detection (5-min mark + configurable timeout)
  • Suspended session handling (2-hour grace period)
  • MassTransit consumers for all timeout handlers
  • SessionTally extensions (reserved, annotating-reserved, totalAllocated, totalEngaged)
  • Algorithm integration (screening + annotation distribution)
  • Stage settings (EnforceAnnotationTarget, IdleSessionTimeoutMinutes)
  • Angular SignalR service + ngrx studyPresence feature
  • Warning banner + notification dialogue
  • Hard lock enforcement on annotation form
  • ExpiredReviewSession audit trail
  • Admin visibility of session states (active, idle, suspended)

Out of Scope (for now)

  • UI showing who else is reviewing the same study (names/avatars)
  • Historical analytics on reviewer behavior patterns
  • Stage aggregate promotion (deferred to post-PR 2461)

Technical Components

Component Location Change Type
ActiveReviewSession entity src/libs/project-management/.../Model/StudyAggregate/ New entity on Study aggregate
ReviewSessionConnection entity src/libs/project-management/.../Model/ New entity + MongoDB collection
ReviewSessionConnection repository src/libs/project-management/.../Mongo.Data/Repositories/ New repository
ExpiredReviewSession entity src/libs/project-management/.../Model/ New entity + MongoDB collection
SessionTally extensions src/libs/project-management/.../Model/StudyAggregate/ Reserved/allocated/engaged computed properties
Stage model src/libs/project-management/.../Model/ProjectAggregate/StageEntity/ EnforceAnnotationTarget, IdleSessionTimeoutMinutes
SignalR Hub src/services/api/.../SignalR/ New or extended hub methods
MassTransit consumers PM service or API service MarkSessionIdle, RemoveIdleSession, RemoveSuspendedSession, CheckConnectionLiveness
MassTransit messages src/libs/project-management/.../Messages/ New message contracts
Study repository filters src/libs/project-management/.../Mongo.Data/Repositories/ TotalAllocatedSessionCount filter + indexes
Atomic assignment pipeline src/libs/project-management/.../Mongo.Data/Repositories/StudyRepository.cs FindOneAndUpdate pipeline builder for study claim
Pipeline audit extension src/libs/mongo/SyRF.Mongo.Common/MongoExtensions.cs WithAuditCeremonyPipeline for pipeline updates
Pipeline UoW overload src/libs/mongo/SyRF.Mongo.Common/MongoUnitOfWorkBase.cs PartialUpdateAndGetAsync accepting PipelineDefinition
StageReviewService src/libs/project-management/.../Core/Services/ Atomic study claim with presence
Angular SignalR service src/services/web/.../core/services/signal-r/ New hub methods, heartbeat, reconnect, idle tracking
Angular ngrx studyPresence src/services/web/src/app/ New feature slice, selectors
Warning banner component src/services/web/src/app/shared/ New component
Notification dialogue src/services/web/src/app/shared/ New MatDialog
Annotation form integration src/services/web/src/app/shared/annotation/ Lock form, idle status, dirty/clean signals
Stage settings UI src/services/web/ New toggles for enforcement + idle timeout
Admin session status UI src/services/web/ Session state visibility (active/idle/suspended)