Skip to content

Commit 4a28830

Browse files
committed
transaction: refuse to resolve shared locks instead of mis-handling them
The re-vendored kvproto exposes shared locks (Op::SharedLock / Op::SharedPessimisticLock). Their contract is unusual: a shared lock's real holders live ONLY in kvrpcpb.LockInfo.shared_lock_infos — the proto comment is explicit that you must "DO NOT read from the wrapper LockInfo", whose own key and lock_version are unset. This client does not implement shared-lock resolution, and every partial handling of one is worse than none: resolving the wrapper checks transaction 0; filtering on the wrapper's fields (use_async_commit, lock_type) silently drops the real members; and the pessimistic-lock special cases in the resolver do not know SharedPessimisticLock. So resolution REFUSES them with an explicit error — in resolve_locks, in LockResolver's crate-public entry point, and in CleanupLocks::execute before any filter runs. Until real support lands, an explicit error is the only answer that cannot roll back a live transaction or skip a dead one. Servers that predate shared locks never produce them, so this is a no-op there. The API-v2 keyspace codecs are made shared-lock-aware separately, because they run on scan results that never reach the resolver. They now take the shape of client-go's codecV2.decodeLockInfo (internal/apicodec/codec_v2.go), which decodes a LockInfo's own Key/PrimaryLock/Secondaries and then recurses into SharedLockInfos — it has no wrapper special case. This client now does the same: convert the lock's OWN key fields, then recurse into the members. An earlier revision skipped a wrapper's own fields entirely, reading the proto's "DO NOT read from the wrapper LockInfo" as covering them. Checking the writer (TiKV's SharedLocks::into_lock_info in components/txn_types/src/lock.rs, identical at v8.5.7 and on master) shows that is only half right, and the half it gets wrong matters: info.set_shared_lock_infos(shared_locks.into()); info.set_key(raw_key); A wrapper sets lock_type, shared_lock_infos and key — the key it locks — and leaves primary_lock and lock_version at their defaults; each member is built from that SAME raw key plus its own primary and version. So the caveat scopes the per-transaction fields, not the locked key. Skipping the wrapper wholesale would have handed scan_locks a physical key beside decoded member keys, while converting it wholesale would have hit the length assertion on the unset primary. Both fields are exercised by tests. The two directions guard differently, on purpose. Truncating, empty bytes can only mean "unset" — an encoded key always carries its 4-byte prefix — so unset fields are skipped rather than run into pretruncate_bytes' length assertion. Encoding, the input is a LOGICAL key and the empty logical key is valid in API v2, so nothing is skipped: scan_locks -> resolve_locks round-trips locks through truncate-then-encode, and skipping empties there would strand a lock on the empty key with no prefix and send resolution after an empty physical key. Wrappers need no encode-side guard because resolution refuses them first. That panic hazard predates the re-vendor: a wrapper arrives the same way whichever proto vintage parses it. Full shared-lock support is deliberately follow-up work. Split out of the kvproto re-vendor at pingyu's suggestion (#550). Tests: 4 new — the resolver refusal (a plain lock passes; a wrapper carrying members and a lock marked shared only by its op are both refused); the codec converting a wrapper's own key alongside its members; a wrapper's unset fields surviving truncation (the other reading, so both are pinned); and a lock on the empty logical key round-tripping through truncate-then-encode. 71 lib tests green. The coverage is unit-level by necessity: CI pins TIKV_VERSION v8.5.5, which predates shared locks, so no integration test in this repo can produce one, and the refusal path is unreachable against that server. v8.5.6 is the first release carrying shared locks, so a CI pin bump within the same release family would make these paths reachable — left to a separate change. Signed-off-by: Eduard R. <eduard@ralphovi.net>
1 parent 2aa94c1 commit 4a28830

4 files changed

Lines changed: 243 additions & 6 deletions

File tree

src/request/keyspace.rs

Lines changed: 176 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -160,23 +160,63 @@ impl TruncateKeyspace for Vec<KvPair> {
160160
}
161161
}
162162

