Screening source-write overhead: diagnosis and measured plan¶
Read-only analysis of the materialised screening write path, against the merged artifact
evidence/source-writes/PS-WRITE-01_ScreeningSourceWrites_2026-09-13.json and the source at
7e0ed90c9. The harness itself is described in
screening-write-benchmark.md.
Headline¶
The uncontended arithmetic is checkable from the artifact, not inferred:
| cell | submissions | commands | commands/submission |
|---|---|---|---|
same_study_1_reviewers_source_only |
30 | 60 | 2.0 |
same_study_1_reviewers_materialized |
30 | 900 | 30.0 |
different_studies_1_reviewers_source_only |
30 | 60 | 2.0 |
different_studies_1_reviewers_materialized |
30 | 900 | 30.0 |
A conflict-free screening decision costs 2 MongoDB commands with the flag off and exactly 30 with it on — a 15x fan-out, not 6x. The aggregate 28,104 / 4,649 = 6.05x is diluted by the baseline's own same-study conflicts and by materialised attempts that abort early.
Latency follows the command count almost linearly on the benchmark host. In same_study_1,
submission p95 84.44 ms against attempt p95 72.81 ms is an 11.6 ms gap covering the 5 pre-transaction
commands — about 2.3 ms per round trip. Twenty-eight extra commands at 2.3 ms is ~64 ms, which is
essentially the whole observed delta. The overhead is round-trip count, not any single slow operation.
Indexes are not the cause. Every hot lookup on the write path is served by a declared unique index
(ProjectStatisticsCurrentRepository, ...NotificationOutboxRepository, ...PublicationGuardRepository,
...ControlRepository, ...SourceOperationReceiptRepository, ...RevisionRepository
InitialiseIndexesAsync), and ProjectStatisticsWriteTestContext creates them in the fixture.
The 30-command trace¶
Flags: materializedProjectStatisticsWrites on, family ProjectScreening serving, project
allowlisted, ActiveReviewerTrackingEnabled = false.
Flag off — 2 commands¶
| # | command | site |
|---|---|---|
| 1 | find pmStudy |
benchmark reload -> MongoRepositoryBase.GetAsync (a fresh RepositoryCache per submission, so always a real read) |
| — | none | ProjectScreeningStatisticsWriter.BeginOperationAsync returns null (IsWriteRequested false) |
| — | none | RequireDurableModeAgreementAsync(session: null, writesEnabled: false) short-circuits |
| 2 | findAndModify pmStudy |
the capacity+version guard replace, un-sessioned |
No session, no transaction, no statistics read. A guard miss adds one projected find
(DiagnoseCapacityGuardFailureAsync) plus one reload find per retry, which is why the baseline
aggregate is 4,649 rather than 2,160.
Flag on — 30 commands¶
Pre-transaction (5, un-sessioned, serial)
| # | command | site |
|---|---|---|
| 1 | find pmStudy |
benchmark reload |
| 2 | find GlobalControl |
ProjectScreeningStatisticsWriter.BeginOperationAsync (epoch capture) |
| 3 | find Control |
same |
| 4 | find PublicationGuard(ProjectScreening) |
same |
| — | none | the reviewer-family epoch loop is empty (reviewer families not serving) |
| 5 | find SourceOperationReceipt |
ResolveByReceiptAsync pre-flight duplicate check |
writer.Prepare(...) is pure — classify, dependants, size routing — and issues no command.
Transaction (24 + commit), opened with ProjectStatisticsTransaction.SnapshotOptions
(ReadConcern.Snapshot).
| # | command | site | document |
|---|---|---|---|
| 6 | find GlobalControl |
StudyRepository durable-mode gate |
singleton |
| 7 | findAndModify pmStudy |
source replace under the shared BuildScreeningSaveFilter guard |
per-study |
| 8 | find GlobalControl |
coordinator | singleton — duplicate of #6, same snapshot |
| 9 | find Control |
coordinator | per-project, hot |
| 10 | find Receipt |
idempotency authority | per-operation |
| 11 | find Guard(ProjectScreening) |
CheckFamilyAdmissionAsync |
per-project, hot |
| 12 | find Guard(ProjectScreening) |
CheckRowAdmissionAsync |
duplicate of #11 |
| 13 | find Current row (project scope) |
LoadRowAsync |
per-project, hot |
| 14 | insert Receipt |
coordinator | per-operation |
| 15 | find Guard(ProjectScreening) |
MaterializeAsync |
duplicate of #11/#12 |
| 16 | find Current row |
LoadRowAsync |
duplicate of #13 |
| 17 | update Current row (CAS on Version) |
MaterializeAsync |
per-project, hot — first shared write |
| 18 | update Guard(ProjectScreening) (CAS) |
RecordPointMaterialization |
per-project, hot |
| 19 | find Revision |
delta duplicate check | per-operation |
| 20 | insert Revision (signed delta) |
coordinator | per-operation |
| 21 | find Guard(MembershipScreening) |
MarkFamilyStaleAsync; guard is null so it returns |
wasted |
| 22 | find Guard(ReviewerScreening) |
same | wasted |
| 23 | find Outbox slot (ProjectScreening) |
CoalesceSlotAsync |
per-project, hot |
| 24 | update Outbox slot (ProjectScreening) |
same | per-project, hot |
| 25 | find Outbox slot (MembershipScreening) |
same | per-project, hot |
| 26 | update Outbox slot (MembershipScreening) |
same | per-project, hot |
| 27 | find Outbox slot (ReviewerScreening) |
same | per-project, hot |
| 28 | update Outbox slot (ReviewerScreening) |
same | per-project, hot |
| 29 | update Control (CAS on Version) |
coordinator | per-project, hottest |
| 30 | commitTransaction |
ProjectStatisticsTransaction |
— |
Total 30, matching the artifact's commands=900 / 30 submissions and commit_attempts=30 exactly.
Where the fan-out comes from¶
- Eleven of the 24 in-transaction commands are reads of documents already read in the same snapshot, or of documents that do not exist. #8 repeats #6; #12 and #15 repeat #11; #16 repeats #13; #21/#22 look up guards for two families that have never been materialised and always return null.
- Every write is a read-then-CAS-replace pair, never an atomic update.
ProjectStatisticsRepositoryBase.TryReplaceAsyncdoes a whole-documentReplaceOnefiltered onVersion, so each mutated document costs two commands. - Three notification slots are coalesced per save.
UpsertNotificationSlotsAsyncunions the affected family with every dependant, andProjectScreeningClassifier.DependentFamiliesunconditionally addsMembershipScreeningandReviewerScreening: three slots x (find+update). - Nothing is batched or pipelined. Every repository call is one
awaiton one round trip. - Even a no-delta save pays a transaction. transaction-admission.md
records this under "Accepted costs"; the artifact shows it biting
(
different_studies_10 ... no_delta_saves=116of 176 saves).
Contention hot spots¶
Every concurrent screening save in a project writes these documents, regardless of which Study it targets:
| document | identity | why it is written every time |
|---|---|---|
ProjectStatisticsControl |
one row per project | AdvanceClocks bumps SourceInvalidationRevision, CommittedProjectionRevision and ClientInvalidationRevision, then the row is CAS-replaced |
ProjectStatisticsCurrent (ProjectScreening, project scope) |
one row per project | all screening moves are emitted at ProjectStatisticsScopeKey.ForProject(); there is no per-study or per-stage sharding |
ProjectStatisticsPublicationGuard (ProjectScreening) |
one row per project | RecordPointMaterialization + CAS |
ProjectStatisticsNotificationOutbox x3 |
three rows per project | one slot per family, coalesced on every save |
That is six shared per-project documents on the critical path of every reviewer decision. Only the receipt and the delta record are contention-free.
The artifact is unambiguous about the consequence. Baseline different-study cells record
version_conflicts=0 at 1, 2, 5 and 10 reviewers — the source documents never collide. The
materialised different-study cells record 0 / 59 / 195 / 432 conflicts and 0 / 0 / 45 / 124 exhausted.
All different-study contention is manufactured by the shared statistics documents.
Under ReadConcern.Snapshot a write conflict is raised at the document level whenever a document
has been modified since the transaction's snapshot. Reads never conflict, so in the original ordering
the first detection point is command #17, the current-row CAS — after about eleven in-transaction
round trips of work that is then thrown away.
The retry budget¶
- The six-argument overload does not retry internally. It catches
TransientTransactionError, aborts, and returnsCapacityGuardSaveResult.VersionStale. This is deliberate: the callback has already runOnSavingand a classified transition, so replaying it against newer source data is prohibited (transaction-admission.md, "A mutable Study callback..."). CommitWithUnknownResultRetryAsyncretries onlyUnknownTransactionCommitResult. The artifact recordsunknown_commit_failures=0in every cell, so it contributed nothing to the measured cost.- The budget that matters is the caller's:
ReviewController.TrySaveScreeningAsyncwithmaxAttempts = 3, an immediate reload and no backoff, mirrored by the benchmark.
Conflicts rose 1,005 -> 1,883 and exhaustion 263 -> 553 because the transaction's write set grew from one document to seven, its duration grew about sevenfold (widening the window in which a competitor invalidates the snapshot), and the budget is unchanged. Raising the budget is rejected: it trades exhaustion for more load on the same hot document.
What the harness measures, and what it does not¶
Not measured, and material:
- A different overload from the current HTTP path. With tracking off,
ReviewControllertakesTrySaveScreeningWithStatisticsAsync(version-only filter), not the capacity-guarded overload. This applies to both arms, so it does not bias the ratio, but the absolute p95 is not the production branch. - The controller does strictly more work than the harness, not less:
ResolveVerifiedActiveReviewerTrackingModeAsync()runs once per attempt, adding one un-sessioned GlobalControlfindper attempt. Thirty commands is a floor for the HTTP path, not a ceiling. - Everything around the save is excluded — HTTP pipeline, authorization, project load, next-study allocation, reservation cleanup, domain-event dispatch, SignalR. Those costs land on both arms in production, so the endpoint-level percentage regression will be materially lower than 660%. The absolute regression (~+60-70 ms of serial round trips) is the honest number to carry forward.
Harness artefacts that could inflate the candidate arm specifically:
- Command recording itself. A
CommandStartedEventsubscriber forces the driver to materialise every command document. The candidate issues 15x more commands, so the recorder's cost is ~15x higher on the candidate arm. It is now opt-in viaSYRF_STATS_COUNT_ROUNDTRIPS, so a no-subscriber control run is possible; before that change it was always on and was an un-quantified, asymmetric confound. - Snapshot read concern is only on the candidate arm. Correct and required, but its cost is not separable from command count in the present data.
- n = 30 per cell. A p95 over 30 samples is effectively the second-worst observation. Treat cell-to-cell ordering as noise-dominated.
- Host. 48 cores,
ContainerResourceCapsApplied = false, Mongo container co-resident with the driver and the test process.
Measured plan¶
Every item is re-measured with the existing benchmark, the same eight cells,
SYRF_STATS_DATASET=PS-WRITE-01. Read every gate as the artifact's *_p95_gate plus the
*_outcomes note. All-submission p95 including exhausted requests remains the metric: a better p95
with a worse saved count is not an improvement.
Measurement hygiene required before any of this counts: run unchanged main and the candidate
back to back on the same idle host with the same iteration and warmup settings, and report both.
Cheap and safe — no protocol change¶
A1. Memoize in-snapshot reads inside one CommitAsync. Removes commands #12, #15 and #16 by
threading the already-loaded guard and current row through DecideAsync -> CheckRowAdmissionAsync
-> materialisation.
Invariant: epochs must still be stamped from authorities read in this transaction's snapshot.
Memoizing a read taken inside the same session is the same snapshot — so this must be session-scoped
state, never a process cache, or the fail-closed durable-mode contract breaks.
Residual: the duplicate in-transaction GlobalControl read (#8 repeating #6) is not removed.
The two reads straddle a component boundary — #6 is StudyRepository's durable-mode gate, which is
the fail-closed mode authority, and #8 is the coordinator's own — so sharing them would change the
IProjectStatisticsTransactionCoordinator.CommitAsync contract rather than memoize inside it. Both
are still present in the measured trace below. One command, deliberately left.
A2. One guard lookup per transaction, for all families. Replaces the separate GetByFamilyAsync
calls (#11, #12, #15, #21, #22) with a single find over ProjectId + MetricFamily $in [...]
covering affected and dependent families, cached for the transaction. The existing
(ProjectId, MetricFamily) unique index serves it.
Invariant: fence and epoch checks must still see the snapshot's values, and a family with no guard
must stay a no-op, not an insert.
A3. Batch the notification slots. One find with the named slot keys plus one BulkWrite for the
three slots, replacing six commands with two.
Invariant: the outbox contract the benchmark asserts —
slots.Max(TargetClientInvalidationRevision) == control.ClientInvalidationRevision — and the
CAS-on-Version fail-closed semantics of ReplaceOrFailAsync must survive as per-op results of the
bulk write.
A4. Order the hot writes first, so a losing attempt fails fast. Issue the control-row CAS and the
current-row CAS immediately after the source replace. A loser then aborts after about three round
trips instead of eleven.
Invariant: nothing about what commits changes — the transaction is still all-or-nothing, and
AdvanceClocks already runs before materialisation. The routing decision must still run before
any write, so the source-only fallback still writes no point-path row.
Gate: different_studies_{2,5,10}_reviewers_materialized commands down sharply with
version_conflicts and exhausted unchanged or better. If exhausted rises, reject.
A5. Parallelise the pre-transaction reads. The epoch capture's independent un-sessioned reads are
issued together.
Invariant: none — these reads are deliberately outside any transaction, and a disagreement with the
in-transaction authorities already routes to the source-only fallback.
Gate: commands unchanged; submission p95 minus attempt p95 narrows.
Cumulative effect on the uncontended path: 30 commands -> 21, asserted by
ProjectScreeningWriteRoundTripBudgetTests. The nine commands removed are four guard reads
(#12, #15, #21, #22), one current-row read (#16) and four of the six outbox commands. This does not
reach the <10% gate and must not be presented as if it might. The remaining floor is one transaction
plus a commit plus the serial round trips against six shared documents.
Needs design — not adopted on this evidence¶
B1. One atomic update per hot document instead of read-then-CAS-replace. $inc/$set pipelines on
Control, Current and Guard would halve their command cost. It does not reduce conflicts —
document-level write conflicts are independent of operator choice — and it changes the
optimistic-concurrency contract that ReplaceOrFailAsync's PublicationRaceLost fail-closed path
depends on.
B2. Striped or split counters for the project-scope screening row. This is the plan's own escape
hatch, and the ½/5/10-reviewer gate asked whether the small project summary is actually hot. It
is. But striping breaks equality-gated serving as currently written: the reader requires
row.LastChangedRevision <= control.CommittedProjectionRevision and an exact
AppliedWriteEpoch/digest/version match. A shard-and-sum read path, checkpoint compatibility and
rebuild semantics all need specifying first. This is the highest-value design item on #3255.
B3. Take the control-row clock advance off the per-write path. Three monotonic counters on one per-project document is the true serialisation point; no batching fixes it. Any alternative changes the ordering guarantee that serving equality, checkpoints and the client-invalidation contract rest on.
B4. Do not coalesce slots for families that were never materialised. Commands #25-#28 write two
shared outbox rows for MembershipScreening and ReviewerScreening whose guards do not exist and
whose MarkFamilyStale was a no-op. Skipping them would remove two of the six hot documents. Whether
a client subscribed to a dark family must still see a revision bump is a contract question.
Open questions this analysis could not answer¶
- How much of the 84 ms is commit versus round trips versus snapshot read concern. The 2.3 ms/RTT
figure comes from one subtraction in one cell; a per-command-duration breakdown needs a run with
CommandSucceededEventdurations. - Whether
CommandStartedEventsubscription materially inflates the candidate arm. Structurally asymmetric, but the magnitude needs the A/B thatSYRF_STATS_COUNT_ROUNDTRIPSnow makes possible. - Whether
ReadConcern.Snapshotcosts anything measurable versus the default on this topology. No A/B exists, and one must not be created by relaxing a correctness invariant. - The write concern in force on commit. A
w:majoritycommit on a three-node container could be a large single-digit-millisecond constant on both arms. - The real HTTP endpoint's overhead percentage. Whether it is 50% or 400% is not derivable from this artifact.
- Why
no_delta_savesis 116/176 indifferent_studies_10_reviewers_materialized. The likely reading is that exhausted submissions leave a study at an older decision, so a later round re-applies the same value andPreparereturns null — inference from the counts, not proof. - Production behaviour under a remote (Atlas) Mongo. Round-trip cost dominates, so a higher RTT would scale the absolute regression roughly linearly with the extra commands. Untested.
- Whether any of these documents are hot in production for reasons outside screening — assignment, annotation and import all touch the same control row.
Outcome (2026-09-15)¶
A1-A5 landed in #3475. The B items were not started.
Measured: 30 -> 21 commands per uncontended materialised save, asserted rather than estimated by
ProjectScreeningWriteRoundTripBudgetTests, which counts the driver's commands for a real
end-to-end save on a replica set and fails above 21. The nine removed commands are four guard reads,
one current-row read and four outbox commands. The recorded trace after the change:
find(pmStudy), find(GlobalControl), find(Control), find(Guard), find(Receipt),
find(GlobalControl), findAndModify(pmStudy),
find(GlobalControl), find(Control), find(Receipt), find(Guard), find(Current),
update(Control), update(Current), insert(Receipt), update(Guard),
find(Revision), insert(Revision),
find(Outbox), update(Outbox) <- one bulk write of three slots,
commitTransaction
Two in-transaction find(GlobalControl) remain, for the reason recorded under A1 above.
Paired run 2026-09-15 (loaded host)¶
main at 0fe1716f6 and #3475 at 6546bb25d, back to back on the same host in the same minute,
SYRF_STATS_ITERATIONS=30 SYRF_STATS_WARMUP=5 SYRF_STATS_COUNT_ROUNDTRIPS=1. Artifacts:
main arm,
#3475 arm.
Fuller commentary in screening-write-benchmark.md.
The host was not idle — load average 20-45 with roughly 130 CI containers running through both arms — so this run does not satisfy the measurement hygiene rule stated above. The p95 columns are indicative only and no ratio derived from them may be quoted. The command, conflict, exhaustion and saved counts are deterministic work counts and are the reliable part.
| Cell | Commands (main → #3475) | Conflicts | Exhausted | Saved | Materialised p95 ms | Source-only p95 ms |
|---|---|---|---|---|---|---|
| same_study 1 | 900 → 630 | 0 → 0 | 0 → 0 | 30 → 30 | 118.72 → 56.53 | 58.55 → 12.62 |
| same_study 2 | 1390 → 1090 | 82 → 80 | 25 → 25 | 35 → 35 | 95.67 → 53.62 | 13.29 → 17.18 |
| same_study 5 | 2812 → 2553 | 343 → 343 | 111 → 111 | 39 → 39 | 84.33 → 56.52 | 15.73 → 26.41 |
| same_study 10 | 5196 → 4974 | 779 → 770 | 251 → 246 | 49 → 54 | 58.99 → 71.41 | 41.99 → 30.72 |
| different_studies 1 | 900 → 630 | 0 → 0 | 0 → 0 | 30 → 30 | 47.91 → 42.95 | 6.44 → 8.68 |
| different_studies 2 | 2612 → 1860 | 58 → 60 | 0 → 0 | 60 → 60 | 103.98 → 75.32 | 38.86 → 5.90 |
| different_studies 5 | 5090 → 3779 | 193 → 193 | 44 → 43 | 106 → 107 | 103.06 → 93.50 | 7.31 → 5.85 |
| different_studies 10 | 9296 → 7142 | 434 → 428 | 125 → 118 | 175 → 182 | 94.07 → 98.10 | 9.94 → 10.15 |
Established: the command reductions, confirmed against the unit measurement. The uncontended cells fall 900 -> 630 over 30 submissions, i.e. 30 -> 21 per save. The contended cells fall further in absolute terms because a losing attempt also does less wasted work.
Not established: A4's contention effect. Conflicts and exhaustion are essentially unchanged
(58->60 / 0->0; 193->193 / 44->43; 434->428 / 125->118) — ordering the contended writes first cuts the
work a loser does, not the number of losers, and the counts show exactly that. No latency claim is
made: the source-only p95 of the same cell moved by up to 6.6x between arms from host load alone
(different_studies 2: 38.86 ms against 5.90 ms), so under this load the overhead ratios measure the
scheduler rather than the change. All eight <10% gates still fail in both arms.
An idle-host re-run is still owed before any rollout claim, and before any p95 or contention statement about this change is made.
Appendix: cell-level evidence¶
| cell | saved | conflicts | exhausted | commands | commit attempts | p95 overhead |
|---|---|---|---|---|---|---|
| same_study_1 baseline / materialized | 30/30 | 0/0 | 0/0 | 60/900 | 0/30 | 660.79% |
| same_study_2 | 60/36 | 30/78 | 0/24 | 210/1356 | 0/36 | 134.28% |
| same_study_5 | 90/46 | 270/334 | 60/104 | 990/2781 | 0/46 | 99.70% |
| same_study_10 | 97/44 | 705/785 | 203/256 | 2309/5199 | 0/44 | 96.22% |
| different_studies_1 | 30/30 | 0/0 | 0/0 | 60/900 | 0/30 | 647.97% |
| different_studies_2 | 60/60 | 0/59 | 0/0 | 120/2626 | 0/60 | 1010.16% |
| different_studies_5 | 150/105 | 0/195 | 0/45 | 300/5070 | 0/105 | 745.78% |
| different_studies_10 | 300/176 | 0/432 | 0/124 | 600/9272 | 0/176 | 495.69% |
unknown_commit_failures = 0 in every cell. Maximum summary bytes 1,199-1,787 — size is not a factor.