Skip to content

Commit 182b507

Browse files
mongo: address PR review comments on _id partitioning
- Add end_inclusive to StringPartitionRange proto (aligns with MySQL PR PeerDB-io#4482 adding the same field; avoids future merge conflict) - Replace sentinel-string approach with end_inclusive=true on the last string partition, using $lte instead of $lt for its MongoDB filter - Sort string _id samples via MongoDB $sort stage instead of Go sort, preserving the collection's collation order - Filter boundary samples by exact equality instead of <=/>= comparison, trusting MongoDB's min/max guarantees - Add e2e tests for string and numeric _id partitioned snapshot flows - Document the double _id gap limitation on numericPartitions (follow-up)
1 parent 4ff837b commit 182b507

5 files changed

Lines changed: 173 additions & 34 deletions

File tree

flow/connectors/mongo/qrep.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -245,13 +245,16 @@ func toRangeFilter(watermarkColumn string, partitionRange *protos.PartitionRange
245245
}},
246246
}, nil
247247
case *protos.PartitionRange_StringRange:
248-
// String _id: half-open [start, end) range. computeStringBoundaries makes
249-
// ranges contiguous and sets the final end just past the real max, so $lt
250-
// gives exact coverage with no gaps or overlap.
248+
// String _id: half-open [start, end) for all but the last partition;
249+
// the last partition is closed [start, end] (EndInclusive == true).
250+
endOp := "$lt"
251+
if r.StringRange.EndInclusive {
252+
endOp = "$lte"
253+
}
251254
return bson.D{
252255
bson.E{Key: watermarkColumn, Value: bson.D{
253256
bson.E{Key: "$gte", Value: r.StringRange.Start},
254-
bson.E{Key: "$lt", Value: r.StringRange.End},
257+
bson.E{Key: endOp, Value: r.StringRange.End},
255258
}},
256259
}, nil
257260
default:

flow/connectors/mongo/qrep_partition.go

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,6 @@ import (
1717
"github.com/PeerDB-io/peerdb/flow/shared"
1818
)
1919