163+
/// Is this key field UNSET, as opposed to carrying the empty logical key?
164+
///
165+
/// An encoded key always carries its 4-byte keyspace prefix, so on the wire an empty
166+
/// byte string means "not set" — never "the empty logical key", which encodes to the
167+
/// bare prefix. Guarding on this keeps the codecs off `pretruncate_bytes`' length
168+
/// assertion for fields a shared-lock wrapper leaves unset, and mirrors client-go's
169+
/// `codecV2.DecodeKey`, which returns early on a zero-length key.
170+
fn is_unset_key(key: &[u8]) -> bool {
171+
key.is_empty()
172+
}
173+
163174
impl TruncateKeyspace for Vec<crate::proto::kvrpcpb::LockInfo> {
164175
fn truncate_keyspace(mut self, keyspace: Keyspace) -> Self {
165176
if !matches!(keyspace, Keyspace::Enable { .. }) {
166177
return self;
167178
}
168179
for lock in &mut self {
169-
take_mut::take(&mut lock.key, |key| {
170-
Key::from(key).truncate_keyspace(keyspace).into()
171-
});
172-
take_mut::take(&mut lock.primary_lock, |primary| {
173-
Key::from(primary).truncate_keyspace(keyspace).into()
174-
});
180+
// Convert this lock's OWN key fields, then recurse into any shared-lock
181+
// members — the shape of client-go's `codecV2.decodeLockInfo`, which
182+
// decodes Key/PrimaryLock/Secondaries and *then* walks SharedLockInfos,
183+
// with no wrapper special case.
184+
//
185+
// Per TiKV's writer (`SharedLocks::into_lock_info`, txn_types/src/lock.rs)
186+
// a wrapper sets `lock_type`, `shared_lock_infos` and `key` — the key it
187+
// locks — and leaves `primary_lock`/`lock_version` at their defaults. Each
188+
// member is built from that SAME raw key plus its own primary and version.
189+
// So the wrapper's key must be converted like any other (skipping it would
190+
// hand `scan_locks` a physical key beside decoded member keys), while its
191+
// unset fields must be left alone — hence no wrapper special case, just the
192+
// per-field guard below.
193+
if !is_unset_key(&lock.key) {
194+
take_mut::take(&mut lock.key, |key| {
195+
Key::from(key).truncate_keyspace(keyspace).into()
196+
});
197+
}
198+
if !is_unset_key(&lock.primary_lock) {
199+
take_mut::take(&mut lock.primary_lock, |primary| {
200+
Key::from(primary).truncate_keyspace(keyspace).into()
201+
});
202+
}
175203
for secondary in lock.secondaries.iter_mut() {
204+
// Unlike `key`/`primary_lock` above, this guard is not an unset-field
205+
// case: an unset `secondaries` is an empty Vec, with no elements to
206+
// visit. A present-but-empty ELEMENT is malformed input (an encoded
207+
// key always carries its prefix); skipping it merely keeps a corrupt
208+
// entry from panicking the codec on `pretruncate_bytes`' length
209+
// assertion.
210+
if is_unset_key(secondary) {
211+
continue;
212+
}
176213
take_mut::take(secondary, |secondary| {
177214
Key::from(secondary).truncate_keyspace(keyspace).into()
178215
});
179216
}
217+
take_mut::take(&mut lock.shared_lock_infos, |members| {
218+
members.truncate_keyspace(keyspace)
219+
});
180220
}
181221
self
182222
}
@@ -188,6 +228,17 @@ impl EncodeKeyspace for Vec<crate::proto::kvrpcpb::LockInfo> {
188228
return self;
189229
}
190230
for lock in &mut self {
231+
// Deliberately NOT symmetric with the TruncateKeyspace impl above. There,
232+
// empty bytes can only mean "unset", because an encoded key always carries
233+
// its 4-byte prefix. Here the input is a LOGICAL key, and the empty logical
234+
// key is valid in API v2 — it encodes to the bare prefix. Skipping empties
235+
// would strand a lock on the empty key with no prefix, and `scan_locks` ->
236+
// `resolve_locks` (transaction/client.rs) round-trips exactly that way, so
237+
// its region lookup would then use an empty physical key.
238+
//
239+
// Shared-lock wrappers do not need the unset-field guard here: resolution
240+
// refuses them (`reject_shared_locks`) before anything acts on the encoded
241+
// result.
191242
take_mut::take(&mut lock.key, |key| {
192243
Key::from(key).encode_keyspace(keyspace, key_mode).into()
193244
});
@@ -203,6 +254,9 @@ impl EncodeKeyspace for Vec<crate::proto::kvrpcpb::LockInfo> {
203254
.into()
204255
});
205256
}
257+
take_mut::take(&mut lock.shared_lock_infos, |members| {
258+
members.encode_keyspace(keyspace, key_mode)
259+
});
206260
}
207261
self
208262
}
@@ -477,4 +531,120 @@ mod tests {
477531
let locks = vec![lock];
478532
assert_eq!(locks.clone().truncate_keyspace(keyspace), locks);
479533
}
534+
535+
/// A shared-lock wrapper carries the key it locks, and its members are built from
536+
/// that same raw key (TiKV's `SharedLocks::into_lock_info`). Both must be converted:
537+
/// skipping the wrapper would hand `scan_locks` a physical key beside decoded member
538+
/// keys. The wrapper's *other* fields are a different matter — see
539+
/// [`unset_lock_key_fields_survive_truncation`].
540+
#[test]
541+
fn shared_lock_wrapper_keys_are_converted_alongside_their_members() {
542+
use crate::proto::kvrpcpb::{LockInfo, Op};
543+
let keyspace = Keyspace::Enable { keyspace_id: 0 };
544+
545+
let wrapper = LockInfo {
546+
key: vec![b'x', 0, 0, 0, b'k'],
547+
lock_type: Op::SharedLock as i32,
548+
shared_lock_infos: vec![LockInfo {
549+
key: vec![b'x', 0, 0, 0, b'm'],
550+
primary_lock: vec![b'x', 0, 0, 0, b'p'],
551+
lock_version: 8,
552+
..Default::default()
553+
}],
554+
..Default::default()
555+
};
556+
557+
let out = vec![wrapper].truncate_keyspace(keyspace);
558+
assert_eq!(
559+
out[0].key,
560+
vec![b'k'],
561+
"the wrapper's own key must be decoded, not left physical"
562+
);
563+
assert_eq!(out[0].shared_lock_infos[0].key, vec![b'm']);
564+
assert_eq!(out[0].shared_lock_infos[0].primary_lock, vec![b'p']);
565+
566+
let back = out.encode_keyspace(keyspace, KeyMode::Txn);
567+
assert_eq!(back[0].key, vec![b'x', 0, 0, 0, b'k']);
568+
assert_eq!(back[0].shared_lock_infos[0].key, vec![b'x', 0, 0, 0, b'm']);
569+
}
570+
571+
/// The empty logical key is VALID in API v2: it encodes to the bare keyspace
572+
/// prefix. `scan_locks` -> `resolve_locks` round-trips locks through
573+
/// truncate-then-encode, so a lock on the empty key must regain its prefix —
574+
/// otherwise resolution would look up the region for an empty physical key.
575+
/// This is why the encode side does not share the truncate side's unset guard.
576+
#[test]
577+
fn a_lock_on_the_empty_logical_key_round_trips() {
578+
use crate::proto::kvrpcpb::LockInfo;
579+
let keyspace = Keyspace::Enable { keyspace_id: 0 };
580+
581+
let physical = vec![LockInfo {
582+
key: vec![b'x', 0, 0, 0],
583+
primary_lock: vec![b'x', 0, 0, 0],
584+
..Default::default()
585+
}];
586+
let logical = physical.clone().truncate_keyspace(keyspace);
587+
assert!(logical[0].key.is_empty(), "the empty logical key");
588+
589+
let back = logical.encode_keyspace(keyspace, KeyMode::Txn);
590+
assert_eq!(
591+
back, physical,
592+
"a lock on the empty logical key must regain its keyspace prefix"
593+
);
594+
}
595+
596+
/// TiKV's `SharedLocks::into_lock_info` leaves a wrapper's `primary_lock` at its
597+
/// default, so the codec meets genuinely unset fields in practice — they must be
598+
/// skipped, not run into `pretruncate_bytes`' length assertion.
599+
#[test]
600+
fn unset_lock_key_fields_survive_truncation() {
601+
use crate::proto::kvrpcpb::{LockInfo, Op};
602+
let keyspace = Keyspace::Enable { keyspace_id: 0 };
603+
604+
// The writer's actual shape: key and members set, primary_lock left default.
605+
let realistic = LockInfo {
606+
key: vec![b'x', 0, 0, 0, b'k'],
607+
lock_type: Op::SharedLock as i32,
608+
shared_lock_infos: vec![LockInfo {
609+
key: vec![b'x', 0, 0, 0, b'k'],
610+
primary_lock: vec![b'x', 0, 0, 0, b'p'],
611+
..Default::default()
612+
}],
613+
..Default::default()
614+
};
615+
let out = vec![realistic].truncate_keyspace(keyspace);
616+
assert_eq!(out[0].key, vec![b'k']);
617+
assert!(
618+
out[0].primary_lock.is_empty(),
619+
"the wrapper's unset primary must be skipped, not truncated"
620+
);
621+
assert_eq!(out[0].shared_lock_infos[0].primary_lock, vec![b'p']);
622+
623+
// Defensively, a wrapper with no key at all must not panic either.
624+
let keyless = LockInfo {
625+
lock_type: Op::SharedLock as i32,
626+
..Default::default()
627+
};
628+
let out = vec![keyless].truncate_keyspace(keyspace);
629+
assert!(out[0].key.is_empty());
630+
}
631+
632+
/// An empty element INSIDE `secondaries` is a different case from the unset
633+
/// fields above: an unset `secondaries` is an empty Vec with no elements, so a
634+
/// present-but-empty element can only be malformed input. It is tolerated —
635+
/// skipped rather than run into `pretruncate_bytes`' length assertion — while
636+
/// the well-formed elements beside it still convert.
637+
#[test]
638+
fn a_malformed_empty_secondary_is_tolerated_not_truncated() {
639+
use crate::proto::kvrpcpb::LockInfo;
640+
let keyspace = Keyspace::Enable { keyspace_id: 0 };
641+
642+
let malformed = LockInfo {
643+
key: vec![b'x', 0, 0, 0, b'k'],
644+
secondaries: vec![vec![], vec![b'x', 0, 0, 0, b's']],
645+
..Default::default()
646+
};
647+
let out = vec![malformed].truncate_keyspace(keyspace);
648+
assert_eq!(out[0].secondaries, vec![vec![], vec![b's']]);
649+
}
480650
}

