|
1 | 1 | package connmysql |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "container/heap" |
| 5 | + "context" |
4 | 6 | "fmt" |
| 7 | + "log/slog" |
5 | 8 | "math/big" |
6 | 9 | "regexp" |
7 | 10 | "strings" |
8 | 11 |
|
| 12 | + "github.com/go-mysql-org/go-mysql/mysql" |
9 | 13 | "github.com/google/uuid" |
| 14 | + "go.temporal.io/sdk/log" |
10 | 15 |
|
11 | 16 | "github.com/PeerDB-io/peerdb/flow/generated/protos" |
| 17 | + "github.com/PeerDB-io/peerdb/flow/pkg/common" |
12 | 18 | "github.com/PeerDB-io/peerdb/flow/shared" |
13 | 19 | ) |
14 | 20 |
|
@@ -118,3 +124,202 @@ func createStringPartition(start string, end string, endInclusive bool) *protos. |
118 | 124 | }, |
119 | 125 | } |
120 | 126 | } |
| 127 | + |
| 128 | +const ( |
| 129 | + base95Min = ' ' // 0x20, lowest printable ASCII -> digit 0 |
| 130 | + base95Max = '~' // 0x7E, highest printable ASCII -> digit 94 |
| 131 | + base95Radix = base95Max - base95Min + 1 |
| 132 | + base95Width = 8 // 95^8 to fit in an uint64 |
| 133 | +) |
| 134 | + |
| 135 | +func stringMidpoint(s1 string, s2 string) string { |
| 136 | + i := 0 |
| 137 | + for i < len(s1) && i < len(s2) && s1[i] == s2[i] { |
| 138 | + i++ |
| 139 | + } |
| 140 | + sharedPrefix := s1[:i] |
| 141 | + s1, s2 = s1[i:], s2[i:] |
| 142 | + mid := (stringToBase95Integer(s1) + stringToBase95Integer(s2)) / 2 |
| 143 | + return strings.TrimRight(sharedPrefix+base95IntegerToString(mid), " ") |
| 144 | +} |
| 145 | + |
| 146 | +func stringToBase95Integer(s string) uint64 { |
| 147 | + if s == "" { |
| 148 | + return 0 |
| 149 | + } |
| 150 | + var res uint64 |
| 151 | + for i := range base95Width { |
| 152 | + var digit uint64 |
| 153 | + if i < len(s) { |
| 154 | + ch := s[i] |
| 155 | + switch { |
| 156 | + case ch < base95Min: |
| 157 | + ch = base95Min |
| 158 | + case ch > base95Max: |
| 159 | + ch = base95Max |
| 160 | + } |
| 161 | + digit = uint64(ch - base95Min) |
| 162 | + } |
| 163 | + res = res*base95Radix + digit |
| 164 | + } |
| 165 | + return res |
| 166 | +} |
| 167 | + |
| 168 | +func base95IntegerToString(n uint64) string { |
| 169 | + digits := make([]byte, base95Width) |
| 170 | + for k := base95Width - 1; k >= 0; k-- { |
| 171 | + digits[k] = base95Min + byte(n%base95Radix) |
| 172 | + n /= base95Radix |
| 173 | + } |
| 174 | + return string(digits) |
| 175 | +} |
| 176 | + |
| 177 | +type stringPartitionEntry struct { |
| 178 | + start string |
| 179 | + end string |
| 180 | + rows uint64 |
| 181 | +} |
| 182 | + |
| 183 | +type stringPartitionHeap []stringPartitionEntry |
| 184 | + |
| 185 | +func (h *stringPartitionHeap) Len() int { return len(*h) } |
| 186 | +func (h *stringPartitionHeap) Less(i, j int) bool { return (*h)[i].rows > (*h)[j].rows } |
| 187 | +func (h *stringPartitionHeap) Swap(i, j int) { (*h)[i], (*h)[j] = (*h)[j], (*h)[i] } |
| 188 | + |
| 189 | +func (h *stringPartitionHeap) Push(x any) { *h = append(*h, x.(stringPartitionEntry)) } |
| 190 | + |
| 191 | +func (h *stringPartitionHeap) Pop() any { |
| 192 | + old := *h |
| 193 | + n := len(old) |
| 194 | + item := old[n-1] |
| 195 | + *h = old[:n-1] |
| 196 | + return item |
| 197 | +} |
| 198 | + |
| 199 | +// interface for unit-testing |
| 200 | +type rangeProber interface { |
| 201 | + estimateRowsInRange(ctx context.Context, tableName string, quotedCol string, start string, end string) (uint64, error) |
| 202 | + fetchNextRealKey( |
| 203 | + ctx context.Context, tableName string, quotedCol string, midpoint string, start string, end string, |
| 204 | + ) (string, bool, error) |
| 205 | +} |
| 206 | + |
| 207 | +// buildAdaptiveStringPartitions splits an arbitrary string watermark column |
| 208 | +// into at most numPartitions partitions using midpoint bisection guided |
| 209 | +// by the query planner's row estimates. It starts from a single [minVal, maxVal] |
| 210 | +// partition and repeatedly splits the largest partition, until it reaches |
| 211 | +// numPartitions or runs out of splittable partitions. |
| 212 | +func buildAdaptiveStringPartitions( |
| 213 | + ctx context.Context, |
| 214 | + prober rangeProber, |
| 215 | + logger log.Logger, |
| 216 | + table *common.QualifiedTable, |
| 217 | + watermarkColumn string, |
| 218 | + minVal string, |
| 219 | + maxVal string, |
| 220 | + numPartitions int64, |
| 221 | +) ([]*protos.QRepPartition, error) { |
| 222 | + tableName := table.MySQL() |
| 223 | + quotedCol := common.QuoteMySQLIdentifier(watermarkColumn) |
| 224 | + |
| 225 | + totalRows, err := prober.estimateRowsInRange(ctx, tableName, quotedCol, minVal, maxVal) |
| 226 | + if err != nil { |
| 227 | + return nil, fmt.Errorf("failed to estimate rows for [%s, %s]: %w", minVal, maxVal, err) |
| 228 | + } |
| 229 | + h := &stringPartitionHeap{{start: minVal, end: maxVal, rows: totalRows}} |
| 230 | + heap.Init(h) |
| 231 | + |
| 232 | + var outputs []stringPartitionEntry |
| 233 | + for int64(len(outputs)+h.Len()) < numPartitions && h.Len() > 0 { |
| 234 | + p := heap.Pop(h).(stringPartitionEntry) |
| 235 | + |
| 236 | + if p.start == p.end { |
| 237 | + outputs = append(outputs, p) |
| 238 | + continue |
| 239 | + } |
| 240 | + |
| 241 | + mid := stringMidpoint(p.start, p.end) |
| 242 | + k, found, err := prober.fetchNextRealKey(ctx, tableName, quotedCol, mid, p.start, p.end) |
| 243 | + if err != nil { |
| 244 | + return nil, fmt.Errorf("failed to fetch next real key for [%s, %s]: %w", p.start, p.end, err) |
| 245 | + } |
| 246 | + if !found { |
| 247 | + outputs = append(outputs, p) |
| 248 | + continue |
| 249 | + } |
| 250 | + |
| 251 | + leftRows, err := prober.estimateRowsInRange(ctx, tableName, quotedCol, p.start, k) |
| 252 | + if err != nil { |
| 253 | + return nil, fmt.Errorf("failed to estimate rows for [%s, %s]: %w", p.start, k, err) |
| 254 | + } |
| 255 | + rightRows, err := prober.estimateRowsInRange(ctx, tableName, quotedCol, k, p.end) |
| 256 | + if err != nil { |
| 257 | + return nil, fmt.Errorf("failed to estimate rows for [%s, %s]: %w", k, p.end, err) |
| 258 | + } |
| 259 | + heap.Push(h, stringPartitionEntry{start: p.start, end: k, rows: leftRows}) |
| 260 | + heap.Push(h, stringPartitionEntry{start: k, end: p.end, rows: rightRows}) |
| 261 | + } |
| 262 | + |
| 263 | + for h.Len() > 0 { |
| 264 | + outputs = append(outputs, heap.Pop(h).(stringPartitionEntry)) |
| 265 | + } |
| 266 | + |
| 267 | + partitions := make([]*protos.QRepPartition, 0, len(outputs)) |
| 268 | + for _, p := range outputs { |
| 269 | + partitions = append(partitions, createStringPartition(p.start, p.end, p.end == maxVal)) |
| 270 | + } |
| 271 | + logger.Info("[mysql] built adaptive string partitions", |
| 272 | + slog.Int64("targetNumPartitions", numPartitions), |
| 273 | + slog.Int("numPartitions", len(partitions))) |
| 274 | + |
| 275 | + return partitions, nil |
| 276 | +} |
| 277 | + |
| 278 | +// fetchNextRealKey returns the smallest real value of the watermark column that |
| 279 | +// is at or past the interpolated midpoint and strictly inside (start, end). The |
| 280 | +// bounds are enforced by MySQL using its column's collation. Return false when |
| 281 | +// no such key exists. |
| 282 | +func (c *MySqlConnector) fetchNextRealKey( |
| 283 | + ctx context.Context, tableName string, quotedCol string, midpoint string, start string, end string, |
| 284 | +) (string, bool, error) { |
| 285 | + query := fmt.Sprintf( |
| 286 | + "SELECT %[1]s FROM %[2]s WHERE %[1]s >= '%[3]s' AND %[1]s > '%[4]s' AND %[1]s < '%[5]s' ORDER BY %[1]s LIMIT 1", |
| 287 | + quotedCol, tableName, mysql.Escape(midpoint), mysql.Escape(start), mysql.Escape(end)) |
| 288 | + rs, err := c.Execute(ctx, query) |
| 289 | + if err != nil { |
| 290 | + return "", false, err |
| 291 | + } |
| 292 | + defer rs.Close() |
| 293 | + if rs.RowNumber() == 0 { |
| 294 | + return "", false, nil |
| 295 | + } |
| 296 | + key, err := rs.GetString(0, 0) |
| 297 | + if err != nil { |
| 298 | + return "", false, fmt.Errorf("failed to read next real key: %w", err) |
| 299 | + } |
| 300 | + return key, true, nil |
| 301 | +} |
| 302 | + |
| 303 | +// estimateRowsInRange asks the query planner for the estimated number of rows in |
| 304 | +// [start, end). The estimate is best-effort: a NULL or zero estimate is returned |
| 305 | +// as 0 rather than an error, so the caller still treats the range as a real partition. |
| 306 | +func (c *MySqlConnector) estimateRowsInRange( |
| 307 | + ctx context.Context, tableName string, quotedCol string, start string, end string, |
| 308 | +) (uint64, error) { |
| 309 | + query := fmt.Sprintf( |
| 310 | + "EXPLAIN SELECT 1 FROM %[1]s WHERE %[2]s >= '%[3]s' AND %[2]s < '%[4]s'", |
| 311 | + tableName, quotedCol, mysql.Escape(start), mysql.Escape(end)) |
| 312 | + rs, err := c.Execute(ctx, query) |
| 313 | + if err != nil { |
| 314 | + return 0, err |
| 315 | + } |
| 316 | + defer rs.Close() |
| 317 | + if rs.RowNumber() == 0 { |
| 318 | + return 0, fmt.Errorf("no resultset for table %s", tableName) |
| 319 | + } |
| 320 | + rows, err := rs.GetUintByName(0, "rows") |
| 321 | + if err != nil { |
| 322 | + return 0, err |
| 323 | + } |
| 324 | + return rows, nil |
| 325 | +} |
0 commit comments