Skip to content

Commit f20f8ce

Browse files
committed
mongo-parallel-snapshot-follow-up
1 parent 1e7a9a9 commit f20f8ce

8 files changed

Lines changed: 223 additions & 72 deletions

File tree

flow/connectors/mongo/qrep.go

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -226,19 +226,18 @@ func toRangeFilter(watermarkColumn string, partitionRange *protos.PartitionRange
226226
bson.E{Key: "$lte", Value: endObjectID},
227227
}},
228228
}, nil
229-
case *protos.PartitionRange_IntRange:
230-
// Numeric _id: inclusive range. PartitionHelper builds non-overlapping
231-
// [start, end] integer ranges. Mongo compares int32/int64 numerically, so
232-
// int64 bounds match int32-typed _ids.
229+
case *protos.PartitionRange_NumericRange:
230+
endOp := "$lt"
231+
if r.NumericRange.EndInclusive {
232+
endOp = "$lte"
233+
}
233234
return bson.D{
234235
bson.E{Key: watermarkColumn, Value: bson.D{
235-
bson.E{Key: "$gte", Value: r.IntRange.Start},
236-
bson.E{Key: "$lte", Value: r.IntRange.End},
236+
bson.E{Key: "$gte", Value: r.NumericRange.Start},
237+
bson.E{Key: endOp, Value: r.NumericRange.End},
237238
}},
238239
}, nil
239240
case *protos.PartitionRange_StringRange:
240-
// String _id: half-open [start, end) for all but the last partition;
241-
// the last partition is closed [start, end] (EndInclusive == true).
242241
endOp := "$lt"
243242
if r.StringRange.EndInclusive {
244243
endOp = "$lte"

flow/connectors/mongo/qrep_partition.go

Lines changed: 34 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,10 @@ const (
2727
// buildPartitions reads the collection's min and max _id (cheap, leverages the
2828
// default _id index) and dispatches to a type-specific partitioner:
2929
// - ObjectID -> uniform division of the 12-byte ObjectID keyspace
30-
// - Int32/64 -> uniform division of the numeric range (reuses PartitionHelper)
30+
// - Int32/64 -> uniform division of the numeric range
3131
// - String -> sampling-based quantile boundaries
3232
//
33-
// Mixed-type _id (min and max differ in type), Double, or any other type fall back
34-
// to a full-table partition. The change stream resume token is captured before this
35-
// runs, so any writes during partitioning are replayed by CDC.
33+
// Mixed-type _id, Double, or any other type fall back to a full-table partition.
3634
func (c *MongoConnector) buildPartitions(
3735
ctx context.Context,
3836
collection *mongo.Collection,
@@ -127,15 +125,10 @@ func (c *MongoConnector) objectIDPartitions(
127125
return partitions, nil
128126
}
129127

130-
// numericPartitions divides a numeric (int32/int64) _id range uniformly, reusing
131-
// the shared PartitionHelper which builds non-overlapping IntPartitionRange
132-
// partitions with tested +1 boundary semantics covering [min, max] inclusive.
133-
//
134-
// Limitation: IntPartitionRange uses inclusive-inclusive [start, end] boundaries,
135-
// so documents with double _id values that fall between two adjacent integer
136-
// boundaries (e.g. _id=2.5 between [1,2] and [3,4]) would be missed. This is an
137-
// edge case for collections with mixed int/double _ids; a follow-up will introduce
138-
// a NumericPartitionRange with half-open [start, end) semantics to address it.
128+
// numericPartitions divides a numeric (int32/int64) _id range uniformly.
129+
// BSON sorts all numeric types together, so int32/int64 min/max cannot rule out
130+
// fractional (double/decimal) _ids in between; contiguous boundaries leave no
131+
// gaps, so those documents are still covered.
139132
func (c *MongoConnector) numericPartitions(
140133
minVal int64,
141134
maxVal int64,
@@ -146,16 +139,34 @@ func (c *MongoConnector) numericPartitions(
146139
return utils.FullTablePartition(), nil
147140
}
148141

149-
c.logger.Info("[mongo] using numeric _id partitioning",
150-
slog.Int64("minID", minVal),
151-
slog.Int64("maxID", maxVal),
152-
slog.Int64("numPartitions", numPartitions))
142+
ranges := computeNumericRanges(minVal, maxVal, numPartitions)
143+
c.logger.Info("[mongo] using numeric _id partitioning", slog.Int("numPartitions", len(ranges)))
144+
partitions := make([]*protos.QRepPartition, 0, len(ranges))
145+
for i, rng := range ranges {
146+
partitions = append(partitions, utils.CreateNumericPartition(rng[0], rng[1], i == len(ranges)-1))
147+
}
148+
return partitions, nil
149+
}
153150

154-
helper := utils.NewPartitionHelper(c.logger)
155-
if err := helper.AddPartitionsWithRange(minVal, maxVal, numPartitions); err != nil {
156-
return nil, fmt.Errorf("failed to build numeric partitions: %w", err)
151+
func computeNumericRanges(minVal int64, maxVal int64, numPartitions int64) [][2]int64 {
152+
// span arithmetic is done in uint64 so it cannot overflow when maxVal-minVal exceeds MaxInt64
153+
span := uint64(maxVal) - uint64(minVal)
154+
step := span / uint64(numPartitions)
155+
if span%uint64(numPartitions) != 0 {
156+
step++
157+
}
158+
159+
ranges := make([][2]int64, 0, numPartitions)
160+
start := minVal
161+
for {
162+
if remaining := uint64(maxVal) - uint64(start); remaining <= step {
163+
ranges = append(ranges, [2]int64{start, maxVal})
164+
return ranges
165+
}
166+
end := int64(uint64(start) + step)
167+
ranges = append(ranges, [2]int64{start, end})
168+
start = end
157169
}
158-
return helper.GetPartitions(), nil
159170
}
160171

161172
// stringPartitions builds partitions for a string _id collection. Because string
@@ -187,26 +198,12 @@ func (c *MongoConnector) stringPartitions(
187198
}
188199

189200
c.logger.Info("[mongo] using sampled string _id partitioning",
190-
slog.String("minID", minVal),
191-
slog.String("maxID", maxVal),
192201
slog.Int("numPartitions", len(ranges)),
193202
slog.Int("sampleCount", len(samples)))
194203

195204
partitions := make([]*protos.QRepPartition, 0, len(ranges))
196205
for i, rng := range ranges {
197-
partitions = append(partitions, &protos.QRepPartition{
198-
PartitionId: uuid.NewString(),
199-
Range: &protos.PartitionRange{
200-
Range: &protos.PartitionRange_StringRange{
201-
StringRange: &protos.StringPartitionRange{
202-
Start: rng[0],
203-
End: rng[1],
204-
EndInclusive: i == len(ranges)-1,
205-
},
206-
},
207-
},
208-
FullTablePartition: false,
209-
})
206+
partitions = append(partitions, utils.CreateStringPartition(rng[0], rng[1], i == len(ranges)-1))
210207
}
211208
return partitions, nil
212209
}
@@ -229,8 +226,8 @@ func (c *MongoConnector) sampleStringIDs(
229226
{Key: "aggregate", Value: collection.Name()},
230227
{Key: "pipeline", Value: bson.A{
231228
bson.D{{Key: "$sample", Value: bson.D{{Key: "size", Value: int32(sampleSize)}}}},
232-
bson.D{{Key: "$sort", Value: bson.D{{Key: DefaultDocumentKeyColumnName, Value: 1}}}},
233229
bson.D{{Key: "$project", Value: bson.D{{Key: DefaultDocumentKeyColumnName, Value: 1}}}},
230+
bson.D{{Key: "$sort", Value: bson.D{{Key: DefaultDocumentKeyColumnName, Value: 1}}}},
234231
}},
235232
{Key: "cursor", Value: bson.D{}},
236233
}

flow/connectors/mongo/qrep_partition_test.go

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package connmongo
22

33
import (
4+
"math"
45
"testing"
56

67
"github.com/stretchr/testify/require"
@@ -117,9 +118,54 @@ func TestComputeStringBoundaries(t *testing.T) {
117118
})
118119
}
119120

120-
func intPartition(start, end int64) *protos.PartitionRange {
121-
return &protos.PartitionRange{Range: &protos.PartitionRange_IntRange{
122-
IntRange: &protos.IntPartitionRange{Start: start, End: end},
121+
func assertContiguousNumeric(t *testing.T, ranges [][2]int64, minVal, maxVal int64) {
122+
t.Helper()
123+
require.NotEmpty(t, ranges)
124+
require.Equal(t, minVal, ranges[0][0])
125+
require.Equal(t, maxVal, ranges[len(ranges)-1][1])
126+
for i := range ranges {
127+
require.Less(t, ranges[i][0], ranges[i][1])
128+
if i+1 < len(ranges) {
129+
require.Equal(t, ranges[i][1], ranges[i+1][0])
130+
}
131+
}
132+
}
133+
134+
func TestComputeNumericRanges(t *testing.T) {
135+
t.Run("even division", func(t *testing.T) {
136+
ranges := computeNumericRanges(1, 100, 10)
137+
require.Len(t, ranges, 10)
138+
assertContiguousNumeric(t, ranges, 1, 100)
139+
})
140+
141+
t.Run("span smaller than partition count", func(t *testing.T) {
142+
ranges := computeNumericRanges(0, 5, 10)
143+
require.Len(t, ranges, 5)
144+
assertContiguousNumeric(t, ranges, 0, 5)
145+
})
146+
147+
t.Run("two adjacent values", func(t *testing.T) {
148+
ranges := computeNumericRanges(7, 8, 4)
149+
require.Len(t, ranges, 1)
150+
assertContiguousNumeric(t, ranges, 7, 8)
151+
})
152+
153+
t.Run("span does not overflow", func(t *testing.T) {
154+
ranges := computeNumericRanges(math.MinInt64+5, math.MaxInt64-5, 10)
155+
require.Len(t, ranges, 10)
156+
assertContiguousNumeric(t, ranges, math.MinInt64+5, math.MaxInt64-5)
157+
})
158+
159+
t.Run("full int64 domain does not overflow", func(t *testing.T) {
160+
ranges := computeNumericRanges(math.MinInt64, math.MaxInt64, 7)
161+
require.Len(t, ranges, 7)
162+
assertContiguousNumeric(t, ranges, math.MinInt64, math.MaxInt64)
163+
})
164+
}
165+
166+
func numericPartition(start, end int64, endInclusive bool) *protos.PartitionRange {
167+
return &protos.PartitionRange{Range: &protos.PartitionRange_NumericRange{
168+
NumericRange: &protos.NumericPartitionRange{Start: start, End: end, EndInclusive: endInclusive},
123169
}}
124170
}
125171

@@ -130,13 +176,24 @@ func stringPartition(start, end string, endInclusive bool) *protos.PartitionRang
130176
}
131177

132178
func TestToRangeFilter(t *testing.T) {
133-
t.Run("int range is inclusive on both ends", func(t *testing.T) {
134-
filter, err := toRangeFilter(DefaultDocumentKeyColumnName, intPartition(10, 20))
179+
t.Run("numeric range is half-open", func(t *testing.T) {
180+
filter, err := toRangeFilter(DefaultDocumentKeyColumnName, numericPartition(10, 20, false))
135181
require.NoError(t, err)
136182
require.Equal(t, bson.D{
137183
bson.E{Key: DefaultDocumentKeyColumnName, Value: bson.D{
138184
bson.E{Key: "$gte", Value: int64(10)},
139-
bson.E{Key: "$lte", Value: int64(20)},
185+
bson.E{Key: "$lt", Value: int64(20)},
186+
}},
187+
}, filter)
188+
})
189+
190+
t.Run("last numeric range is closed", func(t *testing.T) {
191+
filter, err := toRangeFilter(DefaultDocumentKeyColumnName, numericPartition(20, 30, true))
192+
require.NoError(t, err)
193+
require.Equal(t, bson.D{
194+
bson.E{Key: DefaultDocumentKeyColumnName, Value: bson.D{
195+
bson.E{Key: "$gte", Value: int64(20)},
196+
bson.E{Key: "$lte", Value: int64(30)},
140197
}},
141198
}, filter)
142199
})

flow/connectors/mysql/qrep_partition.go

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,11 @@ import (
88

99
"github.com/google/uuid"
1010

11+
"github.com/PeerDB-io/peerdb/flow/connectors/utils"
1112
"github.com/PeerDB-io/peerdb/flow/generated/protos"
1213
"github.com/PeerDB-io/peerdb/flow/shared"
1314
)
1415

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-
1916
var (
2017
uuidLowerRe = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
2118
uuidUpperRe = regexp.MustCompile(`^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$`)
@@ -76,10 +73,10 @@ func buildUuidStringPartitions(
7673
if err != nil {
7774
return nil, fmt.Errorf("failed to convert bigint to uuid: %w", err)
7875
}
79-
partitions = append(partitions, createStringPartition(start, end, false))
76+
partitions = append(partitions, utils.CreateStringPartition(start, end, false))
8077
start = end
8178
}
82-
partitions = append(partitions, createStringPartition(start, maxVal, true))
79+
partitions = append(partitions, utils.CreateStringPartition(start, maxVal, true))
8380
return partitions, nil
8481
}
8582

@@ -103,18 +100,3 @@ func bigIntToUUID(n *big.Int, casing hexCasing) (string, error) {
103100
}
104101
return s, nil
105102
}
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-
}

flow/connectors/utils/monitoring/monitoring.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,8 @@ func addPartitionToQRepRun(ctx context.Context, tx pgx.Tx, flowJobName string,
340340
switch x := partition.Range.Range.(type) {
341341
case *protos.PartitionRange_IntRange:
342342
rangeStart, rangeEnd = new(strconv.FormatInt(x.IntRange.Start, 10)), new(strconv.FormatInt(x.IntRange.End, 10))
343+
case *protos.PartitionRange_NumericRange:
344+
rangeStart, rangeEnd = new(strconv.FormatInt(x.NumericRange.Start, 10)), new(strconv.FormatInt(x.NumericRange.End, 10))
343345
case *protos.PartitionRange_UintRange:
344346
rangeStart, rangeEnd = new(strconv.FormatUint(x.UintRange.Start, 10)), new(strconv.FormatUint(x.UintRange.End, 10))
345347
case *protos.PartitionRange_TimestampRange:

flow/connectors/utils/partition.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,3 +409,33 @@ func (p *PartitionHelper) AddPartitions(partitions []*protos.QRepPartition) {
409409
func (p *PartitionHelper) GetPartitions() []*protos.QRepPartition {
410410
return p.partitions
411411
}
412+
413+
func CreateStringPartition(start string, end string, endInclusive bool) *protos.QRepPartition {
414+
return &protos.QRepPartition{
415+
PartitionId: uuid.NewString(),
416+
Range: &protos.PartitionRange{
417+
Range: &protos.PartitionRange_StringRange{
418+
StringRange: &protos.StringPartitionRange{
419+
Start: start,
420+
End: end,
421+
EndInclusive: endInclusive,
422+
},
423+
},
424+
},
425+
}
426+
}
427+
428+
func CreateNumericPartition(start int64, end int64, endInclusive bool) *protos.QRepPartition {
429+
return &protos.QRepPartition{
430+
PartitionId: uuid.NewString(),
431+
Range: &protos.PartitionRange{
432+
Range: &protos.PartitionRange_NumericRange{
433+
NumericRange: &protos.NumericPartitionRange{
434+
Start: start,
435+
End: end,
436+
EndInclusive: endInclusive,
437+
},
438+
},
439+
},
440+
}
441+
}

0 commit comments

Comments
 (0)