Skip to content

Commit eddb093

Browse files
committed
request: treat an undetermined apply outcome as terminal at commit points
The re-vendored kvproto exposes errorpb.UndeterminedResult: the server telling us the raft apply outcome is UNKNOWN. Today it falls through to the generic region-error arm and is retried like any transient failure. For most requests that is right, but not for a commit point, where "failed" must never be claimed for a transaction that may already be durable. Follow client-go's shape: its transport never retries UndeterminedResult ("should not retry ... processed by the caller", region_request.go) and each ACTION decides. So the plan layer keeps RETRY as the default — replaying an idempotent request resolves the uncertainty, and on backoff exhaustion the error escapes UNCHANGED so callers can still classify it — while plans for which a replay is unsafe opt in to terminal-on-first-sight via retry_multi_region_terminal_on_undetermined: - raw CAS: a replay would compare against its OWN effect and report succeed = false for a write that happened. - the primary commit: client-go returns ErrResultUndetermined here (commit.go) rather than retrying. - async-commit / 1PC prewrites: the prewrite IS the commit point. Stricter than client-go, in the safe direction. Shard aggregation is fixed alongside: collect::<Result<_>>() returns the first error BY SHARD INDEX, so a lower-index shard's determinate error could mask a higher-index shard's UndeterminedResult and tell the caller the request definitely failed. An undetermined error from any shard now wins. Commit paths classify the surfaced error as UndeterminedError. Two places are deliberately conservative and say so in comments: a 1PC prewrite that sharding downgraded to 2PC, and an undetermined error arriving from the commit plan's resolve_lock layer rather than the commit dispatch. Both only over-report uncertainty, which makes the caller verify instead of replay. Split out of the kvproto re-vendor at pingyu's suggestion (#550). Tests: 4 new — terminal for opted-in plans under a generous backoff (proving terminal, not retry-exhausted), preserved unchanged on retry exhaustion, recognizer positive/negative, and the shard-masking hazard. 71 lib tests green. Signed-off-by: Eduard R. <eduard@ralphovi.net>
1 parent d7e581e commit eddb093

4 files changed

Lines changed: 320 additions & 23 deletions

File tree

src/raw/client.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -697,7 +697,10 @@ impl<PdC: PdClient> Client<PdC> {
697697
self.cf.clone(),
698698
);
699699
let plan = crate::request::PlanBuilder::new(self.rpc.clone(), self.keyspace, req)
700-
.retry_multi_region(self.backoff.clone())
700+
// CAS is not replay-safe once the outcome is unknown: if the first attempt
701+
// applied, a retry would compare against the swapped-in value and report
702+
// `succeed = false` for a write that HAPPENED. Terminal on first sight.
703+
.retry_multi_region_terminal_on_undetermined(self.backoff.clone())
701704
.merge(CollectSingle)
702705
.post_process_default()
703706
.plan();

src/request/plan.rs

Lines changed: 240 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,14 @@ where
132132
.collect())
133133
}
134134

