20308a2d16
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
248 lines
9.3 KiB
Go
248 lines
9.3 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
kandevdb "github.com/kandev/kandev/internal/db"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
"github.com/kandev/kandev/internal/task/repository/repoerrors"
|
|
)
|
|
|
|
// SwitchTaskRunner implements repository.TaskRepository.SwitchTaskRunner.
|
|
// See the interface doc comment for the contract; this method is the single
|
|
// transaction system-design's "Control flow" steps 5-7 describe.
|
|
func (r *Repository) SwitchTaskRunner(ctx context.Context, req models.RunnerSwitchRequest) (*models.RunnerSwitchResult, error) {
|
|
tx, err := r.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
if err := kandevdb.LockTaskRowInTx(ctx, tx, r.db.DriverName(), req.TaskID); err != nil {
|
|
if errors.Is(err, kandevdb.ErrTaskRowNotFound) {
|
|
return nil, fmt.Errorf("%w: %s", ErrTaskNotFound, req.TaskID)
|
|
}
|
|
return nil, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err)
|
|
}
|
|
|
|
task, err := r.runnerSwitchReadTaskTx(ctx, tx, req.TaskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
verdict, repoSnapshot, err := r.runnerSwitchEvaluate(ctx, req, task)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !verdict.Editable {
|
|
return nil, &repoerrors.ErrRunnerMutabilityConflict{Reason: verdict.Reason}
|
|
}
|
|
|
|
switch {
|
|
case req.CompatibilityResolutionFailed:
|
|
// The gate applies to this target, but a lookup pre-transaction
|
|
// resolution needed (a repository read, or the clone-URL candidate
|
|
// lookup) errored or timed out. Reported only here, after the
|
|
// mutability gate above has already passed, so a task that is also
|
|
// ineligible for a stable reason reports that reason instead of this
|
|
// retriable one.
|
|
return nil, fmt.Errorf("%w: compatibility resolution failed", repoerrors.ErrRunnerEvaluationUnavailable)
|
|
case req.CompatibilityChecked:
|
|
if err := runnerSwitchConfirmCompatibility(req, repoSnapshot); err != nil {
|
|
return nil, err
|
|
}
|
|
case req.CompatibilityApplicable:
|
|
// The gate applies to this target but pre-transaction resolution
|
|
// skipped it because the repository shape didn't allow evaluation
|
|
// then. Reaching this point already means the mutability gate found
|
|
// exactly one repository now (its own RepositoryCount==0/>1 checks
|
|
// come first and would have rejected otherwise), so the shape did
|
|
// change since resolution and that stale skip cannot be trusted.
|
|
return nil, fmt.Errorf("%w: repository shape changed since compatibility resolution", repoerrors.ErrRunnerEvaluationUnavailable)
|
|
}
|
|
|
|
result, err := r.runnerSwitchApply(ctx, tx, task, req.ExecutorProfileID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (r *Repository) runnerSwitchReadTaskTx(ctx context.Context, tx *sqlx.Tx, taskID string) (*models.Task, error) {
|
|
row := tx.QueryRowContext(ctx, r.db.Rebind(
|
|
`SELECT `+taskSelectColumns("t")+` FROM tasks t WHERE t.id = ?`), taskID)
|
|
task, err := r.scanSingleTask(row)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("%w: %s", ErrTaskNotFound, taskID)
|
|
}
|
|
return nil, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err)
|
|
}
|
|
return task, nil
|
|
}
|
|
|
|
// runnerRepositoryLinkSnapshot is the single attached repository's identity
|
|
// and last-updated column, read inside the switch transaction. Empty when
|
|
// the task does not have exactly one repository at that moment.
|
|
type runnerRepositoryLinkSnapshot struct {
|
|
count int
|
|
repositoryID string
|
|
updatedAt time.Time
|
|
}
|
|
|
|
// runnerSwitchEvaluate gathers every mutability signal inside the open
|
|
// transaction (after the row lock, so every read is as current as the write
|
|
// it guards) and evaluates the mutability gate.
|
|
func (r *Repository) runnerSwitchEvaluate(
|
|
ctx context.Context, req models.RunnerSwitchRequest, task *models.Task,
|
|
) (models.RunnerMutabilityVerdict, runnerRepositoryLinkSnapshot, error) {
|
|
unavailable := func(err error) (models.RunnerMutabilityVerdict, runnerRepositoryLinkSnapshot, error) {
|
|
return models.RunnerMutabilityVerdict{}, runnerRepositoryLinkSnapshot{},
|
|
fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err)
|
|
}
|
|
|
|
repoSnapshot, err := r.runnerRepositoryLinkSnapshot(ctx, task.ID)
|
|
if err != nil {
|
|
return unavailable(err)
|
|
}
|
|
hasSession, err := r.runnerHasSession(ctx, task.ID)
|
|
if err != nil {
|
|
return unavailable(err)
|
|
}
|
|
hasEnvironment, err := r.runnerHasEnvironment(ctx, task.ID)
|
|
if err != nil {
|
|
return unavailable(err)
|
|
}
|
|
hasExecutorRunning, err := r.runnerHasExecutorRunning(ctx, task.ID)
|
|
if err != nil {
|
|
return unavailable(err)
|
|
}
|
|
hasWorkspaceFolder, err := r.runnerHasWorkspaceFolder(ctx, task.ID)
|
|
if err != nil {
|
|
return unavailable(err)
|
|
}
|
|
hasGroupMembership, err := runnerHasGroupMembership(ctx, req, task.ID)
|
|
if err != nil {
|
|
return unavailable(err)
|
|
}
|
|
|
|
signals := models.RunnerSignalsFromTask(task)
|
|
signals.RepositoryCount = repoSnapshot.count
|
|
signals.HasSession = hasSession
|
|
signals.HasEnvironment = hasEnvironment
|
|
signals.HasExecutorRunning = hasExecutorRunning
|
|
signals.HasWorkspaceFolder = hasWorkspaceFolder
|
|
signals.HasActiveGroupMembership = hasGroupMembership
|
|
|
|
return models.EvaluateRunnerMutability(signals), repoSnapshot, nil
|
|
}
|
|
|
|
func runnerHasGroupMembership(ctx context.Context, req models.RunnerSwitchRequest, taskID string) (bool, error) {
|
|
if req.GroupMembershipChecker == nil {
|
|
return false, nil
|
|
}
|
|
return req.GroupMembershipChecker(ctx, taskID)
|
|
}
|
|
|
|
// runnerSwitchConfirmCompatibility enforces that a compatibility verdict
|
|
// resolved before the transaction is only valid for the repository it was
|
|
// resolved against. A mismatch discards the verdict and rejects as the
|
|
// retriable evaluation_unavailable rather than applying a verdict computed
|
|
// against a repository the task no longer has.
|
|
func runnerSwitchConfirmCompatibility(req models.RunnerSwitchRequest, snapshot runnerRepositoryLinkSnapshot) error {
|
|
if snapshot.count != 1 ||
|
|
snapshot.repositoryID != req.ResolvedRepositoryID ||
|
|
!snapshot.updatedAt.Equal(req.ResolvedRepositoryUpdatedAt) {
|
|
return fmt.Errorf("%w: repository link changed since compatibility resolution", repoerrors.ErrRunnerEvaluationUnavailable)
|
|
}
|
|
if !req.CompatibilityCloneURLFound {
|
|
return repoerrors.ErrRunnerCompatibilityConflict
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runnerSwitchApply is stage 7 of the control flow: compare the requested
|
|
// profile against the stored one and either report a no-op success or write
|
|
// the sole metadata change.
|
|
func (r *Repository) runnerSwitchApply(
|
|
ctx context.Context, tx *sqlx.Tx, task *models.Task, executorProfileID string,
|
|
) (*models.RunnerSwitchResult, error) {
|
|
stored, _ := task.Metadata[models.MetaKeyExecutorProfileID].(string)
|
|
if stored == executorProfileID {
|
|
return &models.RunnerSwitchResult{Task: task, Changed: false}, nil
|
|
}
|
|
now := time.Now().UTC()
|
|
if err := r.setTaskMetadataKeyWithExecutor(ctx, tx, task.ID, models.MetaKeyExecutorProfileID, executorProfileID, now); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err)
|
|
}
|
|
if task.Metadata == nil {
|
|
task.Metadata = map[string]interface{}{}
|
|
}
|
|
task.Metadata[models.MetaKeyExecutorProfileID] = executorProfileID
|
|
task.UpdatedAt = now
|
|
return &models.RunnerSwitchResult{Task: task, Changed: true}, nil
|
|
}
|
|
|
|
// runnerRepositoryLinkSnapshot reads through the reader pool (r.ro), not a
|
|
// transaction. Isolation for this read comes from the task row lock the
|
|
// caller already holds (LockTaskRowInTx in SwitchTaskRunner), not from
|
|
// executing inside tx.
|
|
func (r *Repository) runnerRepositoryLinkSnapshot(ctx context.Context, taskID string) (runnerRepositoryLinkSnapshot, error) {
|
|
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(
|
|
`SELECT repository_id, updated_at FROM task_repositories WHERE task_id = ?`), taskID)
|
|
if err != nil {
|
|
return runnerRepositoryLinkSnapshot{}, err
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var snapshot runnerRepositoryLinkSnapshot
|
|
for rows.Next() {
|
|
var repositoryID string
|
|
var updatedAt time.Time
|
|
if err := rows.Scan(&repositoryID, &updatedAt); err != nil {
|
|
return runnerRepositoryLinkSnapshot{}, err
|
|
}
|
|
snapshot.count++
|
|
snapshot.repositoryID = repositoryID
|
|
snapshot.updatedAt = updatedAt
|
|
}
|
|
return snapshot, rows.Err()
|
|
}
|
|
|
|
func (r *Repository) runnerHasSession(ctx context.Context, taskID string) (bool, error) {
|
|
var exists bool
|
|
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(
|
|
`SELECT EXISTS (SELECT 1 FROM task_sessions WHERE task_id = ?)`), taskID).Scan(&exists)
|
|
return exists, err
|
|
}
|
|
|
|
func (r *Repository) runnerHasEnvironment(ctx context.Context, taskID string) (bool, error) {
|
|
var exists bool
|
|
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(
|
|
`SELECT EXISTS (SELECT 1 FROM task_environments WHERE task_id = ?)`), taskID).Scan(&exists)
|
|
return exists, err
|
|
}
|
|
|
|
func (r *Repository) runnerHasExecutorRunning(ctx context.Context, taskID string) (bool, error) {
|
|
var exists bool
|
|
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(
|
|
`SELECT EXISTS (SELECT 1 FROM executors_running WHERE task_id = ?)`), taskID).Scan(&exists)
|
|
return exists, err
|
|
}
|
|
|
|
func (r *Repository) runnerHasWorkspaceFolder(ctx context.Context, taskID string) (bool, error) {
|
|
var exists bool
|
|
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(
|
|
`SELECT EXISTS (SELECT 1 FROM task_workspace_folders WHERE task_id = ?)`), taskID).Scan(&exists)
|
|
return exists, err
|
|
}
|