Skip to content

Commit fc8d9bc

Browse files
committed
merge
2 parents eedb55c + de0645e commit fc8d9bc

29 files changed

Lines changed: 1094 additions & 171 deletions

.github/workflows/flow.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -356,9 +356,9 @@ jobs:
356356
id: ch-version
357357
run: |
358358
if [ "${{ matrix.db-version.ch }}" = "lts" ]; then
359-
echo "ch_version=v25.8.11.66-lts" >> $GITHUB_OUTPUT
359+
echo "ch_version=v25.8.28.1-lts" >> $GITHUB_OUTPUT
360360
elif [ "${{ matrix.db-version.ch }}" = "stable" ]; then
361-
echo "ch_version=v25.12.4.35-stable" >> $GITHUB_OUTPUT
361+
echo "ch_version=v26.3.16.16-lts" >> $GITHUB_OUTPUT
362362
elif [ "${{ matrix.db-version.ch }}" = "latest" ]; then
363363
# note: latest tag does not always reflect the latest version (could be an update on an lts),
364364
# but that is okay as we are only using it to invalidate the cache.

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,7 @@ ancillary.env
2626
# Ephemeral volumes for docker compose
2727
volumes/ch_*
2828

29+
# Renovate debug files
30+
final-updated-packages.txt
31+
renovate.out
32+
update-proposals.json

flow/alerting/classifier.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,12 @@ var (
146146
ErrorNotifyBinlogEventExceededMaxAllowedPacket = ErrorClass{
147147
Class: "NOTIFY_BINLOG_EVENT_EXCEEDED_MAX_ALLOWED_PACKET", action: NotifyUser,
148148
}
149+
ErrorNotifyBinlogPartialRowEventUnsupported = ErrorClass{
150+
Class: "NOTIFY_BINLOG_PARTIAL_ROW_EVENT_UNSUPPORTED", action: NotifyUser,
151+
}
152+
ErrorNotifyBinlogPartialJsonUnsupported = ErrorClass{
153+
Class: "NOTIFY_BINLOG_PARTIAL_JSON_UNSUPPORTED", action: NotifyUser,
154+
}
149155
ErrorNotifyBinlogRowMetadataInvalid = ErrorClass{
150156
Class: "NOTIFY_BINLOG_ROW_METADATA_INVALID", action: NotifyUser,
151157
}
@@ -1120,6 +1126,16 @@ func GetErrorClass(ctx context.Context, err error) (ErrorClass, ErrorInfo) {
11201126
}
11211127
}
11221128

