From 0edde01e4b426c9a891cde0d1a46d001498d0279 Mon Sep 17 00:00:00 2001 From: xuans Date: Mon, 28 Sep 2026 18:11:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A3=80=E6=9F=A5=E7=82=B9=EF=BC=9A=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E6=95=B0=E6=8D=AE=E5=BA=93=E5=9B=9E=E5=BD=92=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=80=99=E9=80=89=E5=B9=B6=E7=BB=A7=E7=BB=AD=E6=A0=B8?= =?UTF-8?q?=E6=9F=A5=E5=BC=95=E6=93=8E=E4=B8=8E=E5=AE=9E=E5=BA=93=E9=98=BB?= =?UTF-8?q?=E5=A1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/go.mod | 8 +- backend/go.sum | 26 ++--- .../internal/database/history_scope_test.go | 28 ++++++ .../internal/database/sqlite_close_test.go | 97 +++++++++++++++++++ 4 files changed, 143 insertions(+), 16 deletions(-) create mode 100644 backend/internal/database/sqlite_close_test.go diff --git a/backend/go.mod b/backend/go.mod index 04078a7..3b640e3 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,6 +3,7 @@ module git.pcux.cn/xuans/Scert/backend go 1.26.6 require ( + github.com/Microsoft/go-winio v0.6.2 github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.3 github.com/alibabacloud-go/tea v1.5.2 github.com/gin-gonic/gin v1.10.1 @@ -16,7 +17,7 @@ require ( golang.org/x/oauth2 v0.36.0 golang.org/x/sys v0.47.0 google.golang.org/api v0.288.0 - modernc.org/sqlite v1.37.0 + modernc.org/sqlite v1.40.1 ) require ( @@ -24,7 +25,6 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect filippo.io/edwards25519 v1.1.1 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect github.com/alibabacloud-go/debug v1.0.1 // indirect github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect @@ -108,8 +108,8 @@ require ( google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.3 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.62.1 // indirect + modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.9.1 // indirect + modernc.org/memory v1.11.0 // indirect software.sslmate.com/src/go-pkcs12 v0.7.3 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index b26e0fb..ad015b2 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -507,26 +507,28 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic= -modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU= -modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw= -modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= -modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s= -modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g= -modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= -modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= +modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY= +modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/backend/internal/database/history_scope_test.go b/backend/internal/database/history_scope_test.go index ef5eed9..85117e9 100644 --- a/backend/internal/database/history_scope_test.go +++ b/backend/internal/database/history_scope_test.go @@ -2,8 +2,11 @@ package database import ( "database/sql" + "errors" "strings" "testing" + + "github.com/go-sql-driver/mysql" ) func seedHistoryScope(t *testing.T, db *sql.DB, driver string) { @@ -202,10 +205,17 @@ func TestHistoryScopeMySQLMigrationRetry(t *testing.T) { } if err := Migrate(ctx, db.DB, "mysql"); err == nil { t.Fatal("故障未阻断迁移") + } else { + var mysqlErr *mysql.MySQLError + // 必须命中注入阶段,不能把索引损坏等其他错误误当作故障注入成功。 + if !errors.As(err, &mysqlErr) || mysqlErr.Number != 1644 || mysqlErr.Message != "test-only" { + t.Fatalf("迁移在注入阶段 %s 之前意外失败,保留服务器日志供诊断:%v", phase, err) + } } if version, err := currentMigrationVersion(ctx, db.DB); err != nil || version != 12 { t.Fatalf("失败版本错误:%d,%v", version, err) } + assertMySQLHistoryIndexes(t, db.DB) if _, err := fixture.admin.ExecContext(ctx, "DROP TRIGGER "+schema+".fail_history"); err != nil { t.Fatal(err) } @@ -213,6 +223,7 @@ func TestHistoryScopeMySQLMigrationRetry(t *testing.T) { if err := Migrate(ctx, db.DB, "mysql"); err != nil { t.Fatal(err) } + assertMySQLHistoryIndexes(t, db.DB) } assertHistoryScope(t, db.DB) if version, err := currentMigrationVersion(ctx, db.DB); err != nil || version != latestMigrationVersion { @@ -221,3 +232,20 @@ func TestHistoryScopeMySQLMigrationRetry(t *testing.T) { }) } } + +func assertMySQLHistoryIndexes(t *testing.T, db *sql.DB) { + t.Helper() + for _, index := range []struct{ table, name, columns string }{ + {"tasks", "idx_tasks_project_history", "project_id,created_at"}, + {"audit_logs", "idx_audit_project_history", "project_id,created_at"}, + {"audit_logs", "idx_audit_task_history", "task_id"}, + } { + var columns sql.NullString + err := db.QueryRowContext(t.Context(), `SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) + FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? AND index_name=?`, + index.table, index.name).Scan(&columns) + if err != nil || !columns.Valid || columns.String != index.columns { + t.Fatalf("历史范围索引结构不完整:%s.%s,字段=%s,错误=%v", index.table, index.name, columns.String, err) + } + } +} diff --git a/backend/internal/database/sqlite_close_test.go b/backend/internal/database/sqlite_close_test.go new file mode 100644 index 0000000..48a5c10 --- /dev/null +++ b/backend/internal/database/sqlite_close_test.go @@ -0,0 +1,97 @@ +package database + +import ( + "context" + "database/sql" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/config" +) + +// 取消可能与驱动返回结果集同时发生;调用方拿不到结果集时也不能遗留读锁和文件句柄。 +func TestSQLiteCanceledQueriesReleaseFiles(t *testing.T) { + for _, prepared := range []bool{false, true} { + name := "direct" + if prepared { + name = "prepared" + } + t.Run(name, func(t *testing.T) { + cfg := config.Default(t.TempDir()).Database + cfg.MaxOpenConns, cfg.MaxIdleConns = 1, 1 + db, err := Open(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if _, err := db.DB.ExecContext(t.Context(), `CREATE TABLE close_probe (id INTEGER PRIMARY KEY); + WITH RECURSIVE values_to_insert(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM values_to_insert WHERE n<128) + INSERT INTO close_probe SELECT n FROM values_to_insert`); err != nil { + t.Fatal(err) + } + query := func(ctx context.Context) (*sql.Rows, error) { + return db.DB.QueryContext(ctx, "SELECT id FROM close_probe") + } + if prepared { + stmt, err := db.DB.PrepareContext(t.Context(), "SELECT id FROM close_probe") + if err != nil { + t.Fatal(err) + } + defer stmt.Close() + query = func(ctx context.Context) (*sql.Rows, error) { return stmt.QueryContext(ctx) } + } + canceled := 0 + for attempt := 0; attempt < 2048; attempt++ { + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + go func() { + // 交替让查询与取消先取得调度,不通过休眠或重试关闭来掩盖泄漏。 + for yield := 0; yield < attempt%8; yield++ { + runtime.Gosched() + } + cancel() + close(done) + }() + rows, err := query(ctx) + if rows != nil { + for rows.Next() { + var value int + if scanErr := rows.Scan(&value); scanErr != nil { + err = scanErr + break + } + } + err = errors.Join(err, rows.Err(), rows.Close()) + } + <-done + cancel() + if errors.Is(err, context.Canceled) { + canceled++ + } else if err != nil { + t.Fatal(err) + } + } + if canceled == 0 { + t.Fatal("没有实际覆盖取消中的查询") + } + // 跨平台检查已取消查询没有留下读事务;关闭后 Windows 还必须立即允许移动文件。 + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + var busy, logFrames, checkpointed int + if err := db.DB.QueryRowContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &logFrames, &checkpointed); err != nil || busy != 0 { + t.Fatalf("查询结束后仍存在未释放的读事务:busy=%d,错误=%v", busy, err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + if err := os.Rename(cfg.DSN, filepath.Join(filepath.Dir(cfg.DSN), "closed.db")); err != nil { + t.Fatalf("关闭后数据库文件仍被占用:%v", err) + } + t.Logf("已验证 %d 次查询,其中 %d 次实际取消", 2048, canceled) + }) + } +}