Project statistics reference (as built)¶
This is the entry point for anyone who wants to know what each materialized statistic counts, how it is
recalculated from scratch, what keeps it up to date, and what can make it stale. It describes the code on
main at 9eeddbac3 (30 September 2026), not the plan. Where the plan and the code differ, this page follows
the code and says so.
It is written for the product owner and for engineers. The detail lives elsewhere and is linked rather than repeated:
- Mutation ownership matrix: every source write, its owner and its transaction shape.
- Calculation and consumer catalogue: exact formulas.
- Technical plan: the design, including Lifecycle and the event and invalidation contract.
- Programme status: what is merged, deployed and accepted.
How it works¶
Stored counts beside the real data¶
Projects and Studies stay the source of truth. Statistics are stored counts kept in separate collections
(pmProjectStatisticsCurrent rows, one per family and scope). Reading a stored count is fast; recalculating
it from every Study in a large project is slow. The stored count is only a cache: if it cannot be trusted, the
page recalculates from the Studies instead (the authoritative calculation, the same code the pages used
before this feature).
Two ways a stored count changes¶
- Quick update (point move). When a reviewer saves one decision or one annotation session, the same
database transaction that saves the Study also adds or subtracts the exact change on the stored counts
(for example "sufficiently screened −1, sufficiently included +1"). The owner is
ProjectStatisticsTransactionCoordinator.CommitAsync. A quick update is refused if it would touch more than 500 counters or 100 stored rows. - Pending entries (fold), screening families, staging pilot. Behind the default-off flag
materializedProjectStatisticsFold, a screening save in a project whose fold mode is Enabled appends its change to the Study itself (a pending entry) instead of updating the stored counts in the same transaction. A background worker in project-management folds entries into the stored counts within about a second, and pages read stored counts plus pending changes together, so the number shown is exact before and after the fold. ProjectScreening and, from fold protocol 2, MembershipScreening and ReviewerScreening are kept this way; the reviewer families only for projects with at most 100 members, and an entry made before a member joined or left is never applied (that family is marked Stale and rebuilt instead). The annotation families a screening save affects are marked Stale by the entry and recalculated live, as today. Annotation and reservation saves keep the quick update until later slices. Only the staging pilot can enable it; see the async point-fold design and the pilot runbook. With the flag off nothing below changes. - Full rebuild (backfill). An administrator (or a scheduled job, where enabled) recalculates a family
from the Studies inside one pinned database snapshot and publishes fresh rows. The entry point is
POST api/admin/project-statistics/{projectId}/<family>/backfill(only rebuilds scopes that are not already current) or/rebuild(forced; also adopts a changed configuration identity). Both runProjectScreeningBackfillServiceor its per-family subclass, synchronously inside the request.
States¶
Every stored row, and every family as a whole, is in one of these states
(ProjectStatisticsScopeState):
| State | Plain meaning | Served? |
|---|---|---|
| Fresh | Matches the Studies as of its last change | Yes, if every other check passes |
| Stale | Known to have missed a change | No: recalculated live |
| Rebuilding | A rebuild holds a lease on it | No |
| Incompatible | Built under a different catalogue, source version or configuration | No |
| Missing | Never built | No |
| Fenced | A large operation is rewriting the underlying data | No |
Anything the code cannot keep exactly right with a quick update is marked Stale in the same transaction
as the source change, or fenced before the change starts. It then stays Stale until a rebuild. Nothing
turns Stale back into Fresh except a rebuild. A quick update never does: before moving a row it applies the
reader's own row checks (ProjectStatisticsServingGate.EvaluateRow: Fresh, versions and configuration digest,
write epoch, projection revision) and refuses a tombstone. A row that fails is not moved; the whole save falls
back to source-only, the source change still commits, and the affected rows are marked Stale
(#3831, see the backfill side effect).
Saves during a backfill¶
A backfill or rebuild marks the served row Rebuilding before it calculates. A quick update that lands
while that row is Rebuilding does not count as a servable baseline, so the save goes source-only, the row and
the family are left Stale, and the backfill loses its publication (PublicationRaceLost) because the source
moved. This is safe — pages recalculate live and nothing wrong is served — but it costs performance: the
family stays on live fallback until a backfill completes with no save during it. With the scheduled
repair off (as on staging) nothing retries on its own. Recovery: re-run Build statistics
(POST api/admin/project-statistics/{id}/backfill, or /rebuild for one scope) at a quiet time. A crashed
rebuild that leaves a row Rebuilding has the same outcome and the same recovery.
Fences¶
A fence says "this data is being rewritten; do not trust any stored count for it".
- Operation fences cover the families a bulk job touches (search import completion, bulk Study update,
legacy question-tally refresh). They are admitted in their own transaction and released as Stale
(
ProjectStatisticsStagedOperationFenceService). While a fence is active, pages recalculate live. - Source-visibility fences are stronger. Two jobs rewrite data in several passes that a single snapshot
cannot make consistent: the agreement-threshold recalculation (
InclusionRecalculationToken) and question deletion (DefinitionRewriteToken). While either token is held, statistics for the whole project answer HTTP 503 "recalculation in progress" instead of a number, because even the live calculation would see a half-rewritten population.
What a page does when a row is not Fresh¶
ProjectStatisticsBundleReader decides for the whole request at once, inside one snapshot:
- all requested rows pass every check → the page gets the stored counts;
- any requested row fails → the page gets a live recalculation of the whole request (never a mix of stored and live numbers);
- a source-visibility token is held, or this server's reviewer-tracking setting disagrees with the durable setting → a typed 503.
Switches¶
Everything is off unless switched on in configuration:
materializedProjectStatisticsWrites— keep stored counts up to date (quick updates, fences);materializedProjectStatisticsServing— allow reading them (requires Writes);- one flag per family group (see the table below);
- one flag per page consumer, plus the
materializedProjectStatisticsPageskill switch; ProjectStatistics:ProjectAllowlist— the list of pilot projects. A project not on it gets no quick updates, fences or stored rows, and is never served from them.
Maintenance jobs are separately off by default and are not configured in any environment:
scheduled repair (ProjectStatisticsRepair:Enabled, hourly), drift check
(ProjectStatisticsDriftCheck:Enabled, weekly), history, delta and receipt maintenance. Details:
fleet operations.
At a glance, as of 2026-09-30 (cluster-gitops 9f37cb7b)¶
"Switched on" comes from cluster-gitops main at 9f37cb7b (merge of
cluster-gitops#1391):
syrf/environments/staging/api/values.yaml and syrf/environments/staging/project-management/values.yaml.
- Families: staging sets Writes, Serving and
materializedProjectStatisticsScreeningon and every other family flag off, so ProjectScreening is the only family on in staging. Staging allowlists one pilot project (00000000-0000-0000-0000-000000000102). Production sets none of these keys, so the chart defaults (allfalse) apply and no family is on in production. - Page consumers in staging:
materializedProjectStatisticsProjectOverview,…Pages,…SignalR,…Exportsand…Historyaretruein both staging values files;…StageOverviewisfalse, and the other consumer flags are unset (defaultfalse). These five previously ran on staging only as runtime overrides (runtime feature flags revision 72). #1391 records them in GitOps; an administrator is to clear the runtime overrides, and this page does not claim that has happened.
| Family | What it means | Scope key | Page consumers (consumer flag) | Family flag | On in staging? | On in production? |
|---|---|---|---|---|---|---|
| ProjectScreening | Screening progress for the whole project | project | Project Overview screening totals (…ProjectOverview); Screening Info and Stage Overview charts (…ScreeningInfo, …StageOverviewBundle); screening history (…History) |
…Screening |
Yes (pilot project) | No |
| MembershipScreening | Each member's screening decisions and agreement | membership (reviewer) | Screening Info and Stage Overview leaderboards; reviewer screening history (…ReviewerHistory) |
…MembershipScreening |
No | No |
| ReviewerScreening | What one reviewer has screened and can still screen | membership (reviewer) | Stage Review progress (…ReviewerProgress); Project Overview own progress (…ProjectReviewerProgress) |
…MembershipScreening |
No | No |
| StageAnnotation | Annotation session progress per stage | stage | Stage Overview annotation pie (…StageOverview) and charts (…StageOverviewBundle); stage history (…StageHistory) |
…Annotation |
No | No |
| MembershipStageAnnotation | Each member's annotation sessions per stage | membership-stage | Stage Overview member tables (…StageOverviewBundle); reviewer annotation history (…ReviewerAnnotationHistory) |
…MembershipAnnotation |
No | No |
| ReviewerAnnotation | What one reviewer has annotated and can still annotate on a stage | membership-stage | Stage Review progress; Project Overview own progress | …MembershipAnnotation |
No | No |
| QuestionAnswers | How many Studies and answers each annotation question has | question | Question designer counts and assignment locks (…QuestionCounts) |
…QuestionAnswers |
No | No |
| DomainReconciliation | Reconciliation progress per stage | stage | none directly (the same numbers reach pages through StageAnnotation) | …Annotation |
No | No |
| SearchPopulation | References imported by each search | search | Project details and search list counts (…SearchCounts) |
…SearchPopulation |
No | No |
| DerivedSummary | Percentages and chart segments computed from the families above | — (virtual) | computed inside the consumers above | …DerivedSummaries (read by no current query) |
— | — |
… stands for materializedProjectStatistics. The current-statistics page consumers also need Pages,
Writes, Serving, their families' flags and the allowlist; the history consumers have their own flag pairs.
The families¶
Each section covers: what it counts; how it is calculated from scratch and which live calculation it must match; what keeps it current; what marks it Stale or fences it; and known gaps. All examples are invented.
ProjectScreening¶
What it counts. For the whole project: how many Studies exist, how many have had enough screening decisions, how many of those are included or excluded, how many are over-screened, plus a grid of Studies by (number of decisions, agreement). Example: 40 Studies; 30 have two agreeing decisions; 25 of those are included and 5 excluded.
Calculated from scratch. POST api/admin/project-statistics/{id}/backfill or /rebuild →
ProjectScreeningBackfillService → ProjectScreeningScopeCalculator. It reads the Project and runs
StudyStatsQuery.GetFullProjectStatsAsync over the project's Studies (through
ProjectScreeningSourceReader), then keeps the ProjectScreening section. Parity reference: the same
section of the legacy full-statistics calculation (StudyRepository.GetFullProjectStatsAsync, used by
ProjectController.GetFullStats). A project with no agreement threshold has no row.
Kept current by quick updates. Every screening decision, correction and reconciliation decision
(ReviewController /review, /screening, /reconcile) moves the counters in the Study's save
transaction (ProjectScreeningStatisticsWriter, ProjectScreeningClassifier).
Marked Stale or fenced by:
- Agreement-threshold change: fenced (typed 503) until the recalculation finishes, then Stale. Example: the manager changes "2 reviewers must agree" to "3"; the Overview answers "recalculating", then shows live numbers until a backfill.
- Search import: fenced from before the first Study is saved until the import completes or fails, then Stale. Example: a 500-reference import is parsed and finishes; screening totals are live throughout and until a backfill.
- Bulk Study update file: fenced for the whole job, then Stale.
- Any quick update refused inside the transaction (write epoch changed, project not enabled, capacity): Stale.
- Drift check (default off) finding a mismatch: row Stale.
- With the MembershipScreening flag on, a decision that changes the counts of a member who has no membership or reviewer row yet (for example a new member before a backfill): Stale, because the whole commit falls back (see MembershipScreening).
Known gaps.
- Any screening save during a backfill leaves the family Stale until a backfill completes with no save during it (saves during a backfill); fixed by re-running Build statistics at a quiet time. A row the drift check marks Stale also stays Stale until a rebuild (#3831 closed the re-stamp).
- #3644: screening history for zero-Study projects.
MembershipScreening¶
What it counts. For each project member: how many Studies they screened, included and excluded, and how many of their decisions agree or disagree with the final outcome. Example: Reviewer A screened 20, included 12, and agreed with the final decision on 18.
Calculated from scratch. POST …/membership-screening/backfill or /rebuild →
MembershipScreeningBackfillService → MembershipScreeningScopeCalculator, one scope per membership
(disabled members included). Parity reference: the member's row in the MembershipScreening section of
StudyStatsQuery.GetFullProjectStatsAsync.
Kept current by quick updates. A screening decision moves every member's row in the same transaction
when the project has at most 100 members and the before-state was captured (ReviewerScreeningPointClassifier
via ProjectScreeningStatisticsWriter.Prepare). These moves require existing Fresh rows, so they cannot
build a row from nothing.
Marked Stale or fenced by:
- A screening decision in a project with more than 100 members, or without a before-snapshot: whole family Stale (one write-epoch advance). Example: Reviewer B's decision in a 150-member project marks every member's row Stale.
- Threshold change, import completion, bulk update: fenced, then Stale (as ProjectScreening).
- A new member: they have no row until a backfill, so reads that include them fall back to live. The quick
update also depends on it.
ReviewerScreeningPointClassifier.Classifyemits moves for every member, including the new one, whenever a decision changes that member's contribution (for example the Study becomes sufficiently screened, which moves their Available/Unavailable counts). These families are inRequireExistingFreshFamilies, soProjectStatisticsTransactionCoordinator.CheckRowAdmissionAsyncfinds no row for the new member and routes the whole commit to the source-only fallback. That marks the touched scopes Stale for ProjectScreening as well as MembershipScreening and ReviewerScreening, and records each family as Stale, so later quick updates are refused until a backfill. Example: Reviewer D joins; the next decision that makes a Study sufficiently screened leaves project, membership and reviewer screening Stale (served live) until the backfills run. This only applies when the MembershipScreening flag is on. It fails safe (Stale, never wrong), but it takes project screening off the fast path.
Known gaps. No membership or permission change pushes an invalidation to open pages (reads are always re-authorized, so nothing leaks; clients refresh on their next poll).
ReviewerScreening¶
What it counts. For one reviewer: Studies they screened, Studies still available to them, Studies no longer available (already sufficiently screened by others), and the project total. Example: Reviewer A has screened 12, 20 are available, 8 are unavailable, 40 in total.
Calculated from scratch. POST …/reviewer-screening/backfill or /rebuild →
ReviewerScreeningBackfillService → ReviewerScreeningScopeCalculator → ReviewerScreeningSourceReader.
Parity reference: the four counts of StudyRepository.GetInvestigatorScreeningStats (used by
GetReviewerStatsForStageAsync and GetReviewerStatsForProjectAsync).
Kept current by quick updates. As MembershipScreening (same writer and conditions). Rebuild contract: reviewer-screening-rebuild.md.
Marked Stale or fenced by. As MembershipScreening. Example: when a Study becomes sufficiently screened, every other reviewer's "available" count drops, so without per-member moves the whole family goes Stale.
Known gaps. As MembershipScreening.
StageAnnotation¶
What it counts. Per stage, separately for included and excluded Studies: how many have annotation
sessions, how many still need sessions, how many have enough completed sessions, how many of those have
started or finished reconciliation, plus a grid by (sessions started, sessions completed). "Enough" is
two sessions, still hard-coded (StudyStats.cs, var minNumberSessions = 2), not the stage's session
target. Example: stage "Extraction", 30 included Studies, 18 with two completed sessions, 5 of those
reconciled.
Calculated from scratch. POST …/stage-annotation/backfill or /rebuild →
StageAnnotationBackfillService → StageAnnotationScopeCalculator, one scope for every stage. It reads
the StageAnnotation section of StudyStatsQuery.GetFullProjectStatsAsync plus
ReadZeroCandidateStageTalliesAsync. Parity reference: the stage section of the legacy full-statistics
calculation. Contract: stage-overview-cutover.md.
Kept current by quick updates. Annotation session save and delete (SubmitAnnotationSessionService,
ReviewController.RemoveSession), and reservation claims or releases that create or remove a stage's
"reserved but no session yet" tally row (TryAtomicAssignStudyCoreAsync, TryAdmitActivityReviewAsync,
TrySaveReservationChangeAsync). All go through ProjectAnnotationStatisticsWriter and
AnnotationStatisticsClassifier.
Marked Stale or fenced by:
- A screening decision that moves a Study between included and excluded: every stage scope Stale. Example: a third reviewer's exclude flips a Study to excluded; all stage rows go Stale.
- A screening decision that also releases a legacy reservation while screening statistics are on: family Stale.
- Threshold change, import completion, bulk update: fenced, then Stale.
Known gaps.
- Fixed by #3831: a quick update on a sibling stage whose row is still Stale (after a rebuild published another stage and recorded the family Fresh) no longer re-stamps it; the save goes source-only and the row stays Stale until rebuilt (worked example 6).
- Fixed by #3838 (#3740 items 3–4): the screened-reservation release uses the shared reservation save with a per-attempt operation id, and transient statistics rejections on claims, admissions and review-settings saves are retried a bounded number of times.
- Older baselines must be rebuilt after #3763 before serving.
MembershipStageAnnotation¶
What it counts. For each member on each stage: their sessions in progress and completed, how many Studies are still open or full for sessions, and the same for reconciliation sessions. Example: Reviewer A on "Extraction" has 3 sessions in progress and 9 completed.
Calculated from scratch. POST …/membership-stage-annotation/backfill or /rebuild →
MembershipStageAnnotationBackfillService → MembershipStageAnnotationScopeCalculator, one scope per
member × stage. Parity reference: the member's StageAnnotationStatsMap entry in the
MembershipAnnotation section of StudyStatsQuery.GetFullProjectStatsAsync.
Kept current by quick updates. Annotation session save and delete move the rows of every member who holds a session on that Study and stage. Reservation changes do not move this family.
Marked Stale or fenced by. Include/exclude flips (every member × stage scope Stale); threshold change, import completion and bulk update (fenced, then Stale). A stage's session target does not affect it while the two-session minimum is hard-coded.
Known gaps. #3840 (writes stop entirely if
…MembershipAnnotation is on but …Annotation is off).
ReviewerAnnotation¶
What it counts. For one reviewer on one stage: included Studies they have in progress, completed, or could still start; Studies unavailable because others have already taken all the slots; excluded Studies in progress or completed; and the total. "Taken all the slots" uses the stage's session target and, when active-reviewer tracking is on, counts reservations as well as sessions. Example: target 2; a Study with two other reviewers' sessions is unavailable to Reviewer C.
Calculated from scratch. POST …/reviewer-annotation/backfill or /rebuild →
ReviewerAnnotationBackfillService → ReviewerAnnotationScopeCalculator → ReviewerAnnotationSourceReader,
using the durable tracking mode stored on the global control row. Parity reference: the counts of
StudyRepository.GetInvestigatorAnnotationStats.
Kept current by quick updates. None. Every reviewer's counts depend on everyone else's sessions and reservations, so this family is only ever marked Stale.
Marked Stale or fenced by:
- A session save/delete or reservation change that changes whether a Study has all its slots taken, or that
flips include/exclude: whole family Stale (
MarkFamilyStale). Otherwise only the acting reviewer's rows. Example: Reviewer A claims the last slot on a Study; Reviewers B and C lose it from "available", so the family goes Stale. - A change to the stage's effective session target (
StageReviewSettingsController.Putor the legacyProjectController.UpdateStage): whole family Stale in the Project save's transaction (ProjectStageConfigurationChange, #3728). Example: target 2 → 3 makes some "unavailable" Studies available again. - Threshold change, import completion, bulk update: fenced, then Stale.
Known gaps.
- #3840: it is served under
…MembershipAnnotation, but its invalidation runs only when…Annotationis also on. With only the first on, its rows can stay Fresh while sessions change. - Changing the active-reviewer tracking setting has no owner that re-marks rows; the system fails closed (typed 503) until the durable setting and the servers agree (Phase 0 matrix M15).
- #3729: items 1–2 are fixed in code; the issue stays open for its remaining items and parity proof.
QuestionAnswers¶
What it counts. For each annotation question: how many Studies have at least one answer, and how many answers there are. Questions with no answers have no row. There is no stage or question-version dimension. Example: Question 1 answered on 14 Studies, 16 answers in total.
Calculated from scratch. POST …/question-answers/backfill or /rebuild →
QuestionAnswersBackfillService → QuestionAnswersScopeCalculator → QuestionAnswerTallyQuery. Parity
reference: the same pipeline as the legacy manual refresh, StudyRepository.GetAnnotationQuestionAnswerTally.
Contract: question-answer-backfill.md.
Kept current by quick updates. None.
Marked Stale or fenced by:
- Question deletion: source-visibility fence (typed 503 for the project) over the question and its
sub-questions, released Stale when the Project saves (
ProjectManagementService.DeleteQuestionAsync, #3178). Example: deleting Question 2 and its sub-question removes their answers from every Study; counts answer "recalculating" until the save completes, then live until a backfill. - Legacy manual tally refresh (
PUT api/projects/{id}/update-annotation-answer-tally): every question fenced, then Stale. - Annotation session save or delete: intended to mark it Stale, but see the gap below.
Known gaps. #3840: a session save marks Stale a project-level row that does not exist, so the per-question rows stay Fresh and old counts would be served. Question deletion is still two separate writes (#3088). Search removal would not fence this family (latent; tracked in #3849).
DomainReconciliation¶
What it counts. Per stage, for included and excluded Studies: how many have enough completed sessions and have not started, have started, or have completed reconciliation. Example: 18 Studies ready, 5 reconciled.
Calculated from scratch. POST …/domain-reconciliation/backfill or /rebuild →
DomainReconciliationBackfillService → DomainReconciliationScopeCalculator, which reuses the
StageAnnotation calculation in the same snapshot. Parity reference: the reconciliation counters of the
legacy stage section. Membership-level reconciliation lives in MembershipStageAnnotation.
Kept current by quick updates. The same annotation session saves and deletes as StageAnnotation (reconciliation sessions use the same route with a reconciliation flag).
Marked Stale or fenced by. As StageAnnotation.
Known gaps. As StageAnnotation. No page reads this family directly today.
SearchPopulation¶
What it counts. For each search linked to the project: how many references its imported file contained
(SystematicSearch.NumberOfStudies), not how many Studies survive today. A search shared by two projects
counts in both. Example: "Search 1" imported 1,200 references.
Calculated from scratch. POST …/search-population/backfill or /rebuild →
SearchPopulationBackfillService → SearchPopulationScopeCalculator, one scope per linked search.
Parity reference: the imported count on each search, as the search list showed it before.
Kept current by quick updates. None.
Marked Stale or fenced by. Search import completion or failure: that search's scope fenced, then Stale. While fenced, the search list returns a typed "unavailable" rather than an old number. Example: while "Search 2" finishes importing, its count is withheld. Public search and project deletion still answer 503, so they cannot change it.
Known gaps. #3474 (join-predicate test and counter naming, before serving); a forced rebuild of one scope used to strand siblings (fixed for the rebuilt family by #3700).
DerivedSummary¶
Not stored. Percentages and chart segments are computed at read time from one coherent set of the families
above (derived-summaries.md). Its flag, materializedProjectStatisticsDerivedSummaries,
is read by no current query, so turning it on changes nothing. The enum value also carries a synthetic
test-only family (ProjectStatisticsSyntheticFamily) that no product code uses.
Activity → affected statistics¶
"Quick" = counters moved in the same transaction. "Stale" = marked Stale in the same transaction. "Fence" = an operation fence, released Stale. "503" = source-visibility fence (typed 503 while running). "—" = no effect on stored counts. Owners and transaction shapes: mutation matrix.
| Activity | PS | MS / RS | SA / DR | MSA | RA | QA | SP |
|---|---|---|---|---|---|---|---|
| Reviewer screens, corrects or reconciles a Study | Quick | Quick (≤100 members) or Stale | Stale if include/exclude flips | Stale if flip | Stale if flip or reservation effect | — | — |
| Reviewer saves or deletes an annotation session | — | — | Quick | Quick | Stale (family if slots change) | Stale — broken, #3840 | — |
| Reviewer claims or releases a Study (reservation) | — | — | Quick if a reserved-only row appears/disappears | — | Stale (family if slots change) | — | — |
| Manager changes agreement threshold | 503 | 503 | 503 | 503 | 503 | 503 (whole project) | 503 (whole project) |
| Manager changes a stage's session target | — | — | — | — | Stale (family) | — | — |
| Manager changes other stage settings | — | — | — | — | — | — | — |
| Manager deletes a question | 503 (whole project) | 503 | 503 | 503 | 503 | 503 then Stale | 503 |
| Manager creates, edits, copies, reorders or detaches a question | — | — | — | — | — | — | — |
| Manager runs the legacy question-tally refresh | — | — | — | — | — | Fence | — |
| Search import (parse phase) | Fence | Fence | Fence | Fence | Fence | — | — |
| Search import completes or fails | Fence | Fence | Fence | Fence | Fence | — | Fence (that search) |
| Bulk Study update file | Fence | Fence | Fence | Fence | Fence | — | — |
| Member joins, is disabled or changes groups; permissions change | — | new member has no row | — | new member has no row | new member has no row | — | — |
| Search or project deletion | route answers 503; nothing changes | ||||||
| Active-reviewer tracking setting changes | 503 on any server whose setting disagrees with the durable one |
"503 (whole project)" means a held source-visibility token blocks every family's stored rows for that project, not only the families the job rewrites.
How to tell whether a number can be trusted¶
A stored count is shown only when all of the following are true at the same moment (one database
snapshot). The checks live in ProjectStatisticsServingGate.
- Writes and Serving are on, the family's flag is on, and the project is on the allowlist.
- The fleet and the project are both switched to Enabled, and the project is not deleted.
- No threshold recalculation or question deletion is running for the project.
- The project's catalogue, storage and source versions match the fleet's.
- The family is Fresh and has no active fence.
- The row being served is Fresh, was built with the same configuration (agreement settings) as the project's statistics control record, and carries the current write epoch. An epoch advance is how a whole family is marked Stale in one step.
- The row is not newer than the last committed change (it belongs to this snapshot).
- No fence covers that scope.
- This server's active-reviewer tracking setting agrees with the durable one.
If any check fails, the page recalculates live (or answers 503 for checks 3 and 9). Separately, every read re-checks the caller's permissions, so a stored count is never shown to someone who could not see the live one.
Limits of these checks. They compare the row with the project's stored configuration record, not with the Project itself. That is why a threshold change raises the fence in the same transaction that saves the new threshold (#3841). They also trust that every writer marked the right rows Stale; the gaps below are places where that trust is misplaced.
Worked examples¶
1. Threshold change¶
The manager changes the rule from "two reviewers must agree" to "three".
ScreeningController.PostScreeningSettingssaves the new rule, sets the inclusion token and fences all seven screening and annotation families in one transaction (#3841). Every statistics read for the project now answers 503. If another threshold's token is still held, the save is refused (409) and nothing changes.- The project-management consumer picks up the command and resumes the same token.
- The consumer rewrites every Study's inclusion data in three passes, then clears the token and leaves the
families Stale. While its job is open,
POST api/projects/{id}/update-study-inclusionrefuses rather than clearing the token under it. - Pages recalculate live until an administrator runs the backfills. If the consumer fails, the token stays;
GET api/admin/project-statistics/{id}/inclusion-recalculation-fencediagnoses it. With the job still open the recovery is to find and redeliver its command (runbook step 2a); with the job closed it is to repeatPOST api/projects/{id}/update-study-inclusionunder the same threshold.
2. Stage session-target change¶
The manager raises "Extraction" from 2 to 3 sessions per Study.
- ReviewerAnnotation depends on the target, so the save marks the whole family Stale in the same transaction. Reviewer progress is recalculated live until a backfill.
- StageAnnotation, MembershipStageAnnotation and DomainReconciliation still use the hard-coded two-session
minimum, so they are correctly left alone. If that minimum is ever replaced by the stage target, these
families must join
ProjectStageConfigurationChange.TargetDependent.
3. Question deletion¶
The manager deletes Question 2, which has one sub-question.
- A definition-rewrite token is raised and the two questions' QuestionAnswers rows are fenced. Every statistics read for the project answers 503.
- One project-wide update removes their answers from every Study.
- The Project is saved and the token released in one transaction; QuestionAnswers is Stale until a backfill.
Annotation session counts do not change: removing answers does not change any session's status. If step 2 succeeds and step 3 fails, the answers are gone but the question remains; the token stays up so no wrong number is served (#3088).
4. Search import¶
A manager imports a 1,000-reference file into a pilot project.
- Before the first Study is saved, the import fences seven families plus the new search's population scope (#3839). While the file is parsed, Studies are saved in batches of 200; live calculations already count them, and pages recalculate live because the stored rows are fenced. If another import or a bulk Study update holds those families, the import is refused with a parse error before anything is saved.
- At completion, the import resumes that fence, reveals the search in one transaction, and releases everything as Stale. A failed or cancelled parse deletes its Studies and the saga's failure step releases the fence as Stale.
- Pages recalculate live until the backfills run.
5. Annotation save changes answer counts¶
Reviewer A completes a session that adds an answer to Question 1.
- StageAnnotation, MembershipStageAnnotation and DomainReconciliation move in the save's transaction.
- ReviewerAnnotation is marked Stale for Reviewer A, or for everyone if the Study just ran out of free slots.
- QuestionAnswers should go Stale but does not: the stale mark targets a project-level row that does not exist, so "Question 1: 14 Studies" stays Fresh (#3840).
6. A Stale sibling scope after a partial backfill (#3831, fixed)¶
StageAnnotation on a project with two stages, S1 and S2.
- A reviewer's decision flips a Study to excluded, so both stage rows and the family go Stale.
- An administrator backfills. S1 publishes first, and publication records the whole family as Fresh.
S2's publication fails (for example a write conflict that outlasts the rebuild's bounded retry and is
refused as
409 RetryExhausted, #3826), so S2's row stays Stale with the old counts. - A reviewer completes a session on S2. The family looks Fresh, so the quick update is admitted by the
family gate, but S2's row fails the reader's own row checks (
ProjectStatisticsServingGate.EvaluateRow). The save falls back to source-only: the session is saved, S2 stays Stale, the family is recorded Stale. - The page recalculates S2 live until a backfill or the scheduled repair (which looks for Stale rows) republishes it. Before #3831 the update was applied to S2's Stale baseline and stamped Fresh, serving a wrong value that repair skipped.
The same check covers a row the drift check marked Stale (also single-scope ProjectScreening), a Rebuilding
row, a tombstone and a row built under a retired digest, version or write epoch; it is the check
MembershipScreening and ReviewerScreening already applied. Its cost is the
backfill side effect. Reproduced by ProjectStatisticsPointPathServabilityTests.
Known gaps across families¶
| Issue | Families | Risk today |
|---|---|---|
| A save during a backfill leaves the family Stale (details; side effect of the #3831 fix, #3845) | all quick-update families | Performance only; re-run Build statistics at a quiet time |
| #3840 QuestionAnswers scope; annotation flag coupling | QA, RA, MSA | Dark |
| #3729 reviewer-annotation staleness | RA | Items 1–2 fixed in code |
| #3727 copied daily snapshots | all, when copying is enabled | Default off |
| #3809 superseded rows never reclaimed | all | Storage growth only |
| #3704 stranded checkpoint observations | history | Default off |
| #3790, #3792 scheduled repair scaling and vanished scopes | all | Default off |
#3826 backfill write conflict: the publication is retried a bounded number of times, then refused as a typed, retryable 409 RetryExhausted (no longer a 500) |
all | Operator repeats the request |
| #3360 runtime flag toggles not seen by project management | all | Use static configuration only |
| #3086 legacy threshold latch never cleared on failure | PS and dependants | Blocks threshold changes |
| #3194 benchmarks not in CI | — | Acceptance evidence |
Not a gap but worth knowing: membership and permission changes do not push an invalidation to open pages, and a new member or new stage has no rows until the next backfill (reads fall back live meanwhile). #3777 is a future transport evaluation, not a defect.