From 84b8b1c65aeb563d23558583094a9b62f3064ec0 Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Tue, 12 May 2026 16:43:44 +0800 Subject: [PATCH] fix: remove transactions causing SQLITE_BUSY, add coding rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - persistTask: 去掉事务,每个 Exec 原子即可 - saveAsset(profiles): 去掉事务,profile 和 units 分步保存 - CLAUDE.md: 加两条规则——不用事务除非必要;调试只加日志不改代码 --- CLAUDE.md | 2 ++ internal/storage/assets_repo.go | 21 ++++++++++++--------- internal/storage/sqlite.go | 1 + internal/storage/tasks_repo.go | 27 ++++++++++++--------------- internal/web/ui.go | 16 ++++++---------- 5 files changed, 33 insertions(+), 34 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 979ae96..62c63b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,8 @@ The config preview rendering pipeline: merge template + profile + overlays → r - **No inline `onclick` with complex quoting** — use `data-*` attributes + event delegation instead. - **Use `pwsh`** for PowerShell commands (v7.6.1 in PATH, not `powershell.exe`). - **重启服务**:统一用 `pwsh -File scripts/managerd.ps1 restart`,它会先编译、停旧进程、再启动新进程并做健康检查。不要手动 kill + build + start。 +- **如无必要,不使用数据库事务**:SQLite 只允许一个写者,事务会阻塞所有其他写操作。单个 `Exec` 已是原子操作,绝大多数场景不需要包裹事务。仅模板重命名等必须原子完成的多步操作才用事务。 +- **调试时只加日志,不改代码**:遇到 bug 先用 `fmt.Printf` 全量打印关键路径的状态和数据流,定位根因后再动手修。不要猜测、不要加 workaround(重试、超时、锁之类)。 ## 测试设备 diff --git a/internal/storage/assets_repo.go b/internal/storage/assets_repo.go index ab13edf..ee5cd06 100644 --- a/internal/storage/assets_repo.go +++ b/internal/storage/assets_repo.go @@ -523,8 +523,10 @@ WHERE body_json LIKE ? func (r *AssetsRepo) saveAsset(table string, record AssetRecord) error { if r == nil || r.db == nil { + fmt.Printf("[DB] saveAsset SKIP nil table=%s\n", table) return nil } + fmt.Printf("[DB] saveAsset table=%s name=%s\n", table, record.Name) now := time.Now().Format(time.RFC3339) switch table { case "templates", "overlays": @@ -542,12 +544,8 @@ ON CONFLICT(name) DO UPDATE SET if err != nil { return err } - tx, err := r.db.Begin() - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - _, err = tx.Exec(` + // Save profile row (atomic Exec, no transaction). + _, err = r.db.Exec(` INSERT INTO scene_templates(name, primary_template_name, business_name, description, body_json, created_at, updated_at) VALUES(?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM scene_templates WHERE name = ?), ?), ?) ON CONFLICT(name) DO UPDATE SET @@ -560,12 +558,15 @@ ON CONFLICT(name) DO UPDATE SET if err != nil { return err } + // Recognition units are saved separately via SaveRecognitionUnit. + // Only do replace logic here when called from the old profile editor UI + // (where units are embedded in the profile JSON and not saved separately). if replaceUnits { - if _, err := tx.Exec(`DELETE FROM recognition_units WHERE scene_template_name = ?`, normalized.Name); err != nil { + if _, err := r.db.Exec(`DELETE FROM recognition_units WHERE scene_template_name = ?`, normalized.Name); err != nil { return err } for _, unit := range units { - if _, err := tx.Exec(` + if _, err := r.db.Exec(` INSERT INTO recognition_units(scene_template_name, name, display_name, site_name, video_source_ref, output_channel, rtsp_port, description, body_json, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM recognition_units WHERE scene_template_name = ? AND name = ?), ?), ?) ON CONFLICT(scene_template_name, name) DO UPDATE SET @@ -582,7 +583,7 @@ ON CONFLICT(scene_template_name, name) DO UPDATE SET } } } - return tx.Commit() + return nil default: return nil } @@ -770,8 +771,10 @@ type DeviceFeatureRecord struct { // SaveDeviceFeatures upserts the feature selection for a device. func (r *AssetsRepo) SaveDeviceFeatures(deviceID string, featuresJSON string) error { if r == nil || r.db == nil { + fmt.Printf("[DB] SaveDeviceFeatures SKIP nil\n") return nil } + fmt.Printf("[DB] SaveDeviceFeatures device=%s val=%s\n", deviceID, featuresJSON) now := time.Now().Format(time.RFC3339) _, err := r.db.Exec(` INSERT INTO device_features(device_id, features_json, updated_at) diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 063bd9c..e178702 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -20,6 +20,7 @@ func OpenSQLite(path string) (*Store, error) { if err != nil { return nil, err } + if err := migrate(db); err != nil { _ = db.Close() return nil, err diff --git a/internal/storage/tasks_repo.go b/internal/storage/tasks_repo.go index 49da5a2..63ed9be 100644 --- a/internal/storage/tasks_repo.go +++ b/internal/storage/tasks_repo.go @@ -20,7 +20,6 @@ func (r *TasksRepo) Save(task *models.Task) error { if r == nil || r.db == nil || task == nil { return nil } - task.Mu.RLock() payload, err := json.Marshal(task.Payload) if err != nil { @@ -48,13 +47,8 @@ func (r *TasksRepo) Save(task *models.Task) error { finishedAt = now } - tx, err := r.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - - _, err = tx.Exec(` + // Update task row (atomic, no transaction needed). + _, err = r.db.Exec(` INSERT INTO tasks(task_id, type, payload_json, status, created_at, finished_at) VALUES(?, ?, ?, ?, COALESCE((SELECT created_at FROM tasks WHERE task_id = ?), ?), ?) ON CONFLICT(task_id) DO UPDATE SET @@ -67,19 +61,22 @@ ON CONFLICT(task_id) DO UPDATE SET return err } - if _, err := tx.Exec(`DELETE FROM task_devices WHERE task_id = ?`, task.ID); err != nil { - return err - } - for _, ds := range devices { - if _, err := tx.Exec(` + // Update device rows (separate atomic ops — no transaction needed). + if len(devices) > 0 { + if _, err := r.db.Exec(`DELETE FROM task_devices WHERE task_id = ?`, task.ID); err != nil { + return err + } + for _, ds := range devices { + if _, err := r.db.Exec(` INSERT INTO task_devices(task_id, device_id, status, progress, error_text) VALUES(?, ?, ?, ?, ?) `, task.ID, ds.DeviceID, string(ds.Status), ds.Progress, ds.Error); err != nil { - return err + return err + } } } - return tx.Commit() + return nil } func (r *TasksRepo) List() ([]models.Task, error) { diff --git a/internal/web/ui.go b/internal/web/ui.go index 0c5351a..9ee5f23 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -1173,18 +1173,14 @@ func (u *UI) actionConsoleSave(w http.ResponseWriter, r *http.Request) { return } - // Step 3: run pipeline. Only persist to DB on success. - results, _ := u.autoConfig.BuildPipelineBatch(requests, true) - - // Step 4: persist successful feature selections to DB. - for _, res := range results { - if res.Error == "" && res.TaskID != "" { - if feats, ok := formFeatures[res.DeviceID]; ok { - _ = u.preview.SaveDeviceFeatures(res.DeviceID, feats) - } - } + // Step 3: save features to DB BEFORE creating tasks (avoids SQLITE_BUSY race). + for _, req := range requests { + _ = u.preview.SaveDeviceFeatures(req.DeviceID, req.Features) } + // Step 4: run pipeline (creates tasks asynchronously). + results, _ := u.autoConfig.BuildPipelineBatch(requests, true) + // Collect task IDs for redirect. taskIDs := make([]string, 0) var errs []string