-
Type:
Improvement
-
Resolution: Won't Do
-
Priority:
Major - P3
-
None
-
Affects Version/s: None
-
Component/s: Transactions
-
None
-
Storage Engines - Transactions
-
616.507
-
SE Transactions - 2026-11-20
-
3
MongoDB starts majority, atClusterTime and secondary reads with begin_transaction and then sets the read timestamp with timestamp_transaction_uint. WiredTiger takes the global transaction lock to check and publish the read timestamp, releases it, and takes it again to rebuild the snapshot if a transaction with updates committed since begin_transaction. The proposed change rebuilds the snapshot under the lock already held, so such a transaction takes the global lock twice instead of three times. In a benchmark with 8 reader and 4 writer threads on a 14-core Apple M4 Pro, reader throughput rose by 27% and writer throughput by 10%. With 4 readers and 4 writers the difference stayed within run-to-run variation.
Problem. __wti_txn_set_read_timestamp (src/txn/txn_timestamp.c:1162-1256 at develop 50c032ba6) takes txn_global->rwlock for read to validate and publish the read timestamp (txn_timestamp.c:1201), releases it (txn_timestamp.c:1237), and then, if the transaction is running, calls __wt_txn_get_snapshot (txn_timestamp.c:1252-1253). __txn_get_snapshot_int returns early only if the session is still in the current WT_GEN_HAS_SNAPSHOT generation (src/txn/txn.c:321-327). Every commit with updates starts a new generation (txn.c:2044-2045), so under a write load the snapshot is rebuilt, and the rebuild takes the same lock again (txn.c:350, txn.c:433).
Effect. A transaction setting its read timestamp via timestamp_transaction or timestamp_transaction_uint after begin_transaction acquires txn_global->rwlock three times when a concurrent write commits: in begin_transaction, to set the read timestamp, and to rebuild the snapshot. MongoDB uses this sequence for majority, atClusterTime, and secondary reads. Each acquisition and release is a CAS on a lock word shared by all threads. A reader that releases the lock and asks for it again queues behind any writer that arrived in between: setting global timestamps, __wt_txn_update_oldest, checkpoint prepare.
Proposed change. Rebuild the snapshot under the read lock already held by __wti_txn_set_read_timestamp:
- Add a locked boolean argument to __txn_get_snapshot_int. When true, the function skips acquiring txn_global->rwlock and releases it on every return path, including the fast path when the snapshot is current.
- Wrap this call in __wti_txn_get_snapshot_and_unlock for use in __wti_txn_set_read_timestamp.
- Keep the current path for disaggregated storage: the disaggregated build can retry, and a retry takes the lock itself.
- Keep the EINVAL return for a read timestamp before the oldest timestamp unchanged: it releases the lock before the snapshot generation is touched, so the old snapshot is never marked as current.
The read timestamp is still checked and published before the snapshot is rebuilt, inside the same timestamp_transaction call. The check stays atomic with respect to the pinned timestamp update, which takes the lock for write. The snapshot cache in __txn_get_snapshot_int and the scan of txn_shared_list do not change.
Measurements. Benchmark 04-b4-read-ts-bench.c (source, harness scripts, and raw data are attached):
- Reader loop: begin_transaction, timestamp_transaction_uint(WT_TS_TXN_TYPE_READ, stable), point search across 100,000 keys, rollback_transaction.
- Writer loop: begin_transaction, random key update with a 100-byte value, timestamp_transaction_uint(WT_TS_TXN_TYPE_COMMIT, next timestamp), commit_transaction.
- Background thread advances stable_timestamp 1,000,000 behind last commit and oldest_timestamp 100,000 behind stable every 1 ms.
- Settings: cache_size=1GB, statistics=(fast). Release builds (-DCMAKE_BUILD_TYPE=Release -DENABLE_PYTHON=0 -DHAVE_UNITTEST=0) of develop 50c032ba6 and the proposed change.
- Host: Apple M4 Pro (14 cores: 10 performance, 4 efficiency), 24 GB RAM, macOS 26.5.2. 10 s measured after 2 s of warm-up, 5 rounds per configuration, build order alternating between rounds, a new database per run. No other benchmark ran on the host; the 1-minute load average was 10.4-26.7 (median 17.8), including the benchmark's own 9 or 13 threads.
Median (min-max) of 5 runs. p is the exact two-sided Mann-Whitney U test, 5 runs against 5 (0.008 is the smallest possible value). The last column counts the rounds in which the change was higher than develop in the same round.
| Readers / writers | Metric | develop | Change applied | Difference | p | Change higher |
|---|---|---|---|---|---|---|
| 4 / 4 | reader transactions/s | 548,996 (527,357-557,623) | 551,520 (549,756-614,331) | +0.5% | 0.095 | 3/5 |
| 4 / 4 | writer transactions/s | 818,330 (793,373-831,490) | 798,520 (744,992-846,613) | -2.4% | 0.690 | 2/5 |
| 4 / 4 | read lock calls per transaction (rwlock_read) | 2.046 (2.020-2.062) | 1.862 (1.836-1.887) | -9.0% | 0.008 | 0/5 |
| 4 / 4 | global lock reads that queued behind a writer, plus __wt_try_readlock calls, per transaction (lock_txn_global_read_count) | 0.020 (0.018-0.022) | 0.025 (0.017-0.029) | +27.9% | 0.310 | 3/5 |
| 4 / 4 | global lock write acquisitions/s (lock_txn_global_write_count) | 9,262 (8,664-11,207) | 16,715 (9,047-19,075) | +80.5% | 0.151 | 5/5 |
| 4 / 4 | transaction table walks per transaction (txn_walk_sessions) | 1.257 (1.254-1.259) | 1.228 (1.224-1.232) | -2.3% | 0.008 | 0/5 |
| 4 / 4 | application wait on the global lock, us/s (lock_txn_global_wait_application) | 556 (456-601) | 463 (383-960) | -16.7% | 0.310 | 1/5 |
| 8 / 4 | reader transactions/s | 651,304 (618,478-653,340) | 828,380 (750,991-865,328) | +27.2% | 0.008 | 5/5 |
| 8 / 4 | writer transactions/s | 534,750 (495,871-545,645) | 587,163 (560,455-615,687) | +9.8% | 0.008 | 5/5 |
| 8 / 4 | read lock calls per transaction (rwlock_read) | 2.453 (2.432-2.461) | 2.173 (2.111-2.205) | -11.4% | 0.008 | 0/5 |
| 8 / 4 | global lock reads that queued behind a writer, plus __wt_try_readlock calls, per transaction (lock_txn_global_read_count) | 0.017 (0.012-0.035) | 0.016 (0.012-0.032) | -5.5% | 0.690 | 3/5 |
| 8 / 4 | global lock write acquisitions/s (lock_txn_global_write_count) | 4,407 (3,740-12,572) | 6,363 (4,576-14,438) | +44.4% | 0.222 | 3/5 |
| 8 / 4 | transaction table walks per transaction (txn_walk_sessions) | 1.381 (1.340-1.404) | 1.342 (1.312-1.392) | -2.8% | 0.310 | 2/5 |
| 8 / 4 | application wait on the global lock, us/s (lock_txn_global_wait_application) | 24,891 (2,345-73,487) | 11,031 (2,725-83,496) | -55.7% | 1.000 | 2/5 |
rwlock_read counts read lock calls on every read-write lock, for reader and writer transactions together. It fell by 9.0% and 11.4%, in every round. With 8 readers and 4 writers, reader and writer throughput rose in all 5 rounds. With 4 readers and 4 writers, throughput differences are within run-to-run variation. No reader had its read timestamp rejected. This benchmark isolates the transaction path; mongod also parses queries, runs cursors and handles network I/O.
Verification. Tested on diagnostic builds (HAVE_DIAGNOSTIC=1, ENABLE_STRICT=1, macOS arm64, AppleClang 21):
- The repository has no test for a commit that lands between begin_transaction and setting the read timestamp. With the snapshot rebuild removed from __wti_txn_set_read_timestamp, the timestamp, prepare, txn and hs Python subsuites (1,312 tests) and the test_format smoke test still pass.
- New test_timestamp31.py: an update committed at timestamp 10 between begin_transaction and setting read timestamp 15 is visible, and is not visible at read timestamp 5. The test also covers a read timestamp rounded up to the oldest timestamp and a read timestamp set after one rejected with EINVAL, through both timestamp_transaction and timestamp_transaction_uint. With the rebuild removed, it fails in 6 of its 8 scenarios (log attached).
- With the change: the timestamp, prepare, txn and hs Python subsuites (1,320 tests including the new one), ctest test_timestamp_abort, test_random_abort and test_format, and two 3-minute test/format runs with timestamps, with and without prepare, pass.