diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index e12674b..c2bff03 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -2612,11 +2612,12 @@ func (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) { // ─── Migration ─────────────────────────────────────────────────────── -const currentSchemaVersion = 4 +const currentSchemaVersion = 5 var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults var migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType var migrateSpeedLimitTunnelBindingFn = migrateSpeedLimitTunnelBinding +var migratePostgresTrafficInt64ColumnsFn = migratePostgresTrafficInt64Columns func getSchemaVersion(db *gorm.DB) int { var v model.SchemaVersion @@ -2680,6 +2681,12 @@ func migrateSchema(db *gorm.DB) error { } } + if ver < 5 { + if err := migratePostgresTrafficInt64ColumnsFn(db); err != nil { + return err + } + } + setSchemaVersion(db, currentSchemaVersion) return nil } @@ -2744,6 +2751,87 @@ func migrateSpeedLimitTunnelBinding(db *gorm.DB) error { return nil } +func migratePostgresTrafficInt64Columns(db *gorm.DB) error { + if db == nil { + return errors.New("nil db") + } + + if db.Dialector.Name() != "postgres" { + return nil + } + + type trafficColumn struct { + TableName string + ColumnName string + } + + columns := []trafficColumn{ + {TableName: "user", ColumnName: "flow"}, + {TableName: "user", ColumnName: "in_flow"}, + {TableName: "user", ColumnName: "out_flow"}, + {TableName: "forward", ColumnName: "in_flow"}, + {TableName: "forward", ColumnName: "out_flow"}, + {TableName: "statistics_flow", ColumnName: "flow"}, + {TableName: "statistics_flow", ColumnName: "total_flow"}, + {TableName: "tunnel", ColumnName: "flow"}, + {TableName: "user_tunnel", ColumnName: "flow"}, + {TableName: "user_tunnel", ColumnName: "in_flow"}, + {TableName: "user_tunnel", ColumnName: "out_flow"}, + {TableName: "peer_share", ColumnName: "max_bandwidth"}, + {TableName: "peer_share", ColumnName: "current_flow"}, + } + + for _, column := range columns { + if err := alterPostgresColumnToBigIntIfNeeded(db, column.TableName, column.ColumnName); err != nil { + return err + } + } + + return nil +} + +func alterPostgresColumnToBigIntIfNeeded(db *gorm.DB, tableName, columnName string) error { + if db == nil { + return errors.New("nil db") + } + + if tableName == "" || columnName == "" { + return errors.New("empty table or column name") + } + + type columnRow struct { + DataType string `gorm:"column:data_type"` + } + + var row columnRow + if err := db.Raw( + `SELECT data_type FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = ? + AND column_name = ?`, + tableName, columnName, + ).Scan(&row).Error; err != nil { + return fmt.Errorf("inspect %s.%s type: %w", tableName, columnName, err) + } + + if row.DataType == "" || strings.EqualFold(row.DataType, "bigint") { + return nil + } + if !strings.EqualFold(row.DataType, "integer") { + return nil + } + + if err := db.Exec(fmt.Sprintf( + "ALTER TABLE %s ALTER COLUMN %s TYPE BIGINT", + quoteSQLIdentifier(tableName), + quoteSQLIdentifier(columnName), + )).Error; err != nil { + return fmt.Errorf("alter %s.%s to bigint: %w", tableName, columnName, err) + } + + return nil +} + func ensurePostgresIDDefaults(db *gorm.DB) error { if db.Dialector.Name() != "postgres" { return nil diff --git a/go-backend/internal/store/repo/repository_migrate_test.go b/go-backend/internal/store/repo/repository_migrate_test.go index 663a68c..d3033e5 100644 --- a/go-backend/internal/store/repo/repository_migrate_test.go +++ b/go-backend/internal/store/repo/repository_migrate_test.go @@ -3,6 +3,7 @@ package repo import ( "database/sql" "errors" + "strings" "testing" gsqlite "github.com/glebarez/sqlite" @@ -250,3 +251,115 @@ func TestMigrateSchemaClearsSpeedLimitTunnelBinding(t *testing.T) { t.Fatalf("expected schema version %d, got %d", currentSchemaVersion, schemaVersion) } } + +func TestMigrateSchemaRunsTrafficInt64MigrationForLegacySchema(t *testing.T) { + db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + sqlDB, _ := db.DB() + if sqlDB != nil { + _ = sqlDB.Close() + } + }) + + if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil { + t.Fatalf("create schema_version: %v", err) + } + if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 4).Error; err != nil { + t.Fatalf("seed schema_version: %v", err) + } + + originalIDRepair := ensurePostgresIDDefaultsFn + ensurePostgresIDDefaultsFn = func(db *gorm.DB) error { + return nil + } + t.Cleanup(func() { + ensurePostgresIDDefaultsFn = originalIDRepair + }) + + called := 0 + originalMigrate := migratePostgresTrafficInt64ColumnsFn + migratePostgresTrafficInt64ColumnsFn = func(db *gorm.DB) error { + called++ + return nil + } + t.Cleanup(func() { + migratePostgresTrafficInt64ColumnsFn = originalMigrate + }) + + if err := migrateSchema(db); err != nil { + t.Fatalf("migrateSchema: %v", err) + } + + if called != 1 { + t.Fatalf("expected traffic bigint migration to run once, got %d", called) + } + + var schemaVersion int + if err := db.Raw(`SELECT version FROM schema_version LIMIT 1`).Row().Scan(&schemaVersion); err != nil { + t.Fatalf("query schema_version: %v", err) + } + if schemaVersion != currentSchemaVersion { + t.Fatalf("expected schema version %d, got %d", currentSchemaVersion, schemaVersion) + } +} + +func TestMigrateSchemaReturnsTrafficInt64MigrationError(t *testing.T) { + db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + sqlDB, _ := db.DB() + if sqlDB != nil { + _ = sqlDB.Close() + } + }) + + if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil { + t.Fatalf("create schema_version: %v", err) + } + if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 4).Error; err != nil { + t.Fatalf("seed schema_version: %v", err) + } + + originalIDRepair := ensurePostgresIDDefaultsFn + ensurePostgresIDDefaultsFn = func(db *gorm.DB) error { + return nil + } + t.Cleanup(func() { + ensurePostgresIDDefaultsFn = originalIDRepair + }) + + wantErr := errors.New("traffic bigint migration failed") + originalMigrate := migratePostgresTrafficInt64ColumnsFn + migratePostgresTrafficInt64ColumnsFn = func(db *gorm.DB) error { + return wantErr + } + t.Cleanup(func() { + migratePostgresTrafficInt64ColumnsFn = originalMigrate + }) + + err = migrateSchema(db) + if !errors.Is(err, wantErr) { + t.Fatalf("expected error %v, got %v", wantErr, err) + } +} + +func TestAlterPostgresColumnToBigIntIfNeededValidatesNames(t *testing.T) { + if err := alterPostgresColumnToBigIntIfNeeded(nil, "peer_share", "max_bandwidth"); err == nil || !strings.Contains(err.Error(), "nil db") { + t.Fatalf("expected nil db error, got %v", err) + } + if err := alterPostgresColumnToBigIntIfNeeded(&gorm.DB{}, "", "max_bandwidth"); err == nil || !strings.Contains(err.Error(), "empty table or column name") { + t.Fatalf("expected empty name error, got %v", err) + } + if err := alterPostgresColumnToBigIntIfNeeded(&gorm.DB{}, "peer_share", ""); err == nil || !strings.Contains(err.Error(), "empty table or column name") { + t.Fatalf("expected empty name error, got %v", err) + } +} diff --git a/plans/019-federation-share-traffic-bigint-migration.md b/plans/019-federation-share-traffic-bigint-migration.md new file mode 100644 index 0000000..1e71b55 --- /dev/null +++ b/plans/019-federation-share-traffic-bigint-migration.md @@ -0,0 +1,21 @@ +# 019 Federation Share Traffic Bigint Migration + +## Checklist + +- [x] Inspect federation share creation failure and identify the PostgreSQL `int4` overflow source. +- [x] Audit other traffic-related legacy PostgreSQL columns that may still be `integer` despite Go models using `int64`. +- [x] Add a schema migration that widens legacy traffic/quota columns from `integer` to `bigint`. +- [x] Add migration tests covering the new schema version branch and error propagation. +- [x] Run focused backend verification for the migration changes. + +## Notes + +- The reported failing value `536870912000` is 500 GiB in bytes and overflows PostgreSQL `int4`. +- The fix widens historical PostgreSQL traffic columns in `user`, `forward`, `statistics_flow`, `tunnel`, `user_tunnel`, and `peer_share` to `BIGINT` when needed. + +## Test Record + +- Command: `cd go-backend && go test ./internal/store/repo/...` +- Result: passed. +- Command: `cd go-backend && go test ./tests/contract/...` +- Result: passed.