Repository navigation
mongodb: take the version-1 history copy out of the transaction Bundle's write set (design decision first; depends on #1975) #1976
Description
Summary
A MongoDB transaction Bundle writes the full resource twice inside its transaction for every create: once to resources and once as the version-1 copy in resource_history. Once search rows leave the transaction (#1975), that history copy is half the documents left in it. WiredTiger caps how much one transaction may write, so this copy may be what keeps the four largest Synthea Bundles (33,562-49,027 entries) failing with error 388.
This issue asks for a design decision first, then code. Two options:
- Write the v1 history row after commit.
- Stop storing it, and synthesize v1 from the current resource until the first update or delete.
Either way, a create-only Bundle should put about one document per entry into the transaction. All the history semantics listed below must stay as they are. Depends on #1975.
Motivation
Each transaction Bundle is one MongoDB multi-document transaction. WiredTiger cannot evict uncommitted updates, and it rolls a transaction back once its uncommitted updates pass eviction_updates_trigger, which defaults to 10% of the cache (read from the WiredTiger source and reproduced in a local lab). Context and the broader failure list are tracked in #1776.
Measured on fhir-benchmark run 37986575337, 2 GB WiredTiger cache, artifacts fhir-benchmark-<leg>-37986575337:
| Leg | Docs inserted per entry-write in the txn | Bundles OK | Entries committed | Where the txn aborts |
|---|---|---|---|---|
mongodb (native search) |
~20.6 [I] (resource + history + ~18.6 search_index rows) |
946 / 1000 | 861,317 / 1,627,336 | ~6.1k entries (5,141-9,372) |
mongodb-elasticsearch (search offloaded) |
2.00 [M] (resource + history) | 996 / 1000 | 1,465,188 / 1,627,336 | 29.9k-33.0k entries |
Sources:
- Docs per entry-write:
import-mongostat.txtinsert opcounters divided by entry-writes, seed Bundles excluded. Aborted attempts also increment the opcounters, so "entry-writes" counts entries written in aborted attempts as well. Divided by committed entries only, the offloaded leg reads 2.18. The 2.00 is measured. The native 20.6 is inferred, because the entry-writes of its third-attempt aborts are estimated. - Bundles OK and entries committed:
import-completeness.txt. - Abort positions: the retry WARN
failed_entryvalues and the error-388 "Entry N" values in each leg's HFS log.
The mongodb-elasticsearch leg shows the shape the native leg will have once #1975 moves search rows out of the transaction:
-
What fails. Only the four largest Bundles. They abort at about the same place in both runs:
Bundle size (entries) Abort entry, run 37986575337 Abort entry, run 37782202746 33,562 33,019 31,737 35,593 31,837 31,869 43,966 29,937 29,878 49,027 31,296 31,403 -
Abort point in bytes [M]. Recomputed from the reconstructed Bundle corpus, every abort falls at 51.1-56.4 MB of cumulative resource JSON.
-
Sizes [M]. The four failing Bundles carry 56.0, 63.6, 82.5 and 87.0 MB of resource JSON. The largest Bundle that commits has 24,265 entries (44.0 MB).
-
What is left in the transaction. On this leg the transaction holds only the
resourcesdocument and itsresource_historycopy. The copy is the full payload, the same bytes as the resource.
What removing the copy would buy. These figures are inferred and not measured:
- About one document per entry should roughly double the byte budget per Bundle, to about 100-110 MB of resource JSON, or a ceiling of about 60k entries [I]. That would cover the 87.0 MB / 49,027-entry Bundle at 2 GB.
- Caveat: it may be less than 2x.
resourcesandresource_historyeach carry 5 indexes counting_id(4 named +_id, read inschema.rs), so dropping the history document may remove less than half the in-cache cost. - Weaker estimate. On-disk sizes reported in perf(mongodb): $bulk-submit runs at ~60 resources/s per file — 23 documents and ~250 index key insertions per resource, 55 GB of indexes against a 7 GB cache #1000 (a different corpus) put
resource_historyat about 58% of theresources+resource_historybytes [I]. That is not a measurement of in-transaction cost. - Measure before deciding. Compare the ceiling of a resource-only transaction against a resource + history transaction on a single
mongodat a 2 GB cache.
Native mongodb leg without #1975. History is 1 of ~20.6 documents per entry there, so this change alone would move the 6.1k ceiling only modestly [I]. That is why this issue depends on #1975. The mongodb-elasticsearch leg would benefit on its own.
Proposed behavior (requirement, not a patch)
A create entry in a MongoDB transaction Bundle no longer inserts its version-1 resource_history document inside the Bundle's transaction. Everything a client can observe about version 1 stays identical to today and to the SQLite/PostgreSQL backends.
The mechanism is open. Candidates:
- (a) Post-commit write. Write v1 history after commit, driven by a durable record written inside the transaction. mongodb: write search_index rows after commit for transaction Bundles of 4,000+ entries, so they fit WiredTiger's per-transaction cache limit #1975's ledger row could carry it.
- (b) Synthesized v1. Do not store v1. Every history reader treats a live
resourcesrow whose version has no history row as that version's history entry. The first update or delete writes the v1 row before overwriting. - (c) A hybrid of (a) and (b).
Scope is also open: does this apply only to Bundle creates, or to every create? The plain create also writes v1 history today. Mixing the two models means every reader handles both indefinitely.
Semantics the decision record must cover
- vread of version 1. Before and after later updates, deletes and restores, it returns the exact v1 body,
meta.versionIdandmeta.lastUpdated. _historyat all three levels. This covers instance, type and system history:- v1 entries appear, in the same order as today.
- Paging cursors stay stable. Type and system history page on
(last_updated, id)overidx_history_type_updatedandidx_history_system_updated. - The count functions return the same totals.
_since/_before(applied tolast_updated) andinclude_deletedbehave as today.
- Versioning on later writes. This covers update, conditional update, patch, delete, conditional delete and
restore_deleted, both in and outside Bundles:- v1 content is never lost when the current row is overwritten.
- Version numbers continue 2, 3, ... as today.
- If-Match / version-conflict behavior is unchanged.
$exportand bulk paths.- Export reads
resourcesfiltered onlast_updated. Confirm it stays unaffected. - Bulk-submit ingest already writes history outside any transaction and dedupes on a unique key. Decide whether it follows the new model.
- Export reads
- Crash or abort between commit and the history write (options a and c):
- A committed resource must not be left with no v1 history permanently.
- Define the repair path: a ledger plus a sweeper, or read-side fallback.
- Define whether a window where
GET [type]/[id]/_history/1returns 404 is acceptable.
- Races.
- An update that lands before a deferred v1 write must not lose v1 or produce a duplicate.
idx_history_identityis unique. - A
purgemust not be followed by a deferred write that brings a history row back.
- An update that lands before a deferred v1 write must not lose v1 or produce a duplicate.
- Other readers of
resource_history. The dashboard and activity figures (count_deltas_by_bucket,activity_histogram,latest_write_marker) and purge's "exists only in history" check must still count and see creates. - Existing data and tenant isolation.
- Data written under today's model stays valid with no migration, or the record names the migration.
- Every new query keeps the
tenant_idfilter.
Acceptance criteria
-
Decision record before implementation. A decision record is merged or posted on this issue. It chooses (a), (b) or (c) and the scope, answers points 1-8 above, and is agreed by a maintainer before implementation starts.
-
Ceiling measured first. On
mongo:7.0at a 2 GB cache, a resource-only transaction and a resource + history transaction are compared, and the measured gain is in the record. If the gain is well under 2x, the record says whether this is still worth doing. -
One document per entry. In a create-only MongoDB transaction Bundle, the transaction inserts about 1 document per entry, plus whatever mongodb: write search_index rows after commit for transaction Bundles of 4,000+ entries, so they fit WiredTiger's per-transaction cache limit #1975's ledger adds per Bundle or chunk:
- on
mongodb-elasticsearch; - on native
mongodbonce mongodb: write search_index rows after commit for transaction Bundles of 4,000+ entries, so they fit WiredTiger's per-transaction cache limit #1975 has landed.
This is checked from mongostat insert opcounters per entry-write. Aborted attempts also increment the counters, so use a run with no aborts or count the entries of aborted attempts too.
- on
-
Largest Bundle commits. The 49,027-entry Synthea Bundle commits on
mongodb-elasticsearchat a 2 GB cache. If it does not, the new measured abort position is reported here. -
Benchmark. fhir-benchmark
mongodb-elasticsearchimports 1000/1000 Bundles at 2 GB.mongodbreaches 1000/1000 once mongodb: write search_index rows after commit for transaction Bundles of 4,000+ entries, so they fit WiredTiger's per-transaction cache limit #1975 is in. -
MongoDB tests (testcontainers) cover each of points 1-7:
- vread v1, and
_historyinstance, type and system with_since, paging and counts, after a Bundle create; - the same after a subsequent update, conditional update, delete and restore;
- purge;
- a simulated failure between commit and the history write, if (a) or (c) is chosen.
- vread v1, and
-
No regressions. The existing versioning and history tests pass unchanged on every backend. SQLite, PostgreSQL and S3 behavior is untouched.
Evidence (origin/main bd9d8ee)
The Bundle transaction
crates/persistence/src/backends/mongodb/storage.rs:3946: one session and transaction per Bundle (begin_required_bundle_transaction_sessionat:1041, transaction started at:1072).crates/persistence/src/backends/mongodb/storage.rs:4572:create_resource_in_bundle_transaction.:4593: the existence check (find_one).:4621: theresourcesinsert.:4646-4652: theresource_historyinsert of the samepayloadas version"1". This is the copy this issue is about.
crates/persistence/src/backends/mongodb/storage.rs:5564,:5573:index_resource_in_bundle_transactionreturns early when search is offloaded. That is the only write-set difference between the two legs.
Later writes (point 3)
crates/persistence/src/backends/mongodb/storage.rs:4690,:4787: Bundle update inserts only the new version's history row. Today it relies on v1 already being inresource_history.crates/persistence/src/backends/mongodb/storage.rs:4831,:4923: Bundle delete writes a tombstone history row.- Non-Bundle writers of history, each with the same assumption:
update_attemptat:2237(history write at:2387);soft_delete_attemptat:2567(:2716);restore_deletedat:2764(:2883).
crates/persistence/src/backends/mongodb/storage.rs:1192,:1308-1320: the plaincreatealso writes v1 history (scope question).
History readers (points 1, 2 and 7)
crates/persistence/src/backends/mongodb/storage.rs:3395,:3406:vreadreads onlyresource_history.crates/persistence/src/backends/mongodb/storage.rs:3555,:3644,:3758:history_instance,history_typeandhistory_systemquery onlyresource_history. The count functions are at:3621,:3737and:3838.crates/persistence/src/backends/mongodb/storage.rs:624-640:apply_history_params_filterapplies_since($gte,:631) and_before($lt) tolast_updated.crates/persistence/src/backends/mongodb/storage.rs:1637,:1817,:1983:count_deltas_by_bucket,activity_histogramandlatest_write_markercount writes fromresource_history(consumed by the dashboard,crates/observability/src/dashboard.rs).crates/persistence/src/backends/mongodb/storage.rs:5669,:5684-5686:purgetreats "exists only in history" as purgeable.
Indexes, export and precedents
crates/persistence/src/backends/mongodb/schema.rs:329-370:ensure_history_indexes, four named indexes plus_id.idx_history_identityis unique (:340-341).crates/persistence/src/backends/mongodb/schema.rs:201:ensure_resources_indexes, four named indexes plus_id.crates/persistence/src/backends/mongodb/bulk_export.rs:50-53:$exportfiltersresources.last_updated, not history.crates/persistence/src/backends/mongodb/bulk_ingest.rs:22-23,:40-42: bulk ingest already writes history outside a transaction and relies on a unique key for replays. This is precedent for non-transactional history writes.crates/persistence/src/backends/mongodb/sync_failures.rs:1: an existing MongoDB "needs reindex" ledger for failed secondary syncs (CompositeStorage swallows secondary sync failures in every sync mode (warn! only) #1334). This is precedent for option (a)'s crash repair.
Risks / open questions
- The gain may be well under 2x. The index count per document is equal across the two collections, and the in-cache cost per document is not measured. This is the first thing to measure.
- Option (b) makes type and system history a merge of two collections. Keeping the current cursor contract and sort without a blocking sort is the hard part.
- Option (a) adds a window where history lags the resource, plus a sweeper to maintain. mongodb: write search_index rows after commit for transaction Bundles of 4,000+ entries, so they fit WiredTiger's per-transaction cache limit #1975 introduces the same shape for search, so the two should share one mechanism.
- The change is MongoDB-only. The record should say whether other backends are expected to follow, and why not if not.
Scope / out of scope
- In scope: the MongoDB backend's v1 history write and every MongoDB reader of
resource_history. - Out of scope:
- moving search rows out of the transaction (mongodb: write search_index rows after commit for transaction Bundles of 4,000+ entries, so they fit WiredTiger's per-transaction cache limit #1975);
- replay behavior (mongodb: a transaction Bundle rolled back for eviction while running alone is replayed twice more, holding the whole admission gate #1895);
- WiredTiger cache or eviction tuning;
- Bundle entry limits;
- other backends.
Related
- MongoDB: WriteConflict aborts exhaust the 3-attempt bundle retry under 20 concurrent imports — 477/1000 benchmark bundles fail with 503 #1776: tracking issue for MongoDB transaction Bundles failing in the benchmark import.
- mongodb: write search_index rows after commit for transaction Bundles of 4,000+ entries, so they fit WiredTiger's per-transaction cache limit #1975: post-commit search indexing. This issue depends on it.
- mongodb: a transaction Bundle rolled back for eviction while running alone is replayed twice more, holding the whole admission gate #1895: stop replaying a lone Bundle's cache rollback.
- docs(persistence): README and #1837 report the error-388 failing entry position as the Bundle size #1956: README reports the 388 failing position as the Bundle size.
- fhir-benchmark: the comparison matrix marks host contention only on postgres crud; legs run on a saturated host look clean #1953: legs run on a loaded host. During the
mongodb-elasticsearchimport in run 37986575337 the host load average was 7-15. The abort positions above match run 37782202746 within 1.3k entries. - fhir-benchmark: the mongodb leg's mongod has no memory limit and reaches 7.3 GiB, while the capacity gate budgets 3 GB on the shared host #1900: mongod memory is uncapped. This is relevant if a larger cache is chosen instead.
- perf(mongodb): $bulk-submit runs at ~60 resources/s per file — 23 documents and ~250 index key insertions per resource, 55 GB of indexes against a 7 GB cache #1000: source of the on-disk collection sizes behind the weaker estimate.
Activity
dougc95 commentedon Oct 28, 2026
Ceiling measured. Decision record below for maintainer review; nothing is implemented yet.
v1_history_copy_ceiling, in the test-only PR test(mongodb): measure the v1 history copy's transaction ceiling #2252) onmongo:7.0:resource_historycopy and 34,300 with it.process_transactioncontrol (resource + history only) aborted 3.7% later than the raw model.#1976 decision record: the version-1 history copy in MongoDB transaction Bundles
Status: proposed. A maintainer needs to agree to it before implementation starts. This record changes no product code. It adds only an ignored measurement harness (section 2).
Code references are at
origin/main6f62064, because the issue's line numbers are out of date. Unless a path says otherwise, files are undercrates/persistence/src/backends/mongodb/.Labels: [M] means measured, or read from code or logs. [I] means inferred.
Summary
insert_manycalls of fixture-shaped documents in one lone transaction onmongo:7.0, in the offloaded document shape (mongodb-elasticsearch). The only real-path calibration covers resource + history (section 2.1). A resource-only transaction holds this many more entries than resource + history:native_deferredshape): the gain is 1.495x [M]. That helps, but on its own it is not enough formongodb1000/1000 [I] (section 2.4).list_versions, instance history and its count;_sincepoller that moves past the commit misses those v1 entries permanently, because they carry the create's timestamp.1. Context
What is in the transaction today [M]:
create_resource_in_bundle_transaction(storage.rs:5519) builds theresourcesdocument and the version-1resource_historydocument from the samepayload(:5552-5580), withcreated_at = last_updated = now(:5546).StagedCreate.history(bundle_creates.rs:84-85).send_create_groupsends it as a second orderedinsert_manyper chunk of 100, right after the resources one (bundle_creates.rs:294,:333-348).bundle_creates.rs:108).Changes since the issue was filed:
search_rows_pending);search_index_pending);storage.rs:4800-4823);Latest benchmark: run 39657850695 (main @ e0bd5cb, 2026-10-21, 2 GB cache). These figures come from that run as summarized for this workflow. Its logs were not re-read for this record [M].
mongodb-elasticsearchmongodb(native)From the issue (run 37986575337) [M]:
Gain needed, measured against the conservative 51.1 MB abort [I]:
Premise correction. The issue assumed both collections have the same index count (4 +
_ideach). At HEAD they do not [M]:resources_id,idx_resources_identity,idx_resources_type_scan,idx_resources_updated,idx_resources_live_tenant,idx_resources_missing_paramsresource_historyschema.rs:388-426)idx_resources_missing_paramsis not partial, so it also indexes the absent field as null (missing_index_builder.rs:28-42).idx_resources_missing_params_fpis partial and skips these documents (:47-58).MissingIndexBuilderruns whenevermissing_metadatais on, whether or not search is offloaded (backend.rs:1323-1343) [M]. The benchmark's offloaded leg therefore very likely has the index [I]. This was not checked on the benchmark host.2. Measurement
2.1 Method
Harness.
crates/persistence/tests/mongodb/v1_history_ceiling.rs.mongodb_tests::v1_history_ceiling::v1_history_copy_ceiling,#[ignore]d.docs/1976-mongodb-v1-history-decision, with a 5-line module registration intests/mongodb_tests.rs(4 lines plus a blank separator). Nosrc/change.cargo test -p helios-persistence --features mongodb --test mongodb_tests v1_history_copy_ceiling -- --ignored --nocapture --test-threads=1.HFS_TEST_V1_CEILING_*settings are documented in the file.Database setup.
mongo:7.0single-member replica set (7.0.43). Flags:--wiredTigerCacheSizeGB=<N>,--oplogSize 1024,transactionLifetimeLimitSeconds=900[M].process_transaction. It is the US Core fixturecrates/hfs/tests/inferno/uscore_bundle_patient_85.json.resourcesdocument and its v1resource_historydocument are read back."1"with identicaldata.resourcesdocuments have nomissing_params[M].MongoBackend::new+initialize()with Inline index builds, so the real indexes exist. ItsV1_CEILING_SETUPline lists them [M].Each run [M]:
insert_manyin chunks of 100 (the production chunk size), UUIDv7 ids, a fresh tenant, inserting until the server aborts.resource: theresourcesinsert only.resource_history: theresourcesinsert, then the history insert, per chunk, in production order.Additional runs [M]:
process_transaction.native_deferredshape: native documents withmissing_params,missing_params_fpand thesearch_rows_pendingmarker. This is the native leg after mongodb: write search_index rows after commit for transaction Bundles of 4,000+ entries, so they fit WiredTiger's per-transaction cache limit #1975.missing_paramson theresourcesdocument inside the transaction (storage.rs:5552,:2549-2556).:6847-6865) [M].Limits:
docsinV1_CEILING_RUNalso counts theresourceshalf of a chunk whose history insert failed (for exampleentries=34300 docs=68700). No figure in this record usesdocs[M].2.2 Results [M]
How to read the table:
tracked dirty bytes in the cacheat the last acknowledged chunk, divided by the entry count.entries_ratio,json_bytes_ratioanddirty_per_entry_ratio.too_large). No run ended with 112 (eviction), 365 or any other code.Commit ceiling at 2 GB, offloaded (
v1c-2-commit.log) [M]:phase=commit(also at 34,400)resource, the single probe at the scan ceiling committed. Its commit ceiling is therefore only bounded: at least 68,700, and at most about the 69,200 scan median.Calibration at 2 GB, offloaded (
v1c-2-control.log) [M]:process_transaction.TooLargeForCacheat entry 35,584.resource_historyscan in the same invocation stopped at 34,300.2.3 Reading
At the last good chunk of every run, tracked dirty bytes were about 10% of the cache [M]:
That matches the issue's reading of
eviction_updates_trigger[I].In the offloaded shape, the history copy costs about 3.16 KB of dirty bytes per entry (6,261 - 3,103), and the resources document costs about 3.10 KB. Each is about half the budget [I, derived].
resource_historyceiling (34.3k at insert, 31.9k-32.2k at commit) is close to the benchmark's offloaded aborts. Those were 28.7k-31.8k in run 39657850695 and 29.9k-33.0k in run 37986575337.2.4 Applying the decision rule [I]
Offloaded leg.
resourcesdocument's own index keys:idx_resources_missing_paramscovers offloaded documents too (section 1, follow-up).Native leg after #1975.
resourcesdocument costs 6,664 dirty bytes per entry, against 3,103 offloaded [M]. Its extra fields (missing_paramsas a multikey array inidx_resources_missing_params,missing_params_fp, and the marker) are the inferred reason. No run varied them alone.native_deferredresource_historyceiling (21,600) sits near the benchmark's native aborts (19.2k-21.1k).ifNoneExisttarget types and no Bundle-path work.Worth doing: yes. For the target leg the gain is about 2x, not well under 2x.
3. Options
What every option needs
A writer rule [M from code read]:
resources.datais the only copy of v1 until the first overwrite.resource_history" marker, the writer:idx_history_identity, not$setOnInsert(section 6.1);$unsets the marker.Why the upsert comes first. The non-Bundle overwriters do not always run in a transaction, even on a replica set [M]:
begin_best_effort_multi_write_session(storage.rs:1500-1523) setssession = Noneand writes without a transaction wheneverstart_session,helloorstart_transactionfails.update_attempt(:2801),soft_delete_attempt(:3144) andrestore_attempt(:3367) use it.update_attemptupdatesresources(:2882-2888) and only afterwards inserts history (:2939-2946), with no atomicity.dataoverwritten, and v1 never stored.The two invariants this gives [I]:
data,version_id,created_atandfhir_versionare still v1's, because every writer clears the marker in the same update that overwrites them.Where the rule applies. Every writer that changes
data,version_idorlast_updated:update_attempt(storage.rs:2789)soft_delete_attempt(:3132)restore_attempt(:3355)write_next_version_in_bundle_transaction(:5757)delete_resource_in_bundle_transaction(:5873), which is also reached fromdelete_with_match_resource_in_bundle_transaction(:6008)bulk_ingest.rs:673,:751-790)Other
resourceswriters [M]. A grep ofupdate_*,replace_*,find_one_and_*anddelete_*calls underbackends/mongodbfinds only:purge_allandpurge_tenant_data;dataorversion_id:deferred_resource_update(deferred_index.rs:283);missing_paramsupdates (reindex_pipeline.rs:690-722).Why a marker. It turns the rule into a field check on a pre-image every writer already reads, and readers never need an anti-join.
Comparison
The 12 readers under (b) [M]. These are the history and dashboard readers of
resource_historyinstorage.rs.purgealso counts the collection (:7003-7020); section 5.7 covers it.vread(:4098)list_versions(:4221)history_instanceand its count (:4258,:4324)history_typeand its count (:4347,:4440)history_systemand its count (:4461,:4541)count_deltas_by_bucket(:1954)count_deltas_by_type_and_bucket(:2035)activity_histogram(:2134)latest_write_marker(:2300)No reader exists outside
storage.rs.bulk_ingest.rsandbundle_creates.rsonly write to the collection, andschema.rsonly creates its indexes [M].Why (b) needs new indexes:
resourcesindexes in history order, restricted to marked documents. Examples: partial{tenant_id, resource_type, last_updated:-1, id:-1}and{tenant_id, last_updated:-1, resource_type:-1, id:-1}, filtered on the marker existing.idx_resources_type_scanand discards unmarked documents;resourcesindex in(last_updated, resource_type, id)order, so it needs a blocking sort.Rejected sub-variants [I]:
$mergecopy fromresourcesintoresource_history.$mergecannot run in a multi-document transaction, so it cannot be serialized against purge (section 6.2). It could bring back a purged v1 row.storage.rs:7015-7020).Scope
create_attempt,storage.rs:2628), batch Bundles, small transaction Bundles and bulk ingest have no transaction-size ceiling to relieve. Moving them only adds windows.Nonewhen search is offloaded (backend.rs:1704-1708).Other backends are not expected to follow. SQLite and PostgreSQL have no comparable per-transaction cache ceiling, and S3 is untouched [I].
4. Recommendation
Option (c), partial form, scoped to transaction Bundles of at least N entries (default 4,000, see section 3).
resourcesdocument.history_v1_pending: <bundle_id>. Keep this separate fromsearch_rows_pendingso the two rewriters stay independent.storage.rs:5040-5066).StagedCreate.historyfor marked creates, and count their payload once in the byte cap (bundle_creates.rs:108).history_v1_pending.search_index_pending(ledger_documents,deferred_index.rs:217), with itsnot_beforeindex. Schema init creates it, so no collection is created inside a transaction.owesflag per document, because what a ref owes differs per ref [M]:inline_index_types(deferred_index.rs:100-112, theifNoneExisttargets) keep their search rows inline and get no search marker, yet they would owe v1.storage.rs:5797and:5942) but owe no v1.rewrite_deferred_batch(deferred_index.rs:470-480) anddeferred_resource_update(:283-300) select and clear onlysearch_rows_pending.deferred_index.rs:955-958), whether or not any marker was found. A history-only document insearch_index_pendingwould be claimed by an old build, treated as finished and deleted, which destroys the debt [I]. A separate collection keeps old builds away from it.write_deferred_search_rows(deferred_index.rs:751, called atstorage.rs:4813).resourcesdocuments that still carryhistory_v1_pending, upserts their v1 rows, and$unsets only that marker.repair_deferred_search_indexreturns early when search is offloaded (deferred_index.rs:888-891).start_mongodb(crates/hfs/src/main.rs:1436), not fromstart_mongodb_elasticsearch(main.rs:4094).$setOnInsert.vread(.., "1")misses, or the history query oflist_versions,history_instanceorhistory_instance_countreturns no rows.resourcesby identity: onefind_oneonidx_resources_identity.rowsbefore the existing sort, the version-cursorretain(parse_cursor_version,storage.rs:1096) and thehas_more/truncate logic. It is filtered by_since,_beforeandinclude_deletedasapply_history_params_filterwould filter it.max(60 s, Bundle deadline)plus up to one interval while HFS runs (main.rs:2391-2393,:2411).Why (c) partial rather than (b) [I]
last_updatedwhen each entry is processed (storage.rs:5546) and become visible only at commit (:5082). The harness's large lone transactions ran 80-124 s._sincepoller that misses them was not looking at a visible resource._lastUpdated-filterable, but absent from type and system_historyand their counts until the step or the repair runs._sincemark past the commit therefore loses those v1 entries permanently, not just late, because they arrive with older create-time stamps.Why not the other forms
_history/1would return 404 for a resource thatGETalready returns. That is the most visible inconsistency, and closing it costs four small marker-gated fallbacks.When to choose (b) instead
Choose (b) if the maintainer holds the issue's "identical to today" requirement literally, with no window at all for type history, system history and the dashboard. In that case, measure (b)'s extra in-transaction index keys first, by adding the two partial indexes to the harness.
5. Semantics under the recommendation (points 1-8)
1. vread of version 1
Today [M]:
vread(storage.rs:4098) does afind_oneon history by(tenant_id, resource_type, id, version_id)(:4109), served by the uniqueidx_history_identity(schema.rs:388-401).meta.lastUpdatedcomes from the row'slast_updated(into_stored_resource,:1221).Under the recommendation:
data;version_id "1";last_updatedequal to the create'screated_at(:5546,:5557; later writers keepcreated_atthroughextract_created_at,:1009);fhir_version."1"falls back to the markedresourcesdocument and synthesizes the same row. While the marker is set,data,version_id,created_atandfhir_versionare still v1's (section 3 invariants) [I].resourcesdocument is no longer marked, and the fallback's one history re-read finds the stored row._history/1does not return 404 for an existing resource [I].2.
_historyat instance, type and system levelToday [M]:
:4258):rows.retain, usingparse_cursor_version,:1096);:4324.:4347) and system (:4461):idx_history_type_updatedandidx_history_system_updated(:1191,:1197;schema.rs:403-426);count+1(:1204);$orcursors (:1164,:1175);:4440and:4541._since($gte),_before($lt) andinclude_deletedare applied inapply_history_params_filter(:1042-1058).Under the recommendation:
"1"and no other history rows._since,_beforeandinclude_deletedfiltering, the same version-cursorretainand the samehas_morelogic as a stored row. A stale or client-supplied cursor of 1 or more still gives an empty page, as today.last_updated, indexes and plans. These tests stay as they are:mongodb_history_type_does_not_read_rows_outside_the_pagemongodb_history_type_paging_is_ordered_and_completemongodb_history_system_does_not_read_rows_outside_the_pagemongodb_history_type_plan_is_a_bounded_index_walk_sincepoller during the window can miss them permanently [I]. Section 4 explains how this differs from today.include_deletedis unaffected, because a delete clears the marker.3. Later writes (update, conditional update, patch, delete, conditional delete, restore), in and out of Bundles
Today [M]:
Each writer reads the current document, does a CAS on
version_id, and inserts only the new version's row:update_attempt:2924-2947soft_delete_attempt:3268-3292restore_attempt:3520-3546:5828-5843:5969-5985next_version(:995) increments the version.If-Match compares against
resources.version_id(update_with_match:4127,delete_with_match:4158).These writers are reached by:
search_impl.rs:1487,:1527);crates/persistence/src/core/storage.rs:1698);storage.rs:5435).Under the recommendation:
resources.version_idis not touched.stages_its_create,:156);storage.rs:1460-1486), so marked documents exist only there [M].update_attempt,soft_delete_attemptandrestore_attemptcan run without a transaction through the fallback inbegin_best_effort_multi_write_session(:1500-1523) [M]. That is why the rule issues the v1 upsert before the marker-clearing update.4.
$exportand bulk paths$exportreads onlyresources, filtered onlast_updated(bulk_export.rs:50-59, used at:242,:269and:396). It is unaffected [M].write_history,bulk_ingest.rs:891-911).$unsets the marker.ExistingResource,bulk_ingest.rs:136-142) keepsversion_id,content(a serdeValueconverted from the BSONdata),created_at,fhir_versionandis_deleted. It keeps neither the marker nor the raw BSONdata[M].datadocument, as the Bundle delete path does, so that the v1 body stays exact.bulk_submit.rs:1653,:1670), so point 3 covers it.5. Crash or abort between commit and the history write
GET [type]/[id]/_history/1does not return 404 for an existing resource, because of the fallback and its re-read.HFS_MONGODB_DEFERRED_INDEX_REPAIR_INTERVAL=0turns the repair off today. With this change, that would also leave history lagging indefinitely (see open question 7).6. Races
See section 6.2.
7. Other readers of
resource_historycount_deltas_by_bucket(:1954)count_deltas_by_type_and_bucket(:2035)activity_histogram(:2134)latest_write_marker(:2300)latest_write_marker(:2300-2350) returns the newestlast_updatedand a count of recent writes capped at 10,000 (RECENT_CAP). The REST dashboard uses it as its change detector (crates/rest/src/dashboard.rs, module doc around lines 80-100).latestdoes not move. Once at least 10,000 recent writes exist,recent_writesdoes not move either.resource_count_series(crates/rest/src/dashboard.rs:339-377) takes the total fromresources(count_by_types) and subtracts history deltas. While the Bundle's v1 rows are missing, every point before the bucket of the Bundle's create time is raised by the number of missing rows. Points from that bucket on are exact.:7015-7020) also checksresources, so it is unaffected [M].list_versionsgets the fallback.count_by_day(:1889) readsresourcesand is unaffected.8. Existing data and tenant isolation
See section 7.
6. Repair, crash model and races
6.1 Repair and crash model
The post-commit writer, per batch, runs one transaction that:
resourcesdocuments that still carryhistory_v1_pending;$unsets onlyhistory_v1_pending, with a filter that still requires it.A ledger document is deleted once all its refs are done.
The repair reuses #1975's machinery, pointed at the history ledger and enabled on both legs:
claim_ledger_chunk,deferred_index.rs:1056);max(60 s, Bundle deadline);DEFERRED_INDEX_REPAIR_CHUNKS_PER_PASS);Why every v1 write is a full-replacement upsert, never a plain insert or
$setOnInsert[I]:insert_manyin the deferred transaction would then hit 11000 and abort on every retry.$setOnInsert.purge(storage.rs:7026-7058) deletesresourcesand thenresource_historyin two separate non-transactional calls, and its comment at:7008-7010treats "exists only in history" as a supported state. So a stale v1 row from an earlier incarnation of the same id can exist.insert_manyhits the uniqueidx_history_identity(bundle_creates.rs:339-348).$setOnInsertwould silently keep the stale row, andvread(.., "1")would return the earlier incarnation's body.resourcesdocument's v1 content is authoritative (section 3 invariants). Every writer that holds a marked pre-image therefore writes the same row, so replacing it is idempotent. A marked create of an id with a stale v1 row ends with the new v1, and the stale v2+ rows of the earlier incarnation remain, as today.Readers stay correct in the "v1 stored, marker still set" state: the vread fallback runs only on a history miss, and the instance fallback only on an empty result, so a stored v1 is never listed twice [I].
6.2 Races [I, from the code paths cited]
An update before the deferred write:
resourcesdocument. One getsWriteConflictand retries from the winner's state. This is the same argument asindex_deferred_batch_once(deferred_index.rs:430).idx_history_identity, so v1 stays single and exact. If two upserts race and one ends in 11000, the server retries an upsert whose filter is an equality match on the unique index's fields (MongoDB 4.2+).WriteConflict(testmongodb_concurrent_updates_from_the_same_version_admit_one).Purge (
storage.rs:6993) is non-transactional. It deletes fromresources, then from history (:7026-7058).purge_all(:7061) does the same. Against the deferred writer:$unsetgetsWriteConflict, and its history upsert rolls back.So the deferred write must run in a transaction that updates the
resourcesdocument. A non-transactional or$mergecopy is not allowed.Upsert-first writers without a transaction. These are bulk ingest, plus update, delete and restore in the session fallback. If a purge lands between their v1 upsert and their update, they can leave an orphan v1 row. Bulk ingest's existing non-transactional history write already has this kind of hazard. The rule widens it slightly.
purge_tenant_data(storage.rs:2428-2449) deletesresource_historybeforeresourcesand before the ledger.resources, then the derived collections, or have the repair skip tenants that are being deleted. The new history ledger must also be covered.search_indexis deleted first, so a deferred search pass that commits beforeresourcesis deleted can leave orphan rows (mongodb:purge_tenant_datadeletes search rows before resources, so a concurrent deferred search-index batch can leave rows for a purged tenant #2259).7. Migration and tenant isolation
No data migration.
Ledger options:
SCHEMA_VERSIONbump (15 today,schema.rs:42; migrations run throughmigrate_schema_async,:85-101);purge_tenant_data.search_index_pendingwith anowesfield, with homogeneous refs per document. This needs no schema bump. But older builds' repair would claim and delete those documents (section 4, item 2), and both rewriters' filters and updates would have to become marker-specific.resources, so no index keys are added inside the transaction.Tenant isolation:
tenant_id(ledger_documents,deferred_index.rs:217). Every deferred-write and repair query takes it from there.idx_resources_identity, which leads withtenant_id.tenant_idequality.Downgrade and rolling upgrade (a new risk):
owesinsearch_index_pending, an older native build's repair would delete history-only documents as finished.8. Tests the implementation PR must include
All are MongoDB testcontainers tests. They need test controls:
resourcesread.meta.versionId,meta.lastUpdated);_historyat instance, type and system level with_since,_beforeand paging, including an instance-level version cursor of 1 or more on a marked resource;include_deleted.transaction_active == false), and with a failure injected between the v1 upsert and the update. v1 survives, the marker is still set, and a retry or the repair completes.data. Also simulate a crash between its two steps, then run the repair.purgeandpurge_allon a marked resource, then the repair: no history row comes back.purge_tenant_dataremoves the history ledger, and a repair racing it leaves nothing.vread(.., "1")returns the new body.list_versionsand the count still return v1.inline_index_types(anifNoneExisttarget) owes v1.count_deltas_by_bucket,count_deltas_by_type_and_bucket,activity_histogramandlatest_write_markerequal the reference.latest_write_markerand on theresource_count_seriesbaseline are asserted.StagedCreatecarries no history document and counts its payload once.mongodb-elasticsearchat 2 GB, or the new abort position is reported;v1_history_copy_ceilingat 2 GB withCOMMIT_PROBE=onandCONTROL_COPIES, with the control run against the new path.9. Open questions for the maintainer
_sincepoller that moves past the commit loses those v1 entries permanently. Or require strict identity with no window, which means (b)?HFS_MONGODB_DEFERRED_HISTORY_MIN_ENTRIES?owesdocuments insearch_index_pending?HFS_MONGODB_DEFERRED_INDEX_REPAIR_INTERVAL=0still turn off the repair once it also owes history? If it stays allowed, it should at least log a warning.resourcesdocument's own cost (missing_paramsmultikey andmissing_params_fp) is the larger share of what is left in the transaction: 6,664 vs 3,103 dirty bytes per entry. Should it get its own issue, linked to mongodb: write search_index rows after commit for transaction Bundles of 4,000+ entries, so they fit WiredTiger's per-transaction cache limit #1975 and MongoDB: WriteConflict aborts exhaust the 3-attempt bundle retry under 20 concurrent imports — 477/1000 benchmark bundles fail with 503 #1776? (Since answered for creates: fix(persistence): write deferred Bundle creates' missing_params after commit #2256 removes it from the transaction and measures the deferred ceiling at 98-99% of the offloaded one. Updates and deletes are on perf(mongodb): a deferred transaction Bundle extracts search rows twice, in the transaction and again after the commit #2035.)start_session,helloorstart_transactionfails #2258)?10. What was not run
Not run:
nativeshape without the marker.native_deferred.clippy --all-targets.Caveats:
Gate that was run, all exit 0 [M]:
cargo fmt -- --checkon the two test files.cargo clippy -p helios-persistence --features mongodb --test mongodb_testswith the repo allow-list: 0 warnings.cargo test -p helios-persistence --features mongodb --test mongodb_tests --no-run.Raw harness lines (verbatim)
test(mongodb): measure the v1 history copy's transaction ceiling