检查点:保存数据库回归修复候选并继续核查引擎与实库阻塞

This commit is contained in:
xuans 2026-09-28 18:11:58 +08:00
parent b11ee746fe
commit 0edde01e4b
4 changed files with 143 additions and 16 deletions

View File

@ -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
)

View File

@ -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=

View File

@ -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)
}
}
}

View File

@ -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)
})
}
}