@@ -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+
135143pub 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
146167impl < 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