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:
- Track which reviewers currently have a study open on the review page
- Record whether a reviewer has started annotating (filled in any form field)
- Persist active reviewer sessions on the study document
- Integrate with the study distribution algorithm so open/in-progress studies are accounted for when presenting studies to other users
- Detect and handle idle reviewers (open study but no interaction) and suspended sessions (connection lost)
- 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:
- Push ActiveReviewSession — typed
.Set()expression (no computed properties on this type, LINQ3 handles it) - Seed tally if missing — if no
SessionTallyexists for this stage, append a zero-valued one. Uses rawBsonDocument$condstage (LINQ3 cannot translate ternary branches with different serializers) - Increment matching tally —
$mapover tallies, incrementNumberOfReservedSessionsfor the matching stage, recompute storedTotalAllocatedSessionCountandTotalEngagedSessionCount. Uses rawBsonDocument$mapstage.
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 updates — Combine 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):
- Session created with clean form → schedule
MarkSessionIdlein 5 minutes - Form becomes dirty (
HasStartedAnnotating = true) → cancelMarkSessionIdle. Session can never go idle while form is dirty. - If
MarkSessionIdlefires (no interaction after 5 min) → setIdleSince = nowon theActiveReviewSession. ScheduleRemoveIdleSessionforStage.IdleSessionTimeoutMinutesin the future. UI shows "Idle" status to reviewer and admins. - If reviewer interacts with form after being marked idle → cancel
RemoveIdleSession, clearIdleSince. UI returns to "Active". - If reviewer removes annotations (form returns to clean, no saved session) → schedule new
MarkSessionIdlein 5 minutes. Cycle repeats.
Phase 2 — Remove idle session (configurable, default 2 hours):
RemoveIdleSessionfires → removeActiveReviewSessionfrom study document. Slot freed. Annotation form becomes unavailable. CreateExpiredReviewSessionaudit record.- If
EnforceAnnotationTargetis 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 == null→ clean disconnect (tab closed, browser closed). Reviewer chose to leave. Session is removed immediately.exception != null→ involuntary 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):
RemoveSuspendedSessionfires → removeActiveReviewSessionfrom study document. Slot freed. CreateExpiredReviewSessionaudit record.- If reviewer reconnects before it fires (SignalR auto-reconnect,
JoinStudyReviewcalled) → clearSuspendedSince, cancelRemoveSuspendedSession.
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:
- Check if the connection's
LastHeartbeatUtcis still stale - If yes → remove the
ReviewSessionConnection, trigger the same remaining-connection check asOnDisconnectedAsync(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:
- A study document changes (ActiveReviewSession added/removed/modified)
- Every replica independently watches the same MongoDB change stream
- Each replica's
StudyCollectionSubscriptionManagerfires on the change - Each replica sends the
StudyReviewPresenceSnapshotto 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 withAnnotationSessionand other domain entities - Lives on Study aggregate root, not ExtractionInfo — serves both screening and annotation distribution
- Upsert semantics for multi-device:
AddOrRefreshActiveReviewSessionreturns existing session rather than throwing on duplicate - SessionCountTarget passed as parameter, not denormalized onto study
- Separate
ReviewSessionConnectioncollection — avoids churning study document on heartbeats - No
LastHeartbeatUtcon ActiveReviewSession — heartbeats are connection-level - No
ConnectionIdson 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.
-
SuspendedSinceandIdleSinceas 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 -
ExpiredReviewSessionaudit 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
FindOneAndUpdatepipeline: push session → seed tally if missing → increment tally. Hybrid typed/BsonDocument due to LINQ3 serializer limitation. Proven in integration tests. - Audit compatibility —
WithAuditCeremonyincompatible with pipeline updates (Combinedoesn't work withPipelineUpdateDefinition). NewWithAuditCeremonyPipelineappends audit fields as an additional$setstage. NewPartialUpdateAndGetAsyncoverload acceptsPipelineDefinition<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, sharedOnDisconnectedAsyncfor both subscription cleanup and study presence cleanup, avoids second connection in Angular SignalR service. The hub grows from 276 to ~360 lines — manageable.INotificationHubClientgainsStudyPresenceUpdated(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) |