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
130351type Boundary int
0 commit comments