Files
kandev/apps/backend/internal/system/storage/runner.go
T
2026-07-28 09:23:42 +01:00

230 lines
6.1 KiB
Go

package storage
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/kandev/kandev/internal/agent/runtime/activity"
)
type RunStore interface {
CreateRun(ctx context.Context, run *MaintenanceRun) error
TransitionRun(ctx context.Context, id string, next RunState, result json.RawMessage, message string) (MaintenanceRun, error)
}
type CleanupProvider interface {
Name() string
Cleanup(ctx context.Context) (map[string]any, error)
}
// ExplicitCleanupProvider may opt into behavior reserved for a specifically named manual run.
type ExplicitCleanupProvider interface {
CleanupExplicit(ctx context.Context) (map[string]any, error)
}
type RunnerConfig struct {
Activity *activity.Coordinator
Store RunStore
Providers []CleanupProvider
Overview OverviewInvalidator
Force bool
NewID func() string
Now func() time.Time
}
type Runner struct {
activity *activity.Coordinator
store RunStore
providers []CleanupProvider
overview OverviewInvalidator
force bool
newID func() string
now func() time.Time
}
const terminalTransitionTimeout = 5 * time.Second
type BusyError struct {
Resources []activity.BusyResource `json:"busy_resources"`
ForceAvailable bool `json:"force_available"`
}
func (e *BusyError) Error() string { return "storage cleanup is blocked by active Kandev work" }
func NewRunner(config RunnerConfig) *Runner {
newID := config.NewID
if newID == nil {
newID = uuid.NewString
}
now := config.Now
if now == nil {
now = time.Now
}
return &Runner{
activity: config.Activity, store: config.Store, providers: config.Providers,
overview: config.Overview, force: config.Force, newID: newID, now: now,
}
}
func (r *Runner) Run(
ctx context.Context,
trigger RunTrigger,
settings StorageMaintenanceSettings,
) (MaintenanceRun, error) {
run, err := r.createRun(ctx, trigger, settings)
if err != nil {
return MaintenanceRun{}, err
}
quietPeriod := quietPeriodForTrigger(trigger, settings)
lease, busy, err := r.acquireMaintenance(ctx, quietPeriod, trigger)
if errors.Is(err, activity.ErrBusy) {
result := marshalRunResult(map[string]any{"busy_resources": busy})
run, transitionErr := r.transitionRun(ctx, run.ID, RunStateSkippedBusy, result, "host resources are busy")
if transitionErr != nil {
return MaintenanceRun{}, transitionErr
}
if trigger == RunTriggerManual {
return run, &BusyError{
Resources: activity.BusyResourcesForKinds(busy),
ForceAvailable: forceAvailable(busy),
}
}
return run, nil
}
if err != nil {
state := RunStateFailed
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
state = RunStateCancelled
}
run, transitionErr := r.transitionRun(ctx, run.ID, state, nil, err.Error())
if transitionErr != nil {
return MaintenanceRun{}, transitionErr
}
return run, err
}
defer lease.Release()
if _, err := r.transitionRun(ctx, run.ID, RunStateRunning, nil, ""); err != nil {
return MaintenanceRun{}, err
}
result, runErr := r.runProviders(lease.Context())
return r.finishRun(ctx, run.ID, lease.Context(), result, runErr)
}
func (r *Runner) acquireMaintenance(
ctx context.Context,
quietPeriod time.Duration,
trigger RunTrigger,
) (*activity.MaintenanceLease, []activity.Kind, error) {
if r.force && trigger == RunTriggerManual {
return r.activity.TryAcquireMaintenanceForce(ctx)
}
return r.activity.TryAcquireMaintenance(ctx, quietPeriod)
}
func forceAvailable(kinds []activity.Kind) bool {
for _, kind := range kinds {
if kind == activity.KindMaintenanceRunning {
return false
}
}
return len(kinds) > 0
}
func quietPeriodForTrigger(trigger RunTrigger, settings StorageMaintenanceSettings) time.Duration {
if trigger == RunTriggerManual {
return 0
}
return time.Duration(settings.IdleForMinutes) * time.Minute
}
func (r *Runner) createRun(
ctx context.Context,
trigger RunTrigger,
settings StorageMaintenanceSettings,
) (MaintenanceRun, error) {
snapshot, err := json.Marshal(settings)
if err != nil {
return MaintenanceRun{}, fmt.Errorf("encode storage settings snapshot: %w", err)
}
run := MaintenanceRun{
ID: r.newID(), Trigger: trigger, State: RunStateQueued,
SettingsSnapshot: snapshot, Result: json.RawMessage(`{}`), StartedAt: r.now().UTC(),
}
if err := r.store.CreateRun(ctx, &run); err != nil {
return MaintenanceRun{}, err
}
return run, nil
}
func (r *Runner) runProviders(ctx context.Context) (map[string]any, error) {
results := make(map[string]any, len(r.providers))
var errs []error
for _, provider := range r.providers {
if ctx.Err() != nil {
break
}
providerResult, err := provider.Cleanup(ctx)
entry := map[string]any{"result": providerResult}
if err != nil {
entry["error"] = err.Error()
errs = append(errs, fmt.Errorf("%s: %w", provider.Name(), err))
}
results[provider.Name()] = entry
}
return results, errors.Join(errs...)
}
func (r *Runner) finishRun(
ctx context.Context,
id string,
maintenanceCtx context.Context,
result map[string]any,
runErr error,
) (MaintenanceRun, error) {
state := RunStateSucceeded
message := ""
if maintenanceCtx.Err() != nil {
state = RunStateCancelled
message = "maintenance preempted by task activity"
if runErr == nil {
runErr = maintenanceCtx.Err()
}
} else if runErr != nil {
state = RunStateFailed
message = runErr.Error()
}
run, err := r.transitionRun(ctx, id, state, marshalRunResult(result), message)
if err != nil {
return MaintenanceRun{}, err
}
if state == RunStateSucceeded && r.overview != nil {
r.overview.Invalidate()
}
return run, runErr
}
func (r *Runner) transitionRun(
ctx context.Context,
id string,
state RunState,
result json.RawMessage,
message string,
) (MaintenanceRun, error) {
transitionCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), terminalTransitionTimeout)
defer cancel()
return r.store.TransitionRun(transitionCtx, id, state, result, message)
}
func marshalRunResult(result any) json.RawMessage {
encoded, err := json.Marshal(result)
if err != nil {
return json.RawMessage(`{"error":"encode maintenance result"}`)
}
return encoded
}