Files
goclaw/internal/backup/tenant_discover_pg.go
thotam 25eaa0166f fix(backup): repair tenant backup/restore (config_secrets order, hooks registry, restore conn check) (#1357)
Closes #1076, #1338.

Fix A — tenant backup aborted with SQLSTATE 42703 "column id does not
exist" because exportQuery() hardcoded ORDER BY id. Several tenant-scoped
tables have composite PKs and no id column. Add a TableDef.OrderBy field,
honor it in exportQuery(), and set it for every id-less registry table:
config_secrets, agent_team_members, tenant_hook_budget, system_configs,
builtin_tool_tenant_configs, skill_tenant_configs, user_agent_profiles.

Fix B — hooks, tenant_hook_budget and webhook config were missing from
the backup registry, silently dropping their data on backup/restore. Add
hooks, tenant_hook_budget, webhooks (preserve) plus the hook_agents junction
(via ParentJoin through hooks, composite PK), and mark hook_executions and
webhook_calls as ephemeral in the skipped list.

Fix C — restoring on a fresh server failed with "N active DB connection(s)
detected" because the gateway's own pool connections were counted as active
clients. Tag pool connections with application_name='goclaw' (pg.OpenDB) and
exclude them in CheckActiveConnections; genuine external clients still block.

Adds unit + integration regression tests, including an export-over-every-
registered-table test that surfaced the additional id-less tables.
2026-07-05 11:56:36 +07:00

68 lines
1.9 KiB
Go

//go:build !sqliteonly
package backup
import (
"context"
"database/sql"
"log/slog"
)
// DiscoverTenantTables queries information_schema for all tables with a tenant_id column.
// Returns table names as a set. Used to detect unregistered tables at backup time.
func DiscoverTenantTables(ctx context.Context, db *sql.DB) (map[string]bool, error) {
rows, err := db.QueryContext(ctx,
`SELECT table_name FROM information_schema.columns
WHERE column_name = 'tenant_id' AND table_schema = 'public'
ORDER BY table_name`)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]bool)
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
continue
}
result[name] = true
}
return result, rows.Err()
}
// ValidateTableRegistry cross-checks the hardcoded table registry against
// the actual database schema. Returns warnings for unregistered tables.
func ValidateTableRegistry(ctx context.Context, db *sql.DB) []string {
discovered, err := DiscoverTenantTables(ctx, db)
if err != nil {
slog.Warn("backup.validate_registry", "error", err)
return nil
}
// Build set of registered table names
registered := make(map[string]bool)
for _, t := range TenantTables() {
registered[t.Name] = true
}
// Ephemeral tables intentionally excluded from backup
skipped := map[string]bool{
"traces": true, "spans": true, "usage_snapshots": true,
"activity_logs": true, "embedding_cache": true,
"pairing_requests": true, "paired_devices": true,
"channel_pending_messages": true, "cron_run_logs": true,
"team_user_grants": true,
// Ephemeral hook/webhook run history — not restored (regenerated at runtime).
"hook_executions": true, "webhook_calls": true,
}
var warnings []string
for table := range discovered {
if !registered[table] && !skipped[table] {
warnings = append(warnings, "table "+table+" has tenant_id but is not in backup registry — data will be skipped")
}
}
return warnings
}