src/request/plan.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,10 @@ where
841841
has_more_batch = false;
842842
}
843843

844+
// BEFORE any filter: a shared-lock wrapper's fields (including
845+
// `use_async_commit`) must not be read — filtering on them would silently
846+
// drop the real member locks. Refuse instead; see `reject_shared_locks`.
847+
crate::transaction::reject_shared_locks(&locks)?;
844848
if self.options.async_commit_only {
845849
locks = locks
846850
.into_iter()

src/transaction/lock.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,35 @@ pub(crate) fn format_key_for_log(key: &[u8]) -> String {
4343
format!("len={}, prefix={}", key.len(), HexRepr(&key[..prefix_len]))
4444
}
4545

46+
/// Refuse to resolve SHARED locks — loudly, before any of them can be mis-handled.
47+
///
48+
/// The contract (`kvrpcpb.LockInfo.shared_lock_infos`) is explicit: a shared lock's
49+
/// real holders live ONLY in `shared_lock_infos` — "DO NOT read from the wrapper
50+
/// LockInfo", whose own `key`/`lock_version` are unset. This client does not implement
51+
/// shared-lock resolution yet, and every partial handling is worse than none:
52+
/// resolving the wrapper checks transaction 0; filtering on wrapper fields silently
53+
/// drops the members; and the pessimistic-lock special cases in this resolver do not
54+
/// know `SharedPessimisticLock`. Until support lands, an explicit error is the only
55+
/// answer that cannot roll back a live transaction or skip a dead one.
56+
///
57+
/// Servers that predate shared locks never produce them, so this is a no-op there.
58+
pub(crate) fn reject_shared_locks(locks: &[kvrpcpb::LockInfo]) -> Result<()> {
59+
let shared = |l: &kvrpcpb::LockInfo| {
60+
!l.shared_lock_infos.is_empty()
61+
|| l.lock_type == kvrpcpb::Op::SharedLock as i32
62+
|| l.lock_type == kvrpcpb::Op::SharedPessimisticLock as i32
63+
};
64+
if locks.iter().any(shared) {
65+
return Err(Error::StringError(
66+
"shared locks (SharedLock/SharedPessimisticLock) are not supported by this \
67+
client yet; refusing to resolve them — resolving the wrapper would target \
68+
the wrong transaction"
69+
.to_owned(),
70+
));
71+
}
72+
Ok(())
73+
}
74+
4675
/// _Resolves_ the given locks. Returns locks still live. When there is no live locks, all the given locks are resolved.
4776
///
4877
/// If a key has a lock, the latest status of the key is unknown. We need to "resolve" the lock,
@@ -56,6 +85,7 @@ pub async fn resolve_locks(
5685
keyspace: Keyspace,
5786
) -> Result<Vec<kvrpcpb::LockInfo> /* live_locks */> {
5887
debug!("resolving locks");
88+
reject_shared_locks(&locks)?;
5989
let ts = pd_client.clone().get_timestamp().await?;
6090
let caller_start_ts = timestamp.version();
6191
let current_ts = ts.version();
@@ -300,6 +330,9 @@ impl LockResolver {
300330
pd_client: Arc<impl PdClient>, // TODO: make pd_client a member of LockResolver
301331
keyspace: Keyspace,
302332
) -> Result<()> {
333+
// Defense in depth: CleanupLocks::execute refuses these before its filters,
334+
// but this entry point is public within the crate.
335+
reject_shared_locks(&locks)?;
303336
if locks.is_empty() {
304337
return Ok(());
305338
}
@@ -619,6 +652,35 @@ mod tests {
619652
use crate::mock::MockPdClient;
620653
use crate::proto::errorpb;
621654

655+
#[test]
656+
fn shared_locks_are_refused_never_misresolved() {
657+
let plain = kvrpcpb::LockInfo {
658+
key: b"k1".to_vec(),
659+
lock_version: 7,
660+
..Default::default()
661+
};
662+
assert!(reject_shared_locks(std::slice::from_ref(&plain)).is_ok());
663+
664+
// A wrapper: key/lock_version deliberately unset per the contract — resolving
665+
// it would check transaction 0. Must be refused, not resolved or filtered.
666+
let wrapper = kvrpcpb::LockInfo {
667+
shared_lock_infos: vec![kvrpcpb::LockInfo {
668+
key: b"k2".to_vec(),
669+
lock_version: 8,
670+
..Default::default()
671+
}],
672+
..Default::default()
673+
};
674+
assert!(reject_shared_locks(&[plain.clone(), wrapper]).is_err());
675+
676+
// Also refused when only the op marks it shared (empty member list).
677+
let by_op = kvrpcpb::LockInfo {
678+
lock_type: kvrpcpb::Op::SharedPessimisticLock as i32,
679+
..Default::default()
680+
};
681+
assert!(reject_shared_locks(&[by_op]).is_err());
682+
}
683+
622684
#[rstest::rstest]
623685
#[case(Keyspace::Disable)]
624686
#[case(Keyspace::Enable { keyspace_id: 0 })]

src/transaction/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ mod client;
2828
mod lock;
2929
pub mod lowering;
3030
mod requests;
31+
pub(crate) use lock::reject_shared_locks;
3132
pub use lock::LockResolver;
3233
pub use lock::ResolveLocksContext;
3334
pub use lock::ResolveLocksOptions;

0 commit comments

Comments
 (0)