Skip to content

Commit 08f76cb

Browse files
committed
feat: Add --include-non-partition-tables flag and fix scan error handling
- Add IncludeNonPartitionTables config option and --include-non-partition-tables flag - Create nonPartitionTableListSQL query to find regular tables matching partition patterns - Update partition discovery in archiver.go and progress.go to optionally include non-partition tables - Fix scan error handling in progress.go to properly propagate errors instead of silently ignoring them - Add partition validation to skip tables without columns during discovery - Refactor partition discovery to use shared helper function for consistency Fixes issue where tables matching partition naming patterns but not actually being PostgreSQL partitions were causing errors. Now users can optionally include these tables for processing. Version 1.6.0
1 parent 0a1ea17 commit 08f76cb

7 files changed

Lines changed: 247 additions & 159 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.7.0] - 2025-11-24
11+
1012
### Added
1113
- **Archive Command:**
14+
- New `--include-non-partition-tables` flag to include regular tables matching partition naming patterns (not just actual PostgreSQL partitions)
1215
- When a table lacks physical partitions, providing `--date-column`, `--start-date`, and `--end-date` now enables synthetic date-window processing so partitionless tables can be archived with the standard workflow
1316
- **pg_dump & dump-hybrid**
1417
- Date-window dumps now reuse the global cache so completed windows or partition groups are skipped immediately on reruns when the S3 object already exists and matches size/MD5 (or multipart ETag)
@@ -18,6 +21,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1821
- Cache files are namespaced by subcommand plus the absolute S3 destination, preventing collisions when the same table is archived by different workflows or sent to different paths
1922
- Existing per-table caches are migrated automatically the first time a scoped cache is loaded
2023

24+
### Fixed
25+
- **Archive & pg_dump Commands:**
26+
- Partition discovery now only returns tables that inherit from the requested parent table, preventing lookups against similarly named tables that are not real partitions (and the resulting "table not found or has no columns" errors)
27+
- Partition permission checks use the same metadata so missing privileges on actual partitions are reported accurately instead of being mistaken for missing tables
28+
- Scan errors during partition discovery in TUI mode are now properly propagated instead of being silently ignored, matching the behavior in debug mode
29+
2130
## [1.5.9] - 2025-11-17
2231

2332
### Fixed

cmd/archiver.go

Lines changed: 73 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -347,22 +347,61 @@ func (a *Archiver) runArchivalProcess(ctx context.Context, _ *tea.Program, _ *Ta
347347
}
348348

349349
a.logger.Debug("Discovering partitions...")
350-
// Inline partition discovery (same logic as progress.go doDiscover)
351-
query := `
352-
SELECT c.relname::text AS tablename
353-
FROM pg_class c
354-
JOIN pg_namespace n ON n.oid = c.relnamespace
355-
WHERE n.nspname = 'public'
356-
AND c.relname LIKE $1
357-
AND c.relkind = 'r'
358-
AND NOT EXISTS (
359-
SELECT 1 FROM pg_inherits WHERE inhparent = c.oid
360-
)
361-
ORDER BY c.relname;
362-
`
350+
var partitions []PartitionInfo
351+
seenTables := make(map[string]bool) // Track tables to avoid duplicates
363352

