329 lines
9.2 KiB
Go
329 lines
9.2 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
|
)
|
|
|
|
type stageAction string
|
|
|
|
const (
|
|
stageActionRun stageAction = "run"
|
|
stageActionSkip stageAction = "skip"
|
|
)
|
|
|
|
const (
|
|
staleReasonForcedReplacement = "upstream stage was force-run"
|
|
staleReasonChangedResult = "upstream stage result changed"
|
|
staleReasonFailure = "upstream stage failed"
|
|
staleReasonSelfSkip = "upstream stage self-skipped"
|
|
staleReasonNotResumable = "upstream stage result was not resumable"
|
|
)
|
|
|
|
type priorStageOutcome struct {
|
|
exists bool
|
|
status manifest.StageStatus
|
|
skipReason string
|
|
outputs int
|
|
}
|
|
|
|
type preExecutionDecision struct {
|
|
action stageAction
|
|
markSelectedStale bool
|
|
selectedStaleReason string
|
|
invalidationReason string
|
|
invalidateWhenStarted bool
|
|
}
|
|
|
|
func decideStageExecution(status manifest.StageStatus, force bool, resume *stage.ResumeValidation) preExecutionDecision {
|
|
if force {
|
|
return preExecutionDecision{
|
|
action: stageActionRun,
|
|
invalidationReason: staleReasonForcedReplacement,
|
|
invalidateWhenStarted: true,
|
|
}
|
|
}
|
|
if status != manifest.StatusSucceeded {
|
|
return preExecutionDecision{action: stageActionRun}
|
|
}
|
|
if resume != nil && !resume.Resumable {
|
|
return preExecutionDecision{
|
|
action: stageActionRun,
|
|
markSelectedStale: true,
|
|
selectedStaleReason: resume.Reason,
|
|
invalidationReason: staleReasonNotResumable,
|
|
}
|
|
}
|
|
return preExecutionDecision{action: stageActionSkip}
|
|
}
|
|
|
|
func recordedStageStatus(m *manifest.Manifest, name string) manifest.StageStatus {
|
|
if m == nil || m.Stages == nil {
|
|
return ""
|
|
}
|
|
sr := m.Stages[name]
|
|
if sr == nil {
|
|
return ""
|
|
}
|
|
return sr.Status
|
|
}
|
|
|
|
func applyPreExecutionDecision(m *manifest.Manifest, name string, decision preExecutionDecision, at time.Time) ([]string, error) {
|
|
if !decision.markSelectedStale {
|
|
return nil, nil
|
|
}
|
|
m.MarkStageStale(name, at, decision.selectedStaleReason)
|
|
return invalidateDependentSucceededStagesWithReason(m, name, at, decision.invalidationReason)
|
|
}
|
|
|
|
func beginStageAttempt(m *manifest.Manifest, name string, decision preExecutionDecision, at time.Time) ([]string, error) {
|
|
m.MarkStageRunning(name, at)
|
|
if !decision.invalidateWhenStarted {
|
|
return nil, nil
|
|
}
|
|
return invalidateDependentSucceededStagesWithReason(m, name, at, decision.invalidationReason)
|
|
}
|
|
|
|
func applyCompletedStageOutcome(
|
|
m *manifest.Manifest,
|
|
name string,
|
|
result *stage.StageResult,
|
|
outputs []manifest.ArtifactRecord,
|
|
semanticConfig *manifest.SemanticConfigFingerprint,
|
|
prior priorStageOutcome,
|
|
at time.Time,
|
|
) ([]string, error) {
|
|
if result != nil && result.Disposition == stage.StageDispositionSkipped {
|
|
m.MarkStageSkipped(name, at, result.SkipReason)
|
|
applyStageResultToManifest(m, name, result)
|
|
setSessionStageSemanticConfig(m, name, semanticConfig)
|
|
if prior.isSameSelfSkip(result.SkipReason) {
|
|
return nil, nil
|
|
}
|
|
return invalidateDependentSucceededStagesWithReason(m, name, at, staleReasonSelfSkip)
|
|
}
|
|
|
|
m.MarkStageSucceeded(name, at, outputs)
|
|
applyStageResultToManifest(m, name, result)
|
|
setSessionStageSemanticConfig(m, name, semanticConfig)
|
|
if prior.exists && prior.status == manifest.StatusSucceeded {
|
|
return nil, nil
|
|
}
|
|
return invalidateDependentSucceededStagesWithReason(m, name, at, staleReasonChangedResult)
|
|
}
|
|
|
|
func capturePriorStageOutcome(m *manifest.Manifest, name string) priorStageOutcome {
|
|
if m == nil || m.Stages == nil || m.Stages[name] == nil {
|
|
return priorStageOutcome{}
|
|
}
|
|
record := m.Stages[name]
|
|
outcome := priorStageOutcome{
|
|
exists: true,
|
|
status: record.Status,
|
|
outputs: len(record.Outputs),
|
|
}
|
|
if record.Error != nil && record.Error.Code == "skipped" {
|
|
outcome.skipReason = record.Error.Message
|
|
}
|
|
return outcome
|
|
}
|
|
|
|
func (o priorStageOutcome) isSameSelfSkip(reason string) bool {
|
|
return o.exists &&
|
|
o.status == manifest.StatusSkipped &&
|
|
o.outputs == 0 &&
|
|
o.skipReason == strings.TrimSpace(reason)
|
|
}
|
|
|
|
func loadManifestIfPresent(ctx context.Context, cfg *config.Config) (*manifest.Manifest, error) {
|
|
path := artifacts.SessionManifestPathForCampaign(
|
|
cfg.Pipeline.Workspace.Root,
|
|
cfg.Session.Campaign,
|
|
cfg.Session.SessionID,
|
|
)
|
|
exists, err := fileExists(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("check manifest %q: %w", path, err)
|
|
}
|
|
if !exists {
|
|
return nil, nil
|
|
}
|
|
store := &manifest.LocalStore{}
|
|
m, err := store.Load(ctx, path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load manifest %q: %w", path, err)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func canonicalStageNames() []string {
|
|
all := stage.All()
|
|
out := make([]string, 0, len(all))
|
|
for _, s := range all {
|
|
if s == nil {
|
|
continue
|
|
}
|
|
out = append(out, s.Name())
|
|
}
|
|
return out
|
|
}
|
|
|
|
type invalidationRelation struct {
|
|
canonical []string
|
|
direct map[string][]string
|
|
}
|
|
|
|
var canonicalInvalidationEdges = map[string][]string{
|
|
"prepare": {"transcribe"},
|
|
"transcribe": {"merge"},
|
|
"merge": {"polish"},
|
|
"polish": {"normalize"},
|
|
"normalize": {"trim"},
|
|
"trim": {"render", "extract"},
|
|
"render": {"analyze"},
|
|
"extract": {"analyze"},
|
|
"analyze": {"publish"},
|
|
"publish": {"notify"},
|
|
"notify": {"cleanup"},
|
|
"cleanup": {},
|
|
}
|
|
|
|
func newInvalidationRelation(registry []stage.Stage, direct map[string][]string) (*invalidationRelation, error) {
|
|
canonical := make([]string, 0, len(registry))
|
|
known := make(map[string]struct{}, len(registry))
|
|
for index, candidate := range registry {
|
|
if candidate == nil {
|
|
return nil, fmt.Errorf("canonical stage registry entry %d is nil", index)
|
|
}
|
|
name := strings.TrimSpace(candidate.Name())
|
|
if name == "" {
|
|
return nil, fmt.Errorf("canonical stage registry entry %d has an empty name", index)
|
|
}
|
|
if _, duplicate := known[name]; duplicate {
|
|
return nil, fmt.Errorf("canonical stage registry contains duplicate stage %q", name)
|
|
}
|
|
known[name] = struct{}{}
|
|
canonical = append(canonical, name)
|
|
}
|
|
|
|
cloned := make(map[string][]string, len(direct))
|
|
for source, targets := range direct {
|
|
if _, ok := known[source]; !ok {
|
|
return nil, fmt.Errorf("invalidation relation classifies unknown stage %q", source)
|
|
}
|
|
cloned[source] = []string{}
|
|
seenTargets := make(map[string]struct{}, len(targets))
|
|
for _, target := range targets {
|
|
if _, ok := known[target]; !ok {
|
|
return nil, fmt.Errorf("invalidation relation edge %q -> %q references an unknown stage", source, target)
|
|
}
|
|
if _, duplicate := seenTargets[target]; duplicate {
|
|
return nil, fmt.Errorf("invalidation relation contains duplicate edge %q -> %q", source, target)
|
|
}
|
|
seenTargets[target] = struct{}{}
|
|
cloned[source] = append(cloned[source], target)
|
|
}
|
|
}
|
|
for _, name := range canonical {
|
|
if _, classified := direct[name]; !classified {
|
|
return nil, fmt.Errorf("invalidation relation is missing classification for stage %q", name)
|
|
}
|
|
}
|
|
|
|
relation := &invalidationRelation{canonical: canonical, direct: cloned}
|
|
visiting := make(map[string]bool, len(canonical))
|
|
visited := make(map[string]bool, len(canonical))
|
|
var visit func(string) error
|
|
visit = func(name string) error {
|
|
if visiting[name] {
|
|
return fmt.Errorf("invalidation relation contains a cycle involving stage %q", name)
|
|
}
|
|
if visited[name] {
|
|
return nil
|
|
}
|
|
visiting[name] = true
|
|
for _, target := range relation.direct[name] {
|
|
if err := visit(target); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
visiting[name] = false
|
|
visited[name] = true
|
|
return nil
|
|
}
|
|
for _, name := range canonical {
|
|
if err := visit(name); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return relation, nil
|
|
}
|
|
|
|
func canonicalInvalidationRelation() (*invalidationRelation, error) {
|
|
return newInvalidationRelation(stage.All(), canonicalInvalidationEdges)
|
|
}
|
|
|
|
func (r *invalidationRelation) Dependents(stageName string) ([]string, error) {
|
|
if r == nil {
|
|
return nil, fmt.Errorf("invalidation relation is nil")
|
|
}
|
|
if _, ok := r.direct[stageName]; !ok {
|
|
return nil, fmt.Errorf("unknown stage %q in invalidation relation", stageName)
|
|
}
|
|
reachable := make(map[string]bool, len(r.canonical))
|
|
var collect func(string)
|
|
collect = func(name string) {
|
|
for _, target := range r.direct[name] {
|
|
if reachable[target] {
|
|
continue
|
|
}
|
|
reachable[target] = true
|
|
collect(target)
|
|
}
|
|
}
|
|
collect(stageName)
|
|
out := make([]string, 0, len(reachable))
|
|
for _, name := range r.canonical {
|
|
if reachable[name] {
|
|
out = append(out, name)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func dependentStageNames(stageName string) ([]string, error) {
|
|
relation, err := canonicalInvalidationRelation()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return relation.Dependents(stageName)
|
|
}
|
|
|
|
func invalidateDependentSucceededStagesWithReason(m *manifest.Manifest, upstreamStage string, at time.Time, reason string) ([]string, error) {
|
|
dependents, err := dependentStageNames(upstreamStage)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if m == nil || m.Stages == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
invalidated := make([]string, 0)
|
|
for _, dependent := range dependents {
|
|
sr := m.Stages[dependent]
|
|
if sr == nil || sr.Status != manifest.StatusSucceeded {
|
|
continue
|
|
}
|
|
m.MarkStageStale(dependent, at, reason)
|
|
invalidated = append(invalidated, dependent)
|
|
}
|
|
return invalidated, nil
|
|
}
|