Conversation
A reader that met a lock of an async-commit transaction whose writer had failed after its prewrite could never resolve it. `resolve_locks` sends only `CheckTxnStatus`, and TiKV never rolls back an expired async-commit primary there (the transaction may already be committed through its secondaries), so the lock stayed "live": the reader backed off and failed with `ResolveLockError`, and every read of those keys failed until GC's `cleanup_locks`, the only path that checked the secondaries, ran below the safe point (about ten minutes later by default). 1PC prewrites that fall back to async commit leave the same locks. `resolve_locks` now handles an expired async-commit primary the way client-go's `resolveAsyncCommitLock` does: it sends `CheckSecondaryLocks` for the primary's secondaries and decides the outcome from the answers: - a committed secondary: committed at its commit_ts; - a rolled-back secondary, or one that was never locked (TiKV writes a rollback record for it, so it can no longer be prewritten): rolled back; - every secondary still locked: committed at the largest min_commit_ts of the primary and the secondaries; - a lock that is not an async-commit lock (the transaction fell back to 2PC in that region): the primary decides, through `CheckTxnStatus` with `force_sync_commit`. It then resolves the primary as well as the lock that was read, so later readers find the final status at once. Unexpired async-commit locks stay live, as before. Two bugs in the code GC's `cleanup_locks` already shared are fixed with it: - merging `CheckSecondaryLocks` answers ignored a rolled-back region (an answer with no locks and no commit_ts). With other regions still locked, `cleanup_locks` then committed the transaction at their min_commit_ts although one of its keys was rolled back. The merge now records the rollback, reports contradictory answers as an error instead of an `assert_eq!` panic, and includes the primary's min_commit_ts in the commit version; - after a fallback to 2PC, the second `CheckTxnStatus` (with `force_sync_commit`) returned the cached "locked" status of the first one. A cached locked status now answers only unforced checks. Tests: unit tests for all four outcomes, an unexpired primary, the outcome rule and the merge's contradictions (four of them fail without the change), and `txn_read_resolves_expired_async_commit_locks` in `tests/failpoint_tests.rs`, which leaves 512 async-commit locks behind with the `after-prewrite` failpoint and reads every key once the locks expire. Signed-off-by: Dinakaran <dinakaranvijayakumar@outlook.com>
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
|
Warning Review limit reachedNext included review available in 59 minutes. View limit detailsLimit details: You’ve used all 2 included reviews currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (3)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Motivation
When an async-commit writer fails after its prewrite (a crash, a lost connection, or the
after-prewritefailpoint), readers cannot get past its locks until GC runs.resolve_locks, which every read plan'sResolveLockstep uses, sends onlyCheckTxnStatus. TiKV never rolls back an expired async-commit primary throughCheckTxnStatus(unlessforce_sync_commitis set), because the transaction may already be committed through its secondaries. The status comes backLocked, the lock is treated as live, and the reader backs off until it fails withResolveLockError. OnlyLockResolver::cleanup_locks, the GC path, callsCheckSecondaryLocks, and it runs only below the GC safe point: about ten minutes later with the defaultgc_life_time. A 1PC prewrite that falls back to async commit leaves the same locks.Found while building a metadata store on
tikv-client. A write that failed after its prewrite left its keys unreadable and unwritable for more than a minute (until GC in our test). We switched that component to plain 2PC as a workaround.Reproduction (new test, below): 16 async-commit transactions × 32 keys,
after-prewritereturning an error. After the 3 s lock TTL, a snapshot read fails on master:What is changed and how it works
In
resolve_locks, an expired async-commit primary (is_expired && use_async_commit) is now resolved the way client-go'sresolveAsyncCommitLock/checkAllSecondariesdo.CheckSecondaryLocksis sent for the primary'ssecondaries, and the outcome follows the async commit protocol:commit_tsmin_commit_tsof the primary and all secondariesCheckTxnStatuswithforce_sync_commitThen the primary is resolved as well as the lock that was read, so later readers of the transaction get its final status from
CheckTxnStatusdirectly. An unexpired async-commit primary is still a live lock, as before. The decision isSecondaryLocksStatus::async_commit_version, shared withcleanup_locks.Two fixes in code
cleanup_locksalready usedCheckSecondaryLocksanswers ignored a rolled-back region. TiKV answers a region in one of three ways: every key locked,commit_tsset, or no locks and nocommit_ts(a key rolled back, or a missing lock it just rolled back). The merge ignored the third answer. With another region still locked,cleanup_locksthen committed the transaction at that region'smin_commit_ts, although one of its keys was rolled back and can never be committed: a partial commit. The merge now recordsrolled_back, returns an error instead of hittingassert_eq!on two different commit timestamps, and rejects "committed and rolled back" as contradictory. The commit version now also includes the primary'smin_commit_ts, as client-go's does (it starts fromstatus.primaryLock.MinCommitTs).check_txn_statuscaches an expiredLockedstatus, and the second call withforce_sync_commit = truereturned that cachedLocked, socleanup_locksfailed with "cleanup_locks fail to clean locks". A cachedLockedstatus now answers only unforced checks.Tests
Unit (
transaction::lock::tests, mock TiKV):expired_async_commit_with_every_secondary_locked_commits_at_the_max_min_commit_tsexpired_async_commit_with_a_rolled_back_secondary_is_rolled_backexpired_async_commit_with_a_committed_secondary_commits_at_its_tsasync_commit_that_fell_back_to_2pc_is_resolved_by_its_primaryunexpired_async_commit_primary_stays_livesecondary_locks_status_decides_the_async_commit_outcomemerging_secondary_locks_refuses_contradictionsThe first four fail without the change.
Integration:
txn_read_resolves_expired_async_commit_locks(tests/failpoint_tests.rs). It leaves 512 async-commit locks withafter-prewrite, waits for the TTL, reads every key through a snapshot, and expects no locks afterwards. On master it fails withResolveLockError. With the change the reads take about 0.4 s and leave no locks. The existingtxn_cleanup_async_commit_locks,txn_cleanup_range_async_commit_locksandtxn_resolve_locksstill pass.All integration suites were run on a tiup playground v8.5.8 (API v2, one TiKV).
cargo test --lib, clippy with-D clippy::allandcargo fmt --checkare clean.Notes for reviewers
ResolveLockper resolved async-commit transaction on the read path. client-go resolves every key of the transaction there. This change stays closer to this crate's existing per-region resolution and resolves only the primary and the region that was read.Check list
Release note: readers resolve the locks of a failed async-commit or 1PC transaction once its primary lock expires, instead of waiting for GC.
cleanup_locksno longer commits an async-commit transaction that has a rolled-back secondary.