From 65988cf6ebbc0e315241f958977bd88fe8715981 Mon Sep 17 00:00:00 2001 From: "richard.li" Date: Tue, 19 May 2026 09:53:14 +0800 Subject: [PATCH 1/6] update docs --- .claude/settings.local.json | 4 +- ...-storage-upload-detach-recorder-context.md | 324 ++------- .../2026-05-15-trailer-flush-rate-limit.md | 676 ++---------------- 3 files changed, 128 insertions(+), 876 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 50d7bb8b..bc367245 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,9 @@ { "permissions": { "allow": [ - "Bash(ssh *)" + "Bash(ssh *)", + "Bash(mkdir -p ~/.claude/skills/learned/gotask-task-work-is-single-threaded)", + "Bash(mkdir -p ~/.claude/skills/learned/context-withoutcancel-for-async-cleanup)" ], "additionalDirectories": [ "/Users/lirichen/Work/GithubRepo/gotask" diff --git a/docs/superpowers/plans/2026-05-15-storage-upload-detach-recorder-context.md b/docs/superpowers/plans/2026-05-15-storage-upload-detach-recorder-context.md index 1d723f4d..74f51619 100644 --- a/docs/superpowers/plans/2026-05-15-storage-upload-detach-recorder-context.md +++ b/docs/superpowers/plans/2026-05-15-storage-upload-detach-recorder-context.md @@ -1,6 +1,8 @@ # Storage Upload 与 Recorder Context 解耦 实施计划 -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **状态:已完成** — 2026-05-15 合并到 develop(`bdde65ae`) +> +> 三个后端(S3 / OSS / COS)均已修复,单元测试全 PASS,130 环境待验证。 **Goal:** 修复 record stop 时 S3 / OSS / COS upload 被 Recorder.Context cancel 误杀的 bug。让 trailer 写完后的对象存储上传不受录制任务生命周期影响 — 录制可以停,但已写好的文件必须完整上传完成。 @@ -8,7 +10,7 @@ 修复:在 `uploadTempFile` 入口把 ctx 用 `context.WithoutCancel(f.ctx)` 包一层,**保留 ctx values(trace/metadata),切断 cancel 信号**。Upload 自带 timeout(已有 `getTimeout()`),不会无限挂。 -**Tech Stack:** Go 1.21+ `context.WithoutCancel`(仓库 Go 1.26.0 已支持)。 +**Tech Stack:** Go 1.21+ `context.WithoutCancel`(仓库 Go 1.24 已支持)。 **问题背景:** - session 实测:3 次 31 路录制(5min × 2 + 15min × 1)全部出现 **31/31 文件进 pending_uploads** @@ -31,27 +33,19 @@ **修复范围:** - 改 S3 / OSS / COS 三个后端的 `uploadTempFile` - 不改 Local(local 不走上传链路,无此 bug) -- 不修 trailer flush 限流(独立 plan `2026-05-15-trailer-flush-rate-limit.md`,本 plan 修完它可以放心做) -- 不动 `pending_uploads/` 重试机制(如果还有少量 case 失败,依赖 retry 队列,那是另一个 issue) - -**Spec:** 见首段 + Task 4 验收节。 +- 不修 trailer flush 限流(独立 plan `2026-05-15-trailer-flush-rate-limit.md`) +- 不动 `pending_uploads/` 重试机制 --- -## 文件结构 +## 文件结构(实际) ``` pkg/storage/ -├── s3.go [改] uploadTempFile: ctx 用 context.WithoutCancel -├── oss.go [改] 同 -├── cos.go [改] 同 -└── upload_detach_ctx_test.go [新] 复现测 + 修复验证(mock S3 / 单元) - -plugin/mp4/pkg/ -└── record_e2e_cancel_test.go [新, 可选] mp4 record stop 时 upload 仍完成的集成测 - -docs/superpowers/plans/ -└── 2026-05-15-storage-upload-detach-recorder-context.md [新] 本文件 +├── s3.go [已改] uploadTempFile: ctx 用 context.WithoutCancel +├── oss.go [已改] 同 +├── cos.go [已改] 同 +└── upload_detach_ctx_test.go [已建] 复现测 + 修复验证(3 个测试全 PASS) ``` --- @@ -61,88 +55,24 @@ docs/superpowers/plans/ **Files:** - Create: `pkg/storage/upload_detach_ctx_test.go` -目标:在不依赖真 S3 / 网络的情况下,验证"父 ctx cancel 后 upload 失败"现象,并在 fix 之后验证"父 ctx cancel 后 upload 仍成功"。 - -- [ ] **Step 1: 写测试骨架(用 fake `uploadFn`,不依赖真 S3)** - -文件 `pkg/storage/upload_detach_ctx_test.go`: +- [x] **Step 1: 写测试骨架** -```go -package storage - -import ( - "context" - "errors" - "sync/atomic" - "testing" - "time" -) - -// TestUploadWithRetry_CancelledParentCtxFails 是当前 bug 复现: -// 父 ctx cancel 后, UploadWithRetry 立刻退. 这是修复**前**的行为, 修复后此测保持通过(用法不变). -func TestUploadWithRetry_CancelledParentCtxFails(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() // pre-cancelled - - rc := RetryConfig{MaxRetries: 3, RetryInterval: 10 * time.Millisecond} - var attempts int32 - err := UploadWithRetry(ctx, rc, "test", "obj-key", - nil, - func() error { - atomic.AddInt32(&attempts, 1) - return errors.New("simulated transient") - }) - - // 当前实现: UploadWithRetry 看 ctx.Done 在 retry wait 处退出 - // 注意 attempt 0 不走 retry wait, 所以 uploadFn 至少跑 1 次 - if err == nil { - t.Fatalf("expected error when parent ctx cancelled, got nil") - } - a := atomic.LoadInt32(&attempts) - if a > 1 { - t.Fatalf("uploadFn should run at most once with cancelled ctx, ran %d times", a) - } -} - -// TestUploadTempFile_DetachedFromParentCtx 验证修复: -// S3File.uploadTempFile 收到的 w.ctx 即使被 cancel, 上传逻辑仍能跑完. -// 不直接连真 S3, 通过 mock storage 抽象层 OR 测 helper 函数. -// 这里测一个抽象的 "detachedCtx" helper, 真正 fix 后改为测真 wrapper. -func TestDetachedCtxIgnoresCancel(t *testing.T) { - parent, cancel := context.WithCancel(context.Background()) - cancel() - - // 修复后的预期行为: 此 helper 返回的 ctx Done() 永不 close - detached := context.WithoutCancel(parent) - - select { - case <-detached.Done(): - t.Fatal("detached ctx should not be cancelled by parent") - default: - // OK - } - if detached.Err() != nil { - t.Fatalf("detached ctx Err should be nil, got %v", detached.Err()) - } -} -``` +文件 `pkg/storage/upload_detach_ctx_test.go` 已建,包含: +- `TestUploadWithRetry_CancelledParentCtxFails`:证明 cancelled ctx 上传失败(bug 现状) +- `TestDetachedCtxIgnoresCancel`:证明 `context.WithoutCancel` helper 行为正确 +- `TestUploadWithRetry_DetachedCtxSucceeds`:证明修复路径有效(detached ctx 上传成功) -- [ ] **Step 2: 跑测试** +- [x] **Step 2: 跑测试** ```bash go test -race -run 'TestUploadWithRetry_CancelledParentCtxFails|TestDetachedCtxIgnoresCancel' ./pkg/storage/... ``` -预期: -- `TestUploadWithRetry_CancelledParentCtxFails` → **PASS**(证 bug 现状:cancelled ctx 上传失败) -- `TestDetachedCtxIgnoresCancel` → **PASS**(证 helper 行为正确) +结果:全 PASS。 -- [ ] **Step 3: 提交测试基线** +- [x] **Step 3: 提交测试基线** -```bash -git add pkg/storage/upload_detach_ctx_test.go -git commit -m "test(storage): 加测复现 record stop 时 upload 被 cancel 的 bug" -``` +提交:`da78bc4f test(storage): 加测复现 record stop 时 upload 被 cancel 的 bug` --- @@ -151,80 +81,28 @@ git commit -m "test(storage): 加测复现 record stop 时 upload 被 cancel 的 **Files:** - Modify: `pkg/storage/s3.go` -- [ ] **Step 1: 看现有 uploadTempFile 实现** - -```bash -sed -n '478,540p' pkg/storage/s3.go -``` - -确认 `w.ctx` 在 4 处使用: -1. `AcquireUploadSlot(w.ctx)` -2. `UploadWithRetry(w.ctx, ...)`(作为父 ctx) -3. `context.WithTimeout(w.ctx, timeout)` —— per attempt timeout -4. (可能还有别的 — grep 一下) - -```bash -grep -n 'w\.ctx' pkg/storage/s3.go -``` +- [x] **Step 1: 看现有 uploadTempFile 实现** -- [ ] **Step 2: 在 uploadTempFile 入口加 detached ctx** +已确认 `w.ctx` 在 `AcquireUploadSlot` 和 `UploadWithRetry` 两处使用。 -找到 `func (w *S3File) uploadTempFile() error {`,把方法最开头改成: +- [x] **Step 2 & 3: 在 uploadTempFile 入口加 detached ctx,替换所有 `w.ctx`** ```go func (w *S3File) uploadTempFile() error { - // 解耦上传 ctx 与文件 ctx (= Recorder.Context). - // Recorder dispose 时其 ctx 被 cancel, 但写好的临时文件应当继续上传完成. - // WithoutCancel 保留 ctx 的 Values (trace / auth), 但 Done() 永不 close. - // 上传链路的真实超时由每次 attempt 的 WithTimeout 控制. - uploadCtx := context.WithoutCancel(w.ctx) - - // 获取上传槽位(并发控制) - if err := AcquireUploadSlot(uploadCtx); err != nil { - return fmt.Errorf("acquire upload slot: %w", err) - } - defer ReleaseUploadSlot() - // ... 后续保持原逻辑, 但把 w.ctx → uploadCtx -``` - -- [ ] **Step 3: 把后续所有 `w.ctx` 替换成 `uploadCtx`** - -替换 `UploadWithRetry(w.ctx, ...)` → `UploadWithRetry(uploadCtx, ...)` - -替换 `context.WithTimeout(w.ctx, timeout)` → `context.WithTimeout(uploadCtx, timeout)` - -grep 确认(应 0 行): - -```bash -grep -n 'w\.ctx' pkg/storage/s3.go -``` - -如果还有非上传路径在用 `w.ctx`(比如 Read 操作),那些不动。本 Step 只关心 upload 路径。具体方法:先看 grep 结果,对每一处判定,再选择性替换。 - -- [ ] **Step 4: 编译验证** - -```bash -go build ./pkg/storage/... + uploadCtx := context.WithoutCancel(w.ctx) + if err := AcquireUploadSlot(uploadCtx); err != nil { ... } + ... + return UploadWithRetry(uploadCtx, rc, ...) +} ``` -- [ ] **Step 5: 跑测试,确认现有测试无回归** +- [x] **Step 4: 编译验证** — PASS -```bash -go test -race -count=1 ./pkg/storage/... -``` +- [x] **Step 5: 跑测试** — 全 PASS -预期:原有测试 + Task 1 加的测试全 PASS。 +- [x] **Step 6: 提交** -- [ ] **Step 6: 提交** - -```bash -git add pkg/storage/s3.go -git commit -m "fix(storage/s3): uploadTempFile 用 WithoutCancel 解耦 Recorder.Context - -record stop 时 Recorder.Context cancel 会导致正在跑的 S3 upload 被中断, -文件落 pending_uploads/ 后无重试. 用 context.WithoutCancel 保留 ctx Values -但忽略 cancel, 让上传完成. 每次 attempt 的真实超时仍由 WithTimeout 控制." -``` +提交:`f062260c fix(storage/s3): uploadTempFile 用 WithoutCancel 解耦 Recorder.Context` --- @@ -234,49 +112,15 @@ record stop 时 Recorder.Context cancel 会导致正在跑的 S3 upload 被中 - Modify: `pkg/storage/oss.go` - Modify: `pkg/storage/cos.go` -- [ ] **Step 1: OSS — 同 Task 2 模式** - -找 `func (f *OSSFile) uploadTempFile() error {`,在方法开头加: - -```go -func (f *OSSFile) uploadTempFile() error { - // 解耦上传 ctx 与文件 ctx (同 s3.go 注释) - uploadCtx := context.WithoutCancel(f.ctx) - - if err := AcquireUploadSlot(uploadCtx); err != nil { - return fmt.Errorf("acquire upload slot: %w", err) - } - defer ReleaseUploadSlot() - // ... 把所有 f.ctx → uploadCtx -``` - -替换 `UploadWithRetry(f.ctx, ...)` → `UploadWithRetry(uploadCtx, ...)` - -grep 验证: - -```bash -grep -n 'f\.ctx' pkg/storage/oss.go -``` - -非上传路径不动;本 Step 只动 `uploadTempFile` 函数体内的 ctx 使用。 +- [x] **Step 1: OSS** — `uploadCtx := context.WithoutCancel(f.ctx)` 已加,`f.ctx` 已替换 -- [ ] **Step 2: COS — 同上** +- [x] **Step 2: COS** — 同上 -文件 `pkg/storage/cos.go`,同样在 `(f *COSFile) uploadTempFile()` 入口加 `uploadCtx := context.WithoutCancel(f.ctx)`,替换函数体内 `f.ctx` → `uploadCtx`。 +- [x] **Step 3: 编译 + 测试** — 全 PASS -- [ ] **Step 3: 编译 + 测试** +- [x] **Step 4: 提交** -```bash -go build ./pkg/storage/... -go test -race -count=1 ./pkg/storage/... -``` - -- [ ] **Step 4: 提交** - -```bash -git add pkg/storage/oss.go pkg/storage/cos.go -git commit -m "fix(storage/oss,cos): uploadTempFile 用 WithoutCancel 解耦父 ctx (同 s3)" -``` +提交:`b5cb6622 fix(storage/oss,cos): uploadTempFile 用 WithoutCancel 解耦父 ctx (同 s3)` --- @@ -293,8 +137,6 @@ git commit -m "fix(storage/oss,cos): uploadTempFile 用 WithoutCancel 解耦父 - [ ] **Step 2: 部署到 130** -参照本 session 已建好的部署流程: - ```bash ssh root@172.16.12.130 \ 'cd /home/project/xde-uat/media-docker-compose && \ @@ -305,114 +147,56 @@ ssh root@172.16.12.130 \ - [ ] **Step 3: 用 API 加 31 路 pull proxy → 等就绪 → 启录制 (5 min) → 停录制** -复用 session 已建好的脚本流程。 - -- [ ] **Step 4: 关键验收指标(quantitative)** +- [ ] **Step 4: 关键验收指标** ```bash ssh root@172.16.12.130 ' echo "=== pending_uploads 数量(应为 0) ===" docker exec xde-monibuca sh -c "ls /monibuca/pending_uploads/*.mp4 2>/dev/null | wc -l" -echo "" -echo "=== docker log 中 upload 成功事件 ===" -docker logs xde-monibuca --since 10m 2>&1 | grep -iE "upload.*success|upload completed|S3.*200" | tail -10 - -echo "" -echo "=== docker log 中 cancel 事件(应消失) ===" +echo "=== cancel 事件(应消失) ===" docker logs xde-monibuca --since 10m 2>&1 | grep -iE "RequestCanceled|context canceled" | tail -5 ' ``` -**修复前对比(已知 baseline)**: -- pending_uploads: **31** -- cancel 事件: **每路 1 条** = 31 条 - -**修复后预期**: -- pending_uploads: **0**(或 <5 — 个别真实失败可容忍) -- cancel 事件: **0**(与 Recorder context 相关的 cancel 应不再出现) +**修复前 baseline**:pending_uploads=31,cancel 事件=31 条 +**修复后预期**:pending_uploads=0,cancel 事件=0 - [ ] **Step 5: 验录制完整性** ```bash -# 直接看 MinIO 上是不是有 31 个 mp4 mc ls --recursive xiding-uat/vidu-media-bucket/live/ | wc -l # 预期: 31 ``` -然后用 ffprobe 校验 31 路(复用 session 流程),预期 PASS=31。 - - [ ] **Step 6: 清理 31 个临时 pull proxy** -复用 session 流程。 - --- -## Task 5: 验收 + 给限流 plan 解锁 - -- [ ] **Step 1: 单元测试全过** - -```bash -go test -race -count=1 ./pkg/storage/... ./plugin/mp4/... -``` +## Task 5: 验收 -- [ ] **Step 2: 代码 grep 自检** +- [x] **Step 1: 单元测试全过** ```bash -echo "=== 三个后端都已加 uploadCtx ===" -grep -nE 'uploadCtx[ ,]|context\.WithoutCancel' pkg/storage/s3.go pkg/storage/oss.go pkg/storage/cos.go - -echo "" -echo "=== 三个后端的 uploadTempFile 内部没有裸 w.ctx / f.ctx ===" -for f in pkg/storage/s3.go pkg/storage/oss.go pkg/storage/cos.go; do - echo "--- $f ---" - awk '/^func.*uploadTempFile/,/^}/' "$f" | grep -nE 'w\.ctx|f\.ctx' -done +go test -race -count=1 ./pkg/storage/... ``` -预期: -- 每个文件 1 处 `WithoutCancel` -- 三个 uploadTempFile 函数体内 0 处裸的 `w.ctx` / `f.ctx` - -- [ ] **Step 3: 130 真实跑过 pending_uploads=0** - -Task 4 已验。 +结果:PASS(含 `upload_detach_ctx_test.go` 3 个新测试) -- [ ] **Step 4: 解锁限流 plan** +- [x] **Step 2: 代码 grep 自检** -修复完成后,`docs/superpowers/plans/2026-05-15-trailer-flush-rate-limit.md` 里 Task 2 Step 4 的 `t.` 字段可以**安全使用** `t.Context` 了 — 因为即使 task ctx cancel,slot acquire 失败只是 skip trailer write,但**已写好的文件仍能上传**(本 plan fix)。 +三个后端 `uploadTempFile` 内均有 `context.WithoutCancel`,无裸 `w.ctx` / `f.ctx`。 -或者更稳:限流 plan 里也用 `context.Background()` 给 slot acquire,与本修复独立。 +- [ ] **Step 3: 130 真实跑过 pending_uploads=0** — 待 Task 4 执行 -- [ ] **Step 5: PR 关联** +- [x] **Step 4: 解锁限流 plan** -在本 PR 描述里写: -- Fixes: record stop 导致 upload cancel 的 bug(session 实测 31/31 全进 pending_uploads) -- 影响:解决数据无人重传的事实丢失 -- 解锁:`trailer-flush-rate-limit` plan 可以安全推进 +本修复完成后,`trailer-flush-rate-limit` plan 的 Task 2 可以安全推进(即使 task ctx cancel,已写好的文件仍能上传)。注:限流 plan Task 2 因 event loop 单线程问题已回滚,见该 plan 文档。 --- -## Self-Review 记录 - -**Spec coverage**: -- "Recorder stop 时 upload 被 cancel" → Task 1 复现 + Task 2/3 修 ✓ -- "三个后端都修" → Task 2 (S3) + Task 3 (OSS, COS) ✓ -- "限流 plan 解锁" → Task 5 step 4 明示 ✓ - -**Placeholder scan**: -- 无 TBD / TODO -- Task 4 镜像 tag `` 是部署时动态填入,非真 placeholder - -**Type consistency**: -- `uploadCtx := context.WithoutCancel(w.ctx/f.ctx)` — 三个后端用相同模式 ✓ -- 未引入新类型 / 接口 ✓ - -**风险**: -- `context.WithoutCancel` 保留 Values 但不传递 cancel — 这是 Go 1.21 引入的,仓库 Go 1.26 已支持 -- Per-attempt 超时(`context.WithTimeout(uploadCtx, timeout)`)仍然有效,不会无限挂 -- 如果 monibuca server 整体退出,Server-level ctx 可能也需要传到上传层让上传能被停掉。目前 plan 不处理这个 — 上传由 retry 机制 + timeout 自然终结。若需 server 级 cancel,加 `cluster/server-level ctx merge`,但**那是另一个 issue** +## 遗留 Issue -**与原 spec 的弱化**: -- 不修 `pending_uploads/` 无自动重试机制(独立 issue;本修复让正常路径不进 pending_uploads,问题减轻 90%+) -- 不动 Local backend(无 ctx 依赖问题) +1. **130 端到端验证**:Task 4 尚未执行,需部署新镜像后验证 pending_uploads=0 +2. **pending_uploads 自动重试**:若仍有少量文件进 pending_uploads(真实网络失败),需独立实现定时补传机制 +3. **Local backend**:无 ctx 依赖问题,不需修改 diff --git a/docs/superpowers/plans/2026-05-15-trailer-flush-rate-limit.md b/docs/superpowers/plans/2026-05-15-trailer-flush-rate-limit.md index 86a1221f..14b3a6ba 100644 --- a/docs/superpowers/plans/2026-05-15-trailer-flush-rate-limit.md +++ b/docs/superpowers/plans/2026-05-15-trailer-flush-rate-limit.md @@ -1,667 +1,133 @@ # MP4 Trailer Flush 限流 实施计划 -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **状态:已完成(部分回滚)** — 2026-05-15 合并到 develop(`be9915a1`) +> +> Task 1(storage API)已合并并保留。Task 2(mp4 plugin 调用)经三次迭代后回滚,原因见下方「执行结果」节。 -**Goal:** 在录制插件的 trailer flush 流程加并发槽位限制,**把 record stop 时磁盘写带宽峰值控制在 300 MB/s 以内**(实测无限流时峰值 1.1 GB/s ≈ SSD 顺序写上限)。 +**Goal:** 在录制插件的 trailer flush 流程加并发槽位限制,把 record stop 时磁盘写带宽峰值控制在 300 MB/s 以内(实测无限流时峰值 1.1 GB/s ≈ SSD 顺序写上限)。 -**目标值推导:** 31 路并发产生 1126 MB/s → 期望 300 MB/s → 槽位数 = 31 × 300/1126 ≈ **8**。默认 `MaxConcurrentTrailerWrites=8`,按硬件可调(NVMe Gen4 放宽 16-32,HDD 收紧 2-4)。 +**目标值推导(原始):** 31 路并发产生 1126 MB/s → 期望 300 MB/s → 槽位数 = 31 × 300/1126 ≈ **8**。默认 `MaxConcurrentTrailerWrites=8`。 -**Architecture:** 在 `pkg/storage` 加一个 `trailerSem` 信号量,配置字段加在 `UploadConfig` 里(与 `MaxConcurrentUploads` 并列),通过 `InitUploadManager` 一次性初始化。**配置归 storage**,因为 trailer 是磁盘 IO 共享关切:mp4/flv/任何录制 plugin 写盘都过 storage 层,配在 mp4 plugin 会造成 flv 也得复制一份。`writeTrailerTask.Start()` 入口 acquire 槽位,`Dispose()` 出口 release。不动 task framework。 +**Architecture:** 在 `pkg/storage` 加一个 `trailerSem` 信号量,配置字段加在 `UploadConfig` 里(与 `MaxConcurrentUploads` 并列),通过 `InitUploadManager` 一次性初始化。**配置归 storage**,因为 trailer 是磁盘 IO 共享关切:mp4/flv/任何录制 plugin 写盘都过 storage 层。 **Tech Stack:** Go channel-based semaphore,沿用 `pkg/storage/upload_manager.go` 既有 `uploadSem` 模式。 -**问题背景:** -- 实测 31 路 5/10/15 min 录制都正常完成,但 `record stop` 那 1-2 秒磁盘写带宽飙到 1126 MB/s ≈ SSD 物理上限 1.1 GB/s -- 原因:`plugin/mp4/pkg/record.go` 的 `writeTrailerQueueTask.AddTask(t)` 把 31 个 trailer task 并发拉起,每个都做 moov 重排 + bufio flush;当用户调 stop API 卸 31 路时所有 31 个 trailer 同时跑 -- 上传层已经有 4-slot 限制(`AcquireUploadSlot`),但 trailer 写盘**没有限制** — 是真正的瓶颈 - -**修复范围:** 仅加 trailer 槽位 + 给 `writeTrailerTask` 上手铐。 -- **不修** record stop 时的 context cancel bug(独立 issue,本 plan Task 4 验证会看到文件仍进 pending_uploads,需 mc cp 救回) -- **不动 flv plugin**:本 plan 只覆盖 mp4 plugin。flv 若有同样问题,可参照 Task 2 模式接同一个 `AcquireTrailerSlot` - -**Spec:** 见本文件首段 + Task 5 验收节。无独立 spec 文件。 - --- -## 文件结构 +## ⚠️ 根因分析修正(执行后发现) -``` -pkg/storage/ -├── upload_manager.go [改] UploadConfig 加 MaxConcurrentTrailerWrites 字段 -│ 加 trailerSem 全局变量 + InitUploadManager 初始化 -│ 加 AcquireTrailerSlot / ReleaseTrailerSlot / Getter -└── upload_manager_test.go [新] 4 个信号量行为单测 +**原始假设(错误):** +> `writeTrailerQueueTask.AddTask(t)` 把 31 个 trailer task 并发拉起,每个都做 moov 重排 + bufio flush -plugin/mp4/pkg/ -└── record.go [改] writeTrailerTask 加 slotAcquired 字段 - Start() 入口 acquire / Dispose() 出口 release +**实际情况:** +`writeTrailerQueueTask` 是全局单例 `task.Work`,其 event loop 是**单线程**的(`event_loop.go:67` 只启动一个 goroutine)。`task.start()` 在 event loop goroutine 中同步调用 `Run()`(`task.go:387`),`Run()` 阻塞期间 event loop 无法处理下一个 task。因此 31 个 trailer task 是**严格串行**的,不存在并发 burst。 -example/.yaml [改] mc 示例添加 maxconcurrenttrailerwrites 注释 +**1126 MB/s 的真正来源:** +单个 trailer 的 `bufio.Flush()` 瞬间打满 SSD 顺序写速度(~1 GB/s)。这是单个 ~300 MB 文件在 <0.3 秒内写完的物理特性,不是多路并发叠加。 -CLAUDE.md [改] 加该配置项说明(若 CLAUDE.md 已有 storage 配置节) +**信号量方案的局限:** +由于 event loop 单线程,`activeTrailerWrites` 永远不超过 1,信号量永远不会触发限流。在 `Run()` 内 acquire slot 时若 slot 满会阻塞 event loop,理论上存在死锁风险(实际因 slot 永远不满而不触发)。 -docs/superpowers/plans/ -└── 2026-05-15-trailer-flush-rate-limit.md [新] 本文件 -``` +**真正需要的方案(未实现,见 Task 6):** +限速 Writer(rate-limited io.Writer),控制单个 trailer 的写盘速率,而非限制并发数。 --- -## Task 1: pkg/storage 加 trailerSem + UploadConfig 字段 - -**Files:** -- Modify: `pkg/storage/upload_manager.go` -- Create: `pkg/storage/upload_manager_test.go` - -- [ ] **Step 1: 写失败测试** +## 执行结果 -文件 `pkg/storage/upload_manager_test.go`: +### Task 1: pkg/storage 加 trailerSem API ✅ 已完成 -```go -package storage - -import ( - "context" - "sync" - "sync/atomic" - "testing" - "time" -) - -// 重新初始化以避免测试间状态污染(map shared 全局变量) -func initForTest(t *testing.T, maxTrailer int) { - t.Helper() - InitUploadManager(UploadConfig{ - MaxConcurrentUploads: 4, - MaxConcurrentTrailerWrites: maxTrailer, - PendingDir: t.TempDir(), - }) -} - -func TestTrailerSlotConcurrencyLimit(t *testing.T) { - initForTest(t, 2) - - var active int32 - var maxObserved int32 - var wg sync.WaitGroup - hold := 100 * time.Millisecond - - for i := 0; i < 6; i++ { - wg.Add(1) - go func() { - defer wg.Done() - if err := AcquireTrailerSlot(context.Background()); err != nil { - t.Errorf("AcquireTrailerSlot: %v", err) - return - } - defer ReleaseTrailerSlot() - cur := atomic.AddInt32(&active, 1) - for { - prev := atomic.LoadInt32(&maxObserved) - if cur <= prev || atomic.CompareAndSwapInt32(&maxObserved, prev, cur) { - break - } - } - time.Sleep(hold) - atomic.AddInt32(&active, -1) - }() - } - wg.Wait() - - if maxObserved > 2 { - t.Fatalf("max concurrent trailers should be ≤ 2, got %d", maxObserved) - } - if got := GetActiveTrailerWrites(); got != 0 { - t.Fatalf("active should be 0 after all done, got %d", got) - } - if got := GetMaxConcurrentTrailerWrites(); got != 2 { - t.Fatalf("GetMaxConcurrentTrailerWrites should be 2, got %d", got) - } -} - -func TestTrailerSlotContextCancel(t *testing.T) { - initForTest(t, 1) - if err := AcquireTrailerSlot(context.Background()); err != nil { - t.Fatalf("first acquire should succeed: %v", err) - } - defer ReleaseTrailerSlot() - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) - defer cancel() - if err := AcquireTrailerSlot(ctx); err == nil { - t.Fatal("second acquire should fail when ctx cancels") - } -} - -func TestTrailerSlotDefaultsTo8(t *testing.T) { - // MaxConcurrentTrailerWrites 留 0 时, 应取 default tag 值 8 - InitUploadManager(UploadConfig{ - MaxConcurrentUploads: 4, - PendingDir: t.TempDir(), - }) - if got := GetMaxConcurrentTrailerWrites(); got != 8 { - t.Fatalf("zero/unset should default to 8, got %d", got) - } -} - -func TestTrailerSlotIndependentFromUploadSem(t *testing.T) { - // 验 trailer sem 不影响 upload sem (各占 4 / 2 槽) - InitUploadManager(UploadConfig{ - MaxConcurrentUploads: 4, - MaxConcurrentTrailerWrites: 2, - PendingDir: t.TempDir(), - }) - if err := AcquireTrailerSlot(context.Background()); err != nil { - t.Fatal(err) - } - defer ReleaseTrailerSlot() - // upload 应仍可正常 acquire (sem 独立) - if err := AcquireUploadSlot(context.Background()); err != nil { - t.Fatal("upload sem should be independent:", err) - } - ReleaseUploadSlot() - if got := GetActiveTrailerWrites(); got != 1 { - t.Fatalf("trailer active should be 1, got %d", got) - } -} -``` - -- [ ] **Step 2: 跑测试,确认失败** - -```bash -go test -run 'TestTrailerSlot' ./pkg/storage/... -``` +提交:`c88be04b feat(storage): 加 trailerSem 限制 record stop 时 trailer 并发写盘` -预期:`undefined: AcquireTrailerSlot` / `unknown field MaxConcurrentTrailerWrites in struct literal of type UploadConfig`。 +**实际文件:** +- `pkg/storage/upload_manager.go`:已加 `trailerSem`、`MaxConcurrentTrailerWrites` 字段、`AcquireTrailerSlot / ReleaseTrailerSlot / GetActiveTrailerWrites / GetMaxConcurrentTrailerWrites` +- `pkg/storage/trailer_slot_test.go`:4 个测试全 PASS(文件名与计划略有差异,内容等价) -- [ ] **Step 3: 在 `pkg/storage/upload_manager.go` 加实现** +**保留理由:** API 完整可用,未来若 trailer queue 改为 worker-pool 并发模式,可直接激活。`UploadConfig.MaxConcurrentTrailerWrites` 字段标注为 `[预留]`。 -3a. `import` 区无需变动(`context`、`log`、`sync/atomic` 都已存在)。 - -3b. 文件顶部 `var (...)` 块,加在现有 `uploadSem` 之后: - -```go - // trailerSem 限制并发 trailer 写盘槽位数,避免多路 record stop 时同时 - // moov-rewrite + bufio flush 把磁盘吃满。与 uploadSem 解耦: - // - trailerSem 控磁盘 IO(mp4/flv 等录制 plugin 共用) - // - uploadSem 控网络上传(s3/cos/oss 后端) - trailerSem chan struct{} - activeTrailerWrites int32 - maxConcurrentTrailers int -``` +### Task 2: writeTrailerTask 用 trailer slot ❌ 回滚 -3c. `UploadConfig` 加一个字段: +三次迭代历史: -```go -type UploadConfig struct { - MaxConcurrentUploads int `desc:"最大并发上传数" default:"4"` - MaxConcurrentTrailerWrites int `desc:"最大并发 trailer 写盘槽位数, 控制 record stop 时磁盘 IO burst (目标 ~300 MB/s SSD)" default:"8"` - PendingDir string `desc:"上传失败文件暂存目录" default:"pending_uploads"` -} -``` - -3d. `InitUploadManager` 函数体里,紧跟 `uploadSem` 初始化之后加: - -```go - if cfg.MaxConcurrentTrailerWrites <= 0 { - cfg.MaxConcurrentTrailerWrites = 8 - } - maxConcurrentTrailers = cfg.MaxConcurrentTrailerWrites - trailerSem = make(chan struct{}, cfg.MaxConcurrentTrailerWrites) -``` - -并把 `log.Printf` 行改成: - -```go - log.Printf("[storage] upload manager initialized: maxConcurrent=%d, maxTrailer=%d, pendingDir=%s", - maxConcurrent, maxConcurrentTrailers, pendingDir) -``` - -3e. 文件末尾(`GetPendingDir` 之后)加: - -```go -// AcquireTrailerSlot 获取一个 trailer 写盘槽位,阻塞直到有可用槽位或 ctx 取消。 -// 配对 ReleaseTrailerSlot;调用方通常在 record stop 流程进入 trailer flush 前 acquire, -// 在 task Dispose / Run 末尾 defer Release。 -// trailerSem 未初始化时为 no-op,立即返回 nil(保证测试 / 早期初始化场景安全)。 -func AcquireTrailerSlot(ctx context.Context) error { - if trailerSem == nil { - return nil - } - select { - case trailerSem <- struct{}{}: - atomic.AddInt32(&activeTrailerWrites, 1) - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -// ReleaseTrailerSlot 释放一个 trailer 写盘槽位。 -// 未初始化时 no-op,保证 defer 安全。 -func ReleaseTrailerSlot() { - if trailerSem == nil { - return - } - <-trailerSem - atomic.AddInt32(&activeTrailerWrites, -1) -} - -// GetActiveTrailerWrites 当前活跃 trailer 写盘数 -func GetActiveTrailerWrites() int32 { - return atomic.LoadInt32(&activeTrailerWrites) -} - -// GetMaxConcurrentTrailerWrites 当前 trailer slot 上限 -func GetMaxConcurrentTrailerWrites() int { - return maxConcurrentTrailers -} -``` - -- [ ] **Step 4: 跑测试,确认 4 个 case 全 PASS** - -```bash -go test -run 'TestTrailerSlot' -race ./pkg/storage/... -``` +| 提交 | 内容 | 结果 | +|------|------|------| +| `6ea556dc` | `Start()` acquire / `Dispose()` release | 问题:slot 覆盖整个 task 生命周期含 S3 upload,MinIO 503 时 upload 重试 21 分钟占着 slot,后续 trailer 全部串行等待 | +| `c46c7255` | slot 移到 `Run()` 内,磁盘 IO 阶段持有,`t.file.Close()` 前显式 release | 实测 131 路 5min disk peak: 1126 MB/s → 211 MB/s(-81%);但数据可信度存疑(见下) | +| `98972ab9` | 回滚 mp4 改动 | 调研确认 event loop 单线程,slot 无效,回滚 | -预期:`PASS: TestTrailerSlotConcurrencyLimit / TestTrailerSlotContextCancel / TestTrailerSlotDefaultsTo8 / TestTrailerSlotIndependentFromUploadSem`。 +**`c46c7255` 的 211 MB/s 数据说明:** +由于 event loop 单线程,`AcquireTrailerSlot` 在 `Run()` 内永远立即返回(slot 永远不满),对磁盘 IO 速率没有实际影响。211 MB/s 的数据可能来自测试环境差异或测量方法,不应作为验收基准。 -- [ ] **Step 5: 跑现有 storage 测试,确认无回归** +### Task 3: 配置示例文档 ✅ 已完成 -```bash -go test ./pkg/storage/... -count=1 -``` +提交:`90a8a95b docs(record-test): 加 maxconcurrenttrailerwrites 配置示例注释` -预期:原有测试 + 新加测试全 PASS。 +`example/record-test/config.yaml` 已加注释,措辞已更新为 `[预留]`,说明当前不影响实际行为。 -- [ ] **Step 6: 提交** +### Task 4 & 5: 端到端验收 ⏭️ 跳过 -```bash -git add pkg/storage/upload_manager.go pkg/storage/upload_manager_test.go -git commit -m "feat(storage): 加 trailerSem 限制 record stop 时 trailer 并发写盘" -``` +Task 2 回滚后,磁盘 burst 问题未解决,端到端验收无意义。 --- -## Task 2: writeTrailerTask 用 trailer slot - -**Files:** -- Modify: `plugin/mp4/pkg/record.go` -- Create: `plugin/mp4/pkg/record_trailer_slot_test.go` - -- [ ] **Step 1: 先 verify task framework 的 context 字段名** - -monibuca 的 `task.Task` 嵌入字段中 context 的访问方式不确定(`t.Context` / `t.Ctx` / `t.GetContext()`)。验: - -```bash -GOTASK_PATH=$(go list -m -f '{{.Dir}}' github.com/langhuihui/gotask) -grep -nE 'Context\s|Ctx\s|func.*Context\(\)' "$GOTASK_PATH/task.go" | head -10 -``` - -或者更稳:从 `plugin/mp4/pkg/record.go` 既有代码里找 — 任何已经访问 ctx 的地方就是答案: - -```bash -grep -n 'task.Context\|t.Context\|task.Ctx\|t.Ctx\|GetContext' plugin/mp4/pkg/record.go | head -5 -``` - -后续 Step 3 代码里的 `t.Context` 用实际字段名替换。**Step 1 不验过不要往下走**。 - -- [ ] **Step 2: 看现有 writeTrailerTask 结构** - -```bash -sed -n '25,55p' plugin/mp4/pkg/record.go -``` +## 文件结构(实际) -预期看到 struct 定义、接收器名 `task`(即 `func (task *writeTrailerTask) Start(...)`,与 import 包 `task` 同名遮蔽)。 - -- [ ] **Step 3: 修改 struct 加 slotAcquired,改接收器为 `t`** - -替换原 struct 定义: - -```go -type writeTrailerTask struct { - task.Task - muxer *Muxer - file storage.File - filePath string - durationMs uint32 // 录像时长(毫秒),用于上传 S3 元数据 - streamPath string // 关联流路径(用于失败追踪) - storageKey string // 存储类型 key(s3/oss/cos/local) - db *gorm.DB // 数据库连接(用于保存失败记录) - dbWrite func(tailJob task.IJob) - - // slotAcquired 标志当前任务是否持有 trailer slot,用于 Dispose 时安全释放 - slotAcquired bool -} ``` +pkg/storage/ +├── upload_manager.go [已改] UploadConfig 加 MaxConcurrentTrailerWrites [预留]字段 +│ trailerSem 全局变量 + InitUploadManager 初始化 +│ AcquireTrailerSlot / ReleaseTrailerSlot / Getter +└── trailer_slot_test.go [已建] 4 个信号量行为单测(全 PASS) -- [ ] **Step 4: 改 Start() 入口加 acquire,改接收器** - -把现有 `func (task *writeTrailerTask) Start() (err error) {` 整方法替换成(**注意:Step 1 验过的真实 context 字段名替换 ``**): - -```go -func (t *writeTrailerTask) Start() (err error) { - // 阻塞获取 trailer slot, 限多路并发 stop 时磁盘 IO burst - if err = storage.AcquireTrailerSlot(t.); err != nil { - t.Warn("acquire trailer slot canceled", "err", err) - return - } - t.slotAcquired = true - t.Info("write trailer start", - "active", storage.GetActiveTrailerWrites(), - "max", storage.GetMaxConcurrentTrailerWrites()) - if err = t.muxer.WriteTrailer(t.file); err != nil { - t.Error("write trailer", "err", err) - if t.file != nil { - t.file.Close() - t.file = nil - } - } - return -} -``` - -- [ ] **Step 5: 把 `Run()` 里所有 `task.xxx` 引用改成 `t.xxx`** - -原代码 `func (t *writeTrailerTask) Run() (err error)` 接收器已经叫 `t`(行 65),里面用的是 `t.muxer / t.file / t.Info` 等。**对比 Run 与 Start 现状:Run 用 `t`,Start 用 `task`**。Step 4 已统一接收器名为 `t`,所以 Run 那部分不用动。 - -但 grep 验一下不一致: - -```bash -grep -nE 'func \(task \*writeTrailerTask\)' plugin/mp4/pkg/record.go -``` - -预期:0 行(Step 4 已经替换)。如果还有别的方法接收器叫 `task`(例如 `Dispose`、`OnStart` 等),同步改名。 - -- [ ] **Step 6: 加 Dispose() 释放槽位** - -在 writeTrailerTask 现有方法之后追加: - -```go -// Dispose 在任务结束(成功/失败/取消)时被 framework 调用. 统一释放 trailer 槽位. -// slotAcquired guard 防止重复释放 (framework 是否多次调 Dispose 取决于版本). -func (t *writeTrailerTask) Dispose() { - if t.slotAcquired { - storage.ReleaseTrailerSlot() - t.slotAcquired = false - } -} -``` - -- [ ] **Step 7: 确认 import 含 `m7s.live/v5/pkg/storage`** - -```bash -head -25 plugin/mp4/pkg/record.go | grep '"m7s.live/v5/pkg/storage"' -``` - -预期已存在(既有代码用了 `storage.File`)。若缺则补。 - -- [ ] **Step 8: 编译验证** - -```bash -go build ./plugin/mp4/... -``` - -预期:通过。 - -- [ ] **Step 9: 写并发 sanity 单测** - -文件 `plugin/mp4/pkg/record_trailer_slot_test.go`(**注意包名 `package mp4`**,不是 `package pkg`): - -```go -package mp4 - -import ( - "context" - "sync" - "sync/atomic" - "testing" - "time" - - "m7s.live/v5/pkg/storage" -) - -func TestRecordTrailerSlotSerialization(t *testing.T) { - storage.InitUploadManager(storage.UploadConfig{ - MaxConcurrentUploads: 4, - MaxConcurrentTrailerWrites: 2, - PendingDir: t.TempDir(), - }) - - var active int32 - var maxObserved int32 - var wg sync.WaitGroup - - for i := 0; i < 6; i++ { - wg.Add(1) - go func() { - defer wg.Done() - if err := storage.AcquireTrailerSlot(context.Background()); err != nil { - t.Errorf("Acquire: %v", err) - return - } - defer storage.ReleaseTrailerSlot() - cur := atomic.AddInt32(&active, 1) - for { - prev := atomic.LoadInt32(&maxObserved) - if cur <= prev || atomic.CompareAndSwapInt32(&maxObserved, prev, cur) { - break - } - } - time.Sleep(50 * time.Millisecond) - atomic.AddInt32(&active, -1) - }() - } - wg.Wait() - if maxObserved > 2 { - t.Fatalf("max active should be ≤ 2, got %d", maxObserved) - } -} -``` - -- [ ] **Step 10: 跑测试** - -```bash -go test -race -run 'TestRecordTrailerSlot' ./plugin/mp4/pkg/... -``` - -预期:PASS。 - -- [ ] **Step 11: 跑 mp4 全测试无回归** - -```bash -go test ./plugin/mp4/... -count=1 -``` - -预期:原有 + 新加全 PASS。 - -- [ ] **Step 12: 提交** - -```bash -git add plugin/mp4/pkg/record.go plugin/mp4/pkg/record_trailer_slot_test.go -git commit -m "feat(mp4): writeTrailerTask 用 trailer 槽位限并发 (Start acquire / Dispose release)" -``` - ---- - -## Task 3: 配置示例文档 - -**Files:** -- Modify: 1 个有 `global.storage` 段的示例 yaml -- Modify: `CLAUDE.md`(若已有 storage 配置节) - -- [ ] **Step 1: 找带 `storage:` 块的示例配置** - -```bash -grep -l '^ storage:' example/**/*.yaml 2>/dev/null -# 已知 example/record-test/config.yaml + example/cluster/config{1,2,3}.yaml 有 -``` - -- [ ] **Step 2: 在 `example/record-test/config.yaml` 的 `global.storage:` 块下加注释(不动其它示例)** - -找到 `storage:` 这一行,在 `s3:` 子段**之前**插入: - -```yaml - storage: - # === 并发限流(默认值即可,按硬件可调) === - # maxconcurrentuploads: 4 # 并发上传 S3/COS/OSS 槽位 (默认 4) - # maxconcurrenttrailerwrites: 8 # 并发 trailer 写盘槽位 (默认 8, 目标 ~300 MB/s SSD) - # 多路 record stop 时所有录制插件 (mp4/flv) 共用该槽位 - # NVMe Gen4 可调 16-32, HDD 设 2-4. 0/负 = 不限流 - s3: - region: "us-east-1" - # ... 原内容 -``` - -- [ ] **Step 3: 看 CLAUDE.md 是否有 storage 配置节** - -```bash -grep -n "MaxConcurrentUploads\|storage 配置\|存储配置\|pkg/storage" CLAUDE.md | head -5 -``` - -- [ ] **Step 4: 若有, 在 `MaxConcurrentUploads` 附近补一行** - -```markdown -- `MaxConcurrentTrailerWrites`:限制 record stop 时 trailer flush 并发写盘数(默认 8)。多路 stop 时所有录制 plugin (mp4/flv) 共用该槽位,目标控制 SSD 磁盘 burst ~300 MB/s。换硬件按 `(目标带宽 MB/s) × 实测并发数 / 实测峰值带宽` 反推;本仓 SSD 实测 31 并发 → 1126 MB/s,故 8 ≈ 290 MB/s。 -``` - -如 CLAUDE.md 没这一节,跳过 Step 4。 - -- [ ] **Step 5: 提交** +plugin/mp4/pkg/ +└── record.go [未改] 回滚到 v5.1.6 baseline,无 slot 调用 -```bash -git add example/record-test/config.yaml CLAUDE.md -git commit -m "docs(storage): MaxConcurrentTrailerWrites 配置项说明" +example/record-test/config.yaml [已改] 加 maxconcurrenttrailerwrites [预留] 注释 ``` --- -## Task 4: 端到端冒烟(手动跑) +## Task 6: 真正解决磁盘 burst(待实现) -**Files:** 仅操作步骤,无代码改动 +**问题:** 单个 trailer 的 `bufio.Flush()` 瞬间打满 SSD(~1 GB/s),是物理写盘速率问题,不是并发问题。 -- [ ] **Step 1: 出新镜像** +**可行方案:** -```bash -./build_tag.sh # 假设产生 v5.2.2.YYMMDDHHMM -./build_docker.sh v5.2.2. -``` +### 方案 A:限速 Writer(推荐) -或在测试机直接 `go build -tags sqlite,s3` 出二进制。 +在 `writeTrailerTask.Run()` 的 `bufio.NewWriterSize(temp, 1<<20)` 外包一个 rate-limited writer: -- [ ] **Step 2: 部署到 130 测试环境** - -```bash -ssh root@172.16.12.130 \ - 'cd /home/project/xde-uat/media-docker-compose && \ - sed -i "s|swr.cn-east-3.myhuaweicloud.com/intetech/monibuca:.*|swr.cn-east-3.myhuaweicloud.com/intetech/monibuca:|" docker-compose-xde-monibuca.yml && \ - docker login -u -p swr.cn-east-3.myhuaweicloud.com && \ - docker compose -f docker-compose-xde-monibuca.yml pull && \ - docker compose -f docker-compose-xde-monibuca.yml up -d --force-recreate' -``` - -Check 日志含 `maxTrailer=8`: +```go +import "golang.org/x/time/rate" -```bash -ssh root@172.16.12.130 'docker logs xde-monibuca 2>&1 | grep maxTrailer' +// 目标:单个 trailer 写盘速率 ≤ 100 MB/s +// 31 路串行时总峰值 ≤ 100 MB/s(远低于 SSD 上限) +limiter := rate.NewLimiter(rate.Limit(100*1024*1024), 1*1024*1024) // 100 MB/s, 1 MB burst +bw := bufio.NewWriterSize(&rateLimitedWriter{w: temp, limiter: limiter}, 1<<20) ``` -- [ ] **Step 3: 跑 31 路 5min 录制对照(复用 session 流程)** +**代价:** 单个 trailer 写盘时间从 ~0.3s 延长到 ~3s(300 MB ÷ 100 MB/s)。31 路串行总时间 ~93s。 -API 加 31 路 pull proxy → 等就绪 → start 录制 (duration=300) → wait → 停。 +**适用场景:** 对磁盘 burst 敏感(共享存储、HDD、RAID 阵列)。 -- [ ] **Step 4: 监控磁盘 burst** +### 方案 B:改 writeTrailerQueueTask 为 worker pool -启动 1/min 监控 (复用 session 现成脚本),重点看 `disk_w_kbps` 在 stop 时的峰值。 +把 `task.Work` 的 event loop 改为多 goroutine 并发处理,这样 `trailerSem` 才有意义。 -**验收标准(量化)**: -- 修复前实测:peak `disk_w_kbps = 1126144` (1126 MB/s × 1 sec) -- 修复后预期:peak `disk_w_kbps < 320 MB/s` (~290 MB/s 持续 ~4 sec) -- 总写入量不变(与录制 + trailer rewrite 数据量同) +**代价:** 需要改 gotask 使用方式或引入自定义 worker pool,改动较大。 -- [ ] **Step 5: 验录制完整性** +**适用场景:** 希望多路 trailer 并发写盘(利用多核 + SSD 并发 IO),同时用 sem 控制总带宽。 -注意:**stop cancel context bug 没修,文件仍会进 pending_uploads**。需手动 mc cp。 +### 方案 C:接受现状 -```bash -ssh root@172.16.12.130 ' - docker exec xde-monibuca ls /monibuca/pending_uploads/*.mp4 | wc -l - # 预期:31 -' -# 然后 mc mirror 救回上传(复用 session 流程) -``` +31 路串行 trailer,每路 ~0.3s,总时间 ~10s,期间磁盘 IO 是脉冲式的(每次 ~1 GB/s 持续 0.3s,间隔 ~0s)。对 NVMe SSD 无实质伤害,只是监控数字难看。 -ffprobe 31 路:预期 PASS=31,时长 300±5 sec。**slot 限流不影响最终文件内容,仅影响写盘节奏**。 +**适用场景:** 磁盘性能充裕,无共享 IO 竞争。 --- -## Task 5: 验收 - -- [ ] **Step 1: 单测覆盖** - -```bash -go test -race -count=1 ./pkg/storage/... ./plugin/mp4/... -``` - -期望全 PASS。 - -- [ ] **Step 2: 函数 / 字段 grep 自检** - -```bash -echo "=== pkg/storage 定义 ===" -grep -n 'AcquireTrailerSlot\|ReleaseTrailerSlot\|GetActiveTrailerWrites\|GetMaxConcurrentTrailerWrites\|MaxConcurrentTrailerWrites' pkg/storage/upload_manager.go - -echo "=== mp4 plugin 使用 ===" -grep -n 'AcquireTrailerSlot\|ReleaseTrailerSlot' plugin/mp4/pkg/record.go -``` - -预期: -- `upload_manager.go`:5 处(4 函数 + 1 config 字段) -- `record.go`:2 处(Start acquire + Dispose release) - -- [ ] **Step 3: 默认值核查** - -```bash -grep -A1 'MaxConcurrentTrailerWrites' pkg/storage/upload_manager.go -``` - -期望看到 `default:"8"` tag。 - -- [ ] **Step 4: 升级后磁盘 burst 实测对比** - -需要 Task 4 真实跑过一次,监控 csv 里 `disk_w_kbps` peak < 320 MB/s。 - -- [ ] **Step 5: 上线前补充 issue 跟踪** - -把 "record stop 时 cancel S3 upload context" bug 单开 issue(本 plan 不修),关联本 PR。 - -把 flv plugin 是否需要同样限流单开评估 issue(mp4 内部 muxer 与 flv 写盘路径不同,需 verify)。 - ---- +## 遗留 Issue -## Self-Review 记录 - -**Spec coverage**: -- "spread trailer flush over 几秒" → Task 1 加信号量;Task 2 应用到 writeTrailerTask;slot=8 时 31 路 trailer 会按 4 批跑(31/8 ≈ 4 批),每批 ~1 秒(盘饱和),共 ~4 秒 spread ✓ -- "控制在 300MB/s" → 信号量 8 × 单 trailer 写盘 ~36 MB/s ≈ 290 MB/s ✓(仍小于 320 MB/s 阈值) -- "配置归 storage" → `UploadConfig.MaxConcurrentTrailerWrites` 在 pkg/storage,mp4/flv 都通过同一槽位 ✓ - -**Placeholder scan**: -- Task 2 Step 4 有 `` 占位 — 这是**有意保留**给 executor 验证后填,已在 Step 1 写明验证命令。非真 placeholder(无法静态填)。 -- 其它任务步骤均有完整代码块。 - -**Type consistency**: -- `AcquireTrailerSlot(ctx context.Context) error` / `ReleaseTrailerSlot()` / `GetActiveTrailerWrites() int32` / `GetMaxConcurrentTrailerWrites() int` — 全 plan 一致 -- `MaxConcurrentTrailerWrites int` 配置字段 — Task 1 Step 3c 定义,Task 1 测试、Task 2 测试、Task 3 yaml 引用一致 -- `slotAcquired bool` struct 字段 — Task 2 Step 3 定义,Step 4 (Start) / Step 6 (Dispose) 引用一致 -- Test 包名:`pkg/storage/upload_manager_test.go` → `package storage`,`plugin/mp4/pkg/record_trailer_slot_test.go` → `package mp4`(与 record.go 同包) - -**与原 spec 的弱化**: -- 不修 stop context cancel bug(明确 out of scope,Task 4 / Task 5 step 5 提醒) -- 不动 flv plugin(同 issue 留 Task 5 step 5 跟踪) -- 用固定信号量而非真令牌桶(更简单,效果等价;token bucket 需引入 `golang.org/x/time/rate` 依赖) - -**Race condition / 并发安全**: -- `trailerSem` 是 channel,本身线程安全 -- `activeTrailerWrites` 用 `atomic.Int32` -- `maxConcurrentTrailers` 仅在 `InitUploadManager` 中赋值,运行时只读 → 不需锁 -- `SetTrailerSlotLimit` 已**移除**(避免运行时重置 sem 时已等待 goroutine 永久挂起的风险),改回 init 一次性配置 +1. **磁盘 burst 未解决**:需要方案 A 或 B(见 Task 6) +2. **flv plugin 评估**:flv 是否有同样的写盘 burst 问题,需独立 verify +3. **record stop cancel bug**:已由 `2026-05-15-storage-upload-detach-recorder-context.md` 修复 From 9a9f23dffca3099d1a79667c7f111b340f8f2140 Mon Sep 17 00:00:00 2001 From: "richard.li" Date: Thu, 21 May 2026 13:00:13 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(storage):=20=E5=8A=A0=20fallocate=20IN?= =?UTF-8?q?SERT=5FRANGE=20=E5=B0=81=E8=A3=85=20+=20RangeInserter=20?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 MP4 trailer 零重写优化 (2026-05-21 plan 阶段 A) 做准备: - rangeinsert.go: RangeInserter 接口 + ErrRangeInsertUnsupported 哨兵错误 - fallocate_linux.go: InsertRange/CollapseRange 封装 unix.Fallocate - fallocate_other.go: 非 Linux stub, 恒返回 unsupported - fallocate_test.go: INSERT/COLLAPSE 行为单测 (//go:build linux) INSERT_RANGE 真实行为依赖 ext4/xfs, darwin 开发环境会因 build tag 跳过 fallocate_test, 留待阶段 A 验收在 Linux 环境跑。 --- pkg/storage/fallocate_linux.go | 35 ++++++++++++++++ pkg/storage/fallocate_other.go | 15 +++++++ pkg/storage/fallocate_test.go | 77 ++++++++++++++++++++++++++++++++++ pkg/storage/rangeinsert.go | 19 +++++++++ 4 files changed, 146 insertions(+) create mode 100644 pkg/storage/fallocate_linux.go create mode 100644 pkg/storage/fallocate_other.go create mode 100644 pkg/storage/fallocate_test.go create mode 100644 pkg/storage/rangeinsert.go diff --git a/pkg/storage/fallocate_linux.go b/pkg/storage/fallocate_linux.go new file mode 100644 index 00000000..225d0946 --- /dev/null +++ b/pkg/storage/fallocate_linux.go @@ -0,0 +1,35 @@ +//go:build linux + +package storage + +import ( + "errors" + "os" + + "golang.org/x/sys/unix" +) + +// InsertRange 在 f 的 offset 处插入 length 字节空间(offset 与 length 均须为 +// 文件系统块大小,通常 4096,的整数倍)。offset 之后的数据逻辑后移、物理 extent +// 不动——这是一个 O(extent 数) 的元数据操作,不重写数据本身。 +// +// 文件系统不支持时(tmpfs / NFS / 非 extent ext4 / 旧 kernel)返回 +// ErrRangeInsertUnsupported,调用方应回退到全量重写路径。 +func InsertRange(f *os.File, offset, length int64) error { + err := unix.Fallocate(int(f.Fd()), unix.FALLOC_FL_INSERT_RANGE, offset, length) + if errors.Is(err, unix.EOPNOTSUPP) || errors.Is(err, unix.ENOSYS) { + return ErrRangeInsertUnsupported + } + return err +} + +// CollapseRange 删除 f 的 [offset, offset+length) 区间(offset 与 length 均须块对齐), +// 其后数据前移。是 InsertRange 的逆操作,用于 INSERT 成功但后续写入失败时把文件 +// 回退到插入前的状态。 +func CollapseRange(f *os.File, offset, length int64) error { + err := unix.Fallocate(int(f.Fd()), unix.FALLOC_FL_COLLAPSE_RANGE, offset, length) + if errors.Is(err, unix.EOPNOTSUPP) || errors.Is(err, unix.ENOSYS) { + return ErrRangeInsertUnsupported + } + return err +} diff --git a/pkg/storage/fallocate_other.go b/pkg/storage/fallocate_other.go new file mode 100644 index 00000000..73bd4663 --- /dev/null +++ b/pkg/storage/fallocate_other.go @@ -0,0 +1,15 @@ +//go:build !linux + +package storage + +import "os" + +// InsertRange 在非 Linux 平台恒返回 ErrRangeInsertUnsupported,调用方回退全量重写。 +func InsertRange(f *os.File, offset, length int64) error { + return ErrRangeInsertUnsupported +} + +// CollapseRange 在非 Linux 平台恒返回 ErrRangeInsertUnsupported。 +func CollapseRange(f *os.File, offset, length int64) error { + return ErrRangeInsertUnsupported +} diff --git a/pkg/storage/fallocate_test.go b/pkg/storage/fallocate_test.go new file mode 100644 index 00000000..4beba603 --- /dev/null +++ b/pkg/storage/fallocate_test.go @@ -0,0 +1,77 @@ +//go:build linux + +package storage + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" +) + +// TestInsertRange 验证 fallocate INSERT_RANGE / COLLAPSE_RANGE 的行为: +// 在文件头插入一个块后原内容整体后移、新空间可写;COLLAPSE 后恢复原状。 +// t.TempDir() 所在文件系统不支持时跳过。 +func TestInsertRange(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "insert.bin") + + // 原始内容:一个块(4096 字节)的 'A' + orig := bytes.Repeat([]byte{'A'}, 4096) + if err := os.WriteFile(p, orig, 0644); err != nil { + t.Fatalf("write orig: %v", err) + } + f, err := os.OpenFile(p, os.O_RDWR, 0644) + if err != nil { + t.Fatalf("open: %v", err) + } + defer f.Close() + + // 在文件头插入一个块 + if err := InsertRange(f, 0, 4096); err != nil { + if errors.Is(err, ErrRangeInsertUnsupported) { + t.Skipf("文件系统不支持 INSERT_RANGE: %v", err) + } + t.Fatalf("InsertRange: %v", err) + } + + // 文件应增长一个块 + if st, _ := f.Stat(); st.Size() != 8192 { + t.Fatalf("size after insert = %d, want 8192", st.Size()) + } + // 原内容应整体后移到 [4096, 8192) + moved := make([]byte, 4096) + if _, err := f.ReadAt(moved, 4096); err != nil { + t.Fatalf("read moved content: %v", err) + } + if !bytes.Equal(moved, orig) { + t.Fatal("original content not preserved after insert") + } + // 新空间 [0, 4096) 可写入并回读 + hdr := bytes.Repeat([]byte{'H'}, 4096) + if _, err := f.WriteAt(hdr, 0); err != nil { + t.Fatalf("write header into hole: %v", err) + } + got := make([]byte, 4096) + if _, err := f.ReadAt(got, 0); err != nil { + t.Fatalf("read header back: %v", err) + } + if !bytes.Equal(got, hdr) { + t.Fatal("header written into inserted range not readable back") + } + + // COLLAPSE_RANGE 撤销插入,文件回到原状 + if err := CollapseRange(f, 0, 4096); err != nil { + t.Fatalf("CollapseRange: %v", err) + } + if st, _ := f.Stat(); st.Size() != 4096 { + t.Fatalf("size after collapse = %d, want 4096", st.Size()) + } + if _, err := f.ReadAt(got, 0); err != nil { + t.Fatalf("read after collapse: %v", err) + } + if !bytes.Equal(got, orig) { + t.Fatal("content not restored after collapse") + } +} diff --git a/pkg/storage/rangeinsert.go b/pkg/storage/rangeinsert.go new file mode 100644 index 00000000..5909b043 --- /dev/null +++ b/pkg/storage/rangeinsert.go @@ -0,0 +1,19 @@ +package storage + +import ( + "errors" + "os" +) + +// ErrRangeInsertUnsupported 表示当前平台或文件系统不支持 fallocate INSERT_RANGE。 +// 调用方收到此错误应回退到全量重写路径。 +var ErrRangeInsertUnsupported = errors.New("storage: range insert unsupported") + +// RangeInserter 是 storage.File 的可选能力:暴露承载数据的底层本地文件句柄, +// 供 MP4 trailer 用 fallocate(INSERT_RANGE) 在文件头就地插入 moov,避免全量重写。 +// 仅本地文件 / 对象存储后端的本地暂存文件可实现。 +type RangeInserter interface { + // LocalFd 返回底层本地文件句柄。句柄所有权仍属 storage.File, + // 调用方可读写 / fallocate / ftruncate,但不得 Close 它。 + LocalFd() *os.File +} From 31c833fcea14e2e3a20c2acc9d77a4df6d1d85dd Mon Sep 17 00:00:00 2001 From: "richard.li" Date: Thu, 21 May 2026 13:04:10 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat(storage/local,s3,oss,cos):=20=E5=9B=9B?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E5=AE=9E=E7=8E=B0=20RangeInserter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocalFile/S3File/OSSFile/COSFile 各加 LocalFd() *os.File, 暴露底层 本地文件句柄 (对象存储后端为上传前的本地暂存文件), 供 MP4 trailer 用 fallocate 原地插 moov。各加编译期断言 var _ RangeInserter。 --- pkg/storage/cos.go | 6 ++++++ pkg/storage/local.go | 6 ++++++ pkg/storage/oss.go | 6 ++++++ pkg/storage/s3.go | 6 ++++++ 4 files changed, 24 insertions(+) diff --git a/pkg/storage/cos.go b/pkg/storage/cos.go index 2b9a77e1..d97dd246 100644 --- a/pkg/storage/cos.go +++ b/pkg/storage/cos.go @@ -448,6 +448,12 @@ func (f *COSFile) downloadToTemp() error { var _ TempFileFinalizer = (*COSFile)(nil) +// LocalFd 返回 COSFile 上传前承载录像数据的本地暂存文件句柄。 +// 实现 storage.RangeInserter,供 MP4 trailer 用 fallocate 原地插 moov。 +func (f *COSFile) LocalFd() *os.File { return f.tempFile } + +var _ RangeInserter = (*COSFile)(nil) + func init() { Factory["cos"] = func(conf any) (Storage, error) { var cosConfig COSStorageConfig diff --git a/pkg/storage/local.go b/pkg/storage/local.go index f000aa61..3730ec8c 100644 --- a/pkg/storage/local.go +++ b/pkg/storage/local.go @@ -634,6 +634,12 @@ type LocalFile struct { // SetMetadata 本地存储无需元数据,提供空实现以满足 File 接口。 func (f *LocalFile) SetMetadata(key, value string) {} +// LocalFd 返回 LocalFile 的底层文件句柄。 +// 实现 storage.RangeInserter,供 MP4 trailer 用 fallocate 原地插 moov。 +func (f *LocalFile) LocalFd() *os.File { return f.File } + +var _ RangeInserter = (*LocalFile)(nil) + // FinalizeFromTemp 用 srcPath 指向的完整文件替换本地目标文件。 // 优先用 os.Rename(同盘移动,零数据写入);跨设备时回退到复制+删除。 // 实现 storage.TempFileFinalizer,供 mp4 trailer 重写消除全量回拷。 diff --git a/pkg/storage/oss.go b/pkg/storage/oss.go index 550125ee..91072445 100644 --- a/pkg/storage/oss.go +++ b/pkg/storage/oss.go @@ -443,6 +443,12 @@ func (f *OSSFile) downloadToTemp() error { var _ TempFileFinalizer = (*OSSFile)(nil) +// LocalFd 返回 OSSFile 上传前承载录像数据的本地暂存文件句柄。 +// 实现 storage.RangeInserter,供 MP4 trailer 用 fallocate 原地插 moov。 +func (f *OSSFile) LocalFd() *os.File { return f.tempFile } + +var _ RangeInserter = (*OSSFile)(nil) + func init() { Factory["oss"] = func(conf any) (Storage, error) { var ossConfig OSSStorageConfig diff --git a/pkg/storage/s3.go b/pkg/storage/s3.go index 31505165..95842acc 100644 --- a/pkg/storage/s3.go +++ b/pkg/storage/s3.go @@ -591,6 +591,12 @@ func (w *S3File) downloadToTemp() error { var _ TempFileFinalizer = (*S3File)(nil) +// LocalFd 返回 S3File 上传前承载录像数据的本地暂存文件句柄。 +// 实现 storage.RangeInserter,供 MP4 trailer 用 fallocate 原地插 moov。 +func (w *S3File) LocalFd() *os.File { return w.tempFile } + +var _ RangeInserter = (*S3File)(nil) + func init() { Factory["s3"] = func(conf any) (Storage, error) { var s3Config S3StorageConfig From d6fb9025d6c7423f6b59a9137681cff0db03a925 Mon Sep 17 00:00:00 2001 From: "richard.li" Date: Thu, 21 May 2026 13:17:34 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat(mp4):=20writeTrailerTask=20=E8=B5=B0?= =?UTF-8?q?=20fallocate=20INSERT=5FRANGE=20=E5=8E=9F=E5=9C=B0=E6=8F=92=20m?= =?UTF-8?q?oov?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit writeTrailerTask.Run() 加 fast path 分派: progressive MP4 优先用 fallocate(INSERT_RANGE) 在文件头就地撑开 moov 空间, mdat 数据零移动, trailer 磁盘写从 O(整个 mdat) 降到 O(moov)。 - runInsertRangeFastPath: 判定标准布局 -> 算 insertLen(4K 对齐) -> shiftSampleOffsets 校正 chunk offset -> INSERT_RANGE -> writeMoovHead 写 [ftyp][moov][free] -> 截掉 Start() 的尾部 moov 兜底副本 -> Close - writeMoovHead: 写头部 + 把暴露的旧 [ftyp][free] 覆盖为 free box - 失败处理: INSERT 后写头失败 -> CollapseRange 撤销, 退回 moov-在-尾 的可播文件; Close/上传失败 -> 登记 pending 补传 - 文件系统不支持 INSERT_RANGE / fMP4 / 非标准布局 -> 回退原全量重写路径 fast path 真实生效依赖 Linux + ext4/xfs, 端到端验证(录制->ffprobe moov-first)归阶段 A 验收 (Task A4, 130 环境)。 --- plugin/mp4/pkg/record.go | 164 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/plugin/mp4/pkg/record.go b/plugin/mp4/pkg/record.go index f12131fb..9169e3c8 100644 --- a/plugin/mp4/pkg/record.go +++ b/plugin/mp4/pkg/record.go @@ -2,6 +2,7 @@ package mp4 import ( "bufio" + "errors" "fmt" "io" "os" @@ -73,6 +74,12 @@ func (t *writeTrailerTask) Run() (err error) { } }() + // 阶段 A:progressive MP4 优先用 fallocate INSERT_RANGE 原地插 moov, + // 把 trailer 磁盘写从 O(mdat 全量) 降到 O(moov)。不支持时回退下方全量重写。 + if handled, e := t.runInsertRangeFastPath(); handled { + return e + } + var temp *os.File temp, err = os.CreateTemp("", "*.mp4") if err != nil { @@ -230,6 +237,163 @@ func (t *writeTrailerTask) Run() (err error) { return } +// shiftSampleOffsets 把所有 track 的 sample 偏移整体加 delta, +// 用于 INSERT_RANGE 把 mdat 逻辑后移后校正 moov 内的 chunk offset。 +func (t *writeTrailerTask) shiftSampleOffsets(delta int64) { + for _, track := range t.muxer.Tracks { + for i := range track.Samplelist { + track.Samplelist[i].Offset += delta + } + } +} + +// writeMoovHead 在 INSERT_RANGE 撑开的 [0,insertLen) 空间写入 [ftyp][moov][free padding], +// 并把插入后暴露在 [insertLen, ...) 的旧 [ftyp][free] 覆盖为一个 free box。 +func (t *writeTrailerTask) writeMoovHead(fd *os.File, ftypBox, moov box.IBox, insertLen, ftypSize, moovSize int64) error { + padPayload := insertLen - ftypSize - moovSize - box.BasicBoxLen + if padPayload < 0 { + return fmt.Errorf("moov head overflow: insertLen=%d ftyp=%d moov=%d", insertLen, ftypSize, moovSize) + } + if _, err := fd.Seek(0, io.SeekStart); err != nil { + return err + } + if _, err := box.WriteTo(fd, ftypBox, moov, box.CreateFreeBox(make([]byte, padPayload))); err != nil { + return err + } + // 旧头部 [ftyp][free](mdatOffset-8 字节,不含 mdat box header)插入后位于 + // [insertLen, ...),覆盖为一个 free box 使 MP4 解析器忽略它。 + oldHeadLen := int64(t.muxer.mdatOffset) - box.BasicBoxLen + if _, err := fd.Seek(insertLen, io.SeekStart); err != nil { + return err + } + if _, err := box.WriteTo(fd, box.CreateBaseBox(box.TypeFREE, uint64(oldHeadLen))); err != nil { + return err + } + return nil +} + +// recoverFastPathFailure 在 INSERT fast path 改写文件后、最终持久化失败时, +// 把本地文件登记到 pending 目录供定时补传。 +func (t *writeTrailerTask) recoverFastPathFailure(localPath string, fileSize int64, cause error) { + if t.db == nil { + return + } + pendingPath, moveErr := storage.MoveToPendingDir(localPath) + if moveErr != nil { + t.Error("move to pending dir failed", "err", moveErr) + return + } + metadata := map[string]string{"video-size-bytes": fmt.Sprintf("%d", fileSize)} + if t.durationMs > 0 { + metadata["video-duration-ms"] = fmt.Sprintf("%d", t.durationMs) + } + m7s.SaveFailedUpload(t.db, pendingPath, t.filePath, t.storageKey, + t.streamPath, fileSize, t.durationMs, metadata, cause) + t.Info("saved failed upload for retry", "pendingPath", pendingPath, "objectKey", t.filePath) +} + +// runInsertRangeFastPath 用 fallocate(INSERT_RANGE) 在文件头就地为 moov 撑开空间, +// 不重写 mdat。handled=false 表示未接管(调用方走全量重写 fallback,状态已复原); +// handled=true 表示已接管,err 为最终结果。 +func (t *writeTrailerTask) runInsertRangeFastPath() (handled bool, err error) { + // 仅标准 progressive MP4 布局 [ftyp32][free8][mdat hdr8] 适用。 + if t.muxer.isFragment() || t.muxer.mdatOffset != 48 || t.muxer.moov == nil { + return false, nil + } + // mdat 触发 64-bit large box 时头部布局非标准,回退。 + if t.muxer.mdatSize+box.BasicBoxLen > 0xFFFFFFFF { + return false, nil + } + inserter, ok := t.file.(storage.RangeInserter) + if !ok { + return false, nil + } + fd := inserter.LocalFd() + if fd == nil { + return false, nil + } + stat, statErr := fd.Stat() + if statErr != nil { + return false, nil + } + preLen := stat.Size() // Start() 后文件长 = mdatOffset + mdatSize + 尾部 moovSize + + ftypBox := t.muxer.CreateFTYPBox() + ftypSize := int64(ftypBox.Size()) + moovSize := int64(t.muxer.moov.Size()) + const blk = 4096 + insertLen := ((ftypSize + moovSize + box.BasicBoxLen + blk - 1) / blk) * blk + + // mdat 逻辑后移 insertLen,重算 moov 内 chunk offset。 + t.shiftSampleOffsets(insertLen) + moov := t.muxer.MakeMoov() + if int64(moov.Size()) != moovSize { + // 偏移跨 4GB 致 stco→co64、moov 变大,insertLen 失准——撤销并回退全量重写。 + t.shiftSampleOffsets(-insertLen) + t.muxer.MakeMoov() + t.Info("insert-range skipped: moov size changed", "filePath", t.filePath) + return false, nil + } + + // —— 此后 INSERT_RANGE 改写底层文件 —— + if e := storage.InsertRange(fd, 0, insertLen); e != nil { + t.shiftSampleOffsets(-insertLen) + t.muxer.MakeMoov() + if !errors.Is(e, storage.ErrRangeInsertUnsupported) { + t.Warn("insert-range failed, falling back to full rewrite", "err", e) + } + return false, nil + } + + localPath := fd.Name() + uploadSize := preLen + insertLen - moovSize // 截掉尾部 moov 后的最终大小 + + if writeErr := t.writeMoovHead(fd, ftypBox, moov, insertLen, ftypSize, moovSize); writeErr != nil { + // 写头失败:撤销插入,退回 moov-在-尾的可播文件再上传。 + t.Error("insert-range write head failed, rolling back", "err", writeErr) + if ce := storage.CollapseRange(fd, 0, insertLen); ce != nil { + t.Error("insert-range rollback failed, file may be corrupted", "err", ce) + t.file.Close() + t.file = nil + err = fmt.Errorf("insert-range write+rollback failed: %w", writeErr) + t.recoverFastPathFailure(localPath, preLen, err) + return true, err + } + t.shiftSampleOffsets(-insertLen) + t.muxer.MakeMoov() + t.Warn("insert-range rolled back, uploading moov-at-tail file", "filePath", t.filePath) + uploadSize = preLen + } else { + // 写头成功:截掉 Start() 留在文件尾的 moov 兜底副本。 + if e := fd.Truncate(uploadSize); e != nil { + t.Error("insert-range truncate tail moov failed (non-fatal)", "err", e) + } + if e := fd.Sync(); e != nil { + t.Error("insert-range sync failed (non-fatal)", "err", e) + } + } + + t.file.SetMetadata("video-size-bytes", fmt.Sprintf("%d", uploadSize)) + if t.durationMs > 0 { + t.file.SetMetadata("video-duration-ms", fmt.Sprintf("%d", t.durationMs)) + } + + // 持久化:对象存储=上传,本地=已就位。 + if e := t.file.Close(); e != nil { + t.Error("insert-range close/upload failed", "err", e, "filePath", t.filePath) + t.file = nil + t.recoverFastPathFailure(localPath, uploadSize, e) + return true, e + } + t.file = nil + if t.dbWrite != nil { + t.dbWrite(&writeTrailerQueueTask) + } + t.Info("insert-range fast path done", + "filePath", t.filePath, "moovBytes", moovSize, "insertedBytes", insertLen) + return true, nil +} + func init() { m7s.Servers.AddTask(&writeTrailerQueueTask) } From 0132f55c8af5f2fb72ce37dd4cd0e144b6050851 Mon Sep 17 00:00:00 2001 From: "richard.li" Date: Thu, 21 May 2026 15:35:31 +0800 Subject: [PATCH 5/6] =?UTF-8?q?docs(plan):=20=E5=8A=A0=20trailer=20?= =?UTF-8?q?=E9=9B=B6=E9=87=8D=E5=86=99=E9=98=B6=E6=AE=B5=20A=20=E7=9A=84?= =?UTF-8?q?=20130=20=E7=AB=AF=E5=88=B0=E7=AB=AF=E9=AA=8C=E6=94=B6=E6=8A=A5?= =?UTF-8?q?=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 31 路 x 15min 真实录制验收: 31/31 走 INSERT_RANGE fast path, trailer 写入从 ≈文件大小 降到 ≈moov 大小 (cam1 48MB->100KB, -99.8%), 产物标准 moov-first MP4 (ffprobe 验证 ftyp->moov->free->mdat), 0 上传失败 / 0 RequestTimeout / record 全入库。 --- ...5-21-trailer-zero-rewrite-stageA-report.md | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-21-trailer-zero-rewrite-stageA-report.md diff --git a/docs/superpowers/plans/2026-05-21-trailer-zero-rewrite-stageA-report.md b/docs/superpowers/plans/2026-05-21-trailer-zero-rewrite-stageA-report.md new file mode 100644 index 00000000..1244256a --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-trailer-zero-rewrite-stageA-report.md @@ -0,0 +1,97 @@ +# Trailer 零重写 阶段 A — 130 端到端验收报告 + +**验收对象**:`2026-05-21-trailer-zero-rewrite.md` plan 阶段 A(`fallocate INSERT_RANGE` 原地插 moov),分支 `feature/trailer-zero-rewrite`(commit `9a9f23df` / `31c833fc` / `d6fb9025`)。 + +**结论:阶段 A 验收通过** ✅。31 路 × 15min 真实录制下,trailer 重写 100% 走 `INSERT_RANGE` fast path,monibuca trailer 写入从「≈整个文件大小」降到「≈moov 大小」(cam1 实测 48MB → 100KB,-99.8%),产物为标准 moov-first MP4,0 失败。 + +--- + +## 1. 验收环境 + +- **目标**:`172.16.12.130` UAT,容器 `xde-monibuca`(`network_mode: host`) +- **镜像**:`swr.cn-east-3.myhuaweicloud.com/intetech/monibuca:v5.2.5.2605211340`(从 `feature/trailer-zero-rewrite` 交叉编译 `example/cluster`,含阶段 A 的 A1-A3) +- **磁盘**:`/dev/sda4` ext4(SSD),系统 / monibuca 录像 / MinIO `/data/minio` / `/tmp` 同分区;`/tmp` 单独挂 tmpfs +- **存储后端**:MinIO 单节点 `172.16.12.130:9000`,桶 `vidu-media-bucket`,直连 IP +- **摄像头**:31 路在线(`192.168.12.x`) +- **测试**:`verify-130.sh run-test`,`STREAM_COUNT=31 RECORD_SECONDS=900`;宿主 `iostat -dx 1 sda` 全程采样 + +## 2. 验收项总览 + +| # | 验收项 | 方法 | 结果 | +|---|---|---|---| +| 1 | `INSERT_RANGE`/`COLLAPSE_RANGE` syscall | `storage.test` 交叉编译到 130 ext4 跑 `TestInsertRange` | **PASS** | +| 2 | `pkg/storage` 单测无回归 | darwin `go test ./pkg/storage/` | **ok** 全 PASS | +| 3 | 31 路录制走 fast path | 日志 `insert-range fast path done` 计数 | **31 / 31** | +| 4 | fast path 异常 | 日志 `skipped`/`rolled back`/`write head failed` | **0** | +| 5 | 上传成功 | 日志 `upload successful` | **31 / 31** | +| 6 | MinIO 锁竞争 | 日志 `RequestTimeout` | **0** | +| 7 | 失败补传 | 日志 `saved failed` + `pending_uploads` | **0** / 0 | +| 8 | record 入库 | `mp4/api/list` 今日 14 点条目 | **31 条,`endTime` 全正常** | +| 9 | 产物 moov-first 可播 | 下载 MinIO 对象 `ffprobe` | **PASS**(见 §3.5) | + +## 3. 关键数据 + +### 3.1 INSERT_RANGE syscall 实证(130 ext4) + +`pkg/storage/fallocate_test.go` 交叉编译为 Linux 二进制,传 130,在 `/root`(`/dev/sda4` ext4)上运行: + +``` +=== RUN TestInsertRange +--- PASS: TestInsertRange (0.00s) +``` + +验证 `fallocate(FALLOC_FL_INSERT_RANGE)` 在文件头插入块后原数据整体后移、新空间可写;`COLLAPSE_RANGE` 可撤销。注:130 的 `/tmp` 为 tmpfs 不支持 `INSERT_RANGE`,测试在 ext4 目录运行才生效(tmpfs 上会正确 SKIP)。 + +### 3.2 31 路 fast path 100% 生效 + +31 路 × 15min 录制(14:03:23 启动,14:18:33 stop),日志统计: + +``` +insert-range fast path done : 31 +insert-range 异常 : 0 +upload successful : 31 +RequestTimeout : 0 +saved failed : 0 +``` + +### 3.3 trailer 写入量塌缩(核心指标) + +fast path 日志 `insertedBytes` = monibuca 在 trailer 阶段往文件写入的**全部字节数**(ftyp + moov + 4K 对齐 padding): + +| 路 | moovBytes | insertedBytes | 对应文件大小 | trailer 写入 / 文件 | +|---|---|---|---|---| +| cam1 | 98 528 | 102 400 (100 KB) | ~48 MB | **0.2%** | +| cam26 | 198 946 | 200 704 (196 KB) | — | — | +| cam18 | 404 044 | 405 504 (396 KB) | — | — | + +对比修复前 fallback 全量重写:trailer 阶段需把整个 `mdat` 复制一遍(≈ 文件大小,百 MB 级)。**fast path 把 monibuca trailer 写入降到「≈moov 大小」,即原来的约 0.2%**。 + +### 3.4 iostat 整盘读数说明(避免误读) + +宿主 `iostat -dx 1 sda` 在 stop 窗口峰值 `wkB/s` ≈ **699 MB/s**。**这不是 trailer 写入** —— `sda` 同时承载 MinIO `/data/minio`,该峰值是 **MinIO 接收 31 路上传(每路几十 MB)落盘**的写入,属上传固有成本,与 trailer 优化无关。 + +monibuca 自身 trailer 写入由 §3.3 的 `insertedBytes`(100-405 KB/路)实证;`docker stats` 报 monibuca 容器 BlockIO 仅 2 MB/s,方向一致(但 host 网络模式下 docker stats 不可靠,不作主证据)。要单独测 trailer 真实磁盘峰值需 monibuca 与 MinIO 分盘,见 plan 风险节。 + +### 3.5 产物 moov-first 验证 + +从 MinIO 下载 cam1 对象 `2026-05-21-14-03-23_291036932.mp4`(50 509 637 B),容器内 `ffprobe`: + +- **box 解析顺序**:`ftyp → moov → free → free → mdat` —— 正是 INSERT fast path 设计布局(moov 紧随 ftyp,旧 `[ftyp][free]` 被 free box 覆盖,`mdat` 在尾且数据零移动) +- 文件头实测:offset 0 = `ftyp`(32B),offset 32 = `moov`(size `0x000180e0` = 98528,与日志 `moovBytes` 一致) +- `ffprobe`:`duration=909.678`,`codec=h264`,`1920x1080`,`format=mov,mp4` —— 完整可播放的标准 moov-first MP4 + +## 4. 结论 + +阶段 A(`fallocate INSERT_RANGE` 原地插 moov)在 130 真实 31 路 × 15min 负载下端到端验收通过: + +- trailer 重写不再复制 `mdat`,monibuca trailer 写入从「≈文件大小」降到「≈moov 大小」(-99.8%) +- `mdat` 数据零移动,产物为标准 progressive moov-first MP4,兼容性无变化 +- 31/31 走 fast path,0 异常、0 上传失败、0 补传、0 `RequestTimeout`,record 全部入库 +- fallback 路径(`INSERT_RANGE` 不支持时的全量重写)保留,本次未触发 + +阶段 B(fMP4 旁路)不在本次执行范围。 + +## 5. 附:测量方法说明 + +- `verify-130.sh` 自动报告(`reports/verify-130-20260521T140214.md`)的「磁盘写峰值 2 MB/s」来自 `docker stats`,host 网络 + bind mount 下不可靠,**不作验收依据**;本报告以日志 `insertedBytes` + `ffprobe` 实证为准。 +- fast path 是否生效的判定锚点:日志 `insert-range fast path done`(成功)/ `insert-range skipped|rolled back`(回退)。本次 31 条 done、0 回退。 From 5e9e331191c78610e52c535bc889b6859e69d4bd Mon Sep 17 00:00:00 2001 From: "richard.li" Date: Thu, 21 May 2026 16:30:36 +0800 Subject: [PATCH 6/6] =?UTF-8?q?docs(plan):=20=E5=8A=A0=20trailer=20?= =?UTF-8?q?=E9=9B=B6=E9=87=8D=E5=86=99=2031=20=E8=B7=AF=2030min=20?= =?UTF-8?q?=E5=BD=95=E5=88=B6=E7=B3=BB=E7=BB=9F=20IO=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 进程级 /proc/pid/io 精确采集分离 trailer 写盘与上传落盘: - trailer 阶段 monibuca 写盘 46MB (31 路 moov), 对比 fallback 全量 重写 ≈13GB, 降约 99.6% - 整盘 iostat 峰值 567MB/s 为 MinIO 接收上传落盘, 与 trailer 无关 - 31/31 fast path, 0 异常/超时/失败, 31 路入库, ffprobe moov-first --- ...21-trailer-zero-rewrite-30min-io-report.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-21-trailer-zero-rewrite-30min-io-report.md diff --git a/docs/superpowers/plans/2026-05-21-trailer-zero-rewrite-30min-io-report.md b/docs/superpowers/plans/2026-05-21-trailer-zero-rewrite-30min-io-report.md new file mode 100644 index 00000000..abf99e6d --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-trailer-zero-rewrite-30min-io-report.md @@ -0,0 +1,79 @@ +# Trailer 零重写 阶段 A — 31 路 30min 录制 系统 IO 测试报告 + +**测试对象**:`feature/trailer-zero-rewrite` 分支阶段 A(`fallocate INSERT_RANGE` 原地插 moov),130 部署镜像 `v5.2.5.2605211340`。 + +**结论**:31 路 × 30min 真实录制下,trailer 重写 100% 走 `INSERT_RANGE` fast path。**进程级 IO 实测:trailer 阶段 monibuca 仅写盘 46 MB**(31 路合计),对比修复前 fallback 全量重写需写 ≈13 GB —— **降低约 99.6%**。0 失败、0 超时、产物标准 moov-first MP4。 + +--- + +## 1. 测试参数 + +| 项 | 值 | +|---|---| +| 流数 | 31 路 RTSP(`192.168.12.x`) | +| 录制时长 | 1800s(30min) | +| record start → stop | 15:48:12 → 16:18:21 | +| 存储后端 | MinIO 单节点 `172.16.12.130:9000`,桶 `vidu-media-bucket` | +| 磁盘 | `/dev/sda4` ext4(SSD),monibuca 暂存 + MinIO 数据 + 系统同分区 | + +## 2. 系统 IO 数据(核心) + +采集方式:全程并行采两路 —— 进程级 `/proc//io`(monibuca pid 3899304 / MinIO pid 7194,每秒)+ 整盘 `iostat -dx 1 sda`。进程级数据能把 monibuca 的 trailer 写入从混杂的整盘 IO 中**精确分离**。 + +### 2.1 进程级 IO(`/proc/pid/io` write_bytes / read_bytes 增量) + +| 窗口 | monibuca 写 | monibuca 读 | MinIO 写 | +|---|---|---|---| +| **录制期** 15:48:12–16:18:21(30min) | **13006 MB** | — | 7 MB | +| **trailer+上传期** 16:18:21 起 | **45.95 MB** | 12997 MB | 13021 MB | + +解读: + +- **录制期 monibuca 写 13006 MB** —— 31 路 RTSP 媒体数据持续写入各自的本地暂存文件(S3File 上传前的 tempFile),30min 累计 ≈13 GB。这是录制本身的固有写入,任何方案都一样。 +- **trailer 期 monibuca 写 45.95 MB** —— 这是 **trailer 重写的实际磁盘写入**。31 路合计 46 MB,平均每路 ≈1.5 MB(= 各路 moov 大小 + 4K 对齐)。`INSERT_RANGE` 在文件头就地撑开 moov 空间,`mdat` 数据零移动,故 trailer 只写 moov。 +- **trailer 期 monibuca 读 12997 MB** —— stop 后读回 31 个暂存文件作为上传源(≈13 GB)。 +- **trailer 期 MinIO 写 13021 MB** —— MinIO 接收 31 路上传并落盘到 `/data/minio`(≈13 GB)。 + +### 2.2 整盘 iostat(`sda`) + +| 指标 | 峰值 | +|---|---| +| `wkB/s` 写带宽峰值 | **567 MB/s**(top: 567 / 480 / 403 / 360 / 359 MB/s) | +| `%util` | 86.5% | + +整盘 567 MB/s 写峰值出现在 trailer+上传期,**主体是 MinIO 落盘那 13 GB**(§2.1),与 monibuca trailer 的 46 MB 不在一个量级。整盘 iostat 无法单独反映 trailer —— 这正是本次用 `/proc/pid/io` 进程级采集的原因。 + +### 2.3 trailer 写入对照 + +| 方案 | trailer 阶段 monibuca 写盘 | 说明 | +|---|---|---| +| 修复前 fallback 全量重写 | ≈13 GB | 把整个 `mdat` 复制一遍(≈ 录制期写入量) | +| **阶段 A `INSERT_RANGE` fast path** | **46 MB** | 仅写 moov,`mdat` 零移动 | +| **降幅** | **≈ -99.6%** | | + +## 3. 功能验收 + +| 项 | 结果 | +|---|---| +| `insert-range fast path done`(日志) | **31 / 31** | +| fast path 异常(skip/rollback/failed) | **0** | +| `upload successful` | **31 / 31** | +| `RequestTimeout`(MinIO 锁) | **0** | +| `saved failed` / `pending_uploads` | **0** / 0 | +| record 入库 | **31 条**,`endTime` 全正常,duration 1803–1809s(~30min) | +| 产物 ffprobe(cam1 `2026-05-21-15-48-12_239445148.mp4`,93 MB) | box 序 `ftyp→moov→free→free→mdat`,moov-first,`duration=1809.188`,h264 1920x1080 ✅ | + +## 4. 结论 + +阶段 A(`fallocate INSERT_RANGE`)在 31 路 × 30min 真实负载下系统 IO 表现: + +- trailer 阶段 monibuca 磁盘写入 **46 MB**(进程级实测),较 fallback 全量重写的 ≈13 GB **降低约 99.6%**;每路 trailer 写入从「≈整个录像文件」降到「≈一个 moov」(~1.5 MB)。 +- `mdat` 数据零移动,产物为标准 progressive moov-first MP4。 +- 31/31 走 fast path,0 异常 / 0 上传失败 / 0 补传 / 0 `RequestTimeout`,录像全部入库。 +- 整盘写峰值 567 MB/s 来自 MinIO 接收上传落盘(31 路 ≈13 GB),属上传固有成本,与 trailer 优化无关;若需进一步降整盘峰值,应将 MinIO 数据盘与 monibuca 暂存盘分离(见 plan 风险节)。 + +## 5. 附:数据采集说明 + +- 进程级 IO:`/proc/3899304/io`(monibuca)、`/proc/7194/io`(MinIO)的 `write_bytes`/`read_bytes`,每秒采样,取窗口端点增量。`write_bytes` 为进程提交到块设备层的字节数,反映真实磁盘写入。 +- `verify-130.sh` 自动报告(`reports/verify-130-20260521T154704.md`)的「磁盘写峰值 3 MB/s」来自 `docker stats`,host 网络模式下不可靠,不作依据。 +- trailer 窗口起点取 record stop 时刻(16:18:21):stop 后 monibuca 不再写录像数据,其 `write_bytes` 增量即纯 trailer 写入。