-
Notifications
You must be signed in to change notification settings - Fork 0
[] Add artifact cleanup Asynq worker for expired artifact deletion and docker-compose MinIO init bucket job — with unit tests #277
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5dafa00
39d390a
c8818cf
a83cae3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| package worker | ||
|
|
||
| import ( | ||
| "context" | ||
| "log/slog" | ||
|
|
||
| "github.com/hibiken/asynq" | ||
|
|
||
| domainrepo "github.com/open-git/backend/internal/domain/repository" | ||
| artifactusecase "github.com/open-git/backend/internal/usecase/artifact" | ||
| ) | ||
|
|
||
| type ArtifactCleanupWorker struct { | ||
| artifactRepo domainrepo.IArtifactRepository | ||
| storage artifactusecase.ArtifactStorage | ||
| bucket string | ||
| } | ||
|
|
||
| func NewArtifactCleanupWorker( | ||
| repo domainrepo.IArtifactRepository, | ||
| storage artifactusecase.ArtifactStorage, | ||
| bucket string, | ||
| ) *ArtifactCleanupWorker { | ||
| return &ArtifactCleanupWorker{ | ||
| artifactRepo: repo, | ||
| storage: storage, | ||
| bucket: bucket, | ||
| } | ||
| } | ||
|
|
||
| func (w *ArtifactCleanupWorker) HandleCleanup(ctx context.Context, task *asynq.Task) error { | ||
| const batchSize = 100 | ||
| for { | ||
| artifacts, err := w.artifactRepo.ListExpired(ctx, batchSize) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if len(artifacts) == 0 { | ||
| break | ||
| } | ||
|
|
||
| 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) | ||
| } | ||
| } | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 [high] SoftDelete 失敗時に artifact が次回バッチで再度 DeleteObject される SoftDelete が失敗してもループを継続し、エラーを記録するだけでその artifact は soft-delete されない。次回のジョブ実行では 該当箇所: if err := w.artifactRepo.SoftDelete(ctx, artifact.ID, artifact.OrganizationID); err != nil {
slog.Error("soft delete artifact", "artifact_id", artifact.ID, "error", err)
}MinIO の
|
||
| if len(artifacts) < batchSize { | ||
| break | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 [critical] SoftDelete が全件失敗すると HandleCleanup が無限ループする 外側の 該当箇所: 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
}
}
@@ -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
}
|
||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 [critical] storage.DeleteObject 失敗時に artifact が再度 ListExpired に現れ無限ループになる storage.DeleteObject が失敗した場合は 該当箇所: 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 失敗 → 修正案: 内側ループでエラーが起きた artifact をスキップカウントし、1 バッチ内でエラーが一定数を超えたら早期 return するか、または @@ -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件も成功しなければループを抜けて無限ループを防ぐ
+ }
|
||
| return nil | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
}
修正案: @@ -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++
}
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| package worker | ||
|
|
||
| import ( | ||
| "context" | ||
| "database/sql" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/google/uuid" | ||
| "github.com/hibiken/asynq" | ||
| "github.com/jmoiron/sqlx" | ||
| _ "github.com/mattn/go-sqlite3" | ||
|
|
||
| "github.com/open-git/backend/internal/domain/entity" | ||
| infrarepo "github.com/open-git/backend/internal/infrastructure/repository" | ||
| ) | ||
|
|
||
| const artifactCleanupTestSchema = ` | ||
| CREATE TABLE organizations ( | ||
| id TEXT PRIMARY KEY, | ||
| login TEXT NOT NULL, | ||
| name TEXT NOT NULL, | ||
| plan_tier TEXT NOT NULL DEFAULT 'free', | ||
| created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | ||
| ); | ||
| CREATE TABLE repositories ( | ||
| id TEXT PRIMARY KEY, | ||
| organization_id TEXT NOT NULL, | ||
| name TEXT NOT NULL | ||
| ); | ||
| CREATE TABLE workflow_runs ( | ||
| id TEXT PRIMARY KEY, | ||
| organization_id TEXT NOT NULL, | ||
| repository_id TEXT NOT NULL, | ||
| workflow TEXT NOT NULL, | ||
| status TEXT NOT NULL, | ||
| conclusion TEXT, | ||
| started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| completed_at TIMESTAMP | ||
| ); | ||
| CREATE TABLE artifacts ( | ||
| id TEXT PRIMARY KEY, | ||
| organization_id TEXT NOT NULL, | ||
| repository_id TEXT NOT NULL, | ||
| workflow_run_id TEXT NOT NULL, | ||
| name TEXT NOT NULL, | ||
| storage_key TEXT NOT NULL, | ||
| size_in_bytes INTEGER NOT NULL DEFAULT 0, | ||
| status TEXT NOT NULL CHECK(status IN ('pending','uploading','completed','failed','expired')), | ||
| retention_days INTEGER NOT NULL, | ||
| created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| expires_at TIMESTAMP NOT NULL, | ||
| deleted_at TIMESTAMP | ||
| ); | ||
| CREATE UNIQUE INDEX idx_artifacts_run_name ON artifacts(workflow_run_id, name) WHERE deleted_at IS NULL; | ||
| ` | ||
|
|
||
| func newArtifactCleanupTestDB(t *testing.T) *sqlx.DB { | ||
| t.Helper() | ||
| db, err := sql.Open("sqlite3", ":memory:") | ||
| if err != nil { | ||
| t.Fatalf("open sqlite: %v", err) | ||
| } | ||
| if _, err := db.Exec(artifactCleanupTestSchema); err != nil { | ||
| _ = db.Close() | ||
| t.Fatalf("create schema: %v", err) | ||
| } | ||
| t.Cleanup(func() { _ = db.Close() }) | ||
| return sqlx.NewDb(db, "sqlite3") | ||
| } | ||
|
|
||
| type mockArtifactCleanupStorage struct { | ||
| deletedKeys []string | ||
| } | ||
|
|
||
| func (m *mockArtifactCleanupStorage) PresignedPutURL(context.Context, string, string, time.Duration) (string, error) { | ||
| return "", nil | ||
| } | ||
|
|
||
| func (m *mockArtifactCleanupStorage) PresignedGetURL(context.Context, string, string, time.Duration) (string, error) { | ||
| return "", nil | ||
| } | ||
|
|
||
| func (m *mockArtifactCleanupStorage) DeleteObject(_ context.Context, _, key string) error { | ||
| m.deletedKeys = append(m.deletedKeys, key) | ||
| return nil | ||
| } | ||
|
|
||
| func TestArtifactCleanupWorker_HandleCleanup(t *testing.T) { | ||
| db := newArtifactCleanupTestDB(t) | ||
| ctx := context.Background() | ||
|
|
||
| orgID := uuid.MustParse("11111111-1111-1111-1111-111111111111") | ||
| repoID := uuid.MustParse("33333333-3333-3333-3333-333333333333") | ||
| runID := uuid.MustParse("44444444-4444-4444-4444-444444444444") | ||
|
|
||
| mustExec(t, db.DB, `INSERT INTO organizations (id, login, name, plan_tier) VALUES (?, ?, ?, ?)`, | ||
| orgID.String(), "acme", "Acme", "free") | ||
| mustExec(t, db.DB, `INSERT INTO repositories (id, organization_id, name) VALUES (?, ?, ?)`, | ||
| repoID.String(), orgID.String(), "widgets") | ||
| mustExec(t, db.DB, `INSERT INTO workflow_runs (id, organization_id, repository_id, workflow, status) VALUES (?, ?, ?, ?, ?)`, | ||
| runID.String(), orgID.String(), repoID.String(), "ci.yml", "completed") | ||
|
|
||
| now := time.Now().UTC() | ||
| expiredKey := "org/acme/repo/widgets/runs/1/expired.zip" | ||
| futureKey := "org/acme/repo/widgets/runs/1/future.zip" | ||
|
|
||
| artifactRepo := infrarepo.NewArtifactRepository(db) | ||
| expiredArtifact := &entity.Artifact{ | ||
| OrganizationID: orgID, | ||
| RunID: runID, | ||
| Name: "expired-artifact", | ||
| StorageKey: expiredKey, | ||
| ExpiresAt: now.Add(-1 * time.Hour), | ||
| } | ||
| futureArtifact := &entity.Artifact{ | ||
| OrganizationID: orgID, | ||
| RunID: runID, | ||
| Name: "future-artifact", | ||
| StorageKey: futureKey, | ||
| ExpiresAt: now.Add(24 * time.Hour), | ||
| } | ||
| if err := artifactRepo.Create(ctx, expiredArtifact); err != nil { | ||
| t.Fatalf("Create expired artifact: %v", err) | ||
| } | ||
| if err := artifactRepo.Create(ctx, futureArtifact); err != nil { | ||
| t.Fatalf("Create future artifact: %v", err) | ||
| } | ||
| if err := artifactRepo.UpdateStatus(ctx, expiredArtifact.ID, entity.ArtifactStatusCompleted, 100); err != nil { | ||
| t.Fatalf("UpdateStatus expired artifact: %v", err) | ||
| } | ||
| if err := artifactRepo.UpdateStatus(ctx, futureArtifact.ID, entity.ArtifactStatusCompleted, 200); err != nil { | ||
| t.Fatalf("UpdateStatus future artifact: %v", err) | ||
| } | ||
|
|
||
| storage := &mockArtifactCleanupStorage{} | ||
| worker := NewArtifactCleanupWorker(artifactRepo, storage, "artifacts") | ||
| task := asynq.NewTask("artifact:cleanup", nil) | ||
| if err := worker.HandleCleanup(ctx, task); err != nil { | ||
| t.Fatalf("HandleCleanup returned error: %v", err) | ||
| } | ||
|
|
||
| var expiredDeletedAt sql.NullTime | ||
| if err := db.QueryRowContext(ctx, `SELECT deleted_at FROM artifacts WHERE id = ?`, expiredArtifact.ID.String()). | ||
| Scan(&expiredDeletedAt); err != nil { | ||
| t.Fatalf("query expired artifact deleted_at: %v", err) | ||
| } | ||
| if !expiredDeletedAt.Valid { | ||
| t.Fatal("expected expired artifact deleted_at to be set") | ||
| } | ||
|
|
||
| var futureDeletedAt sql.NullTime | ||
| if err := db.QueryRowContext(ctx, `SELECT deleted_at FROM artifacts WHERE id = ?`, futureArtifact.ID.String()). | ||
| Scan(&futureDeletedAt); err != nil { | ||
| t.Fatalf("query future artifact deleted_at: %v", err) | ||
| } | ||
| if futureDeletedAt.Valid { | ||
| t.Fatal("expected non-expired artifact deleted_at to remain NULL") | ||
| } | ||
|
|
||
| if len(storage.deletedKeys) != 1 || storage.deletedKeys[0] != expiredKey { | ||
| t.Fatalf("DeleteObject calls = %v, want [%q]", storage.deletedKeys, expiredKey) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 [high] scheduler.Run() 失敗時に Info ログのみでプロセスが継続する
該当箇所:
scheduler.Run()が起動失敗(Redis 接続不可など)した場合、Infoレベルのログを出力するだけでサーバープロセスは正常稼働を続ける。その結果、@hourlyの artifact cleanup タスクが一切エンキューされないが、オペレーターはエラーに気づきにくい。既存のasynqServer.Run(mux)も同様のパターンであればプロジェクトの慣習に沿っているかもしれないが、少なくともErrorレベルのログを使うべきである。code.error_handling.scheduler_goroutine_silent_stop| confidence: 0.92