Skip to content

[] Add artifact cleanup Asynq worker for expired artifact deletion and docker-compose MinIO init bucket job — with unit tests#277

Merged
zoetaka38 merged 4 commits into
mainfrom
feature/019f0d21ff03-019f0d21ff03
Jun 29, 2026
Merged

[] Add artifact cleanup Asynq worker for expired artifact deletion and docker-compose MinIO init bucket job — with unit tests#277
zoetaka38 merged 4 commits into
mainfrom
feature/019f0d21ff03-019f0d21ff03

Conversation

@red-codens

@red-codens red-codens Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Type of Change

  • feat — new feature
  • fix — bug fix
  • chore — tooling / maintenance
  • docs — documentation only
  • refactor — code change that neither fixes a bug nor adds a feature
  • test — adding or updating tests
  • ci — CI/CD configuration

Testing

Checklist

  • Tests pass locally
  • Lint clean (task lint)
  • Commit messages follow Conventional Commits

Task Goal

Target Repository

Corevice/open-git
All work for this task belongs in this repository. Do the implementation inside this repository's directory in the workspace; do not search or edit other repositories unless this task explicitly requires a cross-repo change.

Context

Expired artifacts must be cleaned up asynchronously (per PRD FR-3). This sub-task adds an Asynq periodic worker following the pattern of ci_worker.go and webhook_worker.go, and adds a MinIO init service to docker-compose.yml that creates the artifacts bucket on startup. The worker runs hourly via Asynq cron.

Steps

  1. Edit open-git/backend/internal/infrastructure/queue/asynq_client.go — add constant TypeArtifactCleanup = "artifact:cleanup" and helper:
func EnqueueArtifactCleanup(ctx context.Context, client *asynq.Client) (*asynq.TaskInfo, error) {
    task := asynq.NewTask(TypeArtifactCleanup, nil)
    return client.EnqueueContext(ctx, task, asynq.Queue("default"))
}
  1. Create open-git/backend/internal/worker/artifact_cleanup_worker.go — struct ArtifactCleanupWorker with fields artifactRepo domain.IArtifactRepository, storage domainservice.ArtifactStorage, bucket string; constructor NewArtifactCleanupWorker(repo, storage, bucket); method HandleCleanup(ctx, *asynq.Task) error that calls repo.ListExpired(ctx, 100) in a loop, for each calls storage.DeleteObject(ctx, bucket, artifact.StorageKey) then repo.SoftDelete(ctx, artifact.ID, artifact.OrganizationID), logs errors per artifact without aborting the batch, returns nil after processing all
  2. Create open-git/backend/internal/worker/artifact_cleanup_worker_test.go — using in-memory SQLite (same pattern as ci_worker_test.go): seed one expired artifact (expires_at in past, status=completed) and one non-expired; run HandleCleanup; assert expired artifact's deleted_at is now set; assert non-expired artifact's deleted_at is still NULL; use a mock ArtifactStorage that records calls
  3. Edit `ope

Changes Made

Implement
ing
the
artifact
cleanup
worker
per
the
task
.
Review
ing
existing
patterns
in
the
named
files
.

Checking
artifact
repository
and
storage
interfaces
for
the
worker
implementation
.

Implement
ing
the
changes
across
the
five
named
files
.

Fix
ing
the
test
:
must Exec
expects
* sql .DB,
but
the
test
passes
* sql x .DB.

Implemented
the
artifact
cleanup
async
worker per FR-3. Summary of changes:

Changes

  1. asynq_client.go — Added TypeArtifactCleanup = "artifact:cleanup" and EnqueueArtifactCleanup helper.

  2. artifact_cleanup_worker.go — New ArtifactCleanupWorker that:

    • Lists expired artifacts in batches of 100
    • Deletes
      MinIO objects, then soft-deletes metadata
    • Logs per-artifact errors without stopping the batch
  3. artifact_cleanup_worker_test.go — In-memory
    SQLite test with one expired and one non-expired artifact; verifies only the expired one is cleaned up and storage delete is called.

4
. main.go — Registered HandleCleanup on the Asynq mux and added an @hourly scheduler for
the cleanup task.

  1. docker-compose.yml — Added createbuckets init service that creates the artifacts bucket after MinIO is healthy
    .

[PURPLE-MODIFIED-FILES]
backend/internal/infrastructure/queue/asynq_client.go
backend/internal/worker/artifact_cleanup_worker.go
backend/internal/worker/
artifact
_cleanup
_worker
_test
.go

backend
/cmd
/server
/main
.go

docker
-compose
.yml

[/
P
UR
P
LE
-M
OD
IFIED
-F
ILES
]

Task ID: 019f0d21-ff03-7c3e-9f44-e3442538a921
Generated by Purple Codens

codens-agent and others added 2 commits June 28, 2026 09:23

@orange-codens orange-codens Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🍊 Orange Codens レビュー

🔒 セキュリティ: 今回の diff で最も注目すべき点は、docker-compose.ymlcreatebuckets サービスで MinIO 認証情報がシェルコマンドに直接展開されている点 (CWE-77) と、HandleCleanup のバッチループで SoftDelete 失敗時に無限ループが発生しうる点 (CWE-835) です。また、ListExpired がクロステナントスコープを持つ可能性について、実装側での明示的な検証が推奨されます。Scheduler のグレースフルシャットダウン漏れも運用上のリスクになりえます。

🟡 medium: 1 / 🔵 low: 3

その他の指摘 (4 件)
  • 🟡 [medium] docker-compose.yml:102 — MinIO 認証情報が環境変数経由でシェルコマンドに展開され、コマンドインジェクションの素地がある (confidence: 0.65, sec.sast.injection.command)
  • 🔵 [low] backend/internal/worker/artifact_cleanup_worker.go:33 — SoftDelete 失敗時にループが無限に繰り返す可能性がある (confidence: 0.75, sec.sast.availability.infinite_loop)
  • 🔵 [low] backend/cmd/server/main.go:541 — Asynq Scheduler がグレースフルシャットダウンされず、プロセス終了時にスケジュールが破損する可能性がある (confidence: 0.70, sec.sast.availability.scheduler_no_shutdown)
  • 🔵 [low] backend/internal/worker/artifact_cleanup_worker.go:33 — ListExpired がテナント (OrganizationID) でスコープされているか不明確で、クロステナント削除のリスクがある (confidence: 0.50, sec.sast.authz.missing_tenant_check)

head: 5dafa00 | Orange Codens (P1)

@orange-codens orange-codens Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🍊 Orange Codens レビュー

重大な問題あり: HandleCleanup の外側バッチループで storage.DeleteObject が全件失敗し続けた場合に無限ループに陥るリスクがある(critical)。また SoftDelete 失敗後も次回ジョブで再度 DeleteObject が呼ばれ得る設計上の問題もある。スケジューラのエラーが Info ログのみで握り潰されている点も運用上問題になりうる。テストはハッピーパスのみでエラーパスがカバーされておらず、タスクタイプ文字列のハードコードも保守性を下げている。

🔒 セキュリティ: 今回の差分で最も注意が必要なのは、docker-compose.yml に埋め込まれた MinIO 認証情報のデフォルトフォールバック(:-minioadmin)です。開発環境で環境変数が未設定のまま使用されると公知デフォルト資格情報で MinIO が操作可能になります。artifact_cleanup_worker.go の無限バッチループはタイムアウトや DoS のリスクを持ち、上限設定を推奨します。スケジューラ停止エラーを Info レベルで記録している点はセキュリティ監視の観点から Error レベルへの変更が望ましいです。

🔴 critical: 1 / 🟠 high: 1 / 🟡 medium: 5 / 🔵 low: 5

その他の指摘 (10 件)
  • 🟡 [medium] backend/cmd/server/main.go:546 — scheduler.Run() のエラーが Info ログのみで握りつぶされている (confidence: 0.85, code.error_handling.scheduler_goroutine_silent)
  • 🟡 [medium] backend/internal/worker/artifact_cleanup_worker_test.go:160 — mockArtifactCleanupStorage が bucket 引数を検証していない (confidence: 0.82, maintainability.test.missing_storage_delete_bucket_assertion)
  • 🟡 [medium] backend/internal/worker/artifact_cleanup_worker.go:33 — 外側ループで context のキャンセルをチェックしていない (confidence: 0.80, code.context.no_ctx_check_in_loop)
  • 🟡 [medium] docker-compose.yml:101 — docker-compose の MinIO 認証情報がデフォルト値にフォールバックしハードコード相当になる (confidence: 0.75, sec.sast.injection.command)
  • 🟡 [medium] backend/internal/worker/artifact_cleanup_worker.go:47 — SoftDelete に OrganizationID を渡しているが ListExpired の結果に対するテナント分離が検証されていない (confidence: 0.55, sec.sast.authz.missing_ownership_check)
  • 🔵 [low] backend/internal/worker/artifact_cleanup_worker_test.go:148 — テスト内でタスクタイプ文字列をハードコードしている (confidence: 0.95, maintainability.test.hardcoded_task_type_string)
  • 🔵 [low] backend/internal/worker/artifact_cleanup_worker_test.go:105 — storage.DeleteObject エラー時の継続動作がテストされていない (confidence: 0.88, maintainability.test.missing_error_case)
  • 🔵 [low] backend/cmd/server/main.go:544 — Asynq スケジューラの停止エラーを Info レベルでログしており、異常終了の検知が困難 (confidence: 0.85, sec.sast.info_disclosure.scheduler_error_log)
  • 🔵 [low] docker-compose.yml:93 — createbuckets init サービスに restart ポリシーがなく再起動ループのリスク (confidence: 0.75, maintainability.docker.no_restart_on_init_container)
  • 🔵 [low] backend/internal/worker/artifact_cleanup_worker.go:33 — 期限切れアーティファクトのバッチループに上限がなく、DoS / タスクタイムアウトのリスクがある (confidence: 0.70, sec.sast.dos.unbounded_loop)

head: 39d390a | Orange Codens (P1)

if len(artifacts) < batchSize {
break
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 [critical] storage.DeleteObject 失敗時に artifact が再度 ListExpired に現れ無限ループになる

storage.DeleteObject が失敗した場合は continue で SoftDelete をスキップするため、次のバッチで同じ artifact が再度 ListExpired に返される。batchSize=100 件すべてが削除失敗し続けると、len(artifacts) < batchSize の条件を満たさず外側の for {} が永遠に回り続ける。

該当箇所:

if err := w.storage.DeleteObject(ctx, w.bucket, artifact.StorageKey); err != nil {
    slog.Error("delete artifact object", "artifact_id", artifact.ID, "error", err)
    continue
}

MinIO が一時的に落ちた場合など、全件 DeleteObject 失敗 → len(artifacts) == 100 → ループ継続、という状況が容易に発生する。ワーカーが停止しない限りゴルーチンが詰まり続ける。

修正案: 内側ループでエラーが起きた artifact をスキップカウントし、1 バッチ内でエラーが一定数を超えたら早期 return するか、または ListExpired にオフセット/カーソルを持たせる。あるいは ctx.Done() チェックを先頭に入れて context キャンセルで抜けられるようにする。

@@ -44,6 +44,10 @@
+		successCount := 0
 		for _, artifact := range artifacts {
 			if err := w.storage.DeleteObject(ctx, w.bucket, artifact.StorageKey); err != nil {
 				slog.Error("delete artifact object", "artifact_id", artifact.ID, "error", err)
 				continue
 			}
 			if err := w.artifactRepo.SoftDelete(ctx, artifact.ID, artifact.OrganizationID); err != nil {
 				slog.Error("soft delete artifact", "artifact_id", artifact.ID, "error", err)
+			} else {
+				successCount++
+			}
 		}
+		if successCount == 0 {
+			break // 1件も成功しなければループを抜けて無限ループを防ぐ
+		}

code.logic.infinite_loop_on_storage_error | confidence: 0.92

slog.Error("soft delete artifact", "artifact_id", artifact.ID, "error", 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.

🟠 [high] SoftDelete 失敗時に artifact が次回バッチで再度 DeleteObject される

SoftDelete が失敗してもループを継続し、エラーを記録するだけでその artifact は soft-delete されない。次回のジョブ実行では ListExpired に同じ artifact が返り、storage.DeleteObject が再度呼ばれる。StorageKey が既に削除済みであれば 404 エラーで DeleteObject が失敗し、無限に同じ artifact が処理され続ける可能性がある。

該当箇所:

if err := w.artifactRepo.SoftDelete(ctx, artifact.ID, artifact.OrganizationID); err != nil {
    slog.Error("soft delete artifact", "artifact_id", artifact.ID, "error", err)
}

MinIO の DeleteObject べき等を前提にする設計であれば、少なくともコメントか設計メモを残すべき。べき等でない場合は二重削除を防ぐ仕組みが必要。

code.logic.softdelete_error_ignored_in_loop | confidence: 0.78

…n and docker-compose MinIO init bucket job — with unit tests

@orange-codens orange-codens Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🍊 Orange Codens レビュー

重大な問題あり: HandleCleanup において SoftDelete 失敗時に deleted_at が更新されないアーティファクトが次回バッチでも取得され、バッチサイズが丁度 100 件の場合に無限ループとなるロジックバグがある(critical)。また scheduler.Run() のエラーが Info ログのみで握り潰されており、スケジューラが無音で停止しても気づけない(high)。テストでは queue.TypeArtifactCleanup 定数を使わずマジック文字列をハードコードしており保守性が低下している(medium)。これらの修正を優先してください。

🔒 セキュリティ: 本 PR はアーティファクトクリーンアップワーカーの追加であり、SQL インジェクションや認証バイパスのような高リスクな脆弱性は確認されませんでした。主な懸念点は docker-compose.yml における MinIO 初期化スクリプトでの変数引用符欠如(CWE-78)とデフォルト弱認証情報(CWE-1391)、およびワーカー実装における SoftDelete 失敗時の冪等性欠如です。マルチテナント環境での ListExpired のテナント境界については実装を別途確認することを推奨します。

🔴 critical: 1 / 🟠 high: 1 / 🟡 medium: 3 / 🔵 low: 4 / ⚪ info: 1

その他の指摘 (8 件)
  • 🟡 [medium] backend/internal/worker/artifact_cleanup_worker_test.go:143 — テストで queue.TypeArtifactCleanup 定数を使わずマジック文字列を直接使用している (confidence: 0.95, maintainability.test.magic_string_constant)
  • 🟡 [medium] backend/cmd/server/main.go:541 — AsynqScheduler がグレースフルシャットダウンに組み込まれていない (confidence: 0.82, code.resource_management.scheduler_no_shutdown)
  • 🟡 [medium] docker-compose.yml:103 — docker-compose の MinIO 初期化スクリプトで環境変数がシェルに直接展開される (confidence: 0.72, sec.sast.injection.command)
  • 🔵 [low] backend/internal/worker/artifact_cleanup_worker.go:44 — SoftDelete 失敗後も ListExpired が同一アーティファクトを返し続け、無限ループ的に storage 削除が再試行される可能性 (confidence: 0.80, sec.sast.authz.missing_tenant_check)
  • 🔵 [low] backend/internal/worker/artifact_cleanup_worker_test.go:119 — entity.Artifact の RepositoryID フィールドがテストデータで未設定 (confidence: 0.75, maintainability.test.missing_repositoryid_field)
  • 🔵 [low] docker-compose.yml:93 — createbuckets サービスに restart ポリシーが設定されていない (confidence: 0.72, maintainability.docker.no_restart_policy)
  • 🔵 [low] backend/internal/worker/artifact_cleanup_worker.go:34 — ListExpired がテナント(OrganizationID)境界を跨いで全組織の期限切れアーティファクトを処理する設計になっているか確認が必要 (confidence: 0.45, sec.sast.authz.idor)
  • [info] docker-compose.yml:103 — MinIO の認証情報にデフォルト値 minioadmin が使用されている (confidence: 0.90, sec.sast.crypto.weak_default)

head: c8818cf | Orange Codens (P1)

break
}
}
return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 [critical] SoftDelete失敗時に同一レコードが無限ループで再処理される

該当箇所:

for _, artifact := range artifacts {
    if err := w.storage.DeleteObject(ctx, w.bucket, artifact.StorageKey); err != nil {
        slog.Error("delete artifact object", "artifact_id", artifact.ID, "error", err)
        continue
    }
    if err := w.artifactRepo.SoftDelete(ctx, artifact.ID, artifact.OrganizationID); err != nil {
        slog.Error("soft delete artifact", "artifact_id", artifact.ID, "error", err)
    }
}

if len(artifacts) < batchSize {
    break
}

SoftDelete が失敗した場合、deleted_at が設定されないため当該 artifact は次の ListExpired 呼び出しでも再び取得される。バッチサイズが 100 件ちょうどの場合(len(artifacts) == batchSize)、ループを抜ける条件を満たせず、StorageKey が既に削除済みのオブジェクトに対して DeleteObject を繰り返す無限ループに陥る。最終的には context タイムアウトまでループが継続し、不必要な MinIO API コールが大量発生する。

修正案: SoftDelete 失敗時も continue し、そのアイテムをスキップするか、または処理済み件数ではなく「SoftDelete に成功した件数」でループ継続判定を行う。あるいは SoftDelete 失敗の artifact を除外したカウントをバッチ終了判定に使う。

@@ -47,6 +47,9 @@ func (w *ArtifactCleanupWorker) HandleCleanup(ctx context.Context, task *asynq.Task) error {
 			if err := w.artifactRepo.SoftDelete(ctx, artifact.ID, artifact.OrganizationID); err != nil {
 				slog.Error("soft delete artifact", "artifact_id", artifact.ID, "error", err)
+				continue
 			}
+			successCount++
 		}

code.logic.infinite_loop_on_soft_delete_failure | confidence: 0.88

}
go func() {
if err := scheduler.Run(); err != nil {
middleware.Log().Info("asynq scheduler stopped", "error", 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.

🟠 [high] scheduler.Run() 失敗時に Info ログのみでプロセスが継続する

該当箇所:

go func() {
    if err := scheduler.Run(); err != nil {
        middleware.Log().Info("asynq scheduler stopped", "error", err)
    }
}()

scheduler.Run() が起動失敗(Redis 接続不可など)した場合、Info レベルのログを出力するだけでサーバープロセスは正常稼働を続ける。その結果、@hourly の artifact cleanup タスクが一切エンキューされないが、オペレーターはエラーに気づきにくい。既存の asynqServer.Run(mux) も同様のパターンであればプロジェクトの慣習に沿っているかもしれないが、少なくとも Error レベルのログを使うべきである。

@@ -543,7 +543,7 @@ go func() {
 		if err := scheduler.Run(); err != nil {
-			middleware.Log().Info("asynq scheduler stopped", "error", err)
+			middleware.Log().Error("asynq scheduler stopped", "error", err)
 		}
 }()

code.error_handling.scheduler_goroutine_silent_stop | confidence: 0.92

# Conflicts:
#	backend/cmd/server/main.go
#	backend/go.sum
#	backend/internal/infrastructure/queue/asynq_client.go
@zoetaka38
zoetaka38 merged commit 3da8bcc into main Jun 29, 2026
9 of 13 checks passed

@orange-codens orange-codens Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🍊 Orange Codens レビュー

artifact cleanup worker の基本フローとテスト happy path は PRD の FR-3 に沿っていますが、SoftDelete や DeleteObject が全件失敗した場合に外側ループが終了条件を満たせず無限ループする点が critical です。あわせて、1タスクあたりの処理量上限がなく、失敗パスのテストも不足しています。

🔒 セキュリティ: 本 PR は期限切れアーティファクトを削除する内部 Asynq worker と MinIO バケット初期化の追加であり、diff 上はユーザー入力を直接受け取る処理パスがありません。HandleCleanup は DB から取得した StorageKey を repository / storage 抽象経由で削除するだけで、SQL injection・SSRF・暗号誤用・認証 bypass に該当する具体的コードは見当たりません。Asynq タスクは payload なしで既存 worker と同様 Redis を信頼境界とする設計のため、論理的な脆弱性として報告すべき事項はありません。

🔴 critical: 1 / 🟡 medium: 3 / 🔵 low: 1 / ⚪ info: 1

その他の指摘 (5 件)
  • 🟡 [medium] backend/internal/worker/artifact_cleanup_worker_test.go:89 — 失敗パス(DeleteObject / SoftDelete エラー、無限ループ防止)のテストがない (confidence: 0.88, maintainability.test.missing_failure_cases)
  • 🟡 [medium] backend/internal/worker/artifact_cleanup_worker.go:33 — 1回の HandleCleanup 呼び出しで全期限切れ artifact を処理し切るまでループする (confidence: 0.82, performance.unbounded_work)
  • 🟡 [medium] backend/internal/worker/artifact_cleanup_worker.go:43 — DeleteObject 失敗時に SoftDelete を試行せず、DeleteObject 成功後の SoftDelete 失敗も再試行されない (confidence: 0.78, code.logic.partial_failure)
  • 🔵 [low] backend/internal/worker/artifact_cleanup_worker_test.go:138 — テストでタスク種別を文字列リテラルで指定している (confidence: 0.90, maintainability.naming.magic_string)
  • [info] backend/cmd/server/main.go:636 — 各 server プロセスが独立して @hourly スケジューラを起動する (confidence: 0.55, maintainability.ops.multi_instance_scheduler)

head: a83cae3 | Orange Codens (P1)


if len(artifacts) < batchSize {
break
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 [critical] SoftDelete が全件失敗すると HandleCleanup が無限ループする

外側の for ループは len(artifacts) == 0 または len(artifacts) < batchSize のときのみ終了します。SoftDelete 失敗時はログのみで処理を継続するため、期限切れレコードは DB 上で削除されず、次の反復でも同じバッチが返り続けます。

該当箇所:

	for {
		artifacts, err := w.artifactRepo.ListExpired(ctx, batchSize)
		...
		for _, artifact := range artifacts {
			...
			if err := w.artifactRepo.SoftDelete(ctx, artifact.ID, artifact.OrganizationID); err != nil {
				slog.Error("soft delete artifact", "artifact_id", artifact.ID, "error", err)
			}
		}

		if len(artifacts) < batchSize {
			break
		}
	}

batchSize(100) 件ちょうど返り、全件の SoftDelete が失敗する(DB 障害・権限エラー等)ケースでは len(artifacts) < batchSize にならず、Asynq タスクが完了せずワーカーを占有し続けます。DeleteObject が全件 continue されるケースも同様です。

@@ -48,6 +48,15 @@
 			}
 		}
 
+		deletedCount := 0
+		for _, artifact := range artifacts {
+			// track successful SoftDelete in the inner loop
+		}
+		if deletedCount == 0 && len(artifacts) > 0 {
+			return fmt.Errorf("artifact cleanup made no progress on batch of %d", len(artifacts))
+		}
+
 		if len(artifacts) < batchSize {
 			break
 		}

code.logic.infinite_loop | confidence: 0.93

@zoetaka38
zoetaka38 deleted the feature/019f0d21ff03-019f0d21ff03 branch July 2, 2026 10:49
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