135+
/// Did the server say the request's outcome is UNKNOWN — e.g. a raft timeout where the
136+
/// apply result was never observed (`errorpb.UndeterminedResult`)? A commit receiving
137+
/// this must be reported as undetermined: reporting plain failure would invite the
138+
/// caller to retry effects that may already be durable.
139+
pub(crate) fn is_undetermined_region_error(e: &Error) -> bool {
140+
matches!(e, Error::RegionError(re) if re.undetermined_result.is_some())
141+
}
142+
135143
pub struct RetryableMultiRegion<P: Plan, PdC: PdClient> {
136144
pub(super) inner: P,
137145
pub pd_client: Arc<PdC>,
@@ -141,6 +149,19 @@ pub struct RetryableMultiRegion<P: Plan, PdC: PdClient> {
141149
/// If true, return Ok and preserve all regions' results, even if some of them are Err.
142150
/// Otherwise, return the first Err if there is any.
143151
pub preserve_region_results: bool,
152+
153+
/// Terminal treatment of `errorpb.UndeterminedResult` (an unknown raft apply
154+
/// outcome). client-go's transport never retries it and each ACTION decides
155+
/// (region_request.go: "should not retry ... processed by the caller"); its one
156+
/// terminal action is the primary non-async commit (commit.go: returns
157+
/// ErrResultUndetermined). Plans opt in when a replay could produce a WRONG
158+
/// RESULT (raw CAS replaying against its own effect) or when the request is a
159+
/// commit point and a later, different retry error must not overwrite the
160+
/// uncertainty (primary commit; async/1PC prewrite — stricter than client-go,
161+
/// in the safe direction). Everything else retries: re-applying an idempotent
162+
/// request resolves the uncertainty, and on backoff exhaustion the error
163+
/// escapes UNCHANGED for the caller to classify.
164+
pub terminal_on_undetermined: bool,
144165
}
145166

146167
impl<P: Plan + Shardable, PdC: PdClient> RetryableMultiRegion<P, PdC>
@@ -155,6 +176,7 @@ where
155176
backoff: Backoff,
156177
permits: Arc<Semaphore>,
157178
preserve_region_results: bool,
179+
terminal_on_undetermined: bool,
158180
) -> Result<<Self as Plan>::Result> {
159181
let shards = current_plan.shards(&pd_client).collect::<Vec<_>>().await;
160182
let shards_len = shards.len();
@@ -182,6 +204,7 @@ where
182204
backoff,
183205
permits,
184206
preserve_region_results,
207+
terminal_on_undetermined,
185208
)
186209
.await,
187210
)
@@ -200,12 +223,30 @@ where
200223
})
201224
.collect())
202225
} else {
203-
Ok(results
204-
.into_iter()
205-
.collect::<Result<Vec<_>>>()?
206-
.into_iter()
207-
.flatten()
208-
.collect())
226+
// A terminal undetermined outcome must never be masked by another shard's
227+
// determinate error. `collect::<Result<_>>()` would return the first error
228+
// by shard index, so if a lower-index shard failed with (say) a gRPC error
229+
// while a higher-index shard reported `UndeterminedResult`, the caller would
230+
// be told the request definitely failed — and might replay a transaction
231+
// that may already be durable. So an undetermined error
232+
// from ANY shard wins over a determinate one.
233+
let mut oks = Vec::with_capacity(results.len());
234+
let mut first_err: Option<Error> = None;
235+
let mut undetermined: Option<Error> = None;
236+
for r in results {
237+
match r {
238+
Ok(v) => oks.push(v),
239+
Err(e) if undetermined.is_none() && is_undetermined_region_error(&e) => {
240+
undetermined = Some(e)
241+
}
242+
Err(e) if first_err.is_none() => first_err = Some(e),
243+
Err(_) => {}
244+
}
245+
}
246+
if let Some(e) = undetermined.or(first_err) {
247+
return Err(e);
248+
}
249+
Ok(oks.into_iter().flatten().collect())
209250
}
210251
}
211252

