Repository navigation
Readers yield to replication on a replicant - #6407
Draft
WalidNejmi wants to merge 1 commit into
Draft
WalidNejmi wants to merge 1 commit into
WalidNejmi wants to merge 1 commit into
Conversation
WalidNejmi
force-pushed
the
rep-yield-readers
branch
from
October 24, 2026 14:54
b24fade to
6cef180
Compare
roborivers
approved these changes
Oct 24, 2026
roborivers
left a comment
There was a problem hiding this comment.
Cbuild submission: Success ✓.
Regression testing: Success ✓.
The first 10 failing tests are:
reco-ddlk-sql-pinned [timeout]
roborivers
approved these changes
Oct 24, 2026
roborivers
left a comment
There was a problem hiding this comment.
Cbuild submission: Success ✓.
Regression testing: Success ✓.
WalidNejmi
force-pushed
the
rep-yield-readers
branch
from
October 25, 2026 15:33
6cef180 to
7b5b626
Compare
roborivers
approved these changes
Oct 25, 2026
roborivers
left a comment
There was a problem hiding this comment.
Cbuild submission: Success ✓.
Regression testing: Success ✓.
WalidNejmi
force-pushed
the
rep-yield-readers
branch
from
October 26, 2026 20:57
7b5b626 to
b54a69a
Compare
roborivers
suggested changes
Oct 27, 2026
roborivers
left a comment
There was a problem hiding this comment.
Cbuild submission: Error ⚠.
Regression testing: 0/759 tests failed ⚠.
WalidNejmi
marked this pull request as ready for review
October 28, 2026 00:53
WalidNejmi
force-pushed
the
rep-yield-readers
branch
from
October 28, 2026 01:03
b54a69a to
c1268b4
Compare
WalidNejmi
marked this pull request as draft
October 28, 2026 01:06
On a replicant the replication thread takes a transaction's page locks
in the order of the master's commit record: data and blob pages first,
then the index. Readers go the other way: they hold an index leaf (a
datacopy index here) and then fetch that row's data and blob pages. A
reader that waits for a page replication already holds, while holding a
page replication wants, closes a two-locker cycle. The replicant
deadlock detector uses DB_LOCK_MINWRITE_NOREAD, which never picks a
reader, so replication loses every time: it drops all its locks,
re-collects the transaction from the log and starts again from the
first lock. With many readers on one node new cycles form faster than
it gets through, and a single transaction can lose thousands of
deadlocks and take tens of seconds, long enough for the master to mark
the node incoherent. The existing release points do not help: they act
on readers that have a waiter, and a reader inside a cycle is itself
blocked in a lock wait, so it never reaches them.
Publish how many deadlocks the transaction being applied has lost
(gbl_rep_txn_deadlock_retries, set in the retry loop of __rep_apply and
cleared on every way out of it; the loop serves both the concurrent and
the serial apply paths). It is written and read only through the
comdb2_atomic.h helpers (XCHANGE32, ATOMIC_LOAD32). Once it reaches
rep_yield_retries, readers on that replicant step aside at their next
safe point, whether or not anyone waits on them: at a cursor move
(cursor_move_postop) and when emitting a row (recover_deadlock_evbuffer,
which also covers the sqlwriter flush path). The cursor-move yield is
checked before the locks_check_waiters test, which only gates the
waiter-driven releases, so turning waiter checks off does not turn the
yield off. A yield syncs the deferred data cursors, releases page locks
only, keeping the curtran and so the table locks, so a schema change can
never run under the query, then sleeps up to rep_yield_sleep_ms, waking
early once the transaction is applied or the bdb write lock is wanted.
Cursors re-position lazily, as after any page-lock release. Each
request yields at most once per pagelock_release_interval_ms (100 ms by
default) so readers keep making progress. Yields happen only on the sql
thread that owns the request, never on the master, and only for the
transaction levels the page-lock release already supports (not modsnap
or serializable).
The row-send waiter check in write_response() is left as it is; it
runs about once a second rather than every sql_recover_time ms, so in
practice almost all yields happen at cursor moves.
New tunables: rep_yield_retries (default 10, 0 disables) and
rep_yield_sleep_ms (default 50).
New comdb2_metrics, so a test (or an operator) can see what happened
on each node: rep_max_txn_deadlocks (most deadlocks one replicated
transaction lost), rep_yield_txns (transactions that reached
rep_yield_retries), rep_yields_cursor_move and rep_yields_emit_row
(reader yields at each safe point) and emit_row_lock_releases
(recover_deadlock_evbuffer releases).
Test changes in reco-ddlk-sql:
- RECO_PIN_READERS puts all 16 readers on one replicant, and fails the
test if it cannot find one instead of spreading the readers over the
cluster. A new test, reco-ddlk-sql-pinned, uses it: that is the
layout that reproduces the livelock. It needs a cluster and says so
on a single node.
- The looping readers stop once the writer is done instead of draining
the rest of a 1M row join, which took minutes on a busy host and
pushed the test past its timeout; reader 99 still does the final full
read.
- The master coherency query retries on connect errors, since a slow
connection setup under load failed the whole test.
- At the end it prints a "=== reco-ddlk-sql results ===" block: the
master's coherency checks (how many saw an incoherent node, and the
most at once), the new metrics for each node ("n/a" if a server does
not have them), the slowest write statement and the total time.
Measured on a 3-node docker cluster, debug build, 4 CPUs per node, one
test at a time, one binary and test client for both arms: yield off
(rep_yield_retries 0) and on (the default 10), run alternately.
reco-ddlk-sql-pinned, 4 runs each ("-" means no apply stall over 2 s
was logged):
yield off yield on
result pass/pass/pass/pass pass/pass/pass/pass
incoherent checks 0/0/0/0 0/0/0/0
max deadlocks, 1 txn 262/314/347/3097 11/11/19/13
longest apply stall -/2/4/9 s -/-/-/-
slowest write 1.12/2.13/4.49/9.46 s 0.40/1.02/1.11/1.12 s
write statements 450/480/450/300 540/510/510/510
reco-ddlk-sql (readers on both replicants), 4 runs each, all passed:
with the yield off one transaction lost up to 27, 423, 396 and 39
deadlocks, one run logged stalls of 2 and 5 s, and the slowest writes
took 1.17-5.52 s; with the yield on at most 11-15 deadlocks, no stall
logged and slowest writes of 0.39-0.51 s.
With locks_check_waiters 0 the yield still runs (484 and 448
cursor-move yields, at most 15 and 18 deadlocks per transaction against
1486 and 119 with the yield off), but both arms fail the pinned test:
with waiter checks off, nothing releases the page locks replication
waits on outside a cycle (10-15 s of lock waits per transaction with
the yield, single waits of up to 125 s without it), and the yield does
not cover those waits.
A RelWithDebInfo build with healthy replication (8 readers on one
replicant running index range reads, one writer on the master, no
yields) matched main within noise over 6 alternating runs each:
359.9 vs 362.8 reads/s, 190.5 vs 186.3 writes/s.
recovdlock, recovdlock_emptyblob, blob2, blob_sanity and tunables pass.
Signed-off-by: Walid Nejmi <wnejmi@bloomberg.net>
WalidNejmi
force-pushed
the
rep-yield-readers
branch
from
October 28, 2026 03:10
c1268b4 to
e8edab6
Compare
roborivers
suggested changes
Oct 28, 2026
roborivers
left a comment
There was a problem hiding this comment.
Cbuild submission: Error ⚠.
Regression testing: Success ✓.
roborivers
suggested changes
Oct 28, 2026
roborivers
left a comment
There was a problem hiding this comment.
Cbuild submission: Error ⚠.
Regression testing: Success ✓.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Readers yield to replication on a replicant
Summary
On a replicant (a non-master node), the replication thread can get stuck behind SQL readers. With enough readers on one node, a single replicated transaction loses hundreds or thousands of deadlocks in a row, the node falls more than 10 s behind, and the master marks it incoherent. Clients reading from that node then get stale data. This change makes readers step aside briefly once replication has clearly lost too many deadlocks on one transaction.
Headline numbers: this commit (e8edab6, on
mainc026cb7) with the yield off (rep_yield_retries 0) and on (the default, 10). Same binary and test client for both arms,reco-ddlk-sql-pinned(all 16 readers on one replicant), 3-node cluster, debug build, 4 alternating rounds (setup 1 under Measurements):On older measurements against
mainitself (historical, previous revision of this PR, before the rebase and the review fixes),mainfailed 1 of 4 and 2 of 4 pinned runs with the replicant marked incoherent, and the yield failed none (see "Historical results").Type: bug fix (replication livelock on replicants). Not affected: the master, the deadlock-victim policy, snapshot (modsnap) and serializable readers, and the order in which replication takes its locks.
Root cause
Who takes which locks
reco-ddlk-sqlthey run a 3-table join. For each row they hold a leaf page of a covering ("datacopy") index, then fetch that row's data and blob pages.__lock_get_list). That order puts data and blob pages before index pages.DB_LOCK_MINWRITE_NOREAD: a locker that holds no write locks (a reader) is never chosen as the victim. So replication loses every deadlock against readers. It drops all its locks and retries the whole transaction from the first lock (the retry loop in__rep_apply,berkdb/rep/rep_record.c).The cycle
Readers and replication take the same pages in opposite orders, so two-locker cycles form:
sequenceDiagram participant R as Reader (sql thread) participant L as Lock manager participant P as Replication thread R->>L: read-lock index leaf I (granted) P->>L: write-lock blob page B (granted) P->>L: write-lock index leaf I (waits for R) R->>L: read-lock blob page B (waits for P) Note over L: cycle: R waits for P, P waits for R L-->>P: DB_LOCK_DEADLOCK (MINWRITE_NOREAD never picks a reader) P->>L: release every lock, re-read txn from the log P->>L: retry from the first lock ... meets the next readerWith many readers on one node, replication meets a new cycle almost as soon as it retries. In the RoboRivers failure that started this, one replicant took 27-39 s per transaction, each losing 669-3530 deadlocks. The master's limit is 10 s (
REPTIMEOUT_MINMS), after which it marks the node incoherent.Why the existing release points don't help
Readers already give up locks voluntarily in two places, but both fire only when someone is waiting on the reader's own locks:
write_response()->recover_deadlock_evbuffer()checksbdb_curtran_has_waiters()and does a full release. "Full" means page locks and the curtran (the reader's cursor transaction, which holds its table locks). The check is meant to run everysql_recover_time(10 ms), but the waywrite_response()computes the time since the last check (tv_sec * 1000 - tv_usec / 1000) makes any interval under a second come out zero or negative, so in practice it runs about once a second.cursor_move_postop()(from cursor mini-snapshot take 3 #6256) releases page locks only, paced bypagelock_release_interval_ms. It runs only withlocks_check_waiterson (the default).A reader that is inside a cycle is blocked in a lock wait. It never sends a row or moves its cursor, so it never reaches either place.
The existing escape hatch,
deadlock_rep_retry_max(defaultINT_MAX, read-only), turnsMINWRITE_NOREADoff after N losses. Readers can then be killed. That does end the stall, but in our runs a reader killed inside a blob fetch failed the query with rc 203, because blob fetches have no deadlock recovery.The fix
Replication publishes how many deadlocks it has lost on the transaction it is applying. Once that reaches
rep_yield_retries(10), readers on that node yield at their next cursor move or row send, whether or not anyone waits on their own locks:rep_yield_sleep_ms(50), waking early when the transaction is applied;Readers that are not in a cycle clear out, replication gets all its locks in one pass, and the readers stuck in the cycle are released when it commits.
flowchart LR subgraph before[Before] A1[rep loses deadlock] --> A2[drop all locks, re-read txn] A2 --> A3[retry: next reader blocks it] A3 --> A1 end subgraph after[After] B1[rep loses deadlock] --> B2{lost 10 or more on this txn?} B2 -- no --> B3[retry as before] B3 --> B1 B2 -- yes --> B4[readers on this node release page locks, sleep up to 50 ms] B4 --> B5[rep takes all locks, applies, count reset to 0] B5 --> B6[readers continue] endReplication publishes its loss count (
berkdb/rep/rep_record.c)The count is written with the existing
comdb2_atomic.hhelpers; readers load it the same way.Readers yield (
db/sqlinterfaces.c, newrep_yield_to_replication())It is called from the two existing safe points. In
cursor_move_postop()the call comes before thelocks_check_waiterstest, which only gates the waiter-driven releases below it;rep_yield_to_replication()does its own mode,recover_deadlock_sync_dtaand lock-desired checks, the same ones that used to come before the call:static int cursor_move_postop(BtCursor *pCur) ... if (gbl_debug_sleep_in_cursor_move) poll(NULL, 0, gbl_debug_sleep_in_cursor_move); + if (rep_yield_to_replication(clnt, __func__, __LINE__, &rc)) + return rc; + if (!gbl_locks_check_waiters) return 0;int recover_deadlock_evbuffer(struct sqlclntstate *clnt) { + int rc; + if (rep_yield_to_replication(clnt, __func__, __LINE__, &rc)) { + ATOMIC_ADD64(gbl_rep_yields_emit_row, 1); + return rc; + }Because the row-send check runs only about once a second (see Root cause), almost all yields happen at cursor moves: 0-2 per run at row send against 74-484 at cursor moves in the runs below.
Tunables and metrics
rep_yield_retries(new)rep_yield_sleep_ms(new)pagelock_release_interval_ms(unchanged)All of them can be changed at runtime.
tests/tunables.testis updated.New
comdb2_metricsentries:rep_max_txn_deadlocks(highest since start),rep_yield_txns,rep_yields_cursor_move,rep_yields_emit_row,emit_row_lock_releases(recover_deadlock_evbuffer()full releases).Test changes
tests/tools/reco-ddlk-sql.c:RECO_PIN_READERSputs all 16 readers on one replicant. If it can't find a replicant, the test fails at once ("failed to identify a replicant for the pinned readers") instead of spreading the readers over the cluster. WithoutRECO_PIN_READERSnothing changes.=== reco-ddlk-sql results ===block before its pass/fail line (details in Testing).reco-ddlk-sql-pinnedruns the client withRECO_PIN_READERS=1. It needs a cluster; on a single node it prints "Test requires a cluster" and exits, as other cluster-only tests do.Why it is safe
rep_yield_to_replication()returns after one atomic load (a plain load on x86) and a compare. In thereco-ddlk-sqlruns (readers spread out), 21 to 40 transactions per replicant reached 10 losses over a whole run of about 100 s. With healthy replication (setup 4) no transaction reached 10 and a release build matchedmainwithin noise.DB_LOCK_MINWRITE_NOREADis unchanged. Readers are still never deadlock victims and get no new errors. The yield is voluntary.RECOVER_DEADLOCK_PAGELOCKS_ONLY, as incursor_move_postop()). The curtran stays, so no schema change can run while the reader has no page locks.DB_LOCK_DEADLOCK_CUSTOM(goto err) and the end of thedo ... while, and both reset the count. The same loop serves the serial and the concurrent apply paths.XCHANGE32); readers read it withATOMIC_LOAD32. A reader that reads it just before it changes costs at most one extra or one missed yield. The once-a-second log line's timestamp (last_print) is updated withCAS32, so one thread prints it per second.locks_check_waiters. Turning waiter checks off no longer turns the cursor-move yield off. With waiter checks on (the default) nothing changes: the checks that used to come before the call are the onesrep_yield_to_replication()makes itself, and the waiter-driven releases after it are untouched.pagelock_release_interval_ms(100 ms) per request, and each sleep is at most 50 ms, polled in 5 ms steps. The sleep ends early when the count drops (transaction applied) or the bdb write lock is wanted.recover_deadlock_evbuffer()can run from the sqlwriter flush callback on another thread. The yield only runs on the sql thread that ownsclnt.recover_deadlock_sync_dtaoff, don't yield, because the page-lock-only release doesn't support them. They behave as before.cursor_move_postop()returns it, andwrite_response()fails the request.rep_yield_last_msis cleared inclnt_reset_release_state()with the other release state.main.Measurements
Method: 3-node docker cluster (m1-m3, 4 CPUs each) plus a driver container, one test at a time on a shared 8-CPU host. Setups 1-3: debug build (
-O0,-DCOMDB2_TEST=1) of this commit, and one binary for both arms: the control arm setsrep_yield_retries 0(orlocks_check_waiters 0) through an lrl copy of the test that is not committed, so the tunable is the only difference. Arms alternate, control first. Setup 4: RelWithDebInfo builds of this commit and ofmainc026cb7. Host load average 1.7-13.9 during these runs. Single node: not measured, because a single node has no replication and doesn't take this path.Terms used in the tables:
LONG rep_process_messageline on the readers' replicant (any replicant in setup 2). It is printed only when one message takes more thanrep_longreq(1 s, compared in whole seconds), so "none logged" means under about 2 s.rep_max_txn_deadlocksmetric on the readers' replicant.emit_row_lock_releases.Setup 1: yield off vs. on, pinned readers
reco-ddlk-sql-pinned, 4 rounds of off → on.How to read it:
maindid (see "Historical results").rep_yield_retries(10): readers yield at their next safe point, at most once per 100 ms each, so cycles can still form for a short while after the count reaches 10.Setup 2: yield off vs. on, readers spread over the cluster
reco-ddlk-sql(readers on both replicants), 4 rounds of off → on. Values per replicant (by host name) where they differ.The livelock also forms with readers spread out, just milder: in run 2 without the yield one transaction lost 423 deadlocks and both replicants logged stalls (2 and 5 s). Mean write statements done went from 465 to 555 (+19%).
Setup 3:
locks_check_waiters 0, pinned readersSame pinned test with
locks_check_waiters 0on every node, yield off then on, 2 rounds.How to read it:
locks_check_waiterstest and would not have run here.emit_row_lock_releasesstays 0). The replicant log shows it waiting, not deadlocking: for example "26 lock waits took 14248 ms" for a transaction that lost 13 deadlocks with the yield, and single waits of 114 and 125 s without it. The yield is triggered by lost deadlocks only, so it doesn't cover those waits. This PR does not change that (see "Not changed").Setup 4: overhead with healthy replication, release build
RelWithDebInfo builds of
mainc026cb7 and of this commit (-DCOMDB2_TEST=1for both). A small cluster test, not committed, that uses onlycdb2sql: 100k rows; 8 readers on one replicant each run 1500 index range reads (count/sum/maxover 1001 rows); one writer on the master keeps running 50-row update transactions until the readers finish. 6 rounds each: main → PR for rounds 1-3, PR → main for rounds 4-6.rep_max_txn_deadlocks1-2,rep_yield_txns0, no yields at either safe point. Replication was healthy, so the yield never ran.cdb2sqlreports none. Each reader's wall time was 31.9-34.0 s in every run on both builds.Baseline before the review fixes
Before these fixes, the same experiment ran on the rebased previous revision (c1268b4), debug, 3 rounds on → off pinned plus one spread run with the yield on: all passed; yield on lost at most 10 / 15 / 13 deadlocks per transaction with slowest writes of 1.11 / 1.22 / 0.56 s and 540 / 540 / 570 write statements; yield off lost 386 / 259 / 242 with slowest writes of 1.82 / 1.98 / 1.11 s and 450 write statements each. Spread, yield on: 16 and 16, 0.45 s, 510. These fixes leave the yield's effect where it was.
Historical results (previous revision, against
main)These were measured on the previous revision of this PR (before the rebase onto c026cb7 and before the review fixes; the yield logic was the same apart from the atomics and the
locks_check_waitersordering), against its parentmain8ff152d, with the same test client for both. "Write statements done" here is the master's coherency check count (statements + 3).reco-ddlk-sql-pinned, 4 rounds main → PR:rep-locks tooklines, a lower bound)reco-ddlk-sql(spread), 2 rounds: both arms passed;mainlost up to 62 deadlocks on one transaction with 3 s stalls and slowest writes of 2.98 and 1.21 s, the PR at most 12, no stall logged, 0.82 and 0.52 s.main, 4 runs each:mainfailed 2 of 4 (5 and 13 incoherent at the end, 1 and 3 replication timeouts, stalls of 12 and 16 s), the yield passed 4 of 4.write_response()so the row-send check ran every 10 ms, paused a readerpagelock_release_interval_msbetween row-send full releases and lowered that interval to 25 ms. Measured with the yield off, those changes on their own were worse thanmain(3 of 4 passed, 822-3261 lost deadlocks, 1188-2000 row-send releases against 374-424), so they were dropped.Testing
This commit (e8edab6), 3-node docker cluster, debug build unless noted:
reco-ddlk-sql-pinnedreco-ddlk-sqlrecovdlock_emptyblobrecovdlockblob2blob_sanitytunables(single node)RECO_PIN_READERS=1 make reco-ddlk-sqlfails at once with "failed to identify a replicant for the pinned readers";make reco-ddlk-sql-pinnedprints "Test requires a cluster" and passesreco-ddlk-sql-pinnedwithlocks_check_waiters 0Failures: only with
locks_check_waiters 0(setup 3), in both arms. Classified as replication waiting on readers' page locks outside a deadlock cycle with the waiter-driven releases turned off, not the livelock this PR fixes: lost deadlocks stay at 15-18 per transaction with the yield. The yield-off arm, which ismain's behaviour, fails much worse (124-128 s stalls, 40-41 replication timeouts).RoboRivers: an earlier run on the first version timed out in
reco-ddlk-sql-pinned. Its log shows no incoherent node and no replication timeout. The 10 minutes went to readers reading 1M-row joins to the end after the writer had finished (214-274 s per reader), which the client change here fixes. This commit has not had a RoboRivers result yet.Quarantine:
reco-ddlk-sqlis quarantined intests/quarantine.csv. This PR leaves that entry alone. The newreco-ddlk-sql-pinnedis not quarantined.New test:
reco-ddlk-sql-pinned. Spreading readers over the cluster rarely puts enough of them on one node to form a long livelock; pinning reproduces it reliably (262 or more lost deadlocks per transaction in every yield-off run of setup 1).Results block: both reco tests print it before their pass/fail line:
On a server without the new metrics the per-node values print
n/a. The block is not printed if the test is killed by its timeout.Not changed
DB_LOCK_MINWRITE_NOREAD) anddeadlock_rep_retry_max.locks_check_waiters 0it can still stall (setup 3).write_response()(so it still runs about once a second), the row-send release andpagelock_release_interval_ms.rep_wait_release_ms,pagelock_release_max_wait_ms) and how replication lock time is measured.reco-ddlk-sqlquarantine entry.