364-
pattern := a.config.Table + "_%"
365-
rows, err := a.db.QueryContext(ctx, query, pattern)
353+
// Helper function to process tables from a query result
354+
processTableRows := func(rows *sql.Rows, sourceType string) error {
355+
defer rows.Close()
356+
for rows.Next() {
357+
// Check for cancellation in the loop
358+
select {
359+
case <-ctx.Done():
360+
a.logger.Info("⚠️ Cancellation detected during partition discovery")
361+
return ctx.Err()
362+
default:
363+
}
364+
365+
var tableName string
366+
if err := rows.Scan(&tableName); err != nil {
367+
return fmt.Errorf("failed to scan %s name: %w", sourceType, err)
368+
}
369+
370+
// Skip if we've already seen this table
371+
if seenTables[tableName] {
372+
continue
373+
}
374+
seenTables[tableName] = true
375+
376+
date, ok := a.extractDateFromTableName(tableName)
377+
if !ok {
378+
a.logger.Debug(fmt.Sprintf("Skipping table %s (no valid date)", tableName))
379+
continue
380+
}
381+
382+
// Validate that the table actually has columns before adding it
383+
// This prevents errors later when trying to process tables that exist
384+
// but have no columns or aren't valid tables
385+
schema, schemaErr := a.getTableSchema(ctx, tableName)
386+
if schemaErr != nil {
387+
a.logger.Debug(fmt.Sprintf("Skipping table %s (schema validation failed: %v)", tableName, schemaErr))
388+
continue
389+
}
390+
if schema == nil || len(schema.Columns) == 0 {
391+
a.logger.Debug(fmt.Sprintf("Skipping table %s (no columns found)", tableName))
392+
continue
393+
}
394+
395+
partitions = append(partitions, PartitionInfo{
396+
TableName: tableName,
397+
Date: date,
398+
})
399+
}
400+
return rows.Err()
401+
}
402+
403+
// Query actual partitions
404+
rows, err := a.db.QueryContext(ctx, leafPartitionListSQL, defaultTableSchema, a.config.Table)
366405
if err != nil {
367406
// Check if error is due to cancellation or closed connection
368407
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || isConnectionError(err) {
@@ -371,46 +410,25 @@ func (a *Archiver) runArchivalProcess(ctx context.Context, _ *tea.Program, _ *Ta
371410
}
372411
return fmt.Errorf("failed to query partitions: %w", err)
373412
}
374-
defer rows.Close()
375-
376-
// Check for cancellation immediately after query starts
377-
select {
378-
case <-ctx.Done():
379-
a.logger.Info("⚠️ Cancellation detected after query started")
380-
return ctx.Err()
381-
default:
413+
if err := processTableRows(rows, "partition"); err != nil {
414+
return fmt.Errorf("error iterating over partition rows: %w", err)
382415
}
383416

384-
var partitions []PartitionInfo
385-
for rows.Next() {
386-
// Check for cancellation in the loop
387-
select {
388-
case <-ctx.Done():
389-
a.logger.Info("⚠️ Cancellation detected during partition discovery")
390-
return ctx.Err()
391-
default:
392-
}
393-
394-
var tableName string
395-
if err := rows.Scan(&tableName); err != nil {
396-
return fmt.Errorf("failed to scan partition name: %w", err)
417+
// If enabled, also query non-partition tables matching the pattern
418+
if a.config.IncludeNonPartitionTables {
419+
a.logger.Debug("Including non-partition tables matching pattern...")
420+
nonPartitionRows, err := a.db.QueryContext(ctx, nonPartitionTableListSQL, defaultTableSchema, a.config.Table)
421+
if err != nil {
422+
// Check if error is due to cancellation or closed connection
423+
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || isConnectionError(err) {
424+
a.logger.Info("⚠️ Query cancelled or connection closed")
425+
return context.Canceled
426+
}
427+
return fmt.Errorf("failed to query non-partition tables: %w", err)
397428
}
398-
399-
date, ok := a.extractDateFromTableName(tableName)
400-
if !ok {
401-
a.logger.Debug(fmt.Sprintf("Skipping table %s (no valid date)", tableName))
402-
continue
429+
if err := processTableRows(nonPartitionRows, "non-partition table"); err != nil {
430+
return fmt.Errorf("error iterating over non-partition table rows: %w", err)
403431
}
404-
405-
partitions = append(partitions, PartitionInfo{
406-
TableName: tableName,
407-
Date: date,
408-
})
409-
}
410-
411-
// Check for errors from iterating over rows
412-
if err := rows.Err(); err != nil {
413-
return fmt.Errorf("error iterating over partition rows: %w", err)
414432
}
415433

416434
if len(partitions) == 0 {
@@ -601,18 +619,8 @@ func (a *Archiver) checkTablePermissions(ctx context.Context) error {
601619
}
602620

603621
// Check if we can see and access partition tables
604-
pattern := a.config.Table + "_%"
605-
partitionCheckQuery := `
606-
SELECT tablename
607-
FROM pg_tables
608-
WHERE schemaname = 'public'
609-
AND tablename LIKE $1
610-
AND has_table_privilege('public.' || tablename, 'SELECT')
611-
LIMIT 1
612-
`
613-
614622
var samplePartition string
615-
err = a.db.QueryRowContext(ctx, partitionCheckQuery, pattern).Scan(&samplePartition)
623+
err = a.db.QueryRowContext(ctx, leafPartitionPermissionSQL, defaultTableSchema, a.config.Table).Scan(&samplePartition)
616624
if err != nil && !errors.Is(err, sql.ErrNoRows) {
617625
// Only fail if it's not a "no rows" error
618626
return fmt.Errorf("failed to check partition table permissions: %w", err)
@@ -622,14 +630,9 @@ func (a *Archiver) checkTablePermissions(ctx context.Context) error {
622630
if errors.Is(err, sql.ErrNoRows) {
623631
// Let's see if partitions exist but we can't access them
624632
var partitionExists bool
625-
existsQuery := `
626-
SELECT EXISTS (
627-
SELECT 1 FROM pg_tables
628-
WHERE schemaname = 'public'
629-
AND tablename LIKE $1
630-
)
631-
`
632-
_ = a.db.QueryRowContext(ctx, existsQuery, pattern).Scan(&partitionExists)
633+
if err := a.db.QueryRowContext(ctx, leafPartitionExistsSQL, defaultTableSchema, a.config.Table).Scan(&partitionExists); err != nil {
634+
return fmt.Errorf("failed to check for partition existence: %w", err)
635+
}
633636

634637
if partitionExists {
635638
// Partitions exist but we can't access them

cmd/config.go

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -41,26 +41,27 @@ var (
4141
const regionAuto = "auto"
4242

4343
type Config struct {
44-
Debug bool
45-
LogFormat string
46-
DryRun bool
47-
Workers int
48-
SkipCount bool
49-
CacheViewer bool
50-
ViewerPort int
51-
ChunkSize int // Number of rows to process in each chunk (streaming mode)
52-
Database DatabaseConfig
53-
S3 S3Config
54-
Table string
55-
StartDate string
56-
EndDate string
57-
OutputDuration string
58-
OutputFormat string
59-
Compression string
60-
CompressionLevel int
61-
DateColumn string
62-
DumpMode string // pg_dump mode: schema-only, data-only, schema-and-data
63-
CacheScope CacheScope
44+
Debug bool
45+
LogFormat string
46+
DryRun bool
47+
Workers int
48+
SkipCount bool
49+
CacheViewer bool
50+
ViewerPort int
51+
ChunkSize int // Number of rows to process in each chunk (streaming mode)
52+
IncludeNonPartitionTables bool // Include regular tables matching partition naming pattern
53+
Database DatabaseConfig
54+
S3 S3Config
55+
Table string
56+
StartDate string
57+
EndDate string
58+
OutputDuration string
59+
OutputFormat string
60+
Compression string
61+
CompressionLevel int
62+
DateColumn string
63+
DumpMode string // pg_dump mode: schema-only, data-only, schema-and-data
64+
CacheScope CacheScope
6465
}
6566

6667
type DatabaseConfig struct {

cmd/partition_queries.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package cmd
2+
3+
const defaultTableSchema = "public"
4+
5+
const leafPartitionBaseCTE = `
6+
WITH leaf_partitions AS (
7+
SELECT
8+
child.relname::text AS tablename,
9+
child.oid AS table_oid
10+
FROM pg_inherits i
11+
JOIN pg_class child ON child.oid = i.inhrelid
12+
JOIN pg_namespace child_ns ON child_ns.oid = child.relnamespace
13+
JOIN pg_class parent ON parent.oid = i.inhparent
14+
JOIN pg_namespace parent_ns ON parent_ns.oid = parent.relnamespace
15+
WHERE parent_ns.nspname = $1
16+
AND child_ns.nspname = $1
17+
AND parent.relname = $2
18+
AND child.relkind = 'r'
19+
AND NOT EXISTS (
20+
SELECT 1 FROM pg_inherits WHERE inhparent = child.oid
21+
)
22+
)`
23+
24+
const leafPartitionListSQL = leafPartitionBaseCTE + `
25+
SELECT tablename
26+
FROM leaf_partitions
27+
ORDER BY tablename;
28+
`
29+
30+
const leafPartitionExistsSQL = leafPartitionBaseCTE + `
31+
SELECT EXISTS (
32+
SELECT 1 FROM leaf_partitions
33+
);
34+
`
35+
36+
const leafPartitionPermissionSQL = leafPartitionBaseCTE + `
37+
SELECT tablename
38+
FROM leaf_partitions
39+
WHERE has_table_privilege(table_oid, 'SELECT')
40+
LIMIT 1;
41+
`
42+
43+
// nonPartitionTableListSQL finds regular tables (not partitions) that match
44+
// the partition naming pattern. This is useful when tables follow partition
45+
// naming conventions but aren't actually PostgreSQL partitions.
46+
const nonPartitionTableListSQL = `
47+
SELECT t.tablename::text
48+
FROM pg_tables t
49+
JOIN pg_class c ON c.relname = t.tablename
50+
JOIN pg_namespace n ON n.oid = c.relnamespace
51+
WHERE t.schemaname = $1
52+
AND n.nspname = $1
53+
AND t.tablename LIKE $2 || '_%'
54+
AND t.tablename != $2
55+
AND c.relkind = 'r'
56+
AND NOT EXISTS (
57+
SELECT 1 FROM pg_inherits WHERE inhrelid = c.oid
58+
)
59+
ORDER BY t.tablename;
60+
`

cmd/pg_dump.go

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -649,17 +649,8 @@ func (e *PgDumpExecutor) dumpPartitionsByDateRange(ctx context.Context) error {
649649
}
650650
}
651651

652-
// Discover partitions matching the table pattern
653-
pattern := e.config.Table + "_%"
654-
query := `
655-
SELECT tablename
656-
FROM pg_tables
657-
WHERE schemaname = 'public'
658-
AND tablename LIKE $1
659-
ORDER BY tablename
660-
`
661-
662-
rows, err := e.db.QueryContext(ctx, query, pattern)
652+
// Discover partitions that inherit from the target table
653+
rows, err := e.db.QueryContext(ctx, leafPartitionListSQL, defaultTableSchema, e.config.Table)
663654
if err != nil {
664655
return fmt.Errorf("failed to query partitions: %w", err)
665656
}

0 commit comments

Comments
 (0)