@@ -217,6 +258,7 @@ where
217258
mut backoff: Backoff,
218259
permits: Arc<Semaphore>,
219260
preserve_region_results: bool,
261+
terminal_on_undetermined: bool,
220262
) -> Result<<Self as Plan>::Result> {
221263
let region_ver_id = region.ver_id();
222264
let store_id = region.get_store_id().ok();
@@ -243,6 +285,7 @@ where
243285
backoff,
244286
permits,
245287
preserve_region_results,
288+
terminal_on_undetermined,
246289
err,
247290
)
248291
.await;
@@ -266,6 +309,7 @@ where
266309
backoff,
267310
permits,
268311
preserve_region_results,
312+
terminal_on_undetermined,
269313
e,
270314
)
271315
.await;
@@ -284,6 +328,13 @@ where
284328
"single_shard_handler:execute: region error: {:?}, region: {:?}",
285329
e, region_ver_id
286330
);
331+
// See `terminal_on_undetermined`: for CAS and commit points, an unknown
332+
// apply outcome must surface on FIRST sight — a replay could contradict
333+
// its own effect, and a later different error must not overwrite the
334+
// uncertainty. The caller classifies (commit maps it to UndeterminedError).
335+
if terminal_on_undetermined && e.undetermined_result.is_some() {
336+
return Err(Error::RegionError(Box::new(e)));
337+
}
287338
match backoff.next_delay_duration() {
288339
Some(duration) => {
289340
let region_error_resolved =
@@ -298,6 +349,7 @@ where
298349
backoff,
299350
permits,
300351
preserve_region_results,
352+
terminal_on_undetermined,
301353
)
302354
.await
303355
}
@@ -323,6 +375,7 @@ where
323375
mut backoff: Backoff,
324376
permits: Arc<Semaphore>,
325377
preserve_region_results: bool,
378+
terminal_on_undetermined: bool,
326379
e: Error,
327380
) -> Result<<Self as Plan>::Result> {
328381
debug!("handle_other_error: {:?}", e);
@@ -341,6 +394,7 @@ where
341394
backoff,
342395
permits,
343396
preserve_region_results,
397+
terminal_on_undetermined,
344398
)
345399
.await
346400
}
@@ -392,6 +446,15 @@ pub(crate) async fn handle_region_error<PdC: PdClient>(
392446
} else if e.stale_command.is_some() || e.region_not_found.is_some() {
393447
pd_client.invalidate_region_cache(ver_id).await;
394448
Ok(false)
449+
} else if e.undetermined_result.is_some() {
450+
// The apply outcome is UNKNOWN (a raft timeout, errorpb.UndeterminedResult).
451+
// Default: retry — matching client-go's ACTION layers (ordinary prewrites and
452+
// secondary commits back off and re-send; re-applying an idempotent request
453+
// resolves the uncertainty). Routing is not suspect, so nothing is
454+
// invalidated. On backoff exhaustion the error escapes UNCHANGED, and commit
455+
// paths classify it via `is_undetermined_region_error`. Plans for which a
456+
// replay is unsafe never reach this arm — see `terminal_on_undetermined`.
457+
Ok(false)
395458
} else if e.server_is_busy.is_some()
396459
|| e.raft_entry_too_large.is_some()
397460
|| e.max_timestamp_not_synced.is_some()
@@ -457,6 +520,7 @@ impl<P: Plan, PdC: PdClient> Clone for RetryableMultiRegion<P, PdC> {
457520
pd_client: self.pd_client.clone(),
458521
backoff: self.backoff.clone(),
459522
preserve_region_results: self.preserve_region_results,
523+
terminal_on_undetermined: self.terminal_on_undetermined,
460524
}
461525
}
462526
}
@@ -479,6 +543,7 @@ where
479543
self.backoff.clone(),
480544
concurrency_permits.clone(),
481545
self.preserve_region_results,
546+
self.terminal_on_undetermined,
482547
)
483548
.await
484549
}
@@ -1042,6 +1107,7 @@ mod test {
10421107
pd_client: Arc::new(MockPdClient::default()),
10431108
backoff: Backoff::no_backoff(),
10441109
preserve_region_results: false,
1110+
terminal_on_undetermined: false,
10451111
};
10461112
assert!(plan.execute().await.is_err())
10471113
}
@@ -1062,4 +1128,172 @@ mod test {
10621128

10631129
assert_eq!(results, vec![0, 1, 2]);
10641130
}
1131+
1132+
#[derive(Clone)]
1133+
struct UndeterminedPlan;
1134+
1135+
#[async_trait]
1136+
impl Plan for UndeterminedPlan {
1137+
type Result = BatchGetResponse;
1138+
1139+
async fn execute(&self) -> Result<Self::Result> {
1140+
// The server says the apply outcome is UNKNOWN.
1141+
Ok(BatchGetResponse {
1142+
region_error: Some(errorpb::Error {
1143+
undetermined_result: Some(errorpb::UndeterminedResult::default()),
1144+
..Default::default()
1145+
}),
1146+
..Default::default()
1147+
})
1148+
}
1149+
}
1150+
1151+
impl Shardable for UndeterminedPlan {
1152+
type Shard = ();
1153+
1154+
fn shards(
1155+
&self,
1156+
_: &Arc<impl crate::pd::PdClient>,
1157+
) -> BoxStream<'static, crate::Result<(Self::Shard, RegionWithLeader)>> {
1158+
Box::pin(stream::iter(vec![Ok(((), MockPdClient::region1()))])).boxed()
1159+
}
1160+
1161+
fn apply_shard(&mut self, _: Self::Shard) {}
1162+
1163+
fn apply_store(&mut self, _: &crate::store::RegionStore) -> Result<()> {
1164+
Ok(())
1165+
}
1166+
}
1167+
1168+
#[tokio::test]
1169+
async fn an_undetermined_result_is_terminal_on_first_sight_for_optedin_plans() {
1170+
// CAS and commit points: a replay could contradict its own effect, and a
1171+
// later different error must not overwrite the uncertainty. A GENEROUS
1172+
// backoff proves the error is terminal, not merely retry-exhausted.
1173+
let plan = RetryableMultiRegion {
1174+
inner: UndeterminedPlan,
1175+
pd_client: Arc::new(MockPdClient::default()),
1176+
backoff: Backoff::no_jitter_backoff(1, 2, 10),
1177+
preserve_region_results: false,
1178+
terminal_on_undetermined: true,
1179+
};
1180+
let err = plan.execute().await.unwrap_err();
1181+
assert!(
1182+
is_undetermined_region_error(&err),
1183+
"want the undetermined region error surfaced unchanged, got {err:?}"
1184+
);
1185+
}
1186+
1187+
#[tokio::test]
1188+
async fn an_undetermined_result_stays_recognizable_when_retries_exhaust() {
1189+
// Everything else retries (client-go's action-layer behavior for ordinary
1190+
// prewrites and secondary commits); when the backoff exhausts, the error
1191+
// must escape UNCHANGED so commit paths can still classify it as undetermined.
1192+
let plan = RetryableMultiRegion {
1193+
inner: UndeterminedPlan,
1194+
pd_client: Arc::new(MockPdClient::default()),
1195+
backoff: Backoff::no_backoff(),
1196+
preserve_region_results: false,
1197+
terminal_on_undetermined: false,
1198+
};
1199+
let err = plan.execute().await.unwrap_err();
1200+
assert!(
1201+
is_undetermined_region_error(&err),
1202+
"want the undetermined region error preserved, got {err:?}"
1203+
);
1204+
}
1205+
1206+
#[test]
1207+
fn undetermined_region_errors_are_recognized() {
1208+
// errorpb.UndeterminedResult: the apply outcome is UNKNOWN. The commit path
1209+
// must map this to UndeterminedError — a plain failure would invite the caller
1210+
// to retry effects that may already be durable.
1211+
let undetermined = Error::RegionError(Box::new(errorpb::Error {
1212+
undetermined_result: Some(errorpb::UndeterminedResult::default()),
1213+
..Default::default()
1214+
}));
1215+
assert!(is_undetermined_region_error(&undetermined));
1216+
1217+
let busy = Error::RegionError(Box::new(errorpb::Error {
1218+
server_is_busy: Some(errorpb::ServerIsBusy::default()),
1219+
..Default::default()
1220+
}));
1221+
assert!(!is_undetermined_region_error(&busy));
1222+
assert!(!is_undetermined_region_error(&Error::StringError(
1223+
"x".to_owned()
1224+
)));
1225+
}
1226+
1227+
/// A two-shard plan: the LOWER-index shard fails determinately, the HIGHER-index
1228+
/// shard reports `UndeterminedResult`. Reproduces the masking hazard the shard
1229+
/// aggregation guards against.
1230+
#[derive(Clone)]
1231+
struct MaskingPlan {
1232+
idx: usize,
1233+
}
1234+
1235+
#[async_trait]
1236+
impl Plan for MaskingPlan {
1237+
type Result = BatchGetResponse;
1238+
1239+
async fn execute(&self) -> Result<Self::Result> {
1240+
if self.idx == 0 {
1241+
// Determinate, lower index — the error that MUST NOT win.
1242+
Err(Error::Unimplemented)
1243+
} else {
1244+
// Undetermined, higher index.
1245+
Ok(BatchGetResponse {
1246+
region_error: Some(errorpb::Error {
1247+
undetermined_result: Some(errorpb::UndeterminedResult::default()),
1248+
..Default::default()
1249+
}),
1250+
..Default::default()
1251+
})
1252+
}
1253+
}
1254+
}
1255+
1256+
impl Shardable for MaskingPlan {
1257+
type Shard = usize;
1258+
1259+
fn shards(
1260+
&self,
1261+
_: &Arc<impl crate::pd::PdClient>,
1262+
) -> BoxStream<'static, crate::Result<(Self::Shard, RegionWithLeader)>> {
1263+
Box::pin(stream::iter(vec![
1264+
Ok((0usize, MockPdClient::region1())),
1265+
Ok((1usize, MockPdClient::region2())),
1266+
]))
1267+
.boxed()
1268+
}
1269+
1270+
fn apply_shard(&mut self, shard: Self::Shard) {
1271+
self.idx = shard;
1272+
}
1273+
1274+
fn apply_store(&mut self, _: &crate::store::RegionStore) -> Result<()> {
1275+
Ok(())
1276+
}
1277+
}
1278+
1279+
#[tokio::test]
1280+
async fn an_undetermined_shard_is_not_masked_by_another_shards_error() {
1281+
// THE MASKING HAZARD. Shard 0 fails determinately (lower index); shard 1 is
1282+
// undetermined. First-error-by-index would report shard 0's determinate
1283+
// failure and hide the fact that shard 1 may have applied — claiming clean
1284+
// failure for a write that may be durable.
1285+
// The aggregation must surface the undetermined error instead.
1286+
let plan = RetryableMultiRegion {
1287+
inner: MaskingPlan { idx: 0 },
1288+
pd_client: Arc::new(MockPdClient::default()),
1289+
backoff: Backoff::no_backoff(),
1290+
preserve_region_results: false,
1291+
terminal_on_undetermined: true,
1292+
};
1293+
let err = plan.execute().await.unwrap_err();
1294+
assert!(
1295+
is_undetermined_region_error(&err),
1296+
"the undetermined shard must win over the determinate one, got {err:?}"
1297+
);
1298+
}
10651299
}

0 commit comments

Comments
 (0)