Skip to content

Commit bd316b6

Browse files
committed
support arbitrary string parallel snapshotting
1 parent a7eee79 commit bd316b6

4 files changed

Lines changed: 472 additions & 10 deletions

File tree

flow/connectors/mysql/qrep.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,14 @@ func (c *MySqlConnector) GetQRepPartitions(
164164
}
165165
partitionHelper.AddPartitions(uuidPartitions)
166166
} else {
167-
c.logger.Info("string watermark column is not uuid, falling back to full table partition")
168-
return utils.FullTablePartition(), nil
167+
stringPartitions, err := buildAdaptiveStringPartitions(
168+
ctx, c, c.logger, parsedWatermarkTable, config.WatermarkColumn, start, end, numPartitions)
169+
if err != nil {
170+
c.logger.Warn("failed to build adaptive string partitions, falling back to full table partition",
171+
slog.Any("error", err))
172+
return utils.FullTablePartition(), nil
173+
}
174+
partitionHelper.AddPartitions(stringPartitions)
169175
}
170176
} else if err := partitionHelper.AddPartitionsWithRange(val1.Value(), val2.Value(), numPartitions); err != nil {
171177
return nil, fmt.Errorf("failed to add partitions: %w", err)

flow/connectors/mysql/qrep_partition.go

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
package connmysql
22

33
import (
4+
"container/heap"
5+
"context"
46
"fmt"
7+
"log/slog"
58
"math/big"
69
"regexp"
710
"strings"
811

12+
"github.com/go-mysql-org/go-mysql/mysql"
913
"github.com/google/uuid"
14+
"go.temporal.io/sdk/log"
1015

1116
"github.com/PeerDB-io/peerdb/flow/generated/protos"
17+
"github.com/PeerDB-io/peerdb/flow/pkg/common"
1218
"github.com/PeerDB-io/peerdb/flow/shared"
1319
)
1420

@@ -118,3 +124,202 @@ func createStringPartition(start string, end string, endInclusive bool) *protos.
118124
},
119125
}
120126
}
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

Comments
 (0)