Skip to content

Isolate service import staging directories - #512

Open
GOLDKUN wants to merge 3 commits into
chaitin:mainfrom
GOLDKUN:fix/isolate-service-import-staging
Open

Isolate service import staging directories#512
GOLDKUN wants to merge 3 commits into
chaitin:mainfrom
GOLDKUN:fix/isolate-service-import-staging

Conversation

@GOLDKUN

@GOLDKUN GOLDKUN commented Sep 2, 2026

Copy link
Copy Markdown

问题

普通导入和递归导入使用固定 staging 目录,并在开始时删除该目录;服务替换还使用固定的 previous 目录。

影响

并发导入会删除其他请求正在使用的文件,或互相覆盖 previous 目录,可能造成服务包、descriptor、runtime 和数据库状态不一致。

修复内容

  • 普通导入和递归导入均使用 os.MkdirTemp 创建唯一 staging 目录。
  • Importer 增加导入级互斥,协调同一 daemon 内的提交和文件替换。
  • 服务 previous 备份目录改为唯一临时目录,避免不同 Importer 实例共享固定路径。
  • 保留失败清理和替换回滚行为。

验证

  • go test ./internal/packageimport -run 'TestArchiveExtractorsRejectUnsafePaths|TestParse' 通过。

@monkeyscan

monkeyscan Bot commented Sep 2, 2026

Copy link
Copy Markdown

PR Title: Isolate service import staging directories

Commit: ca894ae

本次变更将 internal/packageimport/importer.go 的导入流程改造为并发安全版本:

  1. 在 Importer 结构体新增 importMu sync.Mutex,并在 Import 与 ImportRecursive 入口加锁串行化整个导入(覆盖准备、构建、运行时安装、提交);
  2. 将固定路径的 staging 目录(.staging-、.staging-recursive-import)改为 os.MkdirTemp 唯一临时目录;replaceServiceDir 的备份目录也改为 "MkdirTemp + 立即 os.Remove" 的预留唯一路径模式,避免并发导入同服务时互相删除 staging/备份目录。

总体设计方向合理:唯一临时目录解决了并发场景下固定路径被对方 RemoveAll 的竞态,互斥锁则保护了提交阶段 replaceServiceDir(旧版本备份、rename 新版本)的原子性。已确认文件内无重入死锁:ImportRecursive 不会调用 Import,importServiceName 仅读取 store,Progress 回调是唯一的外部回调点。

主要问题:(a) 随机临时目录名使"崩溃/强杀后残留目录"失去了原有的下次运行清理机制(旧代码每次启动固定路径时先 RemoveAll),崩溃残留的 .staging-* 与 .previous-* 目录会持续累积磁盘占用;(b) 全局锁粒度偏粗,持有期间跨越用户 Progress 回调与 npm install/build 等长操作,存在重入死锁隐患,且会把不同服务的导入全部串行化。

Comment thread internal/packageimport/importer.go Outdated
Comment thread internal/packageimport/importer.go
@monkeyscan

monkeyscan Bot commented Sep 2, 2026

Copy link
Copy Markdown

PR Title: Isolate service import staging directories

Commit: 4fe9739

该改动对 internal/packageimport 导入器做两项修改:(1) 将原先覆盖整个 Import/ImportRecursive 的全局互斥锁 importMu 改为按 serviceID 粒度的锁表(importLocks + lockService),仅在提交阶段(commitImportedService 前后)加锁,目的是修复历史问题中“持全局锁调用 Progress 回调导致重入死锁”以及“不同服务导入被全局串行化”的缺陷;(2) 新增 sweepStaleImportDirs,在每次导入开始时清理 dataDir 下超过 24 小时的 .staging-* 与 .previous-* 临时目录,以回收崩溃残留,并补充了单元测试。总体评估:锁粒度重构方向正确,基本解决了历史两条 confirmed 问题(提交阶段不再调用回调、不同服务可并发);sweep 能覆盖新引入的随机临时目录残留,方向合理。但新增的 sweep 与“并发导入”这一新能力之间存在未加防护的耦合:sweep 在无锁状态下扫描共享的 artifacts/services 根目录,仅凭“名称 + 目录 mtime”判断陈旧性,而 replaceServiceDir 生成的 .previous-* 备份目录会继承旧服务目录(可能远早于 24h)的 mtime,存在被并发 sweep 误删正在使用的回滚备份的竞态;同时 sweep 的任一清理错误(含并发 sweep 之间的 TOCTOU)会直接中止本可成功的导入,属可用性回归。建议将清理与提交互斥(或重置备份 mtime),并让清理失败不影响导入本身。

