Skip to content

Commit af08f20

Browse files
committed
introduce min/max-id based parallel partitioning for mongodb
1 parent cc39a03 commit af08f20

4 files changed

Lines changed: 320 additions & 84 deletions

File tree

flow/connectors/mongo/qrep.go

Lines changed: 7 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"math"
99
"time"
1010

11-
"github.com/google/uuid"
1211
"go.mongodb.org/mongo-driver/v2/bson"
1312
"go.mongodb.org/mongo-driver/v2/mongo/options"
1413

@@ -35,7 +34,7 @@ func (c *MongoConnector) GetQRepPartitions(
3534
},
3635
}
3736

38-
if config.WatermarkColumn == "" {
37+
if config.WatermarkColumn != DefaultDocumentKeyColumnName {
3938
return fullTablePartition, nil
4039
}
4140

@@ -59,9 +58,6 @@ func (c *MongoConnector) GetQRepPartitions(
5958
if err != nil {
6059
return nil, fmt.Errorf("failed to count documents in collection %s: %w", parseWatermarkTable.Table, err)
6160
}
62-
if totalRows == 0 {
63-
return []*protos.QRepPartition{}, nil
64-
}
6561

6662
// Calculate the number of partitions
6763
adjustedPartitions := shared.AdjustNumPartitions(totalRows, numRowsPerPartition)
@@ -71,77 +67,19 @@ func (c *MongoConnector) GetQRepPartitions(
7167
slog.Int64("adjustedNumPartitions", adjustedPartitions.AdjustedNumPartitions),
7268
slog.Int64("adjustedNumRowsPerPartition", adjustedPartitions.AdjustedNumRowsPerPartition))
7369

74-
// no need to bother with bucketAuto if we have only one partition
75-
if adjustedPartitions.AdjustedNumPartitions == 1 {
76-
return fullTablePartition, nil
77-
}
78-
79-
if config.WatermarkColumn != DefaultDocumentKeyColumnName {
80-
return nil, fmt.Errorf("only %s is currently supported as watermark column for MongoDB connector", DefaultDocumentKeyColumnName)
81-
}
82-
83-
// Use bucketAuto to create partitions based on _id field
84-
bucketAutoPipeline := []bson.D{
85-
{
86-
{Key: "$bucketAuto", Value: bson.D{
87-
{Key: "groupBy", Value: "$" + config.WatermarkColumn},
88-
{Key: "buckets", Value: adjustedPartitions.AdjustedNumPartitions},
89-
}},
90-
},
91-
}
92-
93-
cursor, err := collection.Aggregate(ctx, bucketAutoPipeline)
94-
if err != nil {
95-
return nil, fmt.Errorf("failed to aggregate for bucket partitions: %w", err)
96-
}
97-
defer cursor.Close(ctx)
98-
99-
partitions := make([]*protos.QRepPartition, 0, adjustedPartitions.AdjustedNumPartitions)
100-
for cursor.Next(ctx) {
101-
var bucket struct {
102-
ID struct {
103-
Min bson.ObjectID `bson:"min"`
104-
Max bson.ObjectID `bson:"max"`
105-
} `bson:"_id"`
106-
}
107-
if err := bson.Unmarshal(cursor.Current, &bucket); err != nil {
108-
return nil, fmt.Errorf("failed to unmarshal bucket: %w", err)
109-
}
110-
111-
partitions = append(partitions, &protos.QRepPartition{
112-
PartitionId: uuid.NewString(),
113-
Range: &protos.PartitionRange{
114-
Range: &protos.PartitionRange_ObjectIdRange{
115-
ObjectIdRange: &protos.ObjectIdPartitionRange{
116-
Start: bucket.ID.Min.Hex(),
117-
End: bucket.ID.Max.Hex(),
118-
},
119-
},
120-
},
121-
FullTablePartition: false,
122-
})
123-
}
124-
if err := cursor.Err(); err != nil {
125-
if errors.Is(err, context.Canceled) {
126-
c.logger.Warn("context canceled while performing bucketAuto aggregation",
127-
slog.String("watermark_table", config.WatermarkTable))
128-
} else {
129-
c.logger.Error("error while performing bucketAuto aggregation",
130-
slog.String("watermark_table", config.WatermarkTable),
131-
slog.String("error", err.Error()))
132-
}
133-
return nil, fmt.Errorf("cursor error during bucketAuto aggregation: %w", err)
134-
}
135-
136-
return partitions, nil
70+
return c.minMaxPartitions(ctx, collection, adjustedPartitions.AdjustedNumPartitions)
13771
}
13872

13973
func (c *MongoConnector) GetDefaultPartitionKeyForTables(
14074
ctx context.Context,
14175
input *protos.GetDefaultPartitionKeyForTablesInput,
14276
) (*protos.GetDefaultPartitionKeyForTablesOutput, error) {
77+
mapping := make(map[string]string, len(input.TableMappings))
78+
for _, tm := range input.TableMappings {
79+
mapping[tm.SourceTableIdentifier] = DefaultDocumentKeyColumnName
80+
}
14381
return &protos.GetDefaultPartitionKeyForTablesOutput{
144-
TableDefaultPartitionKeyMapping: nil,
82+
TableDefaultPartitionKeyMapping: mapping,
14583
}, nil
14684
}
14785

@@ -275,8 +213,6 @@ func GetDefaultSchema(internalVersion uint32) types.QRecordSchema {
275213
return types.QRecordSchema{Fields: schema}
276214
}
277215

278-
// with $bucketAuto, buckets except the last bucket treat their max value as exclusive
279-
// we can't tell what bucket is the "last" bucket without additional tracking, so we accept bounday records being inserted twice
280216
func toRangeFilter(watermarkColumn string, partitionRange *protos.PartitionRange) (bson.D, error) {
281217
switch r := partitionRange.Range.(type) {
282218
case *protos.PartitionRange_ObjectIdRange:
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package connmongo
2+
3+
import (
4+
"context"
5+
"encoding/binary"
6+
"fmt"
7+
"log/slog"
8+
"time"
9+
10+
"github.com/google/uuid"
11+
"go.mongodb.org/mongo-driver/v2/bson"
12+
"go.mongodb.org/mongo-driver/v2/mongo"
13+
"go.mongodb.org/mongo-driver/v2/mongo/options"
14+
15+
"github.com/PeerDB-io/peerdb/flow/connectors/utils"
16+
"github.com/PeerDB-io/peerdb/flow/generated/protos"
17+
"github.com/PeerDB-io/peerdb/flow/shared"
18+
)
19+
20+
// minObjectIDForTimestamp creates an ObjectID with the given timestamp and
21+
// all trailing bytes set to 0x00, producing the smallest possible ObjectID
22+
// for that second.
23+
func minObjectIDForTimestamp(t time.Time) bson.ObjectID {
24+
var oid [12]byte
25+
binary.BigEndian.PutUint32(oid[0:4], uint32(t.Unix()))
26+
return oid
27+
}
28+
29+
// maxObjectIDForTimestamp creates an ObjectID with the given timestamp and
30+
// all trailing bytes set to 0xFF, producing the largest possible ObjectID
31+
// for that second.
32+
func maxObjectIDForTimestamp(t time.Time) bson.ObjectID {
33+
var oid [12]byte
34+
binary.BigEndian.PutUint32(oid[0:4], uint32(t.Unix()))
35+
for i := 4; i < 12; i++ {
36+
oid[i] = 0xFF
37+
}
38+
return oid
39+
}
40+
41+
// minMaxPartitions creates partitions by querying only the min and max _id
42+
// from the collection (leveraging the default _id index), then uniformly
43+
// dividing the timestamp range encoded in the ObjectIDs. Note that partition
44+
// may be skewed if document insertion rate varies significantly over time.
45+
func (c *MongoConnector) minMaxPartitions(
46+
ctx context.Context,
47+
collection *mongo.Collection,
48+
numPartitions int64,
49+
) ([]*protos.QRepPartition, error) {
50+
fullTablePartition := []*protos.QRepPartition{{
51+
PartitionId: utils.FullTablePartitionID,
52+
Range: nil,
53+
FullTablePartition: true,
54+
}}
55+
if numPartitions == 1 {
56+
c.logger.Info("[mongo] single partition requested, falling back to full table partition")
57+
return fullTablePartition, nil
58+
}
59+
60+
minRaw, err := findBoundaryID(ctx, collection, 1)
61+
if err != nil {
62+
c.logger.Info("[mongo] could not find min _id, collection may be empty, falling back to full table partition",
63+
slog.String("error", err.Error()))
64+
return fullTablePartition, nil
65+
}
66+
maxRaw, err := findBoundaryID(ctx, collection, -1)
67+
if err != nil {
68+
c.logger.Info("[mongo] could not find max _id, falling back to full table partition",
69+
slog.String("error", err.Error()))
70+
return fullTablePartition, nil
71+
}
72+
73+
// Given MongoDB's type-ordered _id, if both min and max are ObjectID
74+
// then every document in the collection must have an ObjectID _id.
75+
if minRaw.Type != bson.TypeObjectID || maxRaw.Type != bson.TypeObjectID {
76+
c.logger.Info("[mongo] _id contains non-ObjectID type, falling back to full table partition",
77+
slog.String("minType", minRaw.Type.String()),
78+
slog.String("maxType", maxRaw.Type.String()))
79+
return fullTablePartition, nil
80+
}
81+
82+
minID := minRaw.ObjectID()
83+
maxID := maxRaw.ObjectID()
84+
minTs := minID.Timestamp()
85+
maxTs := maxID.Timestamp()
86+
tsRange := maxTs.Unix() - minTs.Unix()
87+
if tsRange <= 0 {
88+
c.logger.Info("[mongo] min/max timestamps are equal, falling back to full table partition")
89+
return fullTablePartition, nil
90+
}
91+
92+
c.logger.Info("[mongo] using min/max ObjectID timestamp partitioning",
93+
slog.Time("minTimestamp", minTs),
94+
slog.Time("maxTimestamp", maxTs),
95+
slog.Int64("numPartitions", numPartitions))
96+
97+
secondsPerPartition := shared.DivCeil(tsRange, numPartitions)
98+
partitions := make([]*protos.QRepPartition, 0, numPartitions)
99+
for i := range numPartitions {
100+
var start, end bson.ObjectID
101+
if i == 0 {
102+
start = minID
103+
} else {
104+
start = minObjectIDForTimestamp(time.Unix(minTs.Unix()+secondsPerPartition*i, 0))
105+
}
106+
if i == numPartitions-1 {
107+
end = maxID
108+
} else {
109+
end = maxObjectIDForTimestamp(time.Unix(minTs.Unix()+secondsPerPartition*(i+1)-1, 0))
110+
}
111+
partitions = append(partitions, &protos.QRepPartition{
112+
PartitionId: uuid.NewString(),
113+
Range: &protos.PartitionRange{
114+
Range: &protos.PartitionRange_ObjectIdRange{
115+
ObjectIdRange: &protos.ObjectIdPartitionRange{
116+
Start: start.Hex(),
117+
End: end.Hex(),
118+
},
119+
},
120+
},
121+
FullTablePartition: false,
122+
})
123+
}
124+
125+
return partitions, nil
126+
}
127+
128+
// findBoundaryID returns the raw _id value of the min (direction=1) or max (direction=-1)
129+
// document in the collection. Because _id is always indexed, this is an efficient O(log n) operation.
130+
// The caller should inspect the returned RawValue.Type to determine the BSON type.
131+
func findBoundaryID(
132+
ctx context.Context,
133+
collection *mongo.Collection,
134+
direction int,
135+
) (bson.RawValue, error) {
136+
findCmd := bson.D{
137+
{Key: "find", Value: collection.Name()},
138+
{Key: "sort", Value: bson.D{{Key: DefaultDocumentKeyColumnName, Value: direction}}},
139+
{Key: "limit", Value: 1},
140+
{Key: "projection", Value: bson.D{{Key: DefaultDocumentKeyColumnName, Value: 1}}},
141+
}
142+
143+
cursor, err := collection.Database().RunCommandCursor(ctx, findCmd, options.RunCmd())
144+
if err != nil {
145+
return bson.RawValue{}, fmt.Errorf("failed to run find command: %w", err)
146+
}
147+
defer cursor.Close(ctx)
148+
149+
if !cursor.Next(ctx) {
150+
return bson.RawValue{}, fmt.Errorf("collection is empty")
151+
}
152+
rv := cursor.Current.Lookup(DefaultDocumentKeyColumnName)
153+
if rv.IsZero() {
154+
return bson.RawValue{}, fmt.Errorf("document _id is nil")
155+
}
156+
return rv, nil
157+
}

flow/e2e/mongo.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"os"
7+
"sort"
78
"testing"
89

910
"github.com/stretchr/testify/require"
@@ -83,6 +84,11 @@ func (s *MongoSource) GetRows(ctx context.Context, suffix, table, cols string) (
8384
recordBatch.Records = append(recordBatch.Records, record)
8485
}
8586

87+
// sort by _id string value to match ClickHouse's `ORDER BY 1`
88+
sort.Slice(recordBatch.Records, func(i, j int) bool {
89+
return recordBatch.Records[i][0].Value().(string) < recordBatch.Records[j][0].Value().(string)
90+
})
91+
8692
return recordBatch, nil
8793
}
8894

0 commit comments

Comments
 (0)