SignalR Active Reviewer Tracking for Study Annotation Distribution¶
Delivered Runtime Contract¶
Relocated from the root
CLAUDE.md. This section states what the shipped code does; the design narrative below records the original intent and still uses some earlier terminology.
activeReviewerTrackingEnabled gates the cross-service review-presence workflow. The API SignalR hub authorizes every join and mutation against the supplied project, stage, and study before subscribing a connection to a study group. A SlotReservation embedded in the Study aggregate represents an unsaved reviewer allocation; MongoDB atomic claim and save filters enforce SessionCountTarget only when the stage's EnforceAnnotationTarget setting is enabled. Reconciliation sessions never consume or require these annotation reservations.
SignalR connections are stored separately from durable ReviewerPresence engagement windows. Multi-tab connections share one current presence. Concurrent first joins that collide on the unique current-presence index read back the winning record without changing its identity or annotation session; other database failures still propagate. Involuntary disconnects enter a bounded suspension grace period, and scheduled Project Management consumers remove expired reservations/presences. The browser consumes authoritative per-study snapshots, ignores stale or duplicate event versions, warns when allocation exceeds the target, and locks saving only when target enforcement is enabled. This is a per-study subscription contract; it does not provide stage-wide live-presence aggregation.
Application authority (M4b, enforced mode only). Presence snapshots are re-decided on fresh facts when each one is
sent: the reviewer must be active (not suspended or pending deactivation) and an active member of the project. A
refused snapshot ends the presence stream; an unreadable authority withholds that snapshot only. A suspended reviewer's
JoinStudyReview, Heartbeat, StartedAnnotating and StoppedAnnotating are refused, and a removed or disabled
member's are refused by the stage policy. Revocation never removes a reservation directly: the capacity contract above
is unchanged, so the reservation is released by the liveness check and suspension grace period, or at once by
LeaveStudyReview, which is never gated. Off/shadow: unchanged.
Review-context refusal (#4158). JoinStudyReview,
LeaveStudyReview, StartedAnnotating, StoppedAnnotating, Heartbeat, SubscribeToStudyPresence and
UnsubscribeFromStudyPresence resolve the project, stage and study through one lookup before any state change. An
unknown project, unknown study, unknown stage, or a study that belongs to a different project is refused with
HubException("The requested review context is not available."), the same text for every case, so the refusal does
not reveal whether an id exists in another project. The refusal reads only: no reservation, connection, presence,
schedule or subscription is created, and nothing is broadcast. JoinStudyReview throws this refusal rather than
returning a typed StudyReviewAccessResult denial, as it always has for a wrong-project study, because a typed denial
carries a presence snapshot of a study that does not exist here. Before #4158 an unknown project or study surfaced as
an unhandled InvalidOperationException ("Sequence contains no elements") instead of this refusal.
Hard-deleted studies (#4253). Legacy immediate search and import-job
deletion (deletionLifecycle off, its default) and preview seeding remove pmStudy documents without touching
ReviewSessionConnection records or joins in flight, so every later hub read of the study may find nothing. A study
that disappears after the review context was read is refused like an unknown study: JoinStudyReview returns the same
HubException when its combined-stage claim re-read or its post-claim capacity re-read finds no document. It removes
the connection record it created and writes nothing else. Cleanup paths treat a missing study like one moved by
a search deletion: they drop the connection record and write nothing else. These paths are disconnect (logged at
Information, not Error), dirty-tab reconcile, failed-join rollback, and the prior-study release when a connection
moves to another study. The release no longer fails the new study's join. The legacy deletion itself closes the
reviewers' current ReviewerPresence windows on the studies it erases, with SearchDeleted, as the reversible
deletion's sweep does (#4273). It reads the study ids first and
closes their presences only after the erasure completed, so an erasure refused by a bulk-update lock closes nothing.
It reads the search again after the erasure and leaves the presences of any study the erasure kept (a lock taken
after its pre-check) open. A failed close is logged at Error and does not fail the completed deletion. A retry after
a partial erasure only closes the presences of the studies that retry erases; those the failed attempt erased stay
open. Preview seeding's erasure does not close presences.
The same holds after the review context was read in StartedAnnotating and StoppedAnnotating: with
materializedProjectStatisticsFold on, a lost upsert race reloads the study uncached, and a reload that finds no
document is refused with the same HubException. Nothing more is saved or scheduled, and the presence is not marked.
The scheduled Project Management review-session commands (CheckConnectionLiveness, MarkSessionIdle,
RemoveIdleSession, RemoveSuspendedSession) treat a missing study like a deletion-marked one, on the first load
and on a conflict reload alike (DeletedStudySessionCleanup). They log at Information ("no longer exists"), write
and schedule nothing more, and complete instead of faulting the message to the error queue. The liveness check
removes the stale connection record before it reads the study, so that record is still dropped
(#4261).
Client handling of refusals (#4254). The browser sees a
refused hub invocation in one of two shapes. ASP.NET Core SignalR always sends a HubException's message, whatever
EnableDetailedErrors says, as An unexpected error occurred invoking '<method>' on the server. HubException:
<message>; a failed hub policy is always the fixed text Failed to invoke '<method>' because user is unauthorized,
because SignalR never sends the failure reason. An exception thrown by an authorization handler is no way out
either: SignalR's invocation semaphore swallows it and never answers the call.
So the server separates "authority temporarily unavailable" from a real denial before the policy fails. When an
enforced application or project/stage decision is Unavailable, SignalRAuthorizationHandler defers
ApplicationAuthorityFailure.UnavailableMessage (application-authority-unavailable) to the per-invocation
HubAuthorizationRefusal and lets the requirement pass; HubAuthorizationRefusalFilter, the first hub filter, then
refuses the invocation with that code before any other filter or the hub method runs. Without the scoped refusal the
handler keeps the plain policy failure (fail closed). Denials, unknown and foreign projects, and the reversible-
deletion refusal are unchanged policy failures, so no existence signal is added.
HubAuthorizationRefusalPipelineTests pins this against a real in-process SignalR server.
Because a thrown handler exception stalls the whole connection (with the default one invocation at a time per
client, every later call waits for a permit that never comes back), the off and shadow modes read the project and
the caller's investigator with non-throwing reads: an unknown project, a missing investigator or an unresolvable
user id is the ordinary policy failure, exactly as enforced mode refuses an unknown project
(#4270). SignalRAuthorizationPipelineTests runs the real
handler through the same in-process server and checks that the connection still answers the next invocation.
An unexpected failure inside the handler, in every mode, is never thrown either
(#4283): a read that faults (a Mongo timeout on the project,
investigator or export-job read, or on the registration check) or a failed UserHasRegisteredEvent dispatch (for
example a duplicate-key race on first login). The handler logs it at error level with the requirement, hub method,
first hub argument (the project id, or the export job id for ProjectExportData) and the exception type only (an
exception message can carry user data), then refuses with the same deferred application-authority-unavailable code
as an enforced outage. Cancellation of the hub connection itself is not a failure: nobody is left to answer, so it
propagates as cancellation. Any other OperationCanceledException counts as unexpected. The browser retries the code
as transient: each burst is retried within the call and reported to Sentry (rate-limited). A fault that keeps
recurring ends the review's retries after three failed bursts in a row, with the temporary refusal and Try again
(#4317, below).
SignalRAuthorizationPipelineTests checks a faulting project read and a failed registration end to end, each
followed by an answered call on the same connection.
hub-refusal.ts (isHubRefusal, hubRefusalReason) treats a policy failure and every non-transient HubException
as a deterministic refusal: no retry and no Sentry report. Transient ones keep the retry-then-Sentry path: the
server's own unavailable outcomes (Authorization is temporarily unavailable., *-unavailable codes,
authority-malformed) and the concurrent-change retry message. A refusal is the server answering, so it opens no
RPC-failure burst (no "Reconnecting…" and no offline countdown); it still emits invocationError$ and rejects. The
API test HubExceptionContractTests enumerates every new HubException(...) site in the API, requires each message
to be classified transient or refusal, and fails if hub-refusal.ts disagrees or lists a text the server never sends.
A refused JoinStudyReview (initial, heartbeat-triggered or the post-reconnect rejoin) or Heartbeat for the active
study retires its claim and heartbeat like a typed denial, keeps the study context, and emits studyReviewRefused$.
The post-reconnect self-heal loop (_rejoinActiveStudyPresence) therefore stops rescheduling itself every 5 s after a
refusal; a transient failure still reschedules it, up to the limit below. StudyPresenceStore.reviewRefusal puts the review page into the
refused display state, which takes precedence over the slot, idle and offline states and does not lock the form.
syrf-review-refusal-banner explains the reason in the reviewer's own terms (SESSION_MESSAGES.refused) and offers
Try again, which re-runs the join; any join result clears the refusal. That is how a reviewer recovers once the
cause is lifted (for example a restored account); a later transport reconnect also runs one rejoin.
A transient failure is retried within the call, but the review does not retry it indefinitely
(#4317).
- Counting. On a connected hub, each JoinStudyReview or Heartbeat burst the server fails counts once. Bursts
can come from the initial join, a join triggered by a heartbeat, a post-reconnect round or the 30 s heartbeat.
A call skipped while the hub is disconnected does not count. Any answered join, or a heartbeat that finds its
connection, resets the count, as do Try again, opening another study, and every new connection (a burst that
failed while the old connection was closing was a lost connection, not a failing server).
- At three failures in a row. The claim and heartbeat are retired as for a refusal, and studyReviewRefused$
emits temporary, the client's own reason (the server never sends it). The 5 s rejoin loop stops, and the
banner says the problem is usually temporary and offers Try again.
- Sentry. Its default dedupe only drops an event identical to the one just before it, and the rejoin loop
alternates SubscribeToStudyPresence and JoinStudyReview. So _reportRpcExhausted sends a given method and
error message at most once every 10 minutes per page. The limit covers every hub method's exhausted bursts, not
just presence. The next report for the same key carries suppressedSinceLastReport. If the failures stop inside
the window, that count is never sent: the first event already says the failure happened.
This also bounds the API's own error log for an authorization handler failure (#4283), which writes one entry for each attempt.
| Display reason | Server signal | Sentry level |
|---|---|---|
access |
policy failure; The requested project is not available. |
info |
account |
subject-suspended, subject-deactivated, subject-deactivation-pending |
info |
impersonation |
non-transient SupportImpersonationCodes (impersonation-*) |
info |
unavailable |
The requested review context is not available. and any other refusal |
warning |
temporary |
none: three failed join/heartbeat bursts in a row on a connected hub (#4317) | warning |
Every display of the banner is logged and reported to Sentry. A refused invocation is not sent to Sentry as
an RPC failure (see above), but the banner it leads to is. StudyPresenceStore.studyReviewRefused calls
ReviewRefusalReporter.report each time the banner appears, or changes reason while shown. A Try again refused for
the same reason keeps the banner up, so it is not a new display. Each display writes a console line
(console.warn for warning, console.info for info) with the reason, hub method (JoinStudyReview or
Heartbeat) and the project, stage and study ids. It also sends a Sentry message Study review refused: <reason>
at the level in the table, fingerprinted by reason, tagged review.refusal_reason and signalr.method, with the
three ids as extra data.
- Why these levels. warning is for the reasons where the cause is on SyRF's side and someone may need to act:
a server that kept failing the review, or a review context it could not find. info is for the caller's own
expected state (changed access, an inactive account, a read-only impersonation). Those are still counted in
Sentry but should not alert anyone.
- PII. The app's Sentry scope adds the signed-in user (name, email) and the project, stage and study names to
every event (SentryService). This report strips them in a scope event processor (keepOnlyReviewRefusalData):
no user, no breadcrumbs, only its two tags and the ids, and the page URL without its query string.
- Rate limit. The same reason and method is reported at most once every 10 minutes per page, using
ReportRateLimiter, which _reportRpcExhausted uses as well. The next report carries
suppressedSinceLastReport. Every display still writes its console line.
Not flagged: every changed path is an invocation the server already refused or could not authorize, and no
capacity, reservation or data semantics change. Against an older API, the web shows an unavailable decision as an
access refusal (recoverable with Try again); an older web treats the new -unavailable code as transient.
The #4317 cap is not flagged either. It only changes what happens after the page has already failed the same
review three times in a row on a live connection: it stops retrying on its own and explains itself, where it used
to retry for as long as the page stayed open.
Executive Summary¶
Completed-target regression evidence (2026-09-08)¶
Investigation #2565, under
#2470, distinguishes already-completed annotations
from concurrent unsaved reservations. The MongoDB integration theory
AlreadyCompletedTarget_WithIncompleteSession_RejectsNewReviewersButPreservesResume
in AtomicAssignmentIntegrationTests exercises target two with two or three completed ordinary sessions
and one existing incomplete session. Each case runs with current tallies and with the legacy
TotalAllocatedSessionCount field absent:
- Twelve concurrent new-reviewer claims return no assignment and create no reservations.
- A new reviewer's direct save is rejected as
AtCapacitywithout persisting that session. - The existing incomplete reviewer can finish the same session without adding another session.
These are synthetic fixtures against the current repository guards, not a reproduction against the reported production study. They assume coherent stored candidate-session counts; corrupt tallies, deployed feature flags, historical versions and the reported browser path are not established by this test. No runtime behavior or flags change in this test/documentation slice, so no new feature flag is needed.
The focused core regression selection (atomic assignment, distribution, active sessions and stage review)
passes 155 tests on base cb20a917c7271980c928f5d10dbb076e8a6c70c2 plus this theory. Separately,
E2E run 34172580868, attempt 5
passed annotation-capacity.spec.ts on membership PR #2271's head ef974e28b, covering reservation caps
and refresh deduplication. That full suite failed elsewhere; it is not overall E2E acceptance.
Before closing #2565 or user ticket #2446, reproduce the reported completed-target scenario through the browser in an authorized environment, verify its flags/settings and tally consistency, and record the findings and follow-up decision. Stage-wide admin presence aggregation and remaining UI acceptance stay separate follow-ups under #2470.
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.
With the server-only realtimePipelineV2 flag on (default off; realtime server plan PR-R1.2), step 3 is the
pod's RealtimeDispatcher instead of StudyCollectionSubscriptionManager: SubscribeToStudyPresence registers in
the RealtimeRegistry, the Study change feed reader feeds the dispatcher, and the dispatcher reads the study once per
250 ms window (PROPOSAL) and sends the same StudyPresenceUpdated snapshot. Revocation, the subscribe-time checks
and the enforced-mode per-delivery decision are unchanged. See
SignalR multi-replica analysis.
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) |