Skip to content

Commit 51fdfc5

Browse files
committed
fix(model): drop leftover prefill_groups unique constraints before AutoMigrate
1 parent 2bf0820 commit 51fdfc5

4 files changed

Lines changed: 468 additions & 83 deletions

File tree

‎AGENTS.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag
8181

8282
**Database compatibility:** All database code MUST work with SQLite, MySQL >= 5.7.8, and PostgreSQL >= 9.6 simultaneously.
8383

84+
- Any change that can affect database behavior MUST be verified before the work is considered complete. This includes ORM/database-driver dependency changes, connection/DSN/protocol or prepared-statement configuration, models and GORM tags, migrations and `AutoMigrate`, constraints and indexes, `Scanner`/`Valuer`/serializer behavior, raw SQL, transactions, and row locking.
85+
- Required database verification MUST exercise real SQLite, MySQL, and PostgreSQL instances. Unit tests, mocks, a successful build, code inspection, or testing only one dialect are not substitutes. Use at least one supported version of each engine; changes that depend on version-specific behavior must also cover the minimum supported version.
86+
- Treat GORM core and its database dialect/driver packages as a compatible version set. Any change to one of them requires checking upstream compatibility and running the complete three-database verification matrix; do not upgrade only the core package and infer that existing drivers remain compatible.
87+
- Schema or migration changes MUST be tested both on a fresh database and by upgrading a representative database created by the latest released version. Run startup/migration at least twice to prove idempotency, and verify that existing data, indexes, constraints, and uniqueness guarantees are preserved. Cover the separately configured log database when the affected path is shared with or used by it.
88+
- Record the exact database versions, commands, and results in the final handoff or pull request. If any required database verification cannot be run, report the blocker explicitly and do not claim the change is database-compatible or complete.
8489
- Prefer GORM methods (`Create`, `Find`, `Where`, `Updates`, etc.) over raw SQL.
8590
- Let GORM handle primary key generation; do not use `AUTO_INCREMENT` or `SERIAL` directly.
8691
- Standard `SELECT ... FOR UPDATE` row locks built with GORM query methods in `model/` MUST use `lockForUpdate(tx)`. Do not use the legacy GORM v1 pattern `tx.Set("gorm:query_option", "FOR UPDATE")`, because GORM v2 silently ignores it and no lock is acquired. Do not duplicate `clause.Locking{Strength: "UPDATE"}` at call sites; the shared helper emits `FOR UPDATE` for MySQL/PostgreSQL and skips it for SQLite, where the syntax is unsupported. Dialect-specific locking with different semantics (for example, a MySQL next-key/gap lock) may use raw SQL only behind explicit database-type branches with valid fallbacks for every supported database.

‎model/main.go‎

Lines changed: 3 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,9 @@ func is64BitIntegerType(dbType common.DatabaseType, dataType string) bool {
315315
}
316316

