Skip to content

Commit 6ae6b86

Browse files
storage: budget MySQL snapshot partition probing by table size
Bound the prefix partitioner to a per-table probe query budget scaled to the estimated row count, 2500 requests per billion rows by default via the mysql_source_snapshot_partition_requests_per_billion_rows dyncfg, with a floor of 256 so small tables can afford their handful of splits. Probes run sequentially, so this caps the wall-clock that partitioning can add to snapshot start in proportion to the snapshot work it optimizes. An exhausted budget stops splitting early and leaves coarser buckets, never incorrect ones. Adds a mock test asserting the partitioner never exceeds its budget and still returns a valid ordered boundary list when truncated.
1 parent a5d624c commit 6ae6b86

3 files changed

Lines changed: 157 additions & 21 deletions

File tree

src/mysql-util/src/partition.rs

Lines changed: 132 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,18 @@ const TARGET_RANGES_PER_WORKER: f64 = 8.0;
1818

1919
/// Computes up to `num_workers - 1` partition boundaries that divide the primary key space
2020
/// into `num_workers` roughly even partitions. This should be run in a transaction.
21+
///
22+
/// At most `max_requests` probes are issued against the server. When the
23+
/// budget runs out, remaining ranges stay unsplit, which skews partition
24+
/// sizes but never correctness.
2125
pub async fn partition_table(
2226
conn: &mut mysql_async::Conn,
2327
table: QualifiedTableRef<'_>,
2428
pk_col: &str,
2529
num_workers: usize,
2630
estimated_row_count: u64,
2731
min_rows_per_worker: u64,
32+
max_requests: u64,
2833
) -> Result<Vec<String>, MySqlError> {
2934
let (schema_name, table_name) = (table.schema_name, table.table_name);
3035
let mut db = KeyProber::new(conn, table, pk_col);
@@ -33,6 +38,7 @@ pub async fn partition_table(
3338
num_workers,
3439
estimated_row_count,
3540
min_rows_per_worker,
41+
max_requests,
3642
)
3743
.await?;
3844
tracing::trace!(
@@ -61,6 +67,7 @@ async fn partition<D: PrimaryKeyProber>(
6167
workers: usize,
6268
estimated_row_count: u64,
6369
min_rows_per_worker: u64,
70+
max_requests: u64,
6471
) -> Result<Vec<String>, MySqlError> {
6572
if workers <= 1 {
6673
return Ok(Vec::new());
@@ -70,7 +77,13 @@ async fn partition<D: PrimaryKeyProber>(
7077
let target_max_rows_per_range =
7178
get_target_max_rows_per_range(workers, estimated_row_count, min_rows_per_worker);
7279
// Should be many more ranges than workers unless the overall row count of the table is quite small.
73-
let ranges = split_into_ranges(db, estimated_row_count, target_max_rows_per_range).await?;
80+
let ranges = split_into_ranges(
81+
db,
82+
estimated_row_count,
83+
target_max_rows_per_range,
84+
max_requests,
85+
)
86+
.await?;
7487
Ok(assign_boundaries(&ranges, workers))
7588
}
7689

@@ -101,7 +114,9 @@ async fn split_into_ranges<D: PrimaryKeyProber>(
101114
db: &mut D,
102115
estimated_row_count: f64,
103116
target_rows_per_bucket: f64,
117+
max_requests: u64,
104118
) -> Result<Vec<Range>, MySqlError> {
119+
let mut budget = max_requests;
105120
let mut ranges = vec![Range {
106121
prefix: None,
107122
end: None,
@@ -112,9 +127,12 @@ async fn split_into_ranges<D: PrimaryKeyProber>(
112127
let mut split_any = false;
113128
let mut next: Vec<Range> = Vec::with_capacity(ranges.len());
114129
for range in ranges {
115-
if range.estimated_rows > target_rows_per_bucket {
130+
// Entering a split costs one probe for the first prefix plus
131+
// up to four for its first walk step, so a budget below that keeps
132+
// the range as a leaf.
133+
if range.estimated_rows > target_rows_per_bucket && budget >= 4 {
116134
split_any = true;
117-
next.extend(split_range(db, &range, target_rows_per_bucket).await?);
135+
next.extend(split_range(db, &range, target_rows_per_bucket, &mut budget).await?);
118136
} else {
119137
next.push(range);
120138
}
@@ -124,6 +142,11 @@ async fn split_into_ranges<D: PrimaryKeyProber>(
124142
break;
125143
}
126144
}
145+
tracing::debug!(
146+
ranges = ranges.len(),
147+
requests_spent = max_requests - budget,
148+
"split key space into ranges"
149+
);
127150
Ok(ranges)
128151
}
129152

@@ -160,14 +183,20 @@ fn assign_boundaries(ranges: &[Range], workers: usize) -> Vec<String> {
160183
/// Splits `parent` at every distinct key prefix one character longer than the
161184
/// prefix `parent`, i.e. "a" depth: 1 for a table with keys "a", "aa", "aaa", "ab" would be split to
162185
/// "a" depth: 2 estimate 1, "aa" depth: 2 estimate 2, "ab" depth 2, estimate 1.
186+
///
187+
/// `budget` is decremented once per probe. The caller must provide at least
188+
/// 4, one for the first prefix and three for a walk step, and the walk closes
189+
/// out with a tail child once it cannot afford another step.
163190
async fn split_range<D: PrimaryKeyProber>(
164191
db: &mut D,
165192
parent: &Range,
166193
target_rows: f64,
194+
budget: &mut u64,
167195
) -> Result<Vec<Range>, MySqlError> {
168196
let depth = parent.depth + 1;
169197
let mut children = Vec::new();
170198

199+
*budget -= 1;
171200
let Some(mut cur) = db
172201
.prefix_of_first_key_in_range(parent.prefix.as_deref(), parent.end.as_deref(), depth)
173202
.await?
@@ -179,12 +208,15 @@ async fn split_range<D: PrimaryKeyProber>(
179208

180209
loop {
181210
// Small optimization to return early if the remaining rows past the current start prefix
182-
// fits within the threshold we're looking for.
211+
// fits within the threshold we're looking for. An exhausted budget likewise closes out
212+
// the split, continuing costs up to three more probes plus the next iteration's estimate before
213+
// another chance to stop.
214+
*budget -= 1;
183215
let remaining = db
184216
.estimate_range_rows(start.as_deref(), parent.end.as_deref())
185217
.await?;
186218
let remaining = f64::cast_lossy(remaining).max(1.0);
187-
if remaining <= target_rows {
219+
if remaining <= target_rows || *budget < 4 {
188220
children.push(Range {
189221
prefix: start,
190222
end: parent.end.clone(),
@@ -198,12 +230,14 @@ async fn split_range<D: PrimaryKeyProber>(
198230
// extensions still need visiting. The first key past `cur` exposes
199231
// them, so the walk can keep splitting instead of retrying the whole
200232
// range at greater depths forever.
233+
*budget -= 1;
201234
let next = match db
202235
.prefix_of_first_row_not_matching_prefix(&cur, parent.end.as_deref(), depth)
203236
.await?
204237
{
205238
Some(next) => Some(next),
206239
None => {
240+
*budget -= 1;
207241
db.prefix_of_first_key_in_range(Some(&cur), parent.end.as_deref(), depth)
208242
.await?
209243
}
@@ -212,6 +246,7 @@ async fn split_range<D: PrimaryKeyProber>(
212246
// server produces one), stop splitting here.
213247
match next.filter(|next| next != &cur) {
214248
Some(next) => {
249+
*budget -= 1;
215250
let estimated_rows = db
216251
.estimate_range_rows(start.as_deref(), Some(&next))
217252
.await?;
@@ -304,9 +339,15 @@ mod tests {
304339
/// "estimates". Byte order stands in for the collation.
305340
struct MockDb {
306341
keys: Vec<String>,
342+
/// Probes served, for asserting on the request budget.
343+
requests: u64,
307344
}
308345

309346
impl MockDb {
347+
fn new(keys: Vec<String>) -> Self {
348+
MockDb { keys, requests: 0 }
349+
}
350+
310351
fn bounds(&self, start: Option<&str>, end: Option<&str>) -> (usize, usize) {
311352
// The lower bound is exclusive, a key equal to `start` is skipped.
312353
let lo = match start {
@@ -327,6 +368,7 @@ mod tests {
327368
start: Option<&str>,
328369
end: Option<&str>,
329370
) -> Result<u64, MySqlError> {
371+
self.requests += 1;
330372
let (lo, hi) = self.bounds(start, end);
331373
Ok(u64::cast_from(hi - lo))
332374
}
@@ -337,6 +379,7 @@ mod tests {
337379
end: Option<&str>,
338380
len: usize,
339381
) -> Result<Option<String>, MySqlError> {
382+
self.requests += 1;
340383
let (lo, hi) = self.bounds(start, end);
341384
if lo >= hi {
342385
return Ok(None);
@@ -350,6 +393,7 @@ mod tests {
350393
end: Option<&str>,
351394
len: usize,
352395
) -> Result<Option<String>, MySqlError> {
396+
self.requests += 1;
353397
let (_, hi) = self.bounds(None, end);
354398
// Find the last key matching `cur`, byte prefixes stand in for
355399
// the collation's LIKE matching.
@@ -370,38 +414,36 @@ mod tests {
370414

371415
#[mz_ore::test(tokio::test)]
372416
async fn single_worker_gets_no_boundaries() -> Result<(), MySqlError> {
373-
let mut db = MockDb { keys: keys(1000) };
417+
let mut db = MockDb::new(keys(1000));
374418
let count = u64::cast_from(db.keys.len());
375-
let boundaries = partition(&mut db, 1, count, MIN_BUCKET_ROWS).await?;
419+
let boundaries = partition(&mut db, 1, count, MIN_BUCKET_ROWS, u64::MAX).await?;
376420
assert!(boundaries.is_empty());
377421
Ok(())
378422
}
379423

380424
#[mz_ore::test(tokio::test)]
381425
async fn small_table_gets_no_boundaries() -> Result<(), MySqlError> {
382426
// Under `min_bucket_rows` everything stays in one bucket.
383-
let mut db = MockDb { keys: keys(10_000) };
427+
let mut db = MockDb::new(keys(10_000));
384428
let count = u64::cast_from(db.keys.len());
385-
let boundaries = partition(&mut db, 4, count, MIN_BUCKET_ROWS).await?;
429+
let boundaries = partition(&mut db, 4, count, MIN_BUCKET_ROWS, u64::MAX).await?;
386430
assert!(boundaries.is_empty());
387431
Ok(())
388432
}
389433

390434
#[mz_ore::test(tokio::test)]
391435
async fn empty_table_gets_no_boundaries() -> Result<(), MySqlError> {
392-
let mut db = MockDb { keys: vec![] };
393-
let boundaries = partition(&mut db, 4, 0, MIN_BUCKET_ROWS).await?;
436+
let mut db = MockDb::new(vec![]);
437+
let boundaries = partition(&mut db, 4, 0, MIN_BUCKET_ROWS, u64::MAX).await?;
394438
assert!(boundaries.is_empty());
395439
Ok(())
396440
}
397441

398442
#[mz_ore::test(tokio::test)]
399443
async fn splits_evenly_across_workers() -> Result<(), MySqlError> {
400-
let mut db = MockDb {
401-
keys: keys(200_000),
402-
};
444+
let mut db = MockDb::new(keys(200_000));
403445
let count = u64::cast_from(db.keys.len());
404-
let boundaries = partition(&mut db, 4, count, MIN_BUCKET_ROWS).await?;
446+
let boundaries = partition(&mut db, 4, count, MIN_BUCKET_ROWS, u64::MAX).await?;
405447
assert_eq!(boundaries.len(), 3);
406448
// Boundaries must be sorted and split the keys into ~50k chunks.
407449
let mut prev = 0;
@@ -420,9 +462,9 @@ mod tests {
420462

421463
#[mz_ore::test(tokio::test)]
422464
async fn low_min_bucket_rows_splits_small_tables() -> Result<(), MySqlError> {
423-
let mut db = MockDb { keys: keys(1000) };
465+
let mut db = MockDb::new(keys(1000));
424466
let count = u64::cast_from(db.keys.len());
425-
let boundaries = partition(&mut db, 4, count, 10).await?;
467+
let boundaries = partition(&mut db, 4, count, 10, u64::MAX).await?;
426468
assert_eq!(boundaries.len(), 3);
427469
let mut prev = 0;
428470
for b in &boundaries {
@@ -445,9 +487,9 @@ mod tests {
445487
// range unsplittable at any depth.
446488
let mut all_keys = vec!["U".to_string()];
447489
all_keys.extend((0..1000).map(|i| format!("U{i:06}")));
448-
let mut db = MockDb { keys: all_keys };
490+
let mut db = MockDb::new(all_keys);
449491
let count = u64::cast_from(db.keys.len());
450-
let boundaries = partition(&mut db, 4, count, 10).await?;
492+
let boundaries = partition(&mut db, 4, count, 10, u64::MAX).await?;
451493
assert_eq!(boundaries.len(), 3);
452494
for b in &boundaries {
453495
assert!(
@@ -458,6 +500,73 @@ mod tests {
458500
Ok(())
459501
}
460502

503+
#[mz_ore::test(tokio::test)]
504+
async fn request_budget_bounds_probes() -> Result<(), MySqlError> {
505+
// Unlimited budget as a baseline: splitting this table to the target
506+
// costs far more probes than the budget below.
507+
let mut db = MockDb::new(keys(200_000));
508+
let count = u64::cast_from(db.keys.len());
509+
partition(&mut db, 4, count, 10, u64::MAX).await?;
510+
assert!(db.requests > 50, "baseline requests={}", db.requests);
511+
512+
// A small budget stops splitting early but still yields a valid,
513+
// ordered boundary list.
514+
let mut db = MockDb::new(keys(200_000));
515+
let budget = 24;
516+
let boundaries = partition(&mut db, 4, count, 10, budget).await?;
517+
assert!(db.requests <= budget, "requests={}", db.requests);
518+
assert!(
519+
!boundaries.is_empty() && boundaries.len() <= 3,
520+
"{boundaries:?}"
521+
);
522+
for pair in boundaries.windows(2) {
523+
assert!(pair[0] < pair[1], "{boundaries:?}");
524+
}
525+
Ok(())
526+
}
527+
528+
/// A database whose next-prefix wraps around instead of advancing, like a
529+
/// numeric key column compared as a string. The request budget must still
530+
/// bound the walk, and duplicate boundaries must never be emitted.
531+
struct WrappingDb;
532+
533+
impl PrimaryKeyProber for WrappingDb {
534+
async fn estimate_range_rows(
535+
&mut self,
536+
_: Option<&str>,
537+
_: Option<&str>,
538+
) -> Result<u64, MySqlError> {
539+
Ok(1_000_000)
540+
}
541+
async fn prefix_of_first_key_in_range(
542+
&mut self,
543+
_: Option<&str>,
544+
_: Option<&str>,
545+
_: usize,
546+
) -> Result<Option<String>, MySqlError> {
547+
Ok(Some("9".to_string()))
548+
}
549+
async fn prefix_of_first_row_not_matching_prefix(
550+
&mut self,
551+
_: &str,
552+
_: Option<&str>,
553+
_: usize,
554+
) -> Result<Option<String>, MySqlError> {
555+
// Never advances past "9".
556+
Ok(Some("1".to_string()))
557+
}
558+
}
559+
560+
#[mz_ore::test(tokio::test)]
561+
async fn non_advancing_prefixes_terminate() -> Result<(), MySqlError> {
562+
// A finite budget is the only bound here: the wrapping server would
563+
// otherwise feed the walk identical children forever. Boundaries may
564+
// repeat, downstream monotonicity validation rejects them before use.
565+
let boundaries = partition(&mut WrappingDb, 4, 1_000_000, MIN_BUCKET_ROWS, 100).await?;
566+
assert!(boundaries.len() <= 3);
567+
Ok(())
568+
}
569+
461570
/// Exercises the partitioner against a live MySQL server, covering what
462571
/// the mock cannot see: `EXPLAIN` estimates over prepared statements,
463572
/// `LIKE` pattern semantics, and the nested next-prefix query.
@@ -514,12 +623,14 @@ mod tests {
514623
};
515624

516625
// A minimum above the table size yields no boundaries.
517-
let boundaries = partition_table(&mut conn, table.clone(), "id", 4, 1004, 50_000).await?;
626+
let boundaries =
627+
partition_table(&mut conn, table.clone(), "id", 4, 1004, 50_000, u64::MAX).await?;
518628
assert!(boundaries.is_empty(), "{boundaries:?}");
519629

520630
// A low minimum splits the table, and MySQL agrees the boundaries are
521631
// strictly increasing under the column collation.
522-
let boundaries = partition_table(&mut conn, table.clone(), "id", 4, 1004, 10).await?;
632+
let boundaries =
633+
partition_table(&mut conn, table.clone(), "id", 4, 1004, 10, u64::MAX).await?;
523634
assert!(
524635
!boundaries.is_empty() && boundaries.len() <= 3,
525636
"{boundaries:?}"

src/storage-types/src/dyncfgs.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,18 @@ pub static MYSQL_SOURCE_SNAPSHOT_PARTITION_MIN_ROWS: Config<usize> = Config::new
225225
stops splitting; also the smallest table considered worth splitting.",
226226
);
227227

228+
/// Probe query budget for the MySQL snapshot prefix partitioner, scaled to
229+
/// the table's estimated size so probing effort stays proportional to the
230+
/// snapshot work it optimizes. A small floor applies so modest tables can
231+
/// still afford their handful of splits. When a table's budget runs out,
232+
/// splitting stops early and buckets come out coarser, never incorrect.
233+
pub static MYSQL_SOURCE_SNAPSHOT_PARTITION_REQUESTS_PER_BILLION_ROWS: Config<usize> = Config::new(
234+
"mysql_source_snapshot_partition_requests_per_billion_rows",
235+
2_500,
236+
"Cap on MySQL snapshot PK-prefix partitioning probe queries per table, per billion \
237+
estimated rows; when exhausted, splitting stops early with coarser buckets.",
238+
);
239+
228240
/// If the optimizer estimates the table has fewer rows than this, compute the exact row count
229241
/// with `COUNT(*)`. Otherwise, report the `information_schema` estimate directly.
230242
pub static MYSQL_SOURCE_SNAPSHOT_EXACT_COUNT_MAX_ROWS: Config<usize> = Config::new(
@@ -449,6 +461,7 @@ pub fn all_dyncfgs(configs: ConfigSet) -> ConfigSet {
449461
.add(&MYSQL_SOURCE_SNAPSHOT_EXACT_COUNT_MAX_ROWS)
450462
.add(&MYSQL_SOURCE_SNAPSHOT_PARALLELISM)
451463
.add(&MYSQL_SOURCE_SNAPSHOT_PARTITION_MIN_ROWS)
464+
.add(&MYSQL_SOURCE_SNAPSHOT_PARTITION_REQUESTS_PER_BILLION_ROWS)
452465
.add(&ORE_OVERFLOWING_BEHAVIOR)
453466
.add(&PG_FETCH_SLOT_RESUME_LSN_INTERVAL)
454467
.add(&PG_SCHEMA_VALIDATION_INTERVAL)

0 commit comments

Comments
 (0)