From 2e2698e281b8d060abb08ebb739008f4270bba26 Mon Sep 17 00:00:00 2001 From: keven1024 Date: Mon, 25 May 2026 13:43:45 +0800 Subject: [PATCH] feat(janitor): implement daily file janitor task to clean up orphaned files and expired uploads --- .../.openspec.yaml | 2 + .../design.md | 69 +++++++++++++++++++ .../proposal.md | 24 +++++++ .../specs/file-janitor/spec.md | 45 ++++++++++++ .../2026-05-25-add-file-janitor-task/tasks.md | 19 +++++ openspec/specs/file-janitor/spec.md | 45 ++++++++++++ 6 files changed, 204 insertions(+) create mode 100644 openspec/changes/archive/2026-05-25-add-file-janitor-task/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-25-add-file-janitor-task/design.md create mode 100644 openspec/changes/archive/2026-05-25-add-file-janitor-task/proposal.md create mode 100644 openspec/changes/archive/2026-05-25-add-file-janitor-task/specs/file-janitor/spec.md create mode 100644 openspec/changes/archive/2026-05-25-add-file-janitor-task/tasks.md create mode 100644 openspec/specs/file-janitor/spec.md diff --git a/openspec/changes/archive/2026-05-25-add-file-janitor-task/.openspec.yaml b/openspec/changes/archive/2026-05-25-add-file-janitor-task/.openspec.yaml new file mode 100644 index 0000000..9e883bf --- /dev/null +++ b/openspec/changes/archive/2026-05-25-add-file-janitor-task/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-25 diff --git a/openspec/changes/archive/2026-05-25-add-file-janitor-task/design.md b/openspec/changes/archive/2026-05-25-add-file-janitor-task/design.md new file mode 100644 index 0000000..d8b1981 --- /dev/null +++ b/openspec/changes/archive/2026-05-25-add-file-janitor-task/design.md @@ -0,0 +1,69 @@ +## Context + +worker 目前所有檔案清理都依賴 `file:remove` 延遲任務,由業務邏輯在適當時機主動排程(上傳完成後設定 TTL、share 刪除時觸發)。若任務在排程前 worker 重啟、Redis 任務丟失或業務邏輯有 bug,檔案就會永久殘留在磁碟上。`asynq` 已提供 `Scheduler` 元件支援 cron 排程,無需引入新依賴。 + +## Goals / Non-Goals + +**Goals:** +- 每天凌晨自動掃描並清理三類漏網檔案 +- 不重複造輪子,清理邏輯複用現有 `file:remove` 任務 +- 不影響現有任務流程與 API + +**Non-Goals:** +- 不處理 `Expire == 0` 的特殊情況 +- 不清理 `fileShareRelational` 裡的孤兒 share 條目 +- 不提供手動觸發的 HTTP 介面 + +## Decisions + +**1. 使用 `asynq.Scheduler` 而非外部 cron,排程時間為每天凌晨 03:00** + +`asynq.Scheduler` 在同一 worker 程式內以獨立 goroutine 運行,透過 Redis 做 leader election(避免多實例重複觸發)。外部 cron 需要額外基礎設施。選擇 Scheduler,cron 表達式為 `0 3 * * *`。 + +``` +main.go + ├── asynq.NewServer(...) // 處理任務 + ├── asynq.NewScheduler(...) // 排程 file:janitor @ 03:00 + └── mux.HandleFunc("file:janitor", tasks.FileJanitor) +``` + +**2. `FileJanitor` 函式放在 `worker/internal/tasks/file.go`,超過 300 行才拆資料夾** + +目前 `file.go` 為 50 行,加入 janitor 後預計約 100 行,遠低於 300 行門檻,直接寫入 `file.go`。若未來該檔超過 300 行,則改為 `worker/internal/tasks/file/` 目錄,拆成 `janitor.go`(清理邏輯)與 `remove.go`(原 `RemoveFile` 邏輯)。 + +**3. Case 1(孤兒本地檔)直接 `os.RemoveAll`,不走 `file:remove`** + +這類檔案在 fileInfoMap 中不存在,`RemoveFile` handler 會直接 return nil(找不到 fileInfo),走 `file:remove` 無法刪除磁碟檔案。必須直接刪除。 + +**4. Case 2、3 透過 `SetFileRemoveTask(id, 0)` 委派** + +複用 `RemoveFile` 現有邏輯(含 share 二次確認),避免重複實作。delay=0 表示立即處理。 + +**5. 掃描策略:一次性全量讀取** + +- 本地目錄:`os.ReadDir(uploadPath)` 取得所有檔名(即 fileId) +- Redis:`GetRedisFileInfoAll()` 一次取得完整 fileInfoMap +- 兩者建立 map 後交叉比對,時間複雜度 O(n) + +``` +本地檔案 set ────┐ + ├─ 差集 → Case 1 +Redis fileInfoMap ┘ + +Redis fileInfoMap ─ 遍歷 ─┬─ type==init && expired → Case 2 + └─ type==already && no share → Case 3 +``` + +**6. Case 3 的 share 關係查詢** + +對每個 `type==already` 的 fileId 呼叫 `GetRedisFileShareRelational(fileId)`,若回傳空 slice 則觸發刪除。此為 O(n) Redis 查詢,數量級與檔案數相同,凌晨低峰期執行可接受。 + +## Risks / Trade-offs + +- **誤刪風險(Case 1)**:本地檔案剛建立但 fileInfoMap 尚未寫入的時間窗口(上傳初始化中)。→ 凌晨執行,正在上傳中的檔案其 init 記錄通常已存在 Redis,風險極低。 +- **Redis 查詢量(Case 3)**:若檔案數量龐大,逐一查詢 fileShareRelational 會產生大量 Redis 請求。→ 目前是輕量平台,可接受;未來可改用 `HGETALL fileShareRelational` 一次取得再做本地比對。 +- **Scheduler 單點**:`asynq.Scheduler` 依賴 Redis leader election,Redis 不可用時排程失敗。→ 可接受,Redis 不可用時整個 worker 本就無法運作。 + +## Migration Plan + +直接部署,無資料遷移。首次執行會清理歷史殘留檔案,屬於預期行為。 diff --git a/openspec/changes/archive/2026-05-25-add-file-janitor-task/proposal.md b/openspec/changes/archive/2026-05-25-add-file-janitor-task/proposal.md new file mode 100644 index 0000000..6ba22d7 --- /dev/null +++ b/openspec/changes/archive/2026-05-25-add-file-janitor-task/proposal.md @@ -0,0 +1,24 @@ +## Why + +現有的檔案清理機制依賴 asynq 延遲任務(`file:remove`),在異常情況下(worker 重啟、任務丟失、Redis 資料不一致)可能導致本地磁碟殘留孤兒檔案,長期累積佔用儲存空間。需要一個每日定期掃描的兜底清理機制。 + +## What Changes + +- 新增 `file:janitor` asynq 任務處理函式(`worker/internal/tasks/janitor.go`) +- 在 `worker/main.go` 中加入 `asynq.Scheduler`,每天凌晨 00:00 自動排程 `file:janitor` +- 在 `worker/main.go` 的 mux 中註冊 `file:janitor` handler + +## Capabilities + +### New Capabilities + +- `file-janitor`: 每日定期掃描並清理三類漏網檔案:本地有但 fileInfoMap 無的孤兒檔、init 狀態已過期的未完成上傳、已完成上傳但無任何 share 關係的孤立檔 + +### Modified Capabilities + +## Impact + +- **worker/internal/tasks/**:新增 `janitor.go` +- **worker/main.go**:新增 `asynq.Scheduler` 及 `file:janitor` handler 註冊 +- 不影響任何 API 或前端 +- 無新增外部依賴(`asynq.Scheduler` 已在現有依賴中) diff --git a/openspec/changes/archive/2026-05-25-add-file-janitor-task/specs/file-janitor/spec.md b/openspec/changes/archive/2026-05-25-add-file-janitor-task/specs/file-janitor/spec.md new file mode 100644 index 0000000..949f7c8 --- /dev/null +++ b/openspec/changes/archive/2026-05-25-add-file-janitor-task/specs/file-janitor/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: 每日定期排程 file:janitor 任務 +系統 SHALL 在每天凌晨 00:00(伺服器本地時間)自動排程一次 `file:janitor` asynq 任務。多個 worker 實例並存時,系統 SHALL 保證同一時間只有一個實例觸發排程(透過 asynq.Scheduler 的 Redis leader election 機制)。 + +#### Scenario: 單實例正常排程 +- **WHEN** worker 啟動且時間到達每天 00:00 +- **THEN** asynq.Scheduler 將 `file:janitor` 任務加入 asynq 佇列一次 + +#### Scenario: 多實例防重複觸發 +- **WHEN** 多個 worker 實例同時運行且時間到達 00:00 +- **THEN** 只有一個實例成功排程,其餘實例因 leader election 未獲鎖而跳過 + +### Requirement: 清理本地孤兒檔案(無 fileInfoMap 記錄) +系統 SHALL 掃描本地上傳目錄,對每個存在於磁碟但在 Redis `fileInfoMap` 中無對應記錄的檔案,直接執行 `os.RemoveAll` 刪除。 + +#### Scenario: 刪除孤兒本地檔案 +- **WHEN** 本地上傳目錄中存在 fileId 目錄,且 Redis fileInfoMap 中無該 fileId 的記錄 +- **THEN** 系統直接刪除該本地目錄 + +#### Scenario: 保留有 fileInfoMap 記錄的檔案 +- **WHEN** 本地上傳目錄中存在 fileId 目錄,且 Redis fileInfoMap 中有該 fileId 的記錄 +- **THEN** 系統不刪除該本地目錄,繼續處理下一個 + +### Requirement: 清理已過期的 init 狀態檔案 +系統 SHALL 遍歷 Redis `fileInfoMap`,對 `FileType == "init"` 且滿足 `CreatedAt + Expire < 當前 Unix 時間戳` 的記錄,透過 `SetFileRemoveTask(fileId, 0)` 排程立即刪除。 + +#### Scenario: 排程刪除已過期 init 檔案 +- **WHEN** fileInfoMap 中存在 FileType=="init" 且 CreatedAt+Expire < now() 的記錄 +- **THEN** 系統呼叫 SetFileRemoveTask(fileId, 0),將 file:remove 任務加入佇列 + +#### Scenario: 保留未過期的 init 檔案 +- **WHEN** fileInfoMap 中存在 FileType=="init" 且 CreatedAt+Expire >= now() 的記錄 +- **THEN** 系統不排程刪除,繼續處理下一個 + +### Requirement: 清理無 share 關係的已完成上傳檔案 +系統 SHALL 遍歷 Redis `fileInfoMap`,對 `FileType == "already"` 且在 Redis `fileShareRelational` 中無任何 share 關係的記錄,透過 `SetFileRemoveTask(fileId, 0)` 排程立即刪除。 + +#### Scenario: 排程刪除無 share 關係的已完成檔案 +- **WHEN** fileInfoMap 中存在 FileType=="already" 的記錄,且 fileShareRelational 中該 fileId 對應的 shareId 列表為空或不存在 +- **THEN** 系統呼叫 SetFileRemoveTask(fileId, 0),將 file:remove 任務加入佇列 + +#### Scenario: 保留有 share 關係的已完成檔案 +- **WHEN** fileInfoMap 中存在 FileType=="already" 的記錄,且 fileShareRelational 中該 fileId 有一個或以上 shareId +- **THEN** 系統不排程刪除,繼續處理下一個 diff --git a/openspec/changes/archive/2026-05-25-add-file-janitor-task/tasks.md b/openspec/changes/archive/2026-05-25-add-file-janitor-task/tasks.md new file mode 100644 index 0000000..e698f18 --- /dev/null +++ b/openspec/changes/archive/2026-05-25-add-file-janitor-task/tasks.md @@ -0,0 +1,19 @@ +## 1. 新增 FileJanitor 任務處理函式 + +- [x] 1.1 在 `worker/internal/tasks/file.go` 末尾新增 `FileJanitor(ctx context.Context, task *asynq.Task) error`(若加入後超過 300 行,則改建 `worker/internal/tasks/file/` 目錄,將 `RemoveFile` 移至 `remove.go`,`FileJanitor` 放至 `janitor.go`) +- [x] 1.2 在 `FileJanitor` 中呼叫 `u.GetUploadDirPath()` 取得本地上傳目錄路徑,並用 `os.ReadDir` 掃描目錄取得本地檔案清單 +- [x] 1.3 呼叫 `models.GetRedisFileInfoAll()` 取得 fileInfoMap 全量資料,建立 fileId set +- [x] 1.4 實作 Case 1:本地存在但 fileInfoMap 無記錄的 fileId,呼叫 `os.RemoveAll(filePath)` 直接刪除 +- [x] 1.5 遍歷 fileInfoMap,實作 Case 2:`FileType == "init"` 且 `CreatedAt + Expire < time.Now().Unix()`,呼叫 `pkgservices.SetFileRemoveTask(fileId, 0)` +- [x] 1.6 遍歷 fileInfoMap,實作 Case 3:`FileType == "already"` 且 `GetRedisFileShareRelational(fileId)` 回傳空 slice,呼叫 `pkgservices.SetFileRemoveTask(fileId, 0)` + +## 2. 在 worker/main.go 中整合 + +- [x] 2.1 在 `mux` 中註冊 `mux.HandleFunc("file:janitor", tasks.FileJanitor)` +- [x] 2.2 建立 `asynq.NewScheduler`,使用與 `asynq.NewServer` 相同的 Redis 連線設定 +- [x] 2.3 呼叫 `scheduler.Register("0 3 * * *", asynq.NewTask("file:janitor", nil))` 設定每日凌晨 03:00 排程 +- [x] 2.4 在 `srv.Run(mux)` 之前啟動 `scheduler.Start()`,並在程式結束時呼叫 `scheduler.Shutdown()` + +## 3. 驗證 + +- [x] 3.1 在 `worker/` 目錄下執行 `go build ./...`,確認編譯通過 diff --git a/openspec/specs/file-janitor/spec.md b/openspec/specs/file-janitor/spec.md new file mode 100644 index 0000000..8ffac63 --- /dev/null +++ b/openspec/specs/file-janitor/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: 每日定期排程 file:janitor 任務 +系統 SHALL 在每天凌晨 03:00(伺服器本地時間)自動排程一次 `file:janitor` asynq 任務。多個 worker 實例並存時,系統 SHALL 保證同一時間只有一個實例觸發排程(透過 asynq.Scheduler 的 Redis leader election 機制)。 + +#### Scenario: 單實例正常排程 +- **WHEN** worker 啟動且時間到達每天 03:00 +- **THEN** asynq.Scheduler 將 `file:janitor` 任務加入 asynq 佇列一次 + +#### Scenario: 多實例防重複觸發 +- **WHEN** 多個 worker 實例同時運行且時間到達 03:00 +- **THEN** 只有一個實例成功排程,其餘實例因 leader election 未獲鎖而跳過 + +### Requirement: 清理本地孤兒檔案(無 fileInfoMap 記錄) +系統 SHALL 掃描本地上傳目錄,對每個存在於磁碟但在 Redis `fileInfoMap` 中無對應記錄的資料夾,直接執行 `os.RemoveAll` 刪除。臨時上傳資料夾命名格式為 `_tmp`,系統 SHALL 將 `_tmp` 後綴去除後再查詢 fileInfoMap,以判斷對應的 fileId 是否存在。 + +#### Scenario: 刪除孤兒本地資料夾 +- **WHEN** 本地上傳目錄中存在名為 `` 或 `_tmp` 的資料夾,且 Redis fileInfoMap 中無該 fileId 的記錄 +- **THEN** 系統直接刪除該本地資料夾(保留原始名稱,不去除 `_tmp`) + +#### Scenario: 保留有 fileInfoMap 記錄的資料夾 +- **WHEN** 本地上傳目錄中存在名為 `` 或 `_tmp` 的資料夾,且 Redis fileInfoMap 中有該 fileId 的記錄 +- **THEN** 系統不刪除該資料夾,繼續處理下一個 + +### Requirement: 清理已過期的 init 狀態檔案 +系統 SHALL 遍歷 Redis `fileInfoMap`,對 `FileType == "init"` 且滿足 `CreatedAt + Expire < 當前 Unix 時間戳` 的記錄,透過 `SetFileRemoveTask(fileId, 0)` 排程立即刪除。 + +#### Scenario: 排程刪除已過期 init 檔案 +- **WHEN** fileInfoMap 中存在 FileType=="init" 且 CreatedAt+Expire < now() 的記錄 +- **THEN** 系統呼叫 SetFileRemoveTask(fileId, 0),將 file:remove 任務加入佇列 + +#### Scenario: 保留未過期的 init 檔案 +- **WHEN** fileInfoMap 中存在 FileType=="init" 且 CreatedAt+Expire >= now() 的記錄 +- **THEN** 系統不排程刪除,繼續處理下一個 + +### Requirement: 清理無 share 關係的已完成上傳檔案 +系統 SHALL 遍歷 Redis `fileInfoMap`,對 `FileType == "already"` 且在 Redis `fileShareRelational` 中無任何 share 關係的記錄,透過 `SetFileRemoveTask(fileId, 0)` 排程立即刪除。 + +#### Scenario: 排程刪除無 share 關係的已完成檔案 +- **WHEN** fileInfoMap 中存在 FileType=="already" 的記錄,且 fileShareRelational 中該 fileId 對應的 shareId 列表為空或不存在 +- **THEN** 系統呼叫 SetFileRemoveTask(fileId, 0),將 file:remove 任務加入佇列 + +#### Scenario: 保留有 share 關係的已完成檔案 +- **WHEN** fileInfoMap 中存在 FileType=="already" 的記錄,且 fileShareRelational 中該 fileId 有一個或以上 shareId +- **THEN** 系統不排程刪除,繼續處理下一個