feat(janitor): implement daily file janitor task to clean up orphaned files and expired uploads

This commit is contained in:
keven1024
2026-05-25 13:43:45 +08:00
parent ab0587bd4d
commit 2e2698e281
6 changed files with 204 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-25

View File

@@ -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 需要額外基礎設施。選擇 Schedulercron 表達式為 `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 electionRedis 不可用時排程失敗。→ 可接受Redis 不可用時整個 worker 本就無法運作。
## Migration Plan
直接部署,無資料遷移。首次執行會清理歷史殘留檔案,屬於預期行為。

View File

@@ -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` 已在現有依賴中)

View File

@@ -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** 系統不排程刪除,繼續處理下一個

View File

@@ -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 ./...`,確認編譯通過

View File

@@ -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` 刪除。臨時上傳資料夾命名格式為 `<fileId>_tmp`,系統 SHALL 將 `_tmp` 後綴去除後再查詢 fileInfoMap以判斷對應的 fileId 是否存在。
#### Scenario: 刪除孤兒本地資料夾
- **WHEN** 本地上傳目錄中存在名為 `<fileId>``<fileId>_tmp` 的資料夾,且 Redis fileInfoMap 中無該 fileId 的記錄
- **THEN** 系統直接刪除該本地資料夾(保留原始名稱,不去除 `_tmp`
#### Scenario: 保留有 fileInfoMap 記錄的資料夾
- **WHEN** 本地上傳目錄中存在名為 `<fileId>``<fileId>_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** 系統不排程刪除,繼續處理下一個