Files
kandev/apps/backend/internal/task/repository/sqlite/resource_cleanup.go
T
Henry 8f21873d45 feat(ssh): reclaim remote task directories on terminal archive or delete (#3000)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-08-25 06:56:59 +00:00

265 lines
9.6 KiB
Go

package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/kandev/kandev/internal/task/models"
)
const taskResourceCleanupColumns = `
id, operation_id, task_id, trigger, state, resource_snapshot, attempts,
next_attempt_at, last_error, created_at, updated_at, completed_at`
func (r *Repository) CreateTaskResourceCleanupJob(ctx context.Context, job *models.TaskResourceCleanupJob) error {
if job.ID == "" {
job.ID = uuid.NewString()
}
now := time.Now().UTC()
job.CreatedAt = now
job.UpdatedAt = now
if job.State == "" {
job.State = models.TaskResourceCleanupStatePending
}
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO task_resource_cleanup_jobs (`+taskResourceCleanupColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(operation_id) DO NOTHING
`), job.ID, job.OperationID, job.TaskID, job.Trigger, job.State,
job.ResourceSnapshot, job.Attempts, job.NextAttemptAt, job.LastError,
job.CreatedAt, job.UpdatedAt, job.CompletedAt)
return err
}
// UpdateTaskResourceCleanupSnapshot writes the resource inventory captured
// after the prepared barrier was reserved. The barrier row exists before the
// inventory query so concurrent session/worktree creation is rejected while
// the snapshot is being assembled.
func (r *Repository) UpdateTaskResourceCleanupSnapshot(ctx context.Context, operationID, snapshot string) error {
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_resource_cleanup_jobs
SET resource_snapshot = ?, updated_at = ?
WHERE operation_id = ? AND state = ?
`), snapshot, time.Now().UTC(), operationID, models.TaskResourceCleanupStatePrepared)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("task resource cleanup job %s not found or not prepared", operationID)
}
return nil
}
// UpdateClaimedTaskResourceCleanupSnapshot persists outcomes produced by one
// exact running cleanup attempt. A newer retry or cancellation wins when the
// claim no longer matches.
func (r *Repository) UpdateClaimedTaskResourceCleanupSnapshot(ctx context.Context, id string, attempt int, snapshot string) (bool, error) {
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_resource_cleanup_jobs
SET resource_snapshot = ?, updated_at = ?
WHERE id = ? AND state = ? AND attempts = ?
`), snapshot, time.Now().UTC(), id, models.TaskResourceCleanupStateRunning, attempt)
if err != nil {
return false, err
}
rows, _ := result.RowsAffected()
return rows == 1, nil
}
// HasActiveTaskResourceCleanupJob reports whether teardown has been admitted
// for a task. The prepared state is included because the cleanup intent is
// persisted before task deletion and before the worker is allowed to run.
func (r *Repository) HasActiveTaskResourceCleanupJob(ctx context.Context, taskID string) (bool, error) {
var active bool
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT EXISTS (
SELECT 1 FROM task_resource_cleanup_jobs
WHERE task_id = ? AND state IN (?, ?, ?, ?)
)
`), taskID,
models.TaskResourceCleanupStatePrepared,
models.TaskResourceCleanupStatePending,
models.TaskResourceCleanupStateRunning,
models.TaskResourceCleanupStateRetryWait,
).Scan(&active)
return active, err
}
func (r *Repository) GetTaskResourceCleanupJobByOperationID(ctx context.Context, operationID string) (*models.TaskResourceCleanupJob, error) {
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT `+taskResourceCleanupColumns+`
FROM task_resource_cleanup_jobs WHERE operation_id = ?
`), operationID)
return scanTaskResourceCleanupJob(row)
}
func (r *Repository) GetTaskResourceCleanupJob(ctx context.Context, id string) (*models.TaskResourceCleanupJob, error) {
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT `+taskResourceCleanupColumns+`
FROM task_resource_cleanup_jobs WHERE id = ?
`), id)
return scanTaskResourceCleanupJob(row)
}
func (r *Repository) ListPreparedTaskResourceCleanupJobs(ctx context.Context) ([]*models.TaskResourceCleanupJob, error) {
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT `+taskResourceCleanupColumns+`
FROM task_resource_cleanup_jobs
WHERE state = ? ORDER BY created_at ASC
`), models.TaskResourceCleanupStatePrepared)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
jobs := make([]*models.TaskResourceCleanupJob, 0)
for rows.Next() {
job, scanErr := scanTaskResourceCleanupJob(rows)
if scanErr != nil {
return nil, scanErr
}
jobs = append(jobs, job)
}
return jobs, rows.Err()
}
func scanTaskResourceCleanupJob(row interface{ Scan(...any) error }) (*models.TaskResourceCleanupJob, error) {
job := &models.TaskResourceCleanupJob{}
err := row.Scan(&job.ID, &job.OperationID, &job.TaskID, &job.Trigger, &job.State,
&job.ResourceSnapshot, &job.Attempts, &job.NextAttemptAt, &job.LastError,
&job.CreatedAt, &job.UpdatedAt, &job.CompletedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("task resource cleanup job not found")
}
return job, err
}
func (r *Repository) ListDueTaskResourceCleanupJobs(ctx context.Context, now time.Time, limit int) ([]*models.TaskResourceCleanupJob, error) {
if limit <= 0 {
limit = 100
}
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
SELECT `+taskResourceCleanupColumns+`
FROM task_resource_cleanup_jobs
WHERE state = ? OR (state = ? AND (next_attempt_at IS NULL OR next_attempt_at <= ?))
ORDER BY created_at ASC LIMIT ?
`), models.TaskResourceCleanupStatePending, models.TaskResourceCleanupStateRetryWait, now.UTC(), limit)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
jobs := make([]*models.TaskResourceCleanupJob, 0)
for rows.Next() {
job, scanErr := scanTaskResourceCleanupJob(rows)
if scanErr != nil {
return nil, scanErr
}
jobs = append(jobs, job)
}
return jobs, rows.Err()
}
func (r *Repository) MarkTaskResourceCleanupJobRunning(ctx context.Context, id string) (bool, error) {
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_resource_cleanup_jobs
SET state = ?, attempts = attempts + 1, next_attempt_at = NULL, updated_at = ?
WHERE id = ? AND state IN (?, ?)
`), models.TaskResourceCleanupStateRunning, now, id,
models.TaskResourceCleanupStatePending, models.TaskResourceCleanupStateRetryWait)
if err != nil {
return false, err
}
count, _ := result.RowsAffected()
return count == 1, nil
}
func (r *Repository) StartPreparedTaskResourceCleanupJob(ctx context.Context, id string) (bool, error) {
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_resource_cleanup_jobs
SET state = ?, next_attempt_at = NULL, updated_at = ?
WHERE id = ? AND state = ?
`), models.TaskResourceCleanupStatePending, now, id, models.TaskResourceCleanupStatePrepared)
if err != nil {
return false, err
}
count, _ := result.RowsAffected()
return count == 1, nil
}
func (r *Repository) CompleteTaskResourceCleanupJob(ctx context.Context, id string, state models.TaskResourceCleanupState, lastError string, nextAttemptAt *time.Time) error {
now := time.Now().UTC()
var completedAt *time.Time
if state == models.TaskResourceCleanupStateSucceeded ||
state == models.TaskResourceCleanupStateFailed ||
state == models.TaskResourceCleanupStateCancelled {
completedAt = &now
}
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_resource_cleanup_jobs
SET state = ?, last_error = ?, next_attempt_at = ?, completed_at = ?, updated_at = ?
WHERE id = ?
`), state, lastError, nextAttemptAt, completedAt, now, id)
return err
}
// CompleteClaimedTaskResourceCleanupJob applies a worker result only to the
// exact running claim that produced it. A concurrent cancellation or a newer
// retry generation wins and keeps its state and historical metadata.
func (r *Repository) CompleteClaimedTaskResourceCleanupJob(
ctx context.Context,
id string,
attempt int,
state models.TaskResourceCleanupState,
lastError string,
nextAttemptAt *time.Time,
) (bool, error) {
now := time.Now().UTC()
var completedAt *time.Time
if state == models.TaskResourceCleanupStateSucceeded ||
state == models.TaskResourceCleanupStateFailed ||
state == models.TaskResourceCleanupStateCancelled {
completedAt = &now
}
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_resource_cleanup_jobs
SET state = ?, last_error = ?, next_attempt_at = ?, completed_at = ?, updated_at = ?
WHERE id = ? AND state = ? AND attempts = ?
`), state, lastError, nextAttemptAt, completedAt, now, id,
models.TaskResourceCleanupStateRunning, attempt)
if err != nil {
return false, err
}
count, _ := result.RowsAffected()
return count == 1, nil
}
func (r *Repository) CancelArchiveTaskResourceCleanupJobs(ctx context.Context, taskID string) error {
now := time.Now().UTC()
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_resource_cleanup_jobs
SET state = ?, completed_at = ?, updated_at = ?
WHERE task_id = ? AND trigger IN (?, ?) AND state IN (?, ?, ?, ?)
`), models.TaskResourceCleanupStateCancelled, now, now, taskID,
models.TaskResourceCleanupTriggerArchive, models.TaskResourceCleanupTriggerCascadeArchive,
models.TaskResourceCleanupStatePrepared, models.TaskResourceCleanupStatePending,
models.TaskResourceCleanupStateRunning,
models.TaskResourceCleanupStateRetryWait)
return err
}
func (r *Repository) ResetRunningTaskResourceCleanupJobs(ctx context.Context) error {
now := time.Now().UTC()
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_resource_cleanup_jobs
SET state = ?, next_attempt_at = ?, updated_at = ? WHERE state = ?
`), models.TaskResourceCleanupStateRetryWait, now, now, models.TaskResourceCleanupStateRunning)
return err
}