Skip to content

Commit e9c5d3f

Browse files
authored
support ctid partitioning for inherited tables (#4084)
- implement getInheritedTables to recursively discover inherited tables and their block count, similar to partitioned tables, except that in this case the root table's blocks are also included - updated SQL query logic for inherited tables: - explicitly list the column names rather than using `*` for when childTableRanges is not nil (i.e. when it's inherited/partitioned table) in case child table has additional columns not present in parent. This is consistent with the behavior today using NTILE. - use FROM ONLY instead of FROM for querying inherited/partitioned tables to avoid double-counting for inherited tables; this does not change behavior for partitioned tables. - note that for inherited tables, CHildTableRanges include all partitions (including the root table), so like partitioned tables, the Range field in QRepPartition will be nil. Testing: - Add e2e tests for single-level and multi-level inherited tables, as well as scenario where child table contains extra columns. - Run locally e2e (identified a separate bug where CDC ignores change events from child inherited tables, but this bug existed before this PR)
1 parent 5c91150 commit e9c5d3f

5 files changed

Lines changed: 490 additions & 173 deletions

File tree

flow/connectors/postgres/qrep.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -230,16 +230,19 @@ func (c *PostgresConnector) getPartitions(
230230
}
231231

232232
var partitionFunc PartitioningFunc
233+
var partitionFuncName string
233234
switch {
234235
case isCTIDWatermarkCol && (hasCTIDOverride || hasPartitionOverride):
235236
partitionFunc = CTIDBlockPartitioningFunc
237+
partitionFuncName = "CTIDBlockPartitioningFunc"
236238
case hasPartitionOverride:
237239
partitionFunc = MinMaxRangePartitioningFunc
240+
partitionFuncName = "MinMaxRangePartitioningFunc"
238241
default:
239242
partitionFunc = NTileBucketPartitioningFunc
243+
partitionFuncName = "NTileBucketPartitioningFunc"
240244
}
241-
242-
c.logger.Info("using partition function", slog.String("partitionFunc", fmt.Sprintf("%T", partitionFunc)))
245+
c.logger.Info("using partition function", slog.String("partitionFunc", partitionFuncName))
243246
return partitionFunc(ctx, partitionParams)
244247
}
245248

@@ -368,7 +371,7 @@ func corePullQRepRecords(
368371
partitionIdLog := slog.String(string(shared.PartitionIDKey), partition.PartitionId)
369372

370373
selectedColumns := "*"
371-
if len(config.Exclude) != 0 {
374+
if len(config.Exclude) != 0 || len(partition.ChildTableRanges) > 0 {
372375
tableSchema, err := internal.LoadTableSchemaFromCatalog(ctx, catalogPool, config.ParentMirrorName, config.DestinationTableIdentifier)
373376
if err != nil {
374377
return 0, 0, fmt.Errorf("failed to load table schema: %w", err)
@@ -507,7 +510,8 @@ func pullChildTableRanges(
507510
return 0, 0, fmt.Errorf("failed to parse child table %s: %w", child.Table, err)
508511
}
509512

510-
query := fmt.Sprintf("SELECT %s FROM %s WHERE %s BETWEEN $1 AND $2",
513+
// ONLY excludes rows from child tables, ensuring we don't double-count inherited rows.
514+
query := fmt.Sprintf("SELECT %s FROM ONLY %s WHERE %s BETWEEN $1 AND $2",
511515
selectedColumns, parsedChild.String(), quotedWatermarkColumn)
512516

513517
rangeStart := pgtype.TID{

flow/connectors/postgres/qrep_partition.go

Lines changed: 134 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package connpostgres
33
import (
44
"cmp"
55
"context"
6+
"errors"
67
"fmt"
78
"log/slog"
89
"maps"
@@ -118,54 +119,43 @@ func MinMaxRangePartitioningFunc(ctx context.Context, pp PartitionParams) ([]*pr
118119
// CTIDBlockPartitioningFunc is a table partition strategy where partitions are created by dividing table
119120
// blocks uniformly. Note that partition boundaries (block ranges) are uniform, but actual row distribution
120121
// may be skewed due to table bloat, deleted tuples, or uneven data distribution across blocks.
121-
// TODO: add support for inherited tables
122122
func CTIDBlockPartitioningFunc(ctx context.Context, pp PartitionParams) ([]*protos.QRepPartition, error) {
123123
if pp.numPartitions <= 0 {
124124
return nil, fmt.Errorf("expect numPartitions to be greater than 0")
125125
}
126126

127-
isPartitioned, err := isPartitionedTable(ctx, pp.tx, pp.watermarkTable)
127+
tc, err := classifyTable(ctx, pp.tx, pp.watermarkTable)
128128
if err != nil {
129129
return nil, err
130130
}
131-
if isPartitioned {
132-
blocksPerTable, err := getPartitionedTables(ctx, pp.tx, pp.watermarkTable)
131+
switch {
132+
case tc.relkind == "p":
133+
blocksPerTable, err := getPartitionedTables(ctx, pp.tx, tc.qualifiedName)
133134
if err != nil {
134-
return nil, fmt.Errorf("failed to get partitioned tables: %w", err)
135+
return nil, fmt.Errorf("failed to get child partitioned tables: %w", err)
135136
}
136-
var totalBlocks int64
137-
for _, blocks := range blocksPerTable {
138-
totalBlocks += blocks
139-
}
140-
if totalBlocks == 0 {
141-
pp.logger.Warn("all child partitions are empty, returning empty partition list")
142-
return nil, nil
137+
return ctidPartitionsForChildTables(pp, blocksPerTable)
138+
case tc.relhassubclass:
139+
blocksPerTable, err := getInheritedTables(ctx, pp.tx, tc.qualifiedName)
140+
if err != nil {
141+
return nil, fmt.Errorf("failed to get child inherited tables: %w", err)
143142
}
144-
pp.logger.Info("[CTIDBlockPartitioning] partitioned table detected",
145-
slog.String("table", pp.watermarkTable),
146-
slog.Int("numPartitionedTable", len(blocksPerTable)),
147-
slog.Int64("totalBlocks", totalBlocks))
148-
return ctidPartitionsForPartitionedTable(pp, blocksPerTable, totalBlocks)
143+
return ctidPartitionsForChildTables(pp, blocksPerTable)
144+
default:
145+
return ctidPartitionsForTable(ctx, pp)
149146
}
147+
}
150148

151-
var totalBlocks pgtype.Int8
152-
if err := pp.tx.QueryRow(ctx,
153-
"SELECT (pg_relation_size(to_regclass($1)) / current_setting('block_size')::int)::bigint",
154-
pp.watermarkTable).Scan(&totalBlocks); err != nil {
155-
return nil, fmt.Errorf("failed to get relation blocks for %s: %w", pp.watermarkTable, err)
149+
// ctidPartitionsForTable generates CTID block partitions for a single (non-partitioned) table.
150+
func ctidPartitionsForTable(ctx context.Context, pp PartitionParams) ([]*protos.QRepPartition, error) {
151+
totalBlocks, err := getTableBlockCount(ctx, pp.tx, pp.watermarkTable)
152+
if err != nil {
153+
return nil, err
156154
}
157-
if !totalBlocks.Valid || totalBlocks.Int64 == 0 {
155+
if totalBlocks == 0 {
158156
pp.logger.Warn("table has 0 blocks, returning empty partition list", slog.String("table", pp.watermarkTable))
159157
return nil, nil
160158
}
161-
return ctidPartitionsForTable(pp, totalBlocks.Int64)
162-
}
163-
164-
// ctidPartitionsForTable generates CTID block partitions for a single (non-partitioned) table.
165-
func ctidPartitionsForTable(pp PartitionParams, totalBlocks int64) ([]*protos.QRepPartition, error) {
166-
if totalBlocks <= 0 {
167-
return nil, fmt.Errorf("invalid block count %d for table %s", totalBlocks, pp.watermarkTable)
168-
}
169159

170160
tidCmp := func(a pgtype.TID, b pgtype.TID) int {
171161
if blockCmp := cmp.Compare(a.BlockNumber, b.BlockNumber); blockCmp != 0 {
@@ -225,22 +215,36 @@ func ctidPartitionsForTable(pp PartitionParams, totalBlocks int64) ([]*protos.QR
225215
return partitionHelper.GetPartitions(), nil
226216
}
227217

228-
// ctidPartitionsForPartitionedTable generates snapshot partitions for a partitioned table.
229-
// It walks child tables sequentially, greedily filling each partition with <blocksPerPartition>
230-
// blocks before starting the next. A single snapshot partition may span multiple child tables,
231-
// and a single child table may be split across snapshot partitions.
218+
// ctidPartitionsForChildTables discovers child tables via the provided discovery function,
219+
// then generates snapshot partitions using a greedy algorithm.
220+
// It walks tables (sorted by name) sequentially, greedily filling each partition with
221+
// <blocksPerPartition> blocks before starting the next. A single snapshot partition may
222+
// span multiple tables, and a single table may be split across snapshot partitions.
232223
//
233-
// Example: children [T1:30 blocks, T2:20 blocks, T3:10 blocks] with blocksPerPartition=25 produces:
224+
// Example: tables [T1:30 blocks, T2:20 blocks, T3:10 blocks] with blocksPerPartition=25 produces:
234225
// - partition 1: [T1:0-24]
235226
// - partition 2: [T1:25-29, T2:0-19]
236227
// - partition 3: [T3:0-9]
237-
func ctidPartitionsForPartitionedTable(
228+
func ctidPartitionsForChildTables(
238229
pp PartitionParams,
239230
blocksPerTable map[string]int64,
240-
totalChildBlocks int64,
241231
) ([]*protos.QRepPartition, error) {
232+
var totalBlocks int64
233+
for _, blocks := range blocksPerTable {
234+
totalBlocks += blocks
235+
}
236+
if totalBlocks == 0 {
237+
pp.logger.Warn("zero non-empty child tables found")
238+
return nil, nil
239+
}
240+
241+
pp.logger.Info("child tables detected",
242+
slog.String("table", pp.watermarkTable),
243+
slog.Int("numChildTables", len(blocksPerTable)),
244+
slog.Int64("totalBlocks", totalBlocks))
245+
242246
sortedTables := slices.Sorted(maps.Keys(blocksPerTable))
243-
blocksPerPartition := max(int64(1), shared.DivCeil(totalChildBlocks, pp.numPartitions))
247+
blocksPerPartition := max(int64(1), shared.DivCeil(totalBlocks, pp.numPartitions))
244248

245249
var partitions []*protos.QRepPartition
246250
childIdx := 0
@@ -280,26 +284,47 @@ func ctidPartitionsForPartitionedTable(
280284
}
281285
}
282286

283-
pp.logger.Info("[CTIDBlockPartitioning] generated partitions for partitioned table",
287+
pp.logger.Info("generated partitions for child tables",
284288
slog.Int("numPartitions", len(partitions)),
285289
slog.Int64("blocksPerPartition", blocksPerPartition),
286-
slog.Int64("totalBlocks", totalChildBlocks))
290+
slog.Int64("totalBlocks", totalBlocks))
287291

288292
return partitions, nil
289293
}
290294

291-
func isPartitionedTable(ctx context.Context, tx pgx.Tx, table string) (bool, error) {
292-
var relkind string
295+
func getTableBlockCount(ctx context.Context, tx pgx.Tx, table string) (int64, error) {
296+
var blocks int64
297+
if err := tx.QueryRow(ctx,
298+
"SELECT (pg_relation_size(to_regclass($1)) / current_setting('block_size')::int)::bigint",
299+
table).Scan(&blocks); err != nil {
300+
return 0, fmt.Errorf("failed to get block count for %s: %w", table, err)
301+
}
302+
return blocks, nil
303+
}
304+
305+
type tableClassification struct {
306+
qualifiedName string
307+
relkind string
308+
relhassubclass bool
309+
}
310+
311+
func classifyTable(ctx context.Context, tx pgx.Tx, table string) (tableClassification, error) {
312+
var classification tableClassification
293313
err := tx.QueryRow(ctx,
294-
`SELECT c.relkind::text FROM pg_class c WHERE c.oid = to_regclass($1)`,
295-
table).Scan(&relkind)
314+
// We fetch the qualified name from pg_class instead of using the input directly because
315+
// the input table name is quoted; and we want the unquoted canonical form for uniformity
316+
`SELECT format('%s.%s', n.nspname, c.relname), c.relkind::text, c.relhassubclass
317+
FROM pg_class c
318+
JOIN pg_namespace n ON c.relnamespace = n.oid
319+
WHERE c.oid = to_regclass($1)`,
320+
table).Scan(&classification.qualifiedName, &classification.relkind, &classification.relhassubclass)
296321
if err != nil {
297-
if err == pgx.ErrNoRows {
298-
return false, fmt.Errorf("table %s not found in pg_class", table)
322+
if errors.Is(err, pgx.ErrNoRows) {
323+
return classification, fmt.Errorf("table %s not found in pg_class", table)
299324
}
300-
return false, fmt.Errorf("failed to check relkind for %s: %w", table, err)
325+
return classification, fmt.Errorf("failed to classify table %s: %w", table, err)
301326
}
302-
return relkind == "p", nil
327+
return classification, nil
303328
}
304329

305330
// getPartitionedTables returns a map of partitioned child table names to their block counts,
@@ -351,6 +376,66 @@ func getPartitionedTables(ctx context.Context, tx pgx.Tx, table string) (map[str
351376
return blocksPerPartitionedTable, nil
352377
}
353378

379+
// getInheritedTables returns a map of table names to their block counts for an inherited
380+
// table hierarchy. Unlike partitioned tables, the parent itself stores data and is included.
381+
func getInheritedTables(ctx context.Context, tx pgx.Tx, table string) (map[string]int64, error) {
382+
blocksPerTable := make(map[string]int64)
383+
384+
numBlocks, err := getTableBlockCount(ctx, tx, table)
385+
if err != nil {
386+
return nil, err
387+
}
388+
if numBlocks > 0 {
389+
blocksPerTable[table] = numBlocks
390+
}
391+
392+
rows, err := tx.Query(ctx, `
393+
SELECT format('%s.%s', n.nspname, c.relname), c.relhassubclass,
394+
(pg_relation_size(c.oid) / current_setting('block_size')::int)::bigint
395+
FROM pg_inherits i
396+
JOIN pg_class c ON i.inhrelid = c.oid
397+
JOIN pg_namespace n ON c.relnamespace = n.oid
398+
WHERE i.inhparent = to_regclass($1)
399+
ORDER BY c.relname
400+
`, table)
401+
if err != nil {
402+
return nil, fmt.Errorf("failed to query child tables for %s: %w", table, err)
403+
}
404+
defer rows.Close()
405+
406+
type tableInfo struct {
407+
name string
408+
hasSubclass bool
409+
blocks int64
410+
}
411+
var children []tableInfo
412+
for rows.Next() {
413+
var info tableInfo
414+
if err := rows.Scan(&info.name, &info.hasSubclass, &info.blocks); err != nil {
415+
return nil, fmt.Errorf("failed to scan child table: %w", err)
416+
}
417+
children = append(children, info)
418+
}
419+
if err := rows.Err(); err != nil {
420+
return nil, err
421+
}
422+
423+
for _, child := range children {
424+
if child.blocks > 0 {
425+
blocksPerTable[child.name] = child.blocks
426+
}
427+
if child.hasSubclass {
428+
childTables, err := getInheritedTables(ctx, tx, child.name)
429+
if err != nil {
430+
return nil, fmt.Errorf("failed to get inherited tables of %s: %w", child.name, err)
431+
}
432+
maps.Copy(blocksPerTable, childTables)
433+
}
434+
}
435+
436+
return blocksPerTable, nil
437+
}
438+
354439
// ComputeNumPartitions computes the number of partitions given desired number of rows
355440
// per partition, with automatic adjustment to respect the maximum partition limit.
356441
// TODO: use estimated row count for partitioned/inherited tables as well

0 commit comments

Comments
 (0)