20-
// stringPartitionUpperSentinel is appended to the collection's max string _id to
21-
// form the exclusive upper bound of the final string partition. It is the highest
22-
// valid Unicode code point, so `max + sentinel` sorts strictly after `max` (and
23-
// after every real _id, since `max` is the maximum). This lets every string
24-
// partition use uniform half-open [start, end) ($gte/$lt) semantics while still
25-
// capturing the maximum document in the last partition.
26-
const stringPartitionUpperSentinel = "\U0010FFFF"
27-
2820
const (
2921
// stringSampleOversample draws more samples than partitions so quantile
3022
// boundaries are well distributed even with clustered keys.
@@ -150,6 +142,12 @@ func (c *MongoConnector) objectIDPartitions(
150142
// numericPartitions divides a numeric (int32/int64) _id range uniformly, reusing
151143
// the shared PartitionHelper which builds non-overlapping IntPartitionRange
152144
// partitions with tested +1 boundary semantics covering [min, max] inclusive.
145+
//
146+
// Limitation: IntPartitionRange uses inclusive-inclusive [start, end] boundaries,
147+
// so documents with double _id values that fall between two adjacent integer
148+
// boundaries (e.g. _id=2.5 between [1,2] and [3,4]) would be missed. This is an
149+
// edge case for collections with mixed int/double _ids; a follow-up will introduce
150+
// a NumericPartitionRange with half-open [start, end) semantics to address it.
153151
func (c *MongoConnector) numericPartitions(
154152
minVal int64,
155153
maxVal int64,
@@ -207,14 +205,15 @@ func (c *MongoConnector) stringPartitions(
207205
slog.Int("sampleCount", len(samples)))
208206

209207
partitions := make([]*protos.QRepPartition, 0, len(ranges))
210-
for _, rng := range ranges {
208+
for i, rng := range ranges {
211209
partitions = append(partitions, &protos.QRepPartition{
212210
PartitionId: uuid.NewString(),
213211
Range: &protos.PartitionRange{
214212
Range: &protos.PartitionRange_StringRange{
215213
StringRange: &protos.StringPartitionRange{
216-
Start: rng[0],
217-
End: rng[1],
214+
Start: rng[0],
215+
End: rng[1],
216+
EndInclusive: i == len(ranges)-1,
218217
},
219218
},
220219
},
@@ -242,6 +241,7 @@ func (c *MongoConnector) sampleStringIDs(
242241
{Key: "aggregate", Value: collection.Name()},
243242
{Key: "pipeline", Value: bson.A{
244243
bson.D{{Key: "$sample", Value: bson.D{{Key: "size", Value: int32(sampleSize)}}}},
244+
bson.D{{Key: "$sort", Value: bson.D{{Key: DefaultDocumentKeyColumnName, Value: 1}}}},
245245
bson.D{{Key: "$project", Value: bson.D{{Key: DefaultDocumentKeyColumnName, Value: 1}}}},
246246
}},
247247
{Key: "cursor", Value: bson.D{}},
@@ -267,20 +267,26 @@ func (c *MongoConnector) sampleStringIDs(
267267
return samples, nil
268268
}
269269

270-
// computeStringBoundaries turns a random sample of string _ids plus the real
271-
// min/max into a contiguous set of half-open [start, end) ranges. Interior
272-
// boundaries are quantiles of the (deduplicated, sorted) sample; the first range
273-
// starts at the real min and the last range ends just past the real max (via the
274-
// sentinel) so coverage of [min, max] is exact with no gaps or overlap.
270+
// computeStringBoundaries turns a pre-sorted (by database collation) sample of
271+
// string _ids plus the real min/max into a contiguous set of ranges. Interior
272+
// boundaries are quantiles of the (deduplicated) sample; the first range starts
273+
// at the real min and the last range ends at the real max. All ranges except the
274+
// last are half-open [start, end); the last is closed [start, end].
275+
//
276+
// samples must already be sorted in the database's collation order; this function
277+
// does not re-sort them, to preserve collation semantics.
275278
//
276279
// It is pure (no I/O) to keep the boundary math unit-testable. Returns fewer ranges
277280
// than numPartitions when the sample yields too few distinct interior boundaries.
278281
func computeStringBoundaries(minVal string, maxVal string, samples []string, numPartitions int64) [][2]string {
279-
// Keep unique sampled values strictly inside (min, max).
282+
// Keep unique sampled values strictly inside (min, max), preserving order.
283+
// We use exact equality to filter boundaries: MongoDB guarantees all sampled
284+
// _ids are within [minVal, maxVal] by its own collation; only exact matches
285+
// with the boundary values would create zero-width partitions.
280286
seen := make(map[string]struct{}, len(samples))
281287
interior := make([]string, 0, len(samples))
282288
for _, s := range samples {
283-
if s <= minVal || s >= maxVal {
289+
if s == minVal || s == maxVal {
284290
continue
285291
}
286292
if _, ok := seen[s]; ok {
@@ -289,7 +295,6 @@ func computeStringBoundaries(minVal string, maxVal string, samples []string, num
289295
seen[s] = struct{}{}
290296
interior = append(interior, s)
291297
}
292-
slices.Sort(interior)
293298

294299
// Pick up to numPartitions-1 evenly spaced interior boundaries (quantiles).
295300
desiredBoundaries := numPartitions - 1
@@ -308,15 +313,16 @@ func computeStringBoundaries(minVal string, maxVal string, samples []string, num
308313
picked = slices.Compact(picked) // drop consecutive duplicate quantiles
309314
}
310315

311-
// Build contiguous half-open ranges: [min, p0), [p0, p1), ..., [pLast, max+sentinel).
316+
// Build ranges: [min, p0), [p0, p1), ..., [pLast-1, pLast), [pLast, max].
317+
// The last range is closed (end_inclusive=true); all others are half-open.
312318
starts := append([]string{minVal}, picked...)
313319
ranges := make([][2]string, len(starts))
314320
for i := range starts {
315321
var end string
316322
if i+1 < len(starts) {
317323
end = starts[i+1]
318324
} else {
319-
end = maxVal + stringPartitionUpperSentinel
325+
end = maxVal
320326
}
321327
ranges[i] = [2]string{starts[i], end}
322328
}

flow/connectors/mongo/qrep_partition_test.go

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,13 @@ func TestIsNumericIDType(t *testing.T) {
4444
}
4545

4646
// assertContiguous verifies the ranges form a gap-free, non-overlapping cover of
47-
// [min, max+sentinel): first start == min, last end == max+sentinel, and each
48-
// range's end equals the next range's start.
47+
// [min, max]: first start == min, last end == max, and each range's end equals
48+
// the next range's start (half-open except for the last which is closed).
4949
func assertContiguous(t *testing.T, ranges [][2]string, minVal, maxVal string) {
5050
t.Helper()
5151
require.NotEmpty(t, ranges)
5252
require.Equal(t, minVal, ranges[0][0], "first partition must start at min")
53-
require.Equal(t, maxVal+stringPartitionUpperSentinel, ranges[len(ranges)-1][1], "last partition must end just past max")
53+
require.Equal(t, maxVal, ranges[len(ranges)-1][1], "last partition must end at max")
5454
for i := range ranges {
5555
require.Less(t, ranges[i][0], ranges[i][1], "range start must be < end")
5656
if i+1 < len(ranges) {
@@ -88,9 +88,10 @@ func TestComputeStringBoundaries(t *testing.T) {
8888
assertContiguous(t, ranges, "a", "z")
8989
})
9090

91-
t.Run("samples outside (min,max) ignored", func(t *testing.T) {
92-
// "a" == min and "z" == max must be dropped; only "m" is a valid interior boundary
93-
samples := []string{"a", "z", "m", "0", "zzzz"}
91+
t.Run("boundary samples ignored", func(t *testing.T) {
92+
// "a" == min and "z" == max must be dropped to avoid zero-width partitions;
93+
// only "m" is a valid interior boundary. Samples come pre-sorted from MongoDB.
94+
samples := []string{"a", "m", "z"}
9495
ranges := computeStringBoundaries("a", "z", samples, 4)
9596
require.Len(t, ranges, 2)
9697
assertContiguous(t, ranges, "a", "z")
@@ -122,9 +123,9 @@ func intPartition(start, end int64) *protos.PartitionRange {
122123
}}
123124
}
124125

125-
func stringPartition(start, end string) *protos.PartitionRange {
126+
func stringPartition(start, end string, endInclusive bool) *protos.PartitionRange {
126127
return &protos.PartitionRange{Range: &protos.PartitionRange_StringRange{
127-
StringRange: &protos.StringPartitionRange{Start: start, End: end},
128+
StringRange: &protos.StringPartitionRange{Start: start, End: end, EndInclusive: endInclusive},
128129
}}
129130
}
130131

@@ -141,7 +142,7 @@ func TestToRangeFilter(t *testing.T) {
141142
})
142143

143144
t.Run("string range is half-open", func(t *testing.T) {
144-
filter, err := toRangeFilter(DefaultDocumentKeyColumnName, stringPartition("com.a", "com.m"))
145+
filter, err := toRangeFilter(DefaultDocumentKeyColumnName, stringPartition("com.a", "com.m", false))
145146
require.NoError(t, err)
146147
require.Equal(t, bson.D{
147148
bson.E{Key: DefaultDocumentKeyColumnName, Value: bson.D{
@@ -151,6 +152,17 @@ func TestToRangeFilter(t *testing.T) {
151152
}, filter)
152153
})
153154

155+
t.Run("last string range is closed", func(t *testing.T) {
156+
filter, err := toRangeFilter(DefaultDocumentKeyColumnName, stringPartition("com.m", "org.z", true))
157+
require.NoError(t, err)
158+
require.Equal(t, bson.D{
159+
bson.E{Key: DefaultDocumentKeyColumnName, Value: bson.D{
160+
bson.E{Key: "$gte", Value: "com.m"},
161+
bson.E{Key: "$lte", Value: "org.z"},
162+
}},
163+
}, filter)
164+
})
165+
154166
t.Run("object id range still supported", func(t *testing.T) {
155167
r := &protos.PartitionRange{Range: &protos.PartitionRange_ObjectIdRange{
156168
ObjectIdRange: &protos.ObjectIdPartitionRange{

flow/e2e/mongo_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,120 @@ func (s MongoClickhouseSuite) Test_Simple_Flow_Partitioned() {
157157
RequireEnvCanceled(t, env)
158158
}
159159

160+
func (s MongoClickhouseSuite) Test_Simple_Flow_Partitioned_StringID() {
161+
t := s.T()
162+
srcDatabase := GetTestDatabase(s.Suffix())
163+
srcTable := "test_simple_partitioned_string_id"
164+
dstTable := "test_simple_dst_partitioned_string_id"
165+
166+
connectionGen := FlowConnectionGenerationConfig{
167+
FlowJobName: AddSuffix(s, srcTable),
168+
TableMappings: TableMappings(s, srcTable, dstTable),
169+
Destination: s.Peer().Name,
170+
}
171+
flowConnConfig := s.generateFlowConnectionConfigsDefaultEnv(connectionGen)
172+
flowConnConfig.DoInitialSnapshot = true
173+
flowConnConfig.SnapshotNumRowsPerPartition = 10
174+
175+
adminClient := s.Source().(*MongoSource).AdminClient()
176+
collection := adminClient.Database(srcDatabase).Collection(srcTable)
177+
178+
docs := make([]any, 1000)
179+
for i := range 1000 {
180+
docs[i] = bson.D{
181+
{Key: "_id", Value: fmt.Sprintf("id-%05d", i)},
182+
{Key: fmt.Sprintf("init_key_%d", i), Value: fmt.Sprintf("init_value_%d", i)},
183+
}
184+
}
185+
_, err := collection.InsertMany(t.Context(), docs)
186+
require.NoError(t, err)
187+
188+
tc := NewTemporalClient(t)
189+
env := ExecutePeerflow(t, tc, flowConnConfig)
190+
191+
EnvWaitForEqualTablesWithNames(env, s, "initial load to match", srcTable, dstTable, "_id,doc")
192+
193+
catalogPool, err := internal.GetCatalogConnectionPoolFromEnv(t.Context())
194+
require.NoError(t, err)
195+
var partitionCount int
196+
require.NoError(t, catalogPool.QueryRow(t.Context(),
197+
`SELECT COUNT(*) FROM peerdb_stats.qrep_partitions WHERE parent_mirror_name = $1`,
198+
flowConnConfig.FlowJobName).Scan(&partitionCount))
199+
require.Greater(t, partitionCount, 1, "expected multiple partitions for 1000 string-id rows")
200+
201+
SetupCDCFlowStatusQuery(t, env, flowConnConfig)
202+
cdcDocs := make([]any, 10)
203+
for i := range 10 {
204+
cdcDocs[i] = bson.D{
205+
{Key: "_id", Value: fmt.Sprintf("cdc-%05d", i)},
206+
{Key: fmt.Sprintf("cdc_key_%d", i), Value: fmt.Sprintf("cdc_value_%d", i)},
207+
}
208+
}
209+
_, err = collection.InsertMany(t.Context(), cdcDocs)
210+
require.NoError(t, err)
211+
212+
EnvWaitForEqualTablesWithNames(env, s, "cdc events to match", srcTable, dstTable, "_id,doc")
213+
env.Cancel(t.Context())
214+
RequireEnvCanceled(t, env)
215+
}
216+
217+
func (s MongoClickhouseSuite) Test_Simple_Flow_Partitioned_NumericID() {
218+
t := s.T()
219+
srcDatabase := GetTestDatabase(s.Suffix())
220+
srcTable := "test_simple_partitioned_numeric_id"
221+
dstTable := "test_simple_dst_partitioned_numeric_id"
222+
223+
connectionGen := FlowConnectionGenerationConfig{
224+
FlowJobName: AddSuffix(s, srcTable),
225+
TableMappings: TableMappings(s, srcTable, dstTable),
226+
Destination: s.Peer().Name,
227+
}
228+
flowConnConfig := s.generateFlowConnectionConfigsDefaultEnv(connectionGen)
229+
flowConnConfig.DoInitialSnapshot = true
230+
flowConnConfig.SnapshotNumRowsPerPartition = 10
231+
232+
adminClient := s.Source().(*MongoSource).AdminClient()
233+
collection := adminClient.Database(srcDatabase).Collection(srcTable)
234+
235+
docs := make([]any, 1000)
236+
for i := range 1000 {
237+
docs[i] = bson.D{
238+
{Key: "_id", Value: int32(i + 1)},
239+
{Key: fmt.Sprintf("init_key_%d", i), Value: fmt.Sprintf("init_value_%d", i)},
240+
}
241+
}
242+
_, err := collection.InsertMany(t.Context(), docs)
243+
require.NoError(t, err)
244+
245+
tc := NewTemporalClient(t)
246+
env := ExecutePeerflow(t, tc, flowConnConfig)
247+
248+
EnvWaitForEqualTablesWithNames(env, s, "initial load to match", srcTable, dstTable, "_id,doc")
249+
250+
catalogPool, err := internal.GetCatalogConnectionPoolFromEnv(t.Context())
251+
require.NoError(t, err)
252+
var partitionCount int
253+
require.NoError(t, catalogPool.QueryRow(t.Context(),
254+
`SELECT COUNT(*) FROM peerdb_stats.qrep_partitions WHERE parent_mirror_name = $1`,
255+
flowConnConfig.FlowJobName).Scan(&partitionCount))
256+
require.Greater(t, partitionCount, 1, "expected multiple partitions for 1000 numeric-id rows")
257+
258+
SetupCDCFlowStatusQuery(t, env, flowConnConfig)
259+
cdcDocs := make([]any, 10)
260+
for i := range 10 {
261+
cdcDocs[i] = bson.D{
262+
{Key: "_id", Value: int32(10000 + i)},
263+
{Key: fmt.Sprintf("cdc_key_%d", i), Value: fmt.Sprintf("cdc_value_%d", i)},
264+
}
265+
}
266+
_, err = collection.InsertMany(t.Context(), cdcDocs)
267+
require.NoError(t, err)
268+
269+
EnvWaitForEqualTablesWithNames(env, s, "cdc events to match", srcTable, dstTable, "_id,doc")
270+
env.Cancel(t.Context())
271+
RequireEnvCanceled(t, env)
272+
}
273+
160274
func (s MongoClickhouseSuite) Test_Snapshot_Collection_With_Single_Document() {
161275
t := s.T()
162276
srcDatabase := GetTestDatabase(s.Suffix())

protos/flow.proto

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,10 @@ message ObjectIdPartitionRange {
327327
message StringPartitionRange {
328328
string start = 1;
329329
string end = 2;
330+
// unlike numeric/temporal ranges, string boundaries cannot be safely
331+
// incremented; so the partition boundaries are [start, end) except
332+
// for the last partition being [start, end]; hence this bool is needed.
333+
bool end_inclusive = 3;
330334
}
331335

332336
message PartitionRange {

0 commit comments

Comments
 (0)