Skip to content

Commit af27673

Browse files
committed
support parallel snapshotting for uuid string
1 parent e6003a9 commit af27673

7 files changed

Lines changed: 466 additions & 10 deletions

File tree

flow/connectors/mysql/qrep.go

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,7 @@ func (c *MySqlConnector) GetQRepPartitions(
3838
) ([]*protos.QRepPartition, error) {
3939
if config.WatermarkColumn == "" || config.NumPartitionsOverride == 1 {
4040
// if no watermark column is specified, return a single partition
41-
return []*protos.QRepPartition{
42-
{
43-
PartitionId: utils.FullTablePartitionID,
44-
Range: nil,
45-
FullTablePartition: true,
46-
},
47-
}, nil
41+
return utils.FullTablePartition(), nil
4842
}
4943

5044
if config.NumPartitionsOverride == 0 && config.NumRowsPerPartition == 0 {
@@ -101,6 +95,10 @@ func (c *MySqlConnector) GetQRepPartitions(
10195
case *protos.PartitionRange_TimestampRange:
10296
time := lastRange.TimestampRange.End.AsTime()
10397
minVal = "'" + time.Format("2006-01-02 15:04:05.999999") + "'"
98+
case *protos.PartitionRange_StringRange:
99+
// Resuming from last partition range is only possible for standalone QRepFlowWorkflow;
100+
// this is a legacy feature and string partitioning is not supported
101+
return nil, errors.New("resuming QRep by a string partition range is not supported")
104102
case *protos.PartitionRange_NullRange:
105103
// this case should never happen because we only add null partition for InitialCopyOnly replication
106104
// (so there shouldn't be a resume scenario with null range)
@@ -177,6 +175,9 @@ func supportsRangePartition(qkind types.QValueKind) bool {
177175
// temporal types
178176
case types.QValueKindDate, types.QValueKindTimestamp:
179177
return true
178+
// string types
179+
case types.QValueKindString:
180+
return true
180181
default:
181182
return false
182183
}
@@ -354,6 +355,19 @@ func (c *MySqlConnector) PullQRepRecords(
354355
case *protos.PartitionRange_TimestampRange:
355356
rangeStart = "'" + x.TimestampRange.Start.AsTime().Format("2006-01-02 15:04:05.999999") + "'"
356357
rangeEnd = "'" + x.TimestampRange.End.AsTime().Format("2006-01-02 15:04:05.999999") + "'"
358+
case *protos.PartitionRange_StringRange:
359+
rangeStart = "'" + mysql.Escape(x.StringRange.Start) + "'"
360+
rangeEnd = "'" + mysql.Escape(x.StringRange.End) + "'"
361+
if config.Query != "" {
362+
// custom query is only possible for standalone QRepFlowWorkflow;
363+
// this is a legacy feature and string partitioning is not supported
364+
return 0, 0, errors.New("can't construct a string range partition for custom queries")
365+
}
366+
if !x.StringRange.EndInclusive {
367+
queryTemplate = fmt.Sprintf(
368+
"SELECT %[1]s FROM %[2]s WHERE %[3]s >= {{.start}} AND %[3]s < {{.end}}",
369+
selectedColumns, parsedSrcTable.MySQL(), common.QuoteMySQLIdentifier(config.WatermarkColumn))
370+
}
357371
case *protos.PartitionRange_NullRange:
358372
if config.Query != "" {
359373
return 0, 0, errors.New("can't construct a null range partition for custom queries")

flow/connectors/mysql/qrep_test.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,17 @@ func TestGetDefaultPartitionKeyForTables(t *testing.T) {
127127
},
128128
expected: map[string]string{"db.ts": "created_at"},
129129
},
130+
{
131+
name: "string primary key is supported",
132+
tableMappings: []*protos.TableMapping{tableMapping("db.uuidpk")},
133+
schemas: map[string]*protos.TableSchema{
134+
"db.uuidpk": {
135+
PrimaryKeyColumns: []string{"id"},
136+
Columns: []*protos.FieldDescription{fieldDesc("id", types.QValueKindString)},
137+
},
138+
},
139+
expected: map[string]string{"db.uuidpk": "id"},
140+
},
130141
{
131142
name: "composite primary key with valid first column",
132143
tableMappings: []*protos.TableMapping{tableMapping("db.composite")},
@@ -146,9 +157,9 @@ func TestGetDefaultPartitionKeyForTables(t *testing.T) {
146157
tableMappings: []*protos.TableMapping{tableMapping("db.composite2")},
147158
schemas: map[string]*protos.TableSchema{
148159
"db.composite2": {
149-
PrimaryKeyColumns: []string{"name", "id"},
160+
PrimaryKeyColumns: []string{"data", "id"},
150161
Columns: []*protos.FieldDescription{
151-
fieldDesc("name", types.QValueKindString),
162+
fieldDesc("data", types.QValueKindBytes),
152163
fieldDesc("id", types.QValueKindInt32),
153164
},
154165
},
@@ -172,6 +183,7 @@ func TestGetDefaultPartitionKeyForTables(t *testing.T) {
172183
tableMapping("db.a"),
173184
tableMapping("db.b"),
174185
tableMapping("db.c"),
186+
tableMapping("db.d"),
175187
},
176188
schemas: map[string]*protos.TableSchema{
177189
"db.a": {
@@ -188,8 +200,14 @@ func TestGetDefaultPartitionKeyForTables(t *testing.T) {
188200
PrimaryKeyColumns: []string{"date"},
189201
Columns: []*protos.FieldDescription{fieldDesc("date", types.QValueKindDate)},
190202
},
203+
"db.d": {
204+
PrimaryKeyColumns: []string{"float"},
205+
Columns: []*protos.FieldDescription{
206+
fieldDesc("bad", types.QValueKindArrayFloat32),
207+
},
208+
},
191209
},
192-
expected: map[string]string{"db.a": "id", "db.c": "date"},
210+
expected: map[string]string{"db.a": "id", "db.b": "uuid", "db.c": "date"},
193211
},
194212
}
195213

flow/connectors/utils/monitoring/monitoring.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,8 @@ func addPartitionToQRepRun(ctx context.Context, tx pgx.Tx, flowJobName string,
362362
rangeEnd = new(rangeEndValue.(string))
363363
case *protos.PartitionRange_ObjectIdRange:
364364
rangeStart, rangeEnd = &x.ObjectIdRange.Start, &x.ObjectIdRange.End
365+
case *protos.PartitionRange_StringRange:
366+
rangeStart, rangeEnd = &x.StringRange.Start, &x.StringRange.End
365367
case *protos.PartitionRange_NullRange:
366368
// leave rangeStart and rangeEnd as nil
367369
default:

flow/connectors/utils/partition.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import (
44
"cmp"
55
"fmt"
66
"log/slog"
7+
"math/big"
8+
"regexp"
9+
"strings"
710
"time"
811

912
"github.com/google/uuid"
@@ -15,8 +18,21 @@ import (
1518
"github.com/PeerDB-io/peerdb/flow/shared"
1619
)
1720

21+
var (
22+
UuidLowerRe = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
23+
UuidUpperRe = regexp.MustCompile(`^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$`)
24+
)
25+
1826
const FullTablePartitionID = "full-table-partition-id"
1927

28+
func FullTablePartition() []*protos.QRepPartition {
29+
return []*protos.QRepPartition{{
30+
PartitionId: FullTablePartitionID,
31+
Range: nil,
32+
FullTablePartition: true,
33+
}}
34+
}
35+
2036
type PartitionRangeType string
2137

2238
const (
@@ -141,6 +157,21 @@ func createUIntPartition(start uint64, end uint64) *protos.QRepPartition {
141157
}
142158
}
143159

160+
func createStringPartition(start string, end string, endInclusive bool) *protos.QRepPartition {
161+
return &protos.QRepPartition{
162+
PartitionId: uuid.NewString(),
163+
Range: &protos.PartitionRange{
164+
Range: &protos.PartitionRange_StringRange{
165+
StringRange: &protos.StringPartitionRange{
166+
Start: start,
167+
End: end,
168+
EndInclusive: endInclusive,
169+
},
170+
},
171+
},
172+
}
173+
}
174+
144175
type PartitionHelper struct {
145176
logger log.Logger
146177
prevStart any
@@ -328,10 +359,97 @@ func (p *PartitionHelper) AddPartitionsWithRange(start any, end any, numPartitio
328359
return err
329360
}
330361
}
362+
case *protos.PartitionRange_StringRange:
363+
return p.addStringPartitions(r.StringRange.Start, r.StringRange.End, numPartitions)
331364
}
332365
return nil
333366
}
334367

368+
type casing int
369+
370+
const (
371+
unknown casing = iota
372+
lower
373+
upper
374+
)
375+
376+
func (p *PartitionHelper) addStringPartitions(minVal string, maxVal string, numPartitions int64) error {
377+
isUuid, hexCasing := detectUuidWithHexCasing(minVal, maxVal)
378+
379+
if !isUuid {
380+
// TODO: implement parallel snapshotting for arbitrary strings
381+
p.logger.Info("string watermark column is not uuid, falling back to full table partition")
382+
p.partitions = append(p.partitions, FullTablePartition()...)
383+
return nil
384+
}
385+
386+
// The regex matching guarantees UUIDs, so error should not happen
387+
minInt, err := uuidToBigInt(minVal)
388+
if err != nil {
389+
return fmt.Errorf("failed to parse min watermark as UUID: %w", err)
390+
}
391+
maxInt, err := uuidToBigInt(maxVal)
392+
if err != nil {
393+
return fmt.Errorf("failed to parse max watermark as UUID: %w", err)
394+
}
395+
396+
start := minVal
397+
step := shared.BigIntDivCeil(new(big.Int).Sub(maxInt, minInt), big.NewInt(numPartitions))
398+
for value := new(big.Int).Add(minInt, step); value.Cmp(maxInt) < 0; value.Add(value, step) {
399+
end, err := bigIntToUUID(value, hexCasing)
400+
if err != nil {
401+
return fmt.Errorf("failed to render partition boundary: %w", err)
402+
}
403+
p.partitions = append(p.partitions, createStringPartition(start, end, false))
404+
start = end
405+
}
406+
p.partitions = append(p.partitions, createStringPartition(start, maxVal, true))
407+
return nil
408+
}
409+
410+
// detectUuidWithHexCasing best-effort classifies a string watermark column by
411+
// inspecting only its min and max bounds: it determines whether both are
412+
// canonical UUIDs, and if so, the shared casing of their hex letters.
413+
//
414+
// Because only the bounds are examined, the classification can be wrong in
415+
// the following rare cases:
416+
// - non-UUID rows can exist between UUID-shaped min/max bounds;
417+
// - non-boundary UUIDs contain a mix of upper and lower case
418+
// - min/max bounds both don't contain hex, so casing blindly defaults to lower
419+
420+
// These cases may lead to partition skew, but will not affect correctness.
421+
func detectUuidWithHexCasing(minVal string, maxVal string) (bool, casing) {
422+
switch {
423+
case UuidLowerRe.MatchString(minVal) && UuidLowerRe.MatchString(maxVal):
424+
return true, lower
425+
case UuidUpperRe.MatchString(minVal) && UuidUpperRe.MatchString(maxVal):
426+
return true, upper
427+
default:
428+
return false, unknown
429+
}
430+
}
431+
432+
func uuidToBigInt(s string) (*big.Int, error) {
433+
u, err := uuid.Parse(s)
434+
if err != nil {
435+
return nil, err
436+
}
437+
return new(big.Int).SetBytes(u[:]), nil
438+
}
439+
440+
func bigIntToUUID(n *big.Int, casing casing) (string, error) {
441+
if n.BitLen() > 128 {
442+
return "", fmt.Errorf("value does not fit in a UUID (%d bits)", n.BitLen())
443+
}
444+
var b [16]byte
445+
n.FillBytes(b[:])
446+
s := uuid.UUID(b).String()
447+
if casing == upper {
448+
s = strings.ToUpper(s)
449+
}
450+
return s, nil
451+
}
452+
335453
func (p *PartitionHelper) getPartitionForStartAndEnd(start any, end any) (*protos.QRepPartition, error) {
336454
if start == nil || end == nil {
337455
return nil, nil
@@ -357,6 +475,8 @@ func (p *PartitionHelper) getPartitionForStartAndEnd(start any, end any) (*proto
357475
return createTimePartition(v, end.(time.Time)), nil
358476
case pgtype.TID:
359477
return createTIDPartition(v, end.(pgtype.TID)), nil
478+
case string:
479+
return createStringPartition(v, end.(string), true), nil
360480
default:
361481
return nil, fmt.Errorf("unsupported type: %T", v)
362482
}

0 commit comments

Comments
 (0)