1129+
if partialJsonUnsupportedError, ok := errors.AsType[*exceptions.MySQLUnsupportedBinlogRowValueOptionsError](err); ok {
1130+
return ErrorNotifyBinlogPartialJsonUnsupported, ErrorInfo{
1131+
Source: ErrorSourceMySQL,
1132+
Code: "UNSUPPORTED_PARTIAL_JSON",
1133+
AdditionalAttributes: map[AdditionalErrorAttributeKey]string{
1134+
ErrorAttributeKeyTable: fmt.Sprintf("%s.%s", partialJsonUnsupportedError.Schema, partialJsonUnsupportedError.Table),
1135+
},
1136+
}
1137+
}
1138+
11231139
if unsupportedDDLError, ok := errors.AsType[*exceptions.MySQLUnsupportedDDLError](err); ok {
11241140
return ErrorNotifyBinlogRowMetadataInvalid, ErrorInfo{
11251141
Source: ErrorSourceMySQL,
@@ -1162,6 +1178,16 @@ func GetErrorClass(ctx context.Context, err error) (ErrorClass, ErrorInfo) {
11621178
}
11631179
}
11641180

1181+
if partialRowEventError, ok := errors.AsType[*exceptions.MySQLUnsupportedPartialRowEventError](err); ok {
1182+
return ErrorNotifyBinlogPartialRowEventUnsupported, ErrorInfo{
1183+
Source: ErrorSourceMySQL,
1184+
Code: "UNSUPPORTED_PARTIAL_ROW_EVENT",
1185+
AdditionalAttributes: map[AdditionalErrorAttributeKey]string{
1186+
ErrorAttributeKeyTable: fmt.Sprintf("%s.%s", partialRowEventError.Schema, partialRowEventError.Table),
1187+
},
1188+
}
1189+
}
1190+
11651191
if mysqlGeometryParseError, ok := errors.AsType[*exceptions.MySQLGeometryParseError](err); ok &&
11661192
strings.Contains(mysqlGeometryParseError.Error(), mysqlGeometryLinearRingNotClosedError) {
11671193
return ErrorUnsupportedDatatype, ErrorInfo{

flow/alerting/classifier_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1255,3 +1255,33 @@ func TestMySQLBinlogIncidentErrorShouldBeNotifyBinlogInvalid(t *testing.T) {
12551255
Code: "BINLOG_INCIDENT",
12561256
}, errInfo)
12571257
}
1258+
1259+
func TestMySQLUnsupportedPartialRowEventShouldBeNotifyPartialRowEventUnsupported(t *testing.T) {
1260+
t.Parallel()
1261+
1262+
err := exceptions.NewMySQLUnsupportedPartialRowEventError(172, "e2e_test", "partial_rows")
1263+
errorClass, errInfo := GetErrorClass(t.Context(), fmt.Errorf("pulling records failed: %w", err))
1264+
assert.Equal(t, ErrorNotifyBinlogPartialRowEventUnsupported, errorClass)
1265+
assert.Equal(t, ErrorInfo{
1266+
Source: ErrorSourceMySQL,
1267+
Code: "UNSUPPORTED_PARTIAL_ROW_EVENT",
1268+
AdditionalAttributes: map[AdditionalErrorAttributeKey]string{
1269+
ErrorAttributeKeyTable: "e2e_test.partial_rows",
1270+
},
1271+
}, errInfo)
1272+
}
1273+
1274+
func TestMySQLUnsupportedBinlogRowValueOptionsErrorShouldBeNotifyBinlogPartialJsonUnsupported(t *testing.T) {
1275+
t.Parallel()
1276+
1277+
err := exceptions.NewMySQLUnsupportedBinlogRowValueOptionsError("mydb", "mytable")
1278+
errorClass, errInfo := GetErrorClass(t.Context(), fmt.Errorf("pulling records failed: %w", err))
1279+
assert.Equal(t, ErrorNotifyBinlogPartialJsonUnsupported, errorClass)
1280+
assert.Equal(t, ErrorInfo{
1281+
Source: ErrorSourceMySQL,
1282+
Code: "UNSUPPORTED_PARTIAL_JSON",
1283+
AdditionalAttributes: map[AdditionalErrorAttributeKey]string{
1284+
ErrorAttributeKeyTable: "mydb.mytable",
1285+
},
1286+
}, errInfo)
1287+
}

flow/connectors/clickhouse/validate.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,15 @@ func (c *ClickHouseConnector) ValidateMirrorDestination(
120120
); err != nil {
121121
return err
122122
}
123+
if err := chvalidate.ValidateClusterShardingKey(
124+
c.Config.Cluster,
125+
tableMapping.ShardingKey,
126+
tableMapping.SourceTableIdentifier,
127+
len(processedSchema.PrimaryKeyColumns) > 0,
128+
sortingKeys,
129+
); err != nil {
130+
return err
131+
}
123132

124133
// if destination table does not exist, we're good
125134
if _, ok := chTableColumnsMapping[dstTableName]; !ok {

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: 14 additions & 38 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. MongoDB
129+
// sorts all numeric types by value, so integer min/max _ids cannot rule out
130+
// fractional (double/decimal) _ids in-between. Therefore we use [start, end) to
131+
// ensure contiguous boundaries.
139132
func (c *MongoConnector) numericPartitions(
140133
minVal int64,
141134
maxVal int64,
@@ -146,16 +139,13 @@ 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))
153-
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)
142+
ranges := utils.ComputeRanges(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))
157147
}
158-
return helper.GetPartitions(), nil
148+
return partitions, nil
159149
}
160150

161151
// stringPartitions builds partitions for a string _id collection. Because string
@@ -187,26 +177,12 @@ func (c *MongoConnector) stringPartitions(
187177
}
188178

189179
c.logger.Info("[mongo] using sampled string _id partitioning",
190-
slog.String("minID", minVal),
191-
slog.String("maxID", maxVal),
192180
slog.Int("numPartitions", len(ranges)),
193181
slog.Int("sampleCount", len(samples)))
194182

