Skip to content

Commit ff4139e

Browse files
authored
support parallel snapshotting for uuid partitioning (#4482)
Add parallel snapshotting support for MySQL when default primary key is a UUID string column. We best-effort detect uuid by evaluating the min and max values in the table; if both min and max value match uuid format (and have a _consistent hex casing_), we can guarantee correctness (see description for `DetectUuidWithHexCasing` for a bit more details). Original plan was to sample first and last N records, but it doesn't add a ton of value here, so just checking min/max values for simplicity. If string column is deemed non-UUID, we fallback to full table partition for now; will follow-up with partitioning for arbitrary string. Testing: add unit and integration tests to ensure uuids PKs are parallel snapshotted while arbitrary string PKs still correctly replicate with full table partition. Fixes: DBI-868
1 parent e490cee commit ff4139e

8 files changed

Lines changed: 503 additions & 11 deletions

File tree

flow/connectors/mysql/qrep.go

Lines changed: 36 additions & 8 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)
@@ -155,7 +153,21 @@ func (c *MySqlConnector) GetQRepPartitions(
155153
if err != nil {
156154
return nil, fmt.Errorf("failed to convert partition maximum to qvalue: %w", err)
157155
}
158-
if err := partitionHelper.AddPartitionsWithRange(val1.Value(), val2.Value(), numPartitions); err != nil {
156+
157+
start, startIsString := val1.Value().(string)
158+
end, endIsString := val2.Value().(string)
159+
if startIsString && endIsString {
160+
if isUUID, casing := detectUuidWithHexCasing(start, end); isUUID {
161+
uuidPartitions, err := buildUuidStringPartitions(start, end, casing, numPartitions)
162+
if err != nil {
163+
return nil, fmt.Errorf("failed to add uuid string partitions: %w", err)
164+
}
165+
partitionHelper.AddPartitions(uuidPartitions)
166+
} else {
167+
c.logger.Info("string watermark column is not uuid, falling back to full table partition")
168+
return utils.FullTablePartition(), nil
169+
}
170+
} else if err := partitionHelper.AddPartitionsWithRange(val1.Value(), val2.Value(), numPartitions); err != nil {
159171
return nil, fmt.Errorf("failed to add partitions: %w", err)
160172
}
161173

@@ -177,6 +189,9 @@ func supportsRangePartition(qkind types.QValueKind) bool {
177189
// temporal types
178190
case types.QValueKindDate, types.QValueKindTimestamp:
179191
return true
192+
// string types
193+
case types.QValueKindString:
194+
return true
180195
default:
181196
return false
182197
}
@@ -354,6 +369,19 @@ func (c *MySqlConnector) PullQRepRecords(
354369
case *protos.PartitionRange_TimestampRange:
355370
rangeStart = "'" + x.TimestampRange.Start.AsTime().Format("2006-01-02 15:04:05.999999") + "'"
356371
rangeEnd = "'" + x.TimestampRange.End.AsTime().Format("2006-01-02 15:04:05.999999") + "'"
372+
case *protos.PartitionRange_StringRange:
373+
rangeStart = "'" + mysql.Escape(x.StringRange.Start) + "'"
374+
rangeEnd = "'" + mysql.Escape(x.StringRange.End) + "'"
375+
if config.Query != "" {
376+
// custom query is only possible for standalone QRepFlowWorkflow;
377+
// this is a legacy feature and string partitioning is not supported
378+
return 0, 0, errors.New("can't construct a string range partition for custom queries")
379+
}
380+
if !x.StringRange.EndInclusive {
381+
queryTemplate = fmt.Sprintf(
382+
"SELECT %[1]s FROM %[2]s WHERE %[3]s >= {{.start}} AND %[3]s < {{.end}}",
383+
selectedColumns, parsedSrcTable.MySQL(), common.QuoteMySQLIdentifier(config.WatermarkColumn))
384+
}
357385
case *protos.PartitionRange_NullRange:
358386
if config.Query != "" {
359387
return 0, 0, errors.New("can't construct a null range partition for custom queries")
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package connmysql
2+
3+
import (
4+
"fmt"
5+
"math/big"
6+
"regexp"
7+
"strings"
8+
9+
"github.com/google/uuid"
10+
11+
"github.com/PeerDB-io/peerdb/flow/generated/protos"
12+
"github.com/PeerDB-io/peerdb/flow/shared"
13+
)
14+
15+
// String watermark partitioning is currently only supported by the MySQL connector,
16+
// so these helpers live in the MySQL connector subtree to prevent accidental usage
17+
// by other connectors.
18+
19+
var (
20+
uuidLowerRe = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
21+
uuidUpperRe = regexp.MustCompile(`^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$`)
22+
)
23+
24+
type hexCasing int
25+
26+
const (
27+
hexCasingUnknown hexCasing = iota
28+
hexCasingLower
29+
hexCasingUpper
30+
)
31+
32+
// detectUuidWithHexCasing best-effort classifies a string watermark column by
33+
// inspecting only its min and max bounds: it determines whether both are
34+
// canonical UUIDs, and if so, the shared casing of their hex letters.
35+
//
36+
// Because only the bounds are examined, the classification can be wrong in
37+
// the following rare cases:
38+
// - non-UUID rows can exist between UUID-shaped min/max bounds;
39+
// - non-boundary UUIDs contain a mix of upper and lower case
40+
// - min/max bounds both don't contain hex, so casing blindly defaults to lower
41+
//
42+
// These cases may lead to partition skew, but will not affect correctness.
43+
func detectUuidWithHexCasing(minVal string, maxVal string) (bool, hexCasing) {
44+
switch {
45+
case uuidLowerRe.MatchString(minVal) && uuidLowerRe.MatchString(maxVal):
46+
return true, hexCasingLower
47+
case uuidUpperRe.MatchString(minVal) && uuidUpperRe.MatchString(maxVal):
48+
return true, hexCasingUpper
49+
default:
50+
return false, hexCasingUnknown
51+
}
52+
}
53+
54+
// buildUuidStringPartitions splits a UUID string watermark column into partitions.
55+
// The original minVal/maxVal are used for first/last partitions to ensure correctness.
56+
func buildUuidStringPartitions(
57+
minVal string, maxVal string, casing hexCasing, numPartitions int64,
58+
) ([]*protos.QRepPartition, error) {
59+
minInt, err := uuidToBigInt(minVal)
60+
if err != nil {
61+
return nil, fmt.Errorf("failed to convert min uuid to bigint: %w", err)
62+
}
63+
maxInt, err := uuidToBigInt(maxVal)
64+
if err != nil {
65+
return nil, fmt.Errorf("failed to convert max uuid to bigint: %w", err)
66+
}
67+
if minInt.Cmp(maxInt) > 0 {
68+
return nil, fmt.Errorf("min uuid (%s) greater than max uuid (%s)", minVal, maxVal)
69+
}
70+
71+
var partitions []*protos.QRepPartition
72+
start := minVal
73+
step := shared.BigIntDivCeil(new(big.Int).Sub(maxInt, minInt), big.NewInt(numPartitions))
74+
for value := new(big.Int).Add(minInt, step); value.Cmp(maxInt) < 0; value.Add(value, step) {
75+
end, err := bigIntToUUID(value, casing)
76+
if err != nil {
77+
return nil, fmt.Errorf("failed to convert bigint to uuid: %w", err)
78+
}
79+
partitions = append(partitions, createStringPartition(start, end, false))
80+
start = end
81+
}
82+
partitions = append(partitions, createStringPartition(start, maxVal, true))
83+
return partitions, nil
84+
}
85+
86+
func uuidToBigInt(s string) (*big.Int, error) {
87+
u, err := uuid.Parse(s)
88+
if err != nil {
89+
return nil, err
90+
}
91+
return new(big.Int).SetBytes(u[:]), nil
92+
}
93+
94+
func bigIntToUUID(n *big.Int, casing hexCasing) (string, error) {
95+
if n.BitLen() > 128 {
96+
return "", fmt.Errorf("value does not fit in a UUID (%d bits)", n.BitLen())
97+
}
98+
var b [16]byte
99+
n.FillBytes(b[:])
100+
s := uuid.UUID(b).String()
101+
if casing == hexCasingUpper {
102+
s = strings.ToUpper(s)
103+
}
104+
return s, nil
105+
}
106+
107+
func createStringPartition(start string, end string, endInclusive bool) *protos.QRepPartition {
108+
return &protos.QRepPartition{
109+
PartitionId: uuid.NewString(),
110+
Range: &protos.PartitionRange{
111+
Range: &protos.PartitionRange_StringRange{
112+
StringRange: &protos.StringPartitionRange{
113+
Start: start,
114+
End: end,
115+
EndInclusive: endInclusive,
116+
},
117+
},
118+
},
119+
}
120+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package connmysql
2+
3+
import (
4+
"regexp"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/PeerDB-io/peerdb/flow/generated/protos"
11+
)
12+
13+
func TestUUIDToBigIntRoundTrip(t *testing.T) {
14+
cases := []struct {
15+
name string
16+
uuid string
17+
casing hexCasing
18+
}{
19+
{"lower case v4", "f47ac10b-58cc-4372-a567-0e02b2c3d479", hexCasingLower},
20+
{"upper case v7", "017F22E2-79B0-7CC3-98C4-DC0C0C07398F", hexCasingUpper},
21+
{"zero", "00000000-0000-0000-0000-000000000000", hexCasingLower},
22+
{"max", "ffffffff-ffff-ffff-ffff-ffffffffffff", hexCasingLower},
23+
{"all digits", "01234567-8901-2345-6789-012345678901", hexCasingLower},
24+
}
25+
for _, tc := range cases {
26+
t.Run(tc.name, func(t *testing.T) {
27+
num, err := uuidToBigInt(tc.uuid)
28+
require.NoError(t, err)
29+
uuid, err := bigIntToUUID(num, tc.casing)
30+
require.NoError(t, err)
31+
assert.Equal(t, tc.uuid, uuid)
32+
})
33+
}
34+
}
35+
36+
func TestUUIDToBigIntInvalid(t *testing.T) {
37+
for _, s := range []string{
38+
"",
39+
"not-a-uuid",
40+
"zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz",
41+
"01234567-8901-2345-6789-012345678901a", // 37 chars
42+
} {
43+
_, err := uuidToBigInt(s)
44+
assert.Error(t, err)
45+
}
46+
}
47+
48+
func TestDetectUUIDCase(t *testing.T) {
49+
cases := []struct {
50+
name string
51+
minVal string
52+
maxVal string
53+
expectIsUUID bool
54+
expectCasing hexCasing
55+
}{
56+
{"both lower", "00000000-0000-0000-0000-000000000000", "f47ac10b-58cc-4372-a567-0e02b2c3d479", true, hexCasingLower},
57+
{"both upper", "00000000-0000-0000-0000-000000000000", "F47AC10B-58CC-4372-A567-0E02B2C3D479", true, hexCasingUpper},
58+
{"all digits", "01234567-8901-2345-6789-012345678901", "09234567-8901-2345-6789-012345678901", true, hexCasingLower},
59+
{"digits min, upper max", "01234567-8901-2345-6789-012345678901", "F47AC10B-58CC-4372-A567-0E02B2C3D479", true, hexCasingUpper},
60+
{"lower min, digits max", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "01234567-8901-2345-6789-012345678901", true, hexCasingLower},
61+
{"mixed case across bounds", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "F47AC10B-58CC-4372-A567-0E02B2C3D479", false, hexCasingUnknown},
62+
{"mixed case within a bound", "00000000-0000-0000-0000-000000000000", "F47ac10b-58cc-4372-a567-0e02b2c3d479", false, hexCasingUnknown},
63+
{"non-uuid", "apple", "banana", false, hexCasingUnknown},
64+
}
65+
for _, tc := range cases {
66+
t.Run(tc.name, func(t *testing.T) {
67+
isUUID, casing := detectUuidWithHexCasing(tc.minVal, tc.maxVal)
68+
assert.Equal(t, tc.expectIsUUID, isUUID)
69+
assert.Equal(t, tc.expectCasing, casing)
70+
})
71+
}
72+
}
73+
74+
func TestBuildUuidStringPartitions(t *testing.T) {
75+
cases := []struct {
76+
name string
77+
minV string
78+
maxV string
79+
expectedCase *regexp.Regexp
80+
}{
81+
{"lower", "018f6e7a-1b2c-7def-8a3b-1c2d3e4f5a6b", "f47ac10b-58cc-4372-a567-0e02b2c3d479", uuidLowerRe},
82+
{"upper", "018F6E7A-1B2C-7DEF-8A3B-1C2D3E4F5A6B", "F47AC10B-58CC-4372-A567-0E02B2C3D479", uuidUpperRe},
83+
}
84+
const numPartitions = 32
85+
for _, tc := range cases {
86+
t.Run(tc.name, func(t *testing.T) {
87+
isUUID, casing := detectUuidWithHexCasing(tc.minV, tc.maxV)
88+
require.True(t, isUUID)
89+
parts, err := buildUuidStringPartitions(tc.minV, tc.maxV, casing, numPartitions)
90+
require.NoError(t, err)
91+
require.Len(t, parts, numPartitions)
92+
93+
prevEnd := ""
94+
for i, part := range parts {
95+
r, ok := part.Range.Range.(*protos.PartitionRange_StringRange)
96+
require.True(t, ok)
97+
sr := r.StringRange
98+
if i == 0 {
99+
assert.Equal(t, tc.minV, sr.Start)
100+
} else {
101+
assert.Equal(t, prevEnd, sr.Start)
102+
assert.Regexp(t, tc.expectedCase, sr.Start)
103+
}
104+
startInt, err := uuidToBigInt(sr.Start)
105+
require.NoError(t, err)
106+
endInt, err := uuidToBigInt(sr.End)
107+
require.NoError(t, err)
108+
assert.Negative(t, startInt.Cmp(endInt))
109+
110+
isLast := i == len(parts)-1
111+
assert.Equal(t, isLast, sr.EndInclusive)
112+
if isLast {
113+
assert.Equal(t, tc.maxV, sr.End)
114+
}
115+
prevEnd = sr.End
116+
}
117+
})
118+
}
119+
}
120+
121+
func TestBuildUuidStringPartitionsSinglePartition(t *testing.T) {
122+
uuid1 := "018f6e7a-1b2c-7def-8a3b-1c2d3e4f5a6b"
123+
uuid2 := "f47ac10b-58cc-4372-a567-0e02b2c3d479"
124+
cases := []struct {
125+
name string
126+
minV string
127+
maxV string
128+
numPartitions int64
129+
}{
130+
{"one partition requested", uuid1, uuid2, 1},
131+
{"min equals max", uuid1, uuid1, 8},
132+
}
133+
for _, tc := range cases {
134+
t.Run(tc.name, func(t *testing.T) {
135+
isUUID, casing := detectUuidWithHexCasing(tc.minV, tc.maxV)
136+
require.True(t, isUUID)
137+
parts, err := buildUuidStringPartitions(tc.minV, tc.maxV, casing, tc.numPartitions)
138+
require.NoError(t, err)
139+
require.Len(t, parts, 1)
140+
sr, ok := parts[0].Range.Range.(*protos.PartitionRange_StringRange)
141+
require.True(t, ok)
142+
assert.Equal(t, tc.minV, sr.StringRange.Start)
143+
assert.Equal(t, tc.maxV, sr.StringRange.End)
144+
assert.True(t, sr.StringRange.EndInclusive)
145+
})
146+
}
147+
}
148+
149+
func TestBuildUuidStringPartitionsInvalid(t *testing.T) {
150+
cases := []struct {
151+
name string
152+
minV string
153+
maxV string
154+
}{
155+
{"arbitrary string", "apple", "banana"},
156+
{"inverted", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "018f6e7a-1b2c-7def-8a3b-1c2d3e4f5a6b"},
157+
{"too short", "018f6e7a-1b2c", "f47ac10b-58cc"},
158+
{"too long", "018f6e7a-1b2c-7def-8a3b-1c2d3e4f5a6bb", "f47ac10b-58cc-4372-a567-0e02b2c3d4799"},
159+
}
160+
const numPartitions = 32
161+
for _, tc := range cases {
162+
t.Run(tc.name, func(t *testing.T) {
163+
_, err := buildUuidStringPartitions(tc.minV, tc.maxV, hexCasingLower, numPartitions)
164+
require.Error(t, err)
165+
})
166+
}
167+
}

0 commit comments

Comments
 (0)