Skip to content

Commit 83aeb72

Browse files
committed
support arbitrary string parallel snapshotting
1 parent 107beb7 commit 83aeb72

4 files changed

Lines changed: 504 additions & 8 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: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
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/connectors/utils"
1217
"github.com/PeerDB-io/peerdb/flow/generated/protos"
18+
"github.com/PeerDB-io/peerdb/flow/pkg/common"
1319
"github.com/PeerDB-io/peerdb/flow/shared"
1420
)
1521

@@ -100,3 +106,220 @@ func bigIntToUUID(n *big.Int, casing hexCasing) (string, error) {
100106
}
101107
return s, nil
102108
}
109+
110+
func createStringPartition(start string, end string, endInclusive bool) *protos.QRepPartition {
111+
return &protos.QRepPartition{
112+
PartitionId: uuid.NewString(),
113+
Range: &protos.PartitionRange{
114+
Range: &protos.PartitionRange_StringRange{
115+
StringRange: &protos.StringPartitionRange{
116+
Start: start,
117+
End: end,
118+
EndInclusive: endInclusive,
119+
},
120+
},
121+
},
122+
}
123+
}
124+
125+
const (
126+
base95Min = ' ' // 0x20, lowest printable ASCII -> digit 0
127+
base95Max = '~' // 0x7E, highest printable ASCII -> digit 94
128+
base95Radix = base95Max - base95Min + 1
129+
base95Width = 8 // 95^8 to fit in an uint64
130+
)
131+
132+
func stringMidpoint(s1 string, s2 string) string {
133+
i := 0
134+
for i < len(s1) && i < len(s2) && s1[i] == s2[i] {
135+
i++
136+
}
137+
sharedPrefix := s1[:i]
138+
s1, s2 = s1[i:], s2[i:]
139+
mid := (stringToBase95Integer(s1) + stringToBase95Integer(s2)) / 2
140+
return strings.TrimRight(sharedPrefix+base95IntegerToString(mid), " ")
141+
}
142+
143+
func stringToBase95Integer(s string) uint64 {
144+
if s == "" {
145+
return 0
146+
}
147+
var res uint64
148+
for i := range base95Width {
149+
var digit uint64
150+
if i < len(s) {
151+
ch := s[i]
152+
switch {
153+
case ch < base95Min:
154+
ch = base95Min
155+
case ch > base95Max:
156+
ch = base95Max
157+
}
158+
digit = uint64(ch - base95Min)
159+
}
160+
res = res*base95Radix + digit
161+
}
162+
return res
163+
}
164+
165+
func base95IntegerToString(n uint64) string {
166+
digits := make([]byte, base95Width)
167+
for k := base95Width - 1; k >= 0; k-- {
168+
digits[k] = base95Min + byte(n%base95Radix)
169+
n /= base95Radix
170+
}
171+
return string(digits)
172+
}
173+
174+
type stringPartitionEntry struct {
175+
start string
176+
end string
177+
rows uint64
178+
}
179+
180+
type stringPartitionHeap []stringPartitionEntry
181+
182+
func (h *stringPartitionHeap) Len() int { return len(*h) }
183+
func (h *stringPartitionHeap) Less(i, j int) bool { return (*h)[i].rows > (*h)[j].rows }
184+
func (h *stringPartitionHeap) Swap(i, j int) { (*h)[i], (*h)[j] = (*h)[j], (*h)[i] }
185+
186+
func (h *stringPartitionHeap) Push(x any) { *h = append(*h, x.(stringPartitionEntry)) }
187+
188+
func (h *stringPartitionHeap) Pop() any {
189+
old := *h
190+
n := len(old)
191+
item := old[n-1]
192+
*h = old[:n-1]
193+
return item
194+
}
195+
196+
// interface for unit-testing
197+
type rangeProber interface {
198+
estimateRowsInRange(ctx context.Context, tableName string, quotedCol string, start string, end string) (uint64, error)
199+
fetchNextRealKey(
200+
ctx context.Context, tableName string, quotedCol string, midpoint string, start string, end string,
201+
) (string, bool, error)
202+
}
203+
204+
// buildAdaptiveStringPartitions splits an arbitrary string watermark column
205+
// into at most numPartitions partitions using midpoint bisection guided
206+
// by the query planner's row estimates. It starts from a single [minVal, maxVal]
207+
// partition and repeatedly splits the largest partition, until it reaches
208+
// numPartitions or runs out of splittable partitions.
209+
func buildAdaptiveStringPartitions(
210+
ctx context.Context,
211+
prober rangeProber,
212+
logger log.Logger,
213+
table *common.QualifiedTable,
214+
watermarkColumn string,
215+
minVal string,
216+
maxVal string,
217+
numPartitions int64,
218+
) ([]*protos.QRepPartition, error) {
219+
tableName := table.MySQL()
220+
quotedCol := common.QuoteMySQLIdentifier(watermarkColumn)
221+
222+
totalRows, err := prober.estimateRowsInRange(ctx, tableName, quotedCol, minVal, maxVal)
223+
if err != nil {
224+
return nil, fmt.Errorf("failed to estimate rows for [%s, %s]: %w", minVal, maxVal, err)
225+
}
226+
h := &stringPartitionHeap{{start: minVal, end: maxVal, rows: totalRows}}
227+
heap.Init(h)
228+
229+
var outputs []stringPartitionEntry
230+
for int64(len(outputs)+h.Len()) < numPartitions && h.Len() > 0 {
231+
p := heap.Pop(h).(stringPartitionEntry)
232+
233+
if p.start == p.end {
234+
outputs = append(outputs, p)
235+
continue
236+
}
237+
238+
mid := stringMidpoint(p.start, p.end)
239+
k, found, err := prober.fetchNextRealKey(ctx, tableName, quotedCol, mid, p.start, p.end)
240+
if err != nil {
241+
return nil, fmt.Errorf("failed to fetch next real key for [%s, %s]: %w", p.start, p.end, err)
242+
}
243+
if !found {
244+
outputs = append(outputs, p)
245+
continue
246+
}
247+
248+
leftRows, err := prober.estimateRowsInRange(ctx, tableName, quotedCol, p.start, k)
249+
if err != nil {
250+
return nil, fmt.Errorf("failed to estimate rows for [%s, %s]: %w", p.start, k, err)
251+
}
252+
rightRows, err := prober.estimateRowsInRange(ctx, tableName, quotedCol, k, p.end)
253+
if err != nil {
254+
return nil, fmt.Errorf("failed to estimate rows for [%s, %s]: %w", k, p.end, err)
255+
}
256+
heap.Push(h, stringPartitionEntry{start: p.start, end: k, rows: leftRows})
257+
heap.Push(h, stringPartitionEntry{start: k, end: p.end, rows: rightRows})
258+
}
259+
260+
for h.Len() > 0 {
261+
outputs = append(outputs, heap.Pop(h).(stringPartitionEntry))
262+
}
263+
264+
partitions := make([]*protos.QRepPartition, 0, len(outputs))
265+
for _, p := range outputs {
266+
partitions = append(partitions, createStringPartition(p.start, p.end, p.end == maxVal))
267+
}
268+
logger.Info("[mysql] built adaptive string partitions",
269+
slog.Int64("targetNumPartitions", numPartitions),
270+
slog.Int("numPartitions", len(partitions)))
271+
272+
return partitions, nil
273+
}
274+
275+
// fetchNextRealKey returns the smallest real value of the watermark column that
276+
// is at or past the interpolated midpoint and strictly inside (start, end). The
277+
// bounds are enforced by MySQL using its column's collation. Return false when
278+
// no such key exists.
279+
func (c *MySqlConnector) fetchNextRealKey(
280+
ctx context.Context, tableName string, quotedCol string, midpoint string, start string, end string,
281+
) (string, bool, error) {
282+
query := fmt.Sprintf(
283+
"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",
284+
quotedCol, tableName, mysql.Escape(midpoint), mysql.Escape(start), mysql.Escape(end))
285+
rs, err := c.Execute(ctx, query)
286+
if err != nil {
287+
return "", false, err
288+
}
289+
defer rs.Close()
290+
if rs.RowNumber() == 0 {
291+
return "", false, nil
292+
}
293+
key, err := rs.GetString(0, 0)
294+
if err != nil {
295+
return "", false, fmt.Errorf("failed to read next real key: %w", err)
296+
}
297+
// GetString is zero-copy over the resultset's row buffer, which rs.Close()
298+
// recycles into a pool where the next query's response overwrites it; the
299+
// key is stored in the heap and outlives the function, so it must own its bytes
300+
return strings.Clone(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 FORMAT=TRADITIONAL 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)