317317
func migrateDB() error {
318+
if err := migratePrefillGroupUniqueness(DB); err != nil {
319+
return err
320+
}
318321
// Migrate price_amount column from float/double to decimal for existing tables
319322
migrateSubscriptionPlanPriceAmount()
320323
// Migrate model_limits column from varchar to text for existing tables
@@ -380,89 +383,6 @@ func migrateDB() error {
380383
return nil
381384
}
382385

383-
func migrateDBFast() error {
384-
385-
var wg sync.WaitGroup
386-
387-
migrations := []struct {
388-
model interface{}
389-
name string
390-
}{
391-
{&Channel{}, "Channel"},
392-
{&Token{}, "Token"},
393-
{&User{}, "User"},
394-
{&UserSession{}, "UserSession"},
395-
{&AuthFlow{}, "AuthFlow"},
396-
{&ExternalIdentityClaim{}, "ExternalIdentityClaim"},
397-
{&PasskeyCredential{}, "PasskeyCredential"},
398-
{&Option{}, "Option"},
399-
{&LoginEncryptionKey{}, "LoginEncryptionKey"},
400-
{&Redemption{}, "Redemption"},
401-
{&Ability{}, "Ability"},
402-
{&Log{}, "Log"},
403-
{&Midjourney{}, "Midjourney"},
404-
{&TopUp{}, "TopUp"},
405-
{&QuotaData{}, "QuotaData"},
406-
{&Task{}, "Task"},
407-
{&Model{}, "Model"},
408-
{&Vendor{}, "Vendor"},
409-
{&PrefillGroup{}, "PrefillGroup"},
410-
{&Setup{}, "Setup"},
411-
{&TwoFA{}, "TwoFA"},
412-
{&TwoFABackupCode{}, "TwoFABackupCode"},
413-
{&Checkin{}, "Checkin"},
414-
{&SubscriptionOrder{}, "SubscriptionOrder"},
415-
{&UserSubscription{}, "UserSubscription"},
416-
{&SubscriptionPreConsumeRecord{}, "SubscriptionPreConsumeRecord"},
417-
{&CustomOAuthProvider{}, "CustomOAuthProvider"},
418-
{&UserOAuthBinding{}, "UserOAuthBinding"},
419-
{&PerfMetric{}, "PerfMetric"},
420-
{&SystemInstance{}, "SystemInstance"},
421-
{&SystemTask{}, "SystemTask"},
422-
{&SystemTaskLock{}, "SystemTaskLock"},
423-
}
424-
// 动态计算migration数量,确保errChan缓冲区足够大
425-
errChan := make(chan error, len(migrations))
426-
427-
for _, m := range migrations {
428-
wg.Add(1)
429-
go func(model interface{}, name string) {
430-
defer wg.Done()
431-
if err := DB.AutoMigrate(model); err != nil {
432-
errChan <- fmt.Errorf("failed to migrate %s: %v", name, err)
433-
}
434-
}(m.model, m.name)
435-
}
436-
437-
// Wait for all migrations to complete
438-
wg.Wait()
439-
close(errChan)
440-
441-
// Check for any errors
442-
for err := range errChan {
443-
if err != nil {
444-
return err
445-
}
446-
}
447-
if err := InitializeUserAuthVersions(); err != nil {
448-
return err
449-
}
450-
if err := InitializeExternalIdentityClaims(); err != nil {
451-
return err
452-
}
453-
if common.UsingMainDatabase(common.DatabaseTypeSQLite) {
454-
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
455-
return err
456-
}
457-
} else {
458-
if err := DB.AutoMigrate(&SubscriptionPlan{}); err != nil {
459-
return err
460-
}
461-
}
462-
common.SysLog("database migrated")
463-
return nil
464-
}
465-
466386
func migrateLOGDB() error {
467387
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
468388
return migrateClickHouseLogDB()

‎model/prefill_group_migration.go‎

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package model
2+
3+
import (
4+
"fmt"
5+
6+
"gorm.io/gorm"
7+
"gorm.io/gorm/clause"
8+
)
9+
10+
const prefillGroupNameIndex = "uk_prefill_name"
11+
12+
type conflictingPrefillGroupUniqueness struct {
13+
constraints []string
14+
indexes []string
15+
}
16+
17+
type prefillGroupNameIndexState struct {
18+
exists bool
19+
valid bool
20+
}
21+
22+
func (conflicts conflictingPrefillGroupUniqueness) empty() bool {
23+
return len(conflicts.constraints) == 0 && len(conflicts.indexes) == 0
24+
}
25+
26+
func inspectConflictingPrefillGroupUniqueness(db *gorm.DB, tableName string) (conflictingPrefillGroupUniqueness, error) {
27+
var conflicts conflictingPrefillGroupUniqueness
28+
if err := db.Raw(`
29+
SELECT constraint_meta.conname
30+
FROM pg_catalog.pg_constraint AS constraint_meta
31+
WHERE constraint_meta.conrelid = to_regclass(?)
32+
AND constraint_meta.contype = 'u'
33+
AND cardinality(constraint_meta.conkey) = 1
34+
AND EXISTS (
35+
SELECT 1
36+
FROM pg_catalog.pg_attribute AS attribute_meta
37+
WHERE attribute_meta.attrelid = constraint_meta.conrelid
38+
AND attribute_meta.attnum = constraint_meta.conkey[1]
39+
AND attribute_meta.attname = ?
40+
)
41+
ORDER BY constraint_meta.conname`, tableName, "name").Scan(&conflicts.constraints).Error; err != nil {
42+
return conflicts, fmt.Errorf("inspect conflicting prefill group unique constraints: %w", err)
43+
}
44+
45+
if err := db.Raw(`
46+
SELECT index_class.relname
47+
FROM pg_catalog.pg_index AS index_meta
48+
JOIN pg_catalog.pg_class AS index_class
49+
ON index_class.oid = index_meta.indexrelid
50+
JOIN pg_catalog.pg_attribute AS attribute_meta
51+
ON attribute_meta.attrelid = index_meta.indrelid
52+
AND attribute_meta.attnum = index_meta.indkey[0]
53+
WHERE index_meta.indrelid = to_regclass(?)
54+
AND index_meta.indisunique
55+
AND NOT index_meta.indisprimary
56+
AND index_meta.indpred IS NULL
57+
AND index_meta.indexprs IS NULL
58+
AND index_meta.indnatts = 1
59+
AND attribute_meta.attname = ?
60+
AND NOT EXISTS (
61+
SELECT 1
62+
FROM pg_catalog.pg_constraint AS constraint_meta
63+
WHERE constraint_meta.conindid = index_meta.indexrelid
64+
)
65+
ORDER BY index_class.relname`, tableName, "name").Scan(&conflicts.indexes).Error; err != nil {
66+
return conflicts, fmt.Errorf("inspect conflicting prefill group unique indexes: %w", err)
67+
}
68+
69+
return conflicts, nil
70+
}
71+
72+
func inspectPrefillGroupNameIndex(db *gorm.DB, tableName string) (prefillGroupNameIndexState, error) {
73+
var state struct {
74+
Exists bool `gorm:"column:index_exists"`
75+
Valid bool `gorm:"column:index_valid"`
76+
}
77+
if err := db.Raw(`
78+
SELECT count(*) > 0 AS index_exists,
79+
COALESCE(bool_or(
80+
index_meta.indisunique
81+
AND index_meta.indisvalid
82+
AND index_meta.indisready
83+
AND NOT index_meta.indisprimary
84+
AND index_meta.indexprs IS NULL
85+
AND index_meta.indnatts = 1
86+
AND attribute_meta.attname = ?
87+
AND pg_get_expr(index_meta.indpred, index_meta.indrelid) = '(deleted_at IS NULL)'
88+
), false) AS index_valid
89+
FROM pg_catalog.pg_index AS index_meta
90+
JOIN pg_catalog.pg_class AS index_class
91+
ON index_class.oid = index_meta.indexrelid
92+
LEFT JOIN pg_catalog.pg_attribute AS attribute_meta
93+
ON attribute_meta.attrelid = index_meta.indrelid
94+
AND attribute_meta.attnum = index_meta.indkey[0]
95+
WHERE index_meta.indrelid = to_regclass(?)
96+
AND index_class.relname = ?`, "name", tableName, prefillGroupNameIndex).Scan(&state).Error; err != nil {
97+
return prefillGroupNameIndexState{}, fmt.Errorf("inspect prefill group partial unique index: %w", err)
98+
}
99+
return prefillGroupNameIndexState{exists: state.Exists, valid: state.Valid}, nil
100+
}
101+
102+
// migratePrefillGroupUniqueness removes global PostgreSQL uniqueness left by
103+
// older GORM versions before AutoMigrate reconciles the current partial index.
104+
func migratePrefillGroupUniqueness(db *gorm.DB) error {
105+
if db == nil {
106+
return fmt.Errorf("migrate prefill group uniqueness: database is nil")
107+
}
108+
if db.Dialector.Name() != "postgres" {
109+
return nil
110+
}
111+
112+
statement := &gorm.Statement{DB: db}
113+
if err := statement.Parse(&PrefillGroup{}); err != nil {
114+
return fmt.Errorf("parse prefill group schema: %w", err)
115+
}
116+
tableName := statement.Schema.Table
117+
conflicts, err := inspectConflictingPrefillGroupUniqueness(db, tableName)
118+
if err != nil {
119+
return err
120+
}
121+
if conflicts.empty() {
122+
return nil
123+
}
124+
125+
return db.Transaction(func(tx *gorm.DB) error {
126+
migrator := tx.Migrator()
127+
if !migrator.HasTable(&PrefillGroup{}) {
128+
return nil
129+
}
130+
131+
if err := tx.Exec(
132+
"LOCK TABLE ? IN ACCESS EXCLUSIVE MODE",
133+
clause.Table{Name: tableName},
134+
).Error; err != nil {
135+
return fmt.Errorf("lock prefill groups for uniqueness migration: %w", err)
136+
}
137+
138+
conflicts, err := inspectConflictingPrefillGroupUniqueness(tx, tableName)
139+
if err != nil {
140+
return err
141+
}
142+
if conflicts.empty() {
143+
return nil
144+
}
145+
146+
if !migrator.HasColumn(&PrefillGroup{}, "DeletedAt") {
147+
if err := migrator.AddColumn(&PrefillGroup{}, "DeletedAt"); err != nil {
148+
return fmt.Errorf("add prefill groups deleted_at column: %w", err)
149+
}
150+
}
151+
152+
for _, constraintName := range conflicts.constraints {
153+
if err := migrator.DropConstraint(&PrefillGroup{}, constraintName); err != nil {
154+
return fmt.Errorf("drop conflicting prefill group constraint %q: %w", constraintName, err)
155+
}
156+
}
157+
for _, indexName := range conflicts.indexes {
158+
if err := migrator.DropIndex(&PrefillGroup{}, indexName); err != nil {
159+
return fmt.Errorf("drop conflicting prefill group index %q: %w", indexName, err)
160+
}
161+
}
162+
163+
targetIndex, err := inspectPrefillGroupNameIndex(tx, tableName)
164+
if err != nil {
165+
return err
166+
}
167+
if !targetIndex.exists {
168+
if err := migrator.CreateIndex(&PrefillGroup{}, prefillGroupNameIndex); err != nil {
169+
return fmt.Errorf("create prefill group partial unique index: %w", err)
170+
}
171+
targetIndex, err = inspectPrefillGroupNameIndex(tx, tableName)
172+
if err != nil {
173+
return err
174+
}
175+
}
176+
if !targetIndex.valid {
177+
return fmt.Errorf("prefill group index %q has an unexpected definition", prefillGroupNameIndex)
178+
}
179+
180+
return nil
181+
})
182+
}

0 commit comments

Comments
 (0)