195183
partitions := make([]*protos.QRepPartition, 0, len(ranges))
196184
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-
})
185+
partitions = append(partitions, utils.CreateStringPartition(rng[0], rng[1], i == len(ranges)-1))
210186
}
211187
return partitions, nil
212188
}
@@ -229,8 +205,8 @@ func (c *MongoConnector) sampleStringIDs(
229205
{Key: "aggregate", Value: collection.Name()},
230206
{Key: "pipeline", Value: bson.A{
231207
bson.D{{Key: "$sample", Value: bson.D{{Key: "size", Value: int32(sampleSize)}}}},
232-
bson.D{{Key: "$sort", Value: bson.D{{Key: DefaultDocumentKeyColumnName, Value: 1}}}},
233208
bson.D{{Key: "$project", Value: bson.D{{Key: DefaultDocumentKeyColumnName, Value: 1}}}},
209+
bson.D{{Key: "$sort", Value: bson.D{{Key: DefaultDocumentKeyColumnName, Value: 1}}}},
234210
}},
235211
{Key: "cursor", Value: bson.D{}},
236212
}

flow/connectors/mongo/qrep_partition_test.go

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/stretchr/testify/require"
77
"go.mongodb.org/mongo-driver/v2/bson"
88

9+
"github.com/PeerDB-io/peerdb/flow/connectors/utils"
910
"github.com/PeerDB-io/peerdb/flow/generated/protos"
1011
)
1112

@@ -117,32 +118,31 @@ 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},
123-
}}
124-
}
125-
126-
func stringPartition(start, end string, endInclusive bool) *protos.PartitionRange {
127-
return &protos.PartitionRange{Range: &protos.PartitionRange_StringRange{
128-
StringRange: &protos.StringPartitionRange{Start: start, End: end, EndInclusive: endInclusive},
129-
}}
130-
}
131-
132121
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))
122+
t.Run("numeric range is half-open", func(t *testing.T) {
123+
filter, err := toRangeFilter(DefaultDocumentKeyColumnName, utils.CreateNumericPartition(10, 20, false).Range)
135124
require.NoError(t, err)
136125
require.Equal(t, bson.D{
137126
bson.E{Key: DefaultDocumentKeyColumnName, Value: bson.D{
138127
bson.E{Key: "$gte", Value: int64(10)},
139-
bson.E{Key: "$lte", Value: int64(20)},
128+
bson.E{Key: "$lt", Value: int64(20)},
129+
}},
130+
}, filter)
131+
})
132+
133+
t.Run("last numeric range is closed", func(t *testing.T) {
134+
filter, err := toRangeFilter(DefaultDocumentKeyColumnName, utils.CreateNumericPartition(20, 30, true).Range)
135+
require.NoError(t, err)
136+
require.Equal(t, bson.D{
137+
bson.E{Key: DefaultDocumentKeyColumnName, Value: bson.D{
138+
bson.E{Key: "$gte", Value: int64(20)},
139+
bson.E{Key: "$lte", Value: int64(30)},
140140
}},
141141
}, filter)
142142
})
143143

144144
t.Run("string range is half-open", func(t *testing.T) {
145-
filter, err := toRangeFilter(DefaultDocumentKeyColumnName, stringPartition("com.a", "com.m", false))
145+
filter, err := toRangeFilter(DefaultDocumentKeyColumnName, utils.CreateStringPartition("com.a", "com.m", false).Range)
146146
require.NoError(t, err)
147147
require.Equal(t, bson.D{
148148
bson.E{Key: DefaultDocumentKeyColumnName, Value: bson.D{
@@ -153,7 +153,7 @@ func TestToRangeFilter(t *testing.T) {
153153
})
154154

155155
t.Run("last string range is closed", func(t *testing.T) {
156-
filter, err := toRangeFilter(DefaultDocumentKeyColumnName, stringPartition("com.m", "org.z", true))
156+
filter, err := toRangeFilter(DefaultDocumentKeyColumnName, utils.CreateStringPartition("com.m", "org.z", true).Range)
157157
require.NoError(t, err)
158158
require.Equal(t, bson.D{
159159
bson.E{Key: DefaultDocumentKeyColumnName, Value: bson.D{

0 commit comments

Comments
 (0)