mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-13 00:14:22 +08:00
1b326cb00e
Explicit-ID backup restores previously left PostgreSQL sequences behind, causing immediate creates to collide until restart. Resolve the active ID sequence and advance it with transactional RESTART while preserving allocated values, caches and increments. Take the complete import write-table lock set with NOWAIT and retry only pre-write contention, avoiding lock-order cycles with existing CRUD transactions. Cover public API/repository restores, all explicit-ID tables, legacy schemas, concurrent creates/imports/cascade deletes, and rollback paths on isolated PostgreSQL.
196 lines
7.2 KiB
Go
196 lines
7.2 KiB
Go
package repo
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"go-backend/internal/store/model"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
var errBackupImportBusy = errors.New("database is busy; retry the backup import")
|
|
|
|
// Only retry lock acquisition failures, which occur before any backup writes.
|
|
// NOWAIT releases partial lock sets instead of deadlocking with existing
|
|
// child-before-parent deletes or forward-before-user traffic updates.
|
|
func backupImportTransaction(db *gorm.DB, importRows func(*gorm.DB) error) error {
|
|
var err error
|
|
for attempt := 0; attempt < 10; attempt++ {
|
|
err = db.Transaction(importRows)
|
|
if !errors.Is(err, errBackupImportBusy) {
|
|
return err
|
|
}
|
|
if attempt < 9 {
|
|
time.Sleep(min(10*time.Millisecond<<attempt, 100*time.Millisecond))
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Lock every table that import can write, including generated-ID child tables.
|
|
// No writes happen until the complete set is held. Existing CRUD transactions
|
|
// can therefore finish regardless of their own parent/child mutation order.
|
|
// The returned subset contains only tables receiving explicit backup IDs.
|
|
func lockPostgresBackupTables(tx *gorm.DB, backup *model.BackupData, types map[string]bool) ([]string, error) {
|
|
if tx.Dialector.Name() != "postgres" {
|
|
return nil, nil
|
|
}
|
|
chains, grants, ports := 0, 0, 0
|
|
for _, tunnel := range backup.Tunnels {
|
|
chains += len(tunnel.ChainTunnels)
|
|
}
|
|
for _, permission := range backup.Permissions {
|
|
grants += len(permission.Grants)
|
|
}
|
|
for _, forward := range backup.Forwards {
|
|
if forward.ForwardPorts != nil {
|
|
ports++
|
|
}
|
|
}
|
|
var tables []string
|
|
for _, item := range []struct {
|
|
kind string
|
|
count int
|
|
name string
|
|
explicitIDs bool
|
|
}{
|
|
{"users", len(backup.Users), "user", true},
|
|
{"speedLimits", len(backup.SpeedLimits), "speed_limit", true},
|
|
{"nodes", len(backup.Nodes), "node", true},
|
|
{"tunnels", len(backup.Tunnels), "tunnel", true},
|
|
{"tunnels", chains, "chain_tunnel", true},
|
|
{"forwards", len(backup.Forwards), "forward", true},
|
|
{"userTunnels", len(backup.UserTunnels), "user_tunnel", true},
|
|
{"tunnelGroups", len(backup.TunnelGroups), "tunnel_group", true},
|
|
{"userGroups", len(backup.UserGroups), "user_group", true},
|
|
{"permissions", len(backup.Permissions), "group_permission", true},
|
|
{"permissions", grants, "group_permission_grant", true},
|
|
{"users", len(backup.Users), "user_quota", false},
|
|
{"forwards", ports, "forward_port", false},
|
|
{"tunnelGroups", len(backup.TunnelGroups), "tunnel_group_tunnel", false},
|
|
{"userGroups", len(backup.UserGroups), "user_group_user", false},
|
|
{"configs", len(FilterBackupConfigs(backup.Configs)), "vite_config", false},
|
|
} {
|
|
if !types[item.kind] || item.count == 0 {
|
|
continue
|
|
}
|
|
// Resolve the same relation as normal unqualified repository queries,
|
|
// even when the first search_path schema has no copy of this table.
|
|
var relation struct {
|
|
Schema string
|
|
Name string
|
|
}
|
|
if err := tx.Raw(`SELECT n.nspname AS schema, c.relname AS name
|
|
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
WHERE c.oid = pg_catalog.to_regclass(?)`, quoteSQLIdentifier(item.name)).Scan(&relation).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if relation.Name == "" {
|
|
return nil, fmt.Errorf("missing import table %s", item.name)
|
|
}
|
|
table := quoteSQLIdentifier(relation.Schema) + "." + quoteSQLIdentifier(relation.Name)
|
|
if err := tx.Exec("LOCK TABLE " + table + " IN SHARE ROW EXCLUSIVE MODE NOWAIT").Error; err != nil {
|
|
var pgError interface{ SQLState() string }
|
|
if errors.As(err, &pgError) && pgError.SQLState() == "55P03" {
|
|
return nil, fmt.Errorf("%w: %w", errBackupImportBusy, err)
|
|
}
|
|
return nil, err
|
|
}
|
|
if item.explicitIDs {
|
|
tables = append(tables, table)
|
|
}
|
|
}
|
|
return tables, nil
|
|
}
|
|
|
|
func advancePostgresBackupSequences(tx *gorm.DB, tables []string) error {
|
|
for _, table := range tables {
|
|
if err := advancePostgresBackupSequence(tx, table); err != nil {
|
|
return fmt.Errorf("advance imported %s id sequence: %w", table, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// The caller holds the table's SHARE ROW EXCLUSIVE lock until commit. Unlike
|
|
// startup's setval repair, RESTART is transactional and invalidates other
|
|
// connections' cached values. Never return to IDs already allocated, even if
|
|
// their rows were deleted or their transactions rolled back.
|
|
func advancePostgresBackupSequence(tx *gorm.DB, table string) error {
|
|
var maxID int64
|
|
if err := tx.Raw("SELECT COALESCE(MAX(id), 0) FROM " + table).Scan(&maxID).Error; err != nil {
|
|
return err
|
|
}
|
|
if maxID <= 0 {
|
|
return nil
|
|
}
|
|
type sequenceInfo struct {
|
|
Schema string
|
|
Name string
|
|
Cache int64
|
|
Increment int64
|
|
Maximum int64
|
|
}
|
|
var sequences []sequenceInfo
|
|
// Prefer the actual default's catalog dependency: a legacy custom nextval
|
|
// default need not own its sequence, and the old owned serial may remain.
|
|
// Identity columns have no pg_attrdef, so use their owned sequence instead.
|
|
if err := tx.Raw(`WITH defaults AS (
|
|
SELECT dep.refobjid AS oid FROM pg_catalog.pg_attrdef def
|
|
JOIN pg_catalog.pg_attribute attr ON attr.attrelid = def.adrelid AND attr.attnum = def.adnum
|
|
JOIN pg_catalog.pg_depend dep ON dep.classid = 'pg_catalog.pg_attrdef'::regclass
|
|
AND dep.objid = def.oid AND dep.refclassid = 'pg_catalog.pg_class'::regclass
|
|
JOIN pg_catalog.pg_sequence seq ON seq.seqrelid = dep.refobjid
|
|
WHERE def.adrelid = ?::regclass AND attr.attname = 'id'
|
|
), candidates AS (
|
|
SELECT oid FROM defaults
|
|
UNION SELECT pg_catalog.pg_get_serial_sequence(?, 'id')::regclass WHERE NOT EXISTS (SELECT 1 FROM defaults)
|
|
)
|
|
SELECT n.nspname AS schema, c.relname AS name,
|
|
s.seqcache AS cache, s.seqincrement AS increment, s.seqmax AS maximum
|
|
FROM pg_catalog.pg_sequence s
|
|
JOIN pg_catalog.pg_class c ON c.oid = s.seqrelid
|
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
WHERE s.seqrelid IN (SELECT oid FROM candidates)`, table, table).Scan(&sequences).Error; err != nil {
|
|
return err
|
|
}
|
|
if len(sequences) != 1 || sequences[0].Increment <= 0 {
|
|
return fmt.Errorf("missing or ambiguous ascending ID sequence")
|
|
}
|
|
sequence := sequences[0]
|
|
name := quoteSQLIdentifier(sequence.Schema) + "." + quoteSQLIdentifier(sequence.Name)
|
|
// Reapplying CACHE without changing its setting takes a sequence DDL lock.
|
|
// Read last_value only after that lock: even direct concurrent nextval calls
|
|
// must finish first. The table locks already exclude normal INSERTs.
|
|
if err := tx.Exec(fmt.Sprintf("ALTER SEQUENCE %s CACHE %d", name, sequence.Cache)).Error; err != nil {
|
|
return err
|
|
}
|
|
var state struct {
|
|
LastValue int64
|
|
IsCalled bool
|
|
}
|
|
if err := tx.Raw("SELECT last_value, is_called FROM " + name).Scan(&state).Error; err != nil {
|
|
return err
|
|
}
|
|
if state.LastValue < 1 {
|
|
return fmt.Errorf("ID sequence must allocate positive IDs")
|
|
}
|
|
if maxID >= sequence.Maximum || (state.IsCalled && state.LastValue > sequence.Maximum-sequence.Increment) {
|
|
return fmt.Errorf("ID sequence exhausted")
|
|
}
|
|
next := state.LastValue
|
|
if state.IsCalled {
|
|
next += sequence.Increment
|
|
}
|
|
if next <= maxID {
|
|
steps := (maxID-next)/sequence.Increment + 1
|
|
if steps > (sequence.Maximum-next)/sequence.Increment {
|
|
return fmt.Errorf("ID sequence exhausted")
|
|
}
|
|
next += steps * sequence.Increment
|
|
}
|
|
return tx.Exec(fmt.Sprintf("ALTER SEQUENCE %s RESTART WITH %d", name, next)).Error
|
|
}
|