Skip to content

Commit f45c14e

Browse files
mongo: partition snapshots for numeric and string _id collections
The MongoDB connector only parallelized initial snapshots when _id was a native ObjectID; numeric or string _id collections fell back to a single serial full-table partition. For large collections keyed by natural ids (e.g. app-store numeric ids or package-name strings) this makes the snapshot too slow to finish within the oplog retention window. Dispatch in GetQRepPartitions on the collection's min/max _id BSON type: - ObjectID: unchanged (uniform division of the 12-byte keyspace). - Int32/Int64: uniform min/max division, reusing utils.PartitionHelper to emit non-overlapping IntPartitionRange partitions. - String: sampling-based quantile boundaries ($sample, honouring the configured read preference) anchored by the real min/max, emitted as a new StringPartitionRange proto type with contiguous half-open ranges. Mixed-type, Double, or empty collections fall back to full-table. Add IntRange/StringRange cases to toRangeFilter and StringRange handling to monitoring.addPartitionToQRepRun. computeStringBoundaries is pure and unit-tested alongside the range-to-filter conversions.
1 parent 222bd34 commit f45c14e

5 files changed

Lines changed: 462 additions & 43 deletions

File tree

flow/connectors/mongo/qrep.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ func (c *MongoConnector) GetQRepPartitions(
7373
return fullTablePartition, nil
7474
}
7575

76-
return c.minMaxPartitions(ctx, collection, adjustedPartitions.AdjustedNumPartitions)
76+
return c.buildPartitions(ctx, collection, adjustedPartitions.AdjustedNumPartitions)
7777
}
7878

7979
func (c *MongoConnector) GetDefaultPartitionKeyForTables(
@@ -234,6 +234,26 @@ func toRangeFilter(watermarkColumn string, partitionRange *protos.PartitionRange
234234
bson.E{Key: "$lte", Value: endObjectID},
235235
}},
236236
}, nil
237+
case *protos.PartitionRange_IntRange:
238+
// Numeric _id: inclusive range. PartitionHelper builds non-overlapping
239+
// [start, end] integer ranges. Mongo compares int32/int64 numerically, so
240+
// int64 bounds match int32-typed _ids.
241+
return bson.D{
242+
bson.E{Key: watermarkColumn, Value: bson.D{
243+
bson.E{Key: "$gte", Value: r.IntRange.Start},
244+
bson.E{Key: "$lte", Value: r.IntRange.End},
245+
}},
246+
}, nil
247+
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.
251+
return bson.D{
252+
bson.E{Key: watermarkColumn, Value: bson.D{
253+
bson.E{Key: "$gte", Value: r.StringRange.Start},
254+
bson.E{Key: "$lt", Value: r.StringRange.End},
255+
}},
256+
}, nil
237257
default:
238258
return nil, fmt.Errorf("unsupported partition range type")
239259
}

flow/connectors/mongo/qrep_partition.go

Lines changed: 263 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"log/slog"
77
"math/big"
8+
"slices"
89

910
"github.com/google/uuid"
1011
"go.mongodb.org/mongo-driver/v2/bson"
@@ -16,37 +17,95 @@ import (
1617
"github.com/PeerDB-io/peerdb/flow/shared"
1718
)
1819

19-
// minMaxPartitions creates partitions by querying only the min and max _id
20-
// from the collection (leveraging the default _id index), then uniformly
21-
// dividing the ObjectID range using integer arithmetic on the full 12-byte
22-
// value. Since the first 4-byte timestamp is the most significant component,
23-
// this naturally partitions by time while also provides best-effort handling
24-
// of edge cases where records are all inserted in the same second.
25-
func (c *MongoConnector) minMaxPartitions(
26-
ctx context.Context,
27-
collection *mongo.Collection,
28-
numPartitions int64,
29-
) ([]*protos.QRepPartition, error) {
30-
fullTablePartition := []*protos.QRepPartition{{
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+
28+
const (
29+
// stringSampleOversample draws more samples than partitions so quantile
30+
// boundaries are well distributed even with clustered keys.
31+
stringSampleOversample = 20
32+
// stringSampleMaxSize caps sampling cost on very large collections.
33+
stringSampleMaxSize = 100000
34+
)
35+
36+
// fullTablePartitions returns the single-partition full-table fallback used when a
37+
// collection cannot be partitioned (unsupported/mixed _id type, empty collection,
38+
// or degenerate min/max range).
39+
func fullTablePartitions() []*protos.QRepPartition {
40+
return []*protos.QRepPartition{{
3141
PartitionId: utils.FullTablePartitionID,
3242
Range: nil,
3343
FullTablePartition: true,
3444
}}
45+
}
3546

36-
minID, maxID, found, err := findMinMaxObjectIDs(ctx, collection, c.config.ReadPreference)
47+
// buildPartitions reads the collection's min and max _id (cheap, leverages the
48+
// default _id index) and dispatches to a type-specific partitioner:
49+
// - ObjectID -> uniform division of the 12-byte ObjectID keyspace
50+
// - Int32/64 -> uniform division of the numeric range (reuses PartitionHelper)
51+
// - String -> sampling-based quantile boundaries
52+
//
53+
// Mixed-type _id (min and max differ in type), Double, or any other type fall back
54+
// to a full-table partition. The change stream resume token is captured before this
55+
// runs, so any writes during partitioning are replayed by CDC.
56+
func (c *MongoConnector) buildPartitions(
57+
ctx context.Context,
58+
collection *mongo.Collection,
59+
numPartitions int64,
60+
) ([]*protos.QRepPartition, error) {
61+
minRaw, err := findBoundaryID(ctx, collection, Lower, c.config.ReadPreference)
3762
if err != nil {
38-
return nil, err
63+
return nil, fmt.Errorf("failed to find min _id: %w", err)
64+
}
65+
if minRaw.IsZero() {
66+
c.logger.Info("[mongo] no documents found, falling back to full table partition")
67+
return fullTablePartitions(), nil
3968
}
40-
if !found {
41-
c.logger.Info("[mongo] no valid min/max ObjectIDs found, falling back to full table partition")
42-
return fullTablePartition, nil
69+
maxRaw, err := findBoundaryID(ctx, collection, Upper, c.config.ReadPreference)
70+
if err != nil {
71+
return nil, fmt.Errorf("failed to find max _id: %w", err)
72+
}
73+
if maxRaw.IsZero() {
74+
c.logger.Info("[mongo] no documents found, falling back to full table partition")
75+
return fullTablePartitions(), nil
4376
}
77+
78+
switch {
79+
case minRaw.Type == bson.TypeObjectID && maxRaw.Type == bson.TypeObjectID:
80+
return c.objectIDPartitions(minRaw.ObjectID(), maxRaw.ObjectID(), numPartitions)
81+
case isNumericIDType(minRaw.Type) && isNumericIDType(maxRaw.Type):
82+
minVal, _ := rawValueToInt64(minRaw)
83+
maxVal, _ := rawValueToInt64(maxRaw)
84+
return c.numericPartitions(minVal, maxVal, numPartitions)
85+
case minRaw.Type == bson.TypeString && maxRaw.Type == bson.TypeString:
86+
return c.stringPartitions(ctx, collection, minRaw.StringValue(), maxRaw.StringValue(), numPartitions)
87+
default:
88+
c.logger.Info("[mongo] _id type not supported for partitioning (or mixed types), falling back to full table partition",
89+
slog.String("minType", minRaw.Type.String()),
90+
slog.String("maxType", maxRaw.Type.String()))
91+
return fullTablePartitions(), nil
92+
}
93+
}
94+
95+
// objectIDPartitions divides the ObjectID keyspace uniformly using integer
96+
// arithmetic on the full 12-byte value. Since the leading 4-byte timestamp is the
97+
// most significant component, this naturally partitions by insertion time.
98+
func (c *MongoConnector) objectIDPartitions(
99+
minID bson.ObjectID,
100+
maxID bson.ObjectID,
101+
numPartitions int64,
102+
) ([]*protos.QRepPartition, error) {
44103
minInt := new(big.Int).SetBytes(minID[:])
45104
maxInt := new(big.Int).SetBytes(maxID[:])
46105
intRange := new(big.Int).Sub(maxInt, minInt)
47106
if intRange.Sign() <= 0 {
48107
c.logger.Info("[mongo] min/max ObjectID range is non-positive, falling back to full table partition")
49-
return fullTablePartition, nil
108+
return fullTablePartitions(), nil
50109
}
51110

52111
c.logger.Info("[mongo] using min/max ObjectID partitioning",
@@ -88,43 +147,205 @@ func (c *MongoConnector) minMaxPartitions(
88147
return partitions, nil
89148
}
90149

91-
// bigIntToObjectID converts a big.Int back to a 12-byte ObjectID.
92-
func bigIntToObjectID(n *big.Int) (bson.ObjectID, error) {
93-
var oid bson.ObjectID
94-
if n.BitLen() > 96 {
95-
return oid, fmt.Errorf("big.Int value exceeds 96 bits (ObjectID size)")
150+
// numericPartitions divides a numeric (int32/int64) _id range uniformly, reusing
151+
// the shared PartitionHelper which builds non-overlapping IntPartitionRange
152+
// partitions with tested +1 boundary semantics covering [min, max] inclusive.
153+
func (c *MongoConnector) numericPartitions(
154+
minVal int64,
155+
maxVal int64,
156+
numPartitions int64,
157+
) ([]*protos.QRepPartition, error) {
158+
if maxVal <= minVal {
159+
c.logger.Info("[mongo] numeric min/max range is non-positive, falling back to full table partition")
160+
return fullTablePartitions(), nil
96161
}
97-
n.FillBytes(oid[:])
98-
return oid, nil
162+
163+
c.logger.Info("[mongo] using numeric _id partitioning",
164+
slog.Int64("minID", minVal),
165+
slog.Int64("maxID", maxVal),
166+
slog.Int64("numPartitions", numPartitions))
167+
168+
helper := utils.NewPartitionHelper(c.logger)
169+
if err := helper.AddPartitionsWithRange(minVal, maxVal, numPartitions); err != nil {
170+
return nil, fmt.Errorf("failed to build numeric partitions: %w", err)
171+
}
172+
return helper.GetPartitions(), nil
99173
}
100174

101-
// findMinMaxObjectIDs returns the min and max object IDs from the collection.
102-
// The two boundary queries are not wrapped in a snapshot session: a concurrent
103-
// insert between them may shift the true min/max, but this is safe because the
104-
// change stream resume token is captured before either query runs. Any writes
105-
// that occur during or after partitioning will be replayed by the change stream.
106-
func findMinMaxObjectIDs(
175+
// stringPartitions builds partitions for a string _id collection. Because string
176+
// keys (e.g. package names) are not uniformly distributed, uniform division of the
177+
// keyspace would be badly skewed; instead we sample the collection and pick quantile
178+
// boundaries so each partition holds a roughly equal share of documents.
179+
func (c *MongoConnector) stringPartitions(
107180
ctx context.Context,
108181
collection *mongo.Collection,
109-
readPreference protos.ReadPreference,
110-
) (bson.ObjectID, bson.ObjectID, bool, error) {
111-
minRaw, err := findBoundaryID(ctx, collection, Lower, readPreference)
182+
minVal string,
183+
maxVal string,
184+
numPartitions int64,
185+
) ([]*protos.QRepPartition, error) {
186+
if minVal >= maxVal {
187+
c.logger.Info("[mongo] string min/max range is non-positive, falling back to full table partition")
188+
return fullTablePartitions(), nil
189+
}
190+
191+
samples, err := c.sampleStringIDs(ctx, collection, numPartitions)
112192
if err != nil {
113-
return bson.ObjectID{}, bson.ObjectID{}, false, fmt.Errorf("failed to find min _id: %w", err)
193+
return nil, err
194+
}
195+
196+
ranges := computeStringBoundaries(minVal, maxVal, samples, numPartitions)
197+
if len(ranges) < 2 {
198+
c.logger.Info("[mongo] insufficient string boundaries from sampling, falling back to full table partition",
199+
slog.Int("sampleCount", len(samples)))
200+
return fullTablePartitions(), nil
201+
}
202+
203+
c.logger.Info("[mongo] using sampled string _id partitioning",
204+
slog.String("minID", minVal),
205+
slog.String("maxID", maxVal),
206+
slog.Int("numPartitions", len(ranges)),
207+
slog.Int("sampleCount", len(samples)))
208+
209+
partitions := make([]*protos.QRepPartition, 0, len(ranges))
210+
for _, rng := range ranges {
211+
partitions = append(partitions, &protos.QRepPartition{
212+
PartitionId: uuid.NewString(),
213+
Range: &protos.PartitionRange{
214+
Range: &protos.PartitionRange_StringRange{
215+
StringRange: &protos.StringPartitionRange{
216+
Start: rng[0],
217+
End: rng[1],
218+
},
219+
},
220+
},
221+
FullTablePartition: false,
222+
})
114223
}
115-
if minRaw.Type != bson.TypeObjectID {
116-
return bson.ObjectID{}, bson.ObjectID{}, false, nil
224+
return partitions, nil
225+
}
226+
227+
// sampleStringIDs draws a random sample of string _id values. $sample with a size
228+
// below ~5% of the collection uses WiredTiger's random cursor, so this is cheap even
229+
// on large collections; it honours the configured read preference (run on a
230+
// secondary to avoid loading the primary).
231+
func (c *MongoConnector) sampleStringIDs(
232+
ctx context.Context,
233+
collection *mongo.Collection,
234+
numPartitions int64,
235+
) ([]string, error) {
236+
sampleSize := numPartitions * stringSampleOversample
237+
if sampleSize > stringSampleMaxSize {
238+
sampleSize = stringSampleMaxSize
117239
}
118240

119-
maxRaw, err := findBoundaryID(ctx, collection, Upper, readPreference)
241+
aggCmd := bson.D{
242+
{Key: "aggregate", Value: collection.Name()},
243+
{Key: "pipeline", Value: bson.A{
244+
bson.D{{Key: "$sample", Value: bson.D{{Key: "size", Value: int32(sampleSize)}}}},
245+
bson.D{{Key: "$project", Value: bson.D{{Key: DefaultDocumentKeyColumnName, Value: 1}}}},
246+
}},
247+
{Key: "cursor", Value: bson.D{}},
248+
}
249+
250+
cursor, err := collection.Database().RunCommandCursor(ctx, aggCmd,
251+
options.RunCmd().SetReadPreference(protoToReadPref[c.config.ReadPreference]))
120252
if err != nil {
121-
return bson.ObjectID{}, bson.ObjectID{}, false, fmt.Errorf("failed to find max _id: %w", err)
253+
return nil, fmt.Errorf("failed to sample _id values: %w", err)
254+
}
255+
defer cursor.Close(ctx)
256+
257+
samples := make([]string, 0, sampleSize)
258+
for cursor.Next(ctx) {
259+
rv := cursor.Current.Lookup(DefaultDocumentKeyColumnName)
260+
if rv.Type == bson.TypeString {
261+
samples = append(samples, rv.StringValue())
262+
}
263+
}
264+
if err := cursor.Err(); err != nil {
265+
return nil, fmt.Errorf("cursor error while sampling _id values: %w", err)
266+
}
267+
return samples, nil
268+
}
269+
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.
275+
//
276+
// It is pure (no I/O) to keep the boundary math unit-testable. Returns fewer ranges
277+
// than numPartitions when the sample yields too few distinct interior boundaries.
278+
func computeStringBoundaries(minVal string, maxVal string, samples []string, numPartitions int64) [][2]string {
279+
// Keep unique sampled values strictly inside (min, max).
280+
seen := make(map[string]struct{}, len(samples))
281+
interior := make([]string, 0, len(samples))
282+
for _, s := range samples {
283+
if s <= minVal || s >= maxVal {
284+
continue
285+
}
286+
if _, ok := seen[s]; ok {
287+
continue
288+
}
289+
seen[s] = struct{}{}
290+
interior = append(interior, s)
291+
}
292+
slices.Sort(interior)
293+
294+
// Pick up to numPartitions-1 evenly spaced interior boundaries (quantiles).
295+
desiredBoundaries := numPartitions - 1
296+
var picked []string
297+
if int64(len(interior)) <= desiredBoundaries {
298+
picked = interior
299+
} else {
300+
picked = make([]string, 0, desiredBoundaries)
301+
for i := int64(1); i <= desiredBoundaries; i++ {
302+
idx := i * int64(len(interior)) / numPartitions
303+
if idx >= int64(len(interior)) {
304+
idx = int64(len(interior)) - 1
305+
}
306+
picked = append(picked, interior[idx])
307+
}
308+
picked = slices.Compact(picked) // drop consecutive duplicate quantiles
122309
}
123-
if maxRaw.Type != bson.TypeObjectID {
124-
return bson.ObjectID{}, bson.ObjectID{}, false, nil
310+
311+
// Build contiguous half-open ranges: [min, p0), [p0, p1), ..., [pLast, max+sentinel).
312+
starts := append([]string{minVal}, picked...)
313+
ranges := make([][2]string, len(starts))
314+
for i := range starts {
315+
var end string
316+
if i+1 < len(starts) {
317+
end = starts[i+1]
318+
} else {
319+
end = maxVal + stringPartitionUpperSentinel
320+
}
321+
ranges[i] = [2]string{starts[i], end}
125322
}
323+
return ranges
324+
}
126325

127-
return minRaw.ObjectID(), maxRaw.ObjectID(), true, nil
326+
// bigIntToObjectID converts a big.Int back to a 12-byte ObjectID.
327+
func bigIntToObjectID(n *big.Int) (bson.ObjectID, error) {
328+
var oid bson.ObjectID
329+
if n.BitLen() > 96 {
330+
return oid, fmt.Errorf("big.Int value exceeds 96 bits (ObjectID size)")
331+
}
332+
n.FillBytes(oid[:])
333+
return oid, nil
334+
}
335+
336+
func isNumericIDType(t bson.Type) bool {
337+
return t == bson.TypeInt32 || t == bson.TypeInt64
338+
}
339+
340+
func rawValueToInt64(rv bson.RawValue) (int64, bool) {
341+
switch rv.Type {
342+
case bson.TypeInt32:
343+
return int64(rv.Int32()), true
344+
case bson.TypeInt64:
345+
return rv.Int64(), true
346+
default:
347+
return 0, false
348+
}
128349
}
129350

130351
type Boundary int

0 commit comments

Comments
 (0)