@@ -3,6 +3,7 @@ package connpostgres
33import (
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
122122func 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