Skip to content

Commit fcfd0ea

Browse files
mysql-util: rework partitioner walk into a BFS with surrogate sort keys
Simplify children_prefixes into a clean prefix walk that accepts skipping exact keys shorter than the probe depth. Split breadth first with a coarse target of 1/max(workers, 8) of the table, restore key order via per-parent ordinal sort keys instead of client-side key comparison, and fold boundary assignment into compute_boundaries. Estimates are u64 end to end and a missing optimizer estimate is now a named MissingRowEstimate error raised inside estimate_range_rows.
1 parent 500c1ad commit fcfd0ea

3 files changed

Lines changed: 155 additions & 216 deletions

File tree

src/mysql-util/src/lib.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,16 @@ pub enum MySqlError {
111111
column_name: String,
112112
error: String,
113113
},
114+
#[error(
115+
"missing row estimate in '{qualified_table_name}' for key range ({lower_bound}, {upper_bound})"
116+
)]
117+
MissingRowEstimate {
118+
qualified_table_name: String,
119+
/// Redacted at construction, safe to log.
120+
lower_bound: String,
121+
/// Redacted at construction, safe to log.
122+
upper_bound: String,
123+
},
114124
#[error("unsupported data types: {columns:?}")]
115125
UnsupportedDataTypes { columns: Vec<UnsupportedDataType> },
116126
#[error("duplicated column names in table '{qualified_table_name}': {columns:?}")]

src/mysql-util/src/partition.rs

Lines changed: 91 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,15 @@
88
// by the Apache License, Version 2.0.
99

1010
use mz_ore::cast::CastLossy;
11+
use mz_ore::str::redact;
1112

1213
use crate::{KeyProber, MySqlError, QualifiedTableRef};
1314

14-
/// When partitioning the data, how many ranges per worker should we break the
15-
/// keyspace into. This helps avoid underestimates for large row counts resulting
16-
/// in severe skew.
17-
const TARGET_RANGES_PER_WORKER: f64 = 8.0;
18-
1915
/// Computes up to `num_workers - 1` partition boundaries that divide the primary key space
20-
/// into `num_workers` roughly even partitions. This should be run in a transaction.
16+
/// into `num_workers` roughly even partitions.
17+
/// This should be run in a repeatable read transaction against a primary key varchar/char column
18+
/// with collation that compares character by character (no contractions, expansions or ignorable
19+
/// characters).
2120
pub async fn partition_table(
2221
conn: &mut mysql_async::Conn,
2322
table: QualifiedTableRef<'_>,
@@ -38,22 +37,26 @@ pub async fn partition_table(
3837
tracing::trace!(
3938
schema = schema_name,
4039
table = table_name,
41-
?boundaries,
40+
// The boundaries are user data, redacted outside of CI.
41+
boundaries = ?redact(&boundaries),
4242
"partitioned table by pk prefix"
4343
);
4444
Ok(boundaries)
4545
}
4646

4747
#[derive(Debug)]
48-
struct Range {
49-
/// `None` for the beginning of the key space.
50-
prefix: Option<String>,
51-
/// Exclusive end, `None` for the final open range.
48+
struct Prefix {
49+
/// Empty for the beginning of the key space.
50+
prefix: String,
51+
/// Exclusive end, `None` for the final open prefix.
5252
end: Option<String>,
53-
/// Row estimate for the range, at least 1.
54-
estimated_rows: f64,
55-
/// Prefix length this range was split at.
53+
/// Row estimate for the prefix, at least 1.
54+
estimated_rows: u64,
55+
/// Length this prefix was split at.
5656
depth: usize,
57+
/// Use the position within each parent as a surrogate sort key to maintain the sort ordering
58+
/// specified by MySQL.
59+
surrogate_sort_key: Vec<usize>,
5760
}
5861

5962
async fn partition(
@@ -65,178 +68,122 @@ async fn partition(
6568
if workers <= 1 {
6669
return Ok(Vec::new());
6770
}
68-
let estimated_row_count = f64::cast_lossy(estimated_row_count.max(1));
69-
let min_rows_per_worker = f64::cast_lossy(min_rows_per_worker.max(1));
70-
let target_max_rows_per_range =
71-
get_target_max_rows_per_range(workers, estimated_row_count, min_rows_per_worker);
72-
// 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?;
74-
Ok(assign_boundaries(&ranges, workers))
75-
}
71+
let estimated_row_count = estimated_row_count.max(1);
7672

77-
fn get_target_max_rows_per_range(
78-
workers: usize,
79-
estimated_row_count: f64,
80-
min_rows_per_worker: f64,
81-
) -> f64 {
82-
// Break up the key space into smaller ranges to more accurately rebuild the per-worker ranges later with less skew.
83-
let estimated_rows_per_worker =
84-
(estimated_row_count / f64::cast_lossy(workers)).max(min_rows_per_worker);
85-
// Respect min_rows_per_worker as a lower bound for the granularity with which we attempt to break up the table.
86-
// No need to add this overhead for small tables. Estimate accuracy isn't super clear for small numbers.
87-
let target_rows_per_range =
88-
(estimated_rows_per_worker / TARGET_RANGES_PER_WORKER).max(min_rows_per_worker);
73+
// Estimates vary wildly especially near the full table size (see `KeyProber::estimate_range_rows` for more details).
74+
// Estimates tend to get more useful as smaller chunks, so break up the table into at least 1/8ths before selecting partitions.
75+
// Prefixes estimate at least one row, so a target below one never
76+
// converges.
77+
let target_max_rows_per_prefix = (f64::cast_lossy(estimated_row_count)
78+
/ f64::cast_lossy(workers.max(8)))
79+
.max(f64::cast_lossy(min_rows_per_worker))
80+
.max(1.0);
8981

90-
tracing::debug!(
91-
estimated_row_count,
92-
workers,
93-
estimated_rows_per_worker,
94-
target_rows_per_range,
95-
"partitioning key space"
96-
);
97-
target_rows_per_range
82+
compute_boundaries(db, workers, estimated_row_count, target_max_rows_per_prefix).await
9883
}
9984

100-
async fn split_into_ranges(
85+
async fn compute_boundaries(
10186
db: &mut KeyProber<'_>,
102-
estimated_row_count: f64,
103-
target_rows_per_bucket: f64,
104-
) -> Result<Vec<Range>, MySqlError> {
105-
let mut ranges = vec![Range {
106-
prefix: None,
87+
workers: usize,
88+
estimated_row_count: u64,
89+
target_rows_per_prefix: f64,
90+
) -> Result<Vec<String>, MySqlError> {
91+
// BFS of prefixes, splitting until estimates fall under the target.
92+
let mut final_prefixes: Vec<Prefix> = vec![];
93+
let mut pending_prefixes = vec![Prefix {
94+
prefix: String::new(),
10795
end: None,
10896
estimated_rows: estimated_row_count,
10997
depth: 0,
98+
surrogate_sort_key: Vec::new(),
11099
}];
111-
loop {
112-
let mut split_any = false;
113-
let mut next: Vec<Range> = Vec::with_capacity(ranges.len());
114-
for range in ranges {
115-
if range.estimated_rows > target_rows_per_bucket {
116-
split_any = true;
117-
next.extend(split_range(db, &range, target_rows_per_bucket).await?);
118-
} else {
119-
next.push(range);
100+
101+
while !pending_prefixes.is_empty() {
102+
let mut next: Vec<Prefix> = Vec::with_capacity(pending_prefixes.len());
103+
for prefix in pending_prefixes {
104+
for child in children_prefixes(db, &prefix).await? {
105+
if f64::cast_lossy(child.estimated_rows) > target_rows_per_prefix {
106+
next.push(child);
107+
} else {
108+
final_prefixes.push(child);
109+
}
120110
}
121111
}
122-
ranges = next;
123-
if !split_any {
124-
break;
125-
}
112+
pending_prefixes = next;
126113
}
127-
Ok(ranges)
128-
}
114+
final_prefixes.sort_unstable_by(|a, b| a.surrogate_sort_key.cmp(&b.surrogate_sort_key));
129115

130-
/// Accumulates `ranges` (in key order) into `workers` buckets of roughly
131-
/// equal estimated rows and returns the bucket edges as boundaries.
132-
fn assign_boundaries(ranges: &[Range], workers: usize) -> Vec<String> {
133-
// Emit a boundary each time the cumulative estimated rows pass the next
134-
// worker's share.
135-
let total: f64 = ranges.iter().map(|r| r.estimated_rows).sum();
116+
// Recompute the total after partitioning the table to get more even splits because the actual row count and the
117+
// granularly estimated row count can diverge from the original top level estimate.
118+
let total: f64 = final_prefixes
119+
.iter()
120+
.map(|r| f64::cast_lossy(r.estimated_rows))
121+
.sum();
136122
let per_worker = total / f64::cast_lossy(workers);
137123
tracing::debug!(
138-
ranges = ranges.len(),
124+
prefixes = final_prefixes.len(),
139125
total_estimated_rows = total,
140126
per_worker,
141-
"assigning prefix ranges to workers"
127+
"assigning prefixes to workers"
142128
);
143129
let mut boundaries: Vec<String> = Vec::with_capacity(workers - 1);
144130
let mut rows_seen = 0.0;
145-
for range in ranges {
131+
for prefix in &final_prefixes {
146132
if boundaries.len() == workers - 1 {
147133
break;
148134
}
149-
rows_seen += range.estimated_rows;
135+
rows_seen += f64::cast_lossy(prefix.estimated_rows);
150136
if rows_seen >= f64::cast_lossy(boundaries.len() + 1) * per_worker {
151-
// The final range's end is None (open), it can never be a boundary.
152-
if let Some(end) = &range.end {
137+
// The final prefix's end is None (open), it can never be a boundary.
138+
if let Some(end) = &prefix.end {
153139
boundaries.push(end.clone());
154140
}
155141
}
156142
}
157-
boundaries
143+
Ok(boundaries)
158144
}
159145

160-
/// Splits `parent` at every distinct key prefix one character longer than the
161-
/// prefix `parent`, i.e. "a" depth: 1 for a table with keys "a", "aa", "aaa", "ab" would be split to
162-
/// "a" depth: 2 estimate 1, "aa" depth: 2 estimate 2, "ab" depth 2, estimate 1.
163-
async fn split_range(
146+
/// Splits `parent` into prefixes one character longer. i.e. prefix "a", upper bound "b" in table
147+
/// with pks: ["a", "ab", "abc", "abd", "af", "bb"] will return: ["ab", "af"].
148+
///
149+
/// Note: This will drop the key "a" on the floor. We accept this because we only lose
150+
/// at max one row per prefix we step deeper into.
151+
async fn children_prefixes(
164152
db: &mut KeyProber<'_>,
165-
parent: &Range,
166-
target_rows: f64,
167-
) -> Result<Vec<Range>, MySqlError> {
153+
parent: &Prefix,
154+
) -> Result<Vec<Prefix>, MySqlError> {
168155
let depth = parent.depth + 1;
169156
let mut children = Vec::new();
170157

158+
// Guaranteed to return None or a key longer than the current prefix assuming the upper
159+
// bound correctly caps keys to the current prefix and we're in a transaction where
160+
// new keys with a shorter length can't be inserted. Note that this only holds for
161+
// collations that sort character-by-character.
171162
let Some(mut cur) = db
172-
.prefix_of_first_key_in_range(parent.prefix.as_deref(), parent.end.as_deref(), depth)
163+
.prefix_of_first_key_in_range(&parent.prefix, parent.end.as_deref(), depth)
173164
.await?
174165
else {
175166
return Ok(children);
176167
};
177-
// The first child inherits the parent's start.
178-
let mut start = parent.prefix.clone();
179168

180169
loop {
181-
// Small optimization to return early if the remaining rows past the current start prefix
182-
// fits within the threshold we're looking for.
183-
// A missing optimizer estimate reads as an empty range, which the
184-
// splitting loop drops or leaves unsplit.
185-
let remaining = db
186-
.estimate_range_rows(start.as_deref(), parent.end.as_deref())
187-
.await?
188-
.unwrap_or(0);
189-
let remaining = f64::cast_lossy(remaining).max(1.0);
190-
if remaining <= target_rows {
191-
children.push(Range {
192-
prefix: start,
193-
end: parent.end.clone(),
194-
estimated_rows: remaining,
195-
depth,
196-
});
197-
return Ok(children);
198-
}
199-
// When `cur` is an exact key shorter than `depth`, every key extending
200-
// it matches `cur`, so next_prefix exhausts even though those
201-
// extensions still need visiting. The first key past `cur` exposes
202-
// them, so the walk can keep splitting instead of retrying the whole
203-
// range at greater depths forever.
204-
let next = match db
170+
let next = db
205171
.prefix_of_first_row_not_matching_prefix(&cur, parent.end.as_deref(), depth)
206-
.await?
207-
{
208-
Some(next) => Some(next),
209-
None => {
210-
db.prefix_of_first_key_in_range(Some(&cur), parent.end.as_deref(), depth)
211-
.await?
212-
}
213-
};
214-
// A prefix equal to `cur` cannot advance the walk (only a misbehaving
215-
// server produces one), stop splitting here.
216-
match next.filter(|next| next != &cur) {
217-
Some(next) => {
218-
let estimated_rows = db
219-
.estimate_range_rows(start.as_deref(), Some(&next))
220-
.await?
221-
.unwrap_or(0);
222-
children.push(Range {
223-
prefix: start.clone(),
224-
end: Some(next.clone()),
225-
estimated_rows: f64::cast_lossy(estimated_rows).max(1.0),
226-
depth,
227-
});
228-
start = Some(next.clone());
229-
cur = next;
230-
}
231-
None => {
232-
children.push(Range {
233-
prefix: start,
234-
end: parent.end.clone(),
235-
estimated_rows: remaining,
236-
depth,
237-
});
238-
return Ok(children);
239-
}
172+
.await?;
173+
let end = next.clone().or_else(|| parent.end.clone());
174+
let estimated_rows = db.estimate_range_rows(&cur, end.as_deref()).await?;
175+
let mut surrogate_sort_key = parent.surrogate_sort_key.clone();
176+
surrogate_sort_key.push(children.len());
177+
children.push(Prefix {
178+
prefix: cur,
179+
end: end.clone(),
180+
estimated_rows: estimated_rows.max(1),
181+
depth,
182+
surrogate_sort_key,
183+
});
184+
match next {
185+
Some(next) => cur = next,
186+
None => return Ok(children),
240187
}
241188
}
242189
}

0 commit comments

Comments
 (0)