fix: remove transactions causing SQLITE_BUSY, add coding rules

- persistTask: 去掉事务,每个 Exec 原子即可
- saveAsset(profiles): 去掉事务,profile 和 units 分步保存
- CLAUDE.md: 加两条规则——不用事务除非必要;调试只加日志不改代码
This commit is contained in:
tian 2026-05-12 16:43:44 +08:00
parent 700b2246c7
commit 84b8b1c65a
5 changed files with 33 additions and 34 deletions

View File

@ -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重试、超时、锁之类
## 测试设备

View File

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

View File

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

View File

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

View File

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