if err := os.RemoveAll(filepath.Join(root, entry.Name())); err != nil {
return err
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

无锁的 sweepStaleImportDirs 会误删并发提交中的 .previous-* 回滚备份目录

本次改动移除了全局导入锁(改为仅提交阶段持有按服务 ID 的 lockService),使不同服务的导入可并发执行;同时每次导入开始都会调用 sweepStaleImportDirs 扫描整个 artifacts/services 目录,删除名字匹配 .staging-* 或包含 .previous- 且 mtime 早于 24h 的目录。这里存在一个真实的竞态:replaceServiceDir(importer.go:1557)提交时用 MkdirTemp 生成 ..previous-* 名字后先把该临时目录 Remove 掉,再把现有 serviceDir os.Rename 成该备份名。os.Rename 不会改变目录自身的 mtime,因此这个“刚刚创建”的备份目录继承的是旧服务目录的 mtime;对于一个已稳定运行、距上次导入/更新超过一天的服务的重导入,备份目录的 mtime 天生早于 24h 截止线。此时若另一个服务的导入正在执行 sweep,就会把这个正在提交中、仍承载旧版本服务数据的备份目录判定为崩溃残留并 RemoveAll 删除。一旦随后的 Store.UpsertService 失败,commitImportedService 里的 rollback() 会执行 os.Rename(backupDir, serviceDir),但备份已被删除导致回滚失败——磁盘上是尚未登记到存储的新版本,存储中仍是旧记录,且旧版本已无副本可恢复,造成服务目录与 DB 不一致/无法回滚。同类问题也作用于超长导入:staging 目录只在直接子项增删时刷新自身 mtime,npm install 等深层写入不会刷新顶层 staging 目录的 mtime,运行超过 24h 的导入其 staging 目录也会被并发 sweep 误删。

Problem code:

Changed code at internal/packageimport/importer.go:61-72

Recommendation:
让清理逻辑与提交过程互斥,或让年龄阈值能区分“正在使用的备份/暂存目录”与“崩溃残留”。最小改动:在 replaceServiceDir 将 serviceDir 重命名为备份目录后立即 os.Chtimes(backupDir, time.Now(), time.Now()) 重置 mtime,使提交窗口内的备份被视为新目录而不会被 sweep 命中(崩溃残留仍会在 24h 后被清理)。更稳妥的做法是为 sweep 与 replaceServiceDir 引入共享锁/活跃目录登记表,保证 sweep 不会与任何进行中的提交重叠。

Suggested diff:

--- a/internal/packageimport/importer.go
+++ b/internal/packageimport/importer.go
@@ replaceServiceDir
 	if err := os.Rename(serviceDir, backupDir); err != nil {
 		return nil, nil, err
 	}
+	// 备份目录继承旧 serviceDir 的 mtime,可能早于 24h 阈值;重置为当前时间以避免
+	// 并发 sweepStaleImportDirs 将进行中的回滚备份误判为陈旧残留并删除。
+	_ = os.Chtimes(backupDir, time.Now(), time.Now())
 }

}
if err := sweepStaleImportDirs(stagingBase); err != nil {
return Result{}, err
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sweepStaleImportDirs 的清理错误会中止本可成功的导入(含并发 sweep 的 TOCTOU 误伤)

新引入的 sweepStaleImportDirs 属于尽力而为(best-effort)的残留清理,但调用方把它与导入本身强耦合:Import 与 ImportRecursive 中只要 sweep 返回任意错误就直接 return,使一次本可成功的导入被清理逻辑的失败所阻断。具体有两类触发路径:(1) 单个条目出错即中止——sweep 对任一目录执行 entry.Info() 或 os.RemoveAll 失败(权限不足、Windows 下目录被占用、或条目在 ReadDir 后被并发移除)都会向上返回错误并中止整个导入;(2) 并发导入间 TOCTOU——本次改动允许不同服务并发导入,多个导入会同时对本应互斥清理的同一 artifacts/services 根目录执行 sweep,A 的 sweep 删除某陈旧目录后,B 的 sweep 若已 ReadDir 到该条目,随后 entry.Info() 会得到 ENOENT 并返回错误,导致 B 的导入被误伤中止。改动前(无 sweep)这些清理失败不会影响导入可用性,因此这是本次改动引入的可用性回归。

Problem code:

Changed code at internal/packageimport/importer.go:185-187

Recommendation:
将 sweep 视为纯尽力而为的后台清理:单条目的 Info/RemoveAll 失败应记录后继续(continue)而不是中止整个 sweep;仅在根目录 ReadDir 完全失败时可选择忽略或返回,但不应让导入因此失败。更理想的做法是把清理与导入解耦(如独立的周期任务),避免每次导入的成败都依赖于清理的结果。

Suggested diff:

--- a/internal/packageimport/importer.go
+++ b/internal/packageimport/importer.go
@@ func sweepStaleImportDirs(root string) error {
 		info, err := entry.Info()
 		if err != nil {
-			return err
+			// 条目可能已被并发 sweep 移除等,属尽力而为清理,跳过即可。
+			continue
 		}
 		if info.ModTime().Before(cutoff) {
 			if err := os.RemoveAll(filepath.Join(root, entry.Name())); err != nil {
-				return err
+				continue
 			}
 		}
 	}

@monkeyscan

monkeyscan Bot commented Sep 2, 2026

Copy link
Copy Markdown

PR Title: Isolate service import staging directories

Commit: c47848b

本次变更(internal/packageimport/importer.go)将 Importer 的 service 级导入锁从 map[string]*sync.Mutex 重构为 map[string]*serviceImportLock(内含 sync.Mutex 与引用计数 refs),目标是在最后一个持有者释放后将 map 条目删除,避免旧实现中条目永不清理导致的无限增长。

审查结论:经对 lockService 及两处调用点(Import L257-259、ImportRecursive L550-552,均为同步且恰好一次调用返回的解锁闭包)的多种并发交错推演,引用计数逻辑是正确的——每个 lockService 调用在 importMu 保护下 refs++,闭包在释放 mu 后于 importMu 保护下 refs--,计数严格配对;map 条目仅在 refs==0(无任何持有/等待者)时删除,且创建与删除均在 importMu 临界区内,不会出现同一 service 两把锁同时生效或删除时有 goroutine 仍引用旧锁的情况;解锁闭包采用“先解锁 mu、后持 importMu 递减”的顺序也不会造成死锁或提前回收。未发现该重构引入的高置信度正确性、数据完整性或并发安全缺陷。

唯一提交的发现为低严重度测试缺口:该并发原语是同一 service 提交互斥的唯一保障,而 importer_test.go(2457 行)中没有任何并发对同一 serviceID 调用 lockService 的用例来守护互斥性与条目回收行为。文件内另有两条历史已确认发现(sweepStaleImportDirs 清理与并发提交/TOCTOU 相关),不属于本 diff 引入,未重复上报。

@GOLDKUN

GOLDKUN commented Sep 2, 2026

Copy link
Copy Markdown
Author

Follow-up fixes after review:

  • Replaced the global import lock with per-Service keyed locks, so unrelated imports can proceed concurrently and Progress callbacks/builds are not held under one global lock.
  • Idle keyed locks are released from the map.
  • Added stale staging/previous directory sweeping with an age threshold to reclaim crash leftovers.
  • Added stale-directory cleanup coverage.

Verification: cleanup tests pass; full packageimport CI remains the authoritative integration gate.

lock.mu.Unlock()
i.importMu.Lock()
lock.refs--
if lock.refs == 0 && i.importLocks[serviceID] == lock {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lockService 引用计数与条目回收逻辑缺少直接并发测试

本次改动把 service 提交锁从 map[string]*sync.Mutex(条目永不清理)改为带引用计数的 serviceImportLock,并在最后一个持有者释放时从 importLocks 中删除条目。lockService 现在是防止同一 service 两个并发提交(commitImportedService)同时执行的唯一互斥原语,而新增的 refs 计数/删除逻辑非常微妙:一旦未来重构破坏 refs 配对(例如在持有锁期间提前 return 或异常路径漏调解锁闭包导致 refs 永不归零),或让删除提前发生,都可能造成同一 service 并发提交同时进入 commitImportedService(目录切换与 Store.UpsertService 交错,导致服务目录与 DB 不一致、回滚失效),或使本变更想修复的 map 无限增长问题复发。已核对 importer_test.go(2457 行):现有测试均为顺序执行的导入/辅助函数用例,没有任何并发对同一 serviceID 调用 lockService 的测试,也未验证互斥与条目回收。该行为有实际回归风险却无测试守护,属于本变更暴露的测试缺口。

Problem code:

Changed code at internal/packageimport/importer.go:61

Recommendation:
新增一个直接针对 lockService 的单元测试:(1) 多个 goroutine 并发对同一 serviceID 调用 lockService 并在临界区内执行有状态操作,断言临界区互不重叠(同一时刻仅一个持有者);(2) 全部解锁后断言 i.importLocks 中该 serviceID 的条目已被删除(refs 归零触发回收);(3) 对不同 serviceID 并发加锁,断言互不阻塞、可并行持有,以验证 per-service 而非全局互斥。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant