Skip to content
quarry Artifact [4a6c55574f]
Login

Artifact [4a6c55574f]

Artifact 4a6c55574ffedac7a8cacc6b39575bff79977a8224b62ad6721286b41318ee22:


package ci

import (
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/url"
	"path"
	"regexp"
	"slices"
	"strings"
	"sync"
	"time"
)

// errTransient marks a launch failure that says nothing about the
// check-in's own pipelines — the store answered strangely, or the
// engine could not be reached. The watermark must NOT advance past
// one: the next round retries, and nothing is silently lost. Its
// counterpart is the content fault (a pipeline that does not compile,
// an engine that refused the document), which DOES advance, because
// retrying a bad definition forever would wedge the watermark — the
// original rationale, kept.
var errTransient = errors.New("transient")

// transient wraps err so the trigger holds the watermark.
func transient(err error) error {
	return fmt.Errorf("%w: %w", errTransient, err)
}

// engineUnreachable reports a network-shaped engine error — the engine
// never answered, as opposed to answering with a refusal.
func engineUnreachable(err error) bool {
	var u *url.Error
	return errors.As(err, &u)
}

// Store is the record-keeping the trigger needs; the OLTP database
// satisfies it.
type Store interface {
	CIWatermark(repo string) (string, bool)
	SetCIWatermark(repo, checkin string) error
	AddRun(runID, repo, checkin, workflow, pipeline, status string) error
	// The deferral (029c4730): check-ins waiting for a worker slot, and
	// how many runs are in flight forge-wide to compare with capacity.
	Defer(repo string, c Checkin) error
	Deferred(repo string) ([]Checkin, error)
	Undefer(repo, checkin string) error
	// RunPipelines lists the pipelines with a run at a check-in that
	// has, or will have, a verdict: not cancelled, not still deferred.
	RunPipelines(repo, checkin string) ([]string, error)
	// Refuse records that a pipeline at a check-in will not run, and
	// why, as a failing check on subject.
	Refuse(repo, subject, pipeline, reason string) error
}

// Repo is the read side the trigger needs: which check-ins are new, and
// what the repository's pipelines look like AT that check-in.
type Repo interface {
	// NewCheckins returns check-ins after the watermark, oldest first.
	// An empty watermark means "the tip only" — a first run must not
	// launch a pipeline for every check-in in a repository's history,
	// which on an imported corpus would be tens of thousands.
	NewCheckins(after string, limit int) ([]Checkin, error)
	// Files lists a check-in's paths.
	Files(version string) ([]string, error)
	// Read returns one file's bytes at a check-in.
	Read(path, version string) ([]byte, error)
	// HasOpenLanding says a branch is proposed somewhere, which is what
	// exempts its check-ins from waiting when the worker is full.
	HasOpenLanding(branch string) bool
	// DefaultBranch names the line a deploy builds from (trunk, main).
	DefaultBranch() string
	// BranchOf names the branch a check-in is on, "" when unknown.
	BranchOf(hash string) string
}

// Checkin is the context a pipeline receives.
type Checkin struct {
	Hash    string
	Branch  string
	Date    string
	User    string
	Comment string
	// DefsAt is where the pipeline definitions are read, when Hash is not
	// a check-in: a premerge pair's runs use the branch tip's pipelines.
	DefsAt string
	// Tag is the tag whose adding started this launch (scopeTag).
	Tag string
}

// defsAt is the check-in whose pipeline files a launch reads.
func (c Checkin) defsAt() string {
	if c.DefsAt != "" {
		return c.DefsAt
	}
	return c.Hash
}

// Pair is the canonical spelling of a premerge subject (RFC 0009): the
// branch tip merged into the target tip.
func Pair(branchTip, targetTip string) string {
	return "pair:" + branchTip + ":" + targetTip
}

// splitPair reads a Pair subject back into its two tips.
func splitPair(s string) (branchTip, targetTip string, ok bool) {
	rest, ok := strings.CutPrefix(s, "pair:")
	if !ok {
		return "", "", false
	}
	branchTip, targetTip, ok = strings.Cut(rest, ":")
	return branchTip, targetTip, ok && branchTip != "" && targetTip != ""
}

// Trigger launches pipelines for check-ins a repository has not seen.
// It is deliberately quiet about the ordinary case — most repositories
// have no ci/ directory, and that is not a condition worth logging on
// every sync round.
func Trigger(e *Engine, st Store, repo string, r Repo, cloneURL string) error {
	if !e.Enabled() {
		return nil
	}
	mark, ok := st.CIWatermark(repo)
	if !ok {
		// First sight of this repository: take the tip as the watermark
		// without running anything. Launching the whole history would
		// be a denial of service against the engine and would say
		// nothing useful about code nobody just changed.
		tip, err := r.NewCheckins("", 1)
		if err != nil || len(tip) == 0 {
			return err
		}
		return st.SetCIWatermark(repo, tip[len(tip)-1].Hash)
	}
	fresh, err := r.NewCheckins(mark, 20)
	if err != nil {
		return err
	}
	// The workers are asked about room only once something is actually
	// about to start: a repository with no pipelines, or a round with
	// nothing new, never touches the engine.
	var rm *room
	need := func() *room {
		if rm == nil {
			x := slots(e, st)
			rm = &x
		}
		return rm
	}
	drain(e, st, repo, r, cloneURL, need)
	for _, c := range fresh {
		pipelines, err := pipelinesAt(r, c)
		if err != nil {
			log.Printf("ci: %s %.12s: %v — watermark held, retrying next round", repo, c.Hash, err)
			return nil
		}
		if len(pipelines) > 0 && defers(*need(), c, r) {
			if err := st.Defer(repo, c); err != nil {
				return err
			}
			log.Printf("ci: %s %.12s on %s: the worker is full; waiting for a slot", repo, c.Hash, c.Branch)
			if err := st.SetCIWatermark(repo, c.Hash); err != nil {
				return err
			}
			continue
		}
		if err := launch(e, st, repo, r, c, cloneURL, pipelines, scopePush); err != nil {
			if errors.Is(err, errTransient) {
				// The failure says nothing about this check-in's own
				// pipelines. Hold the watermark and stop — the next
				// round retries from here, in order, losing nothing.
				// (2026-08-29: a drifted zit parser answered "no
				// files" for three trunk check-ins and the old code
				// walked the watermark past all of them, silently.)
				log.Printf("ci: %s %.12s: %v — watermark held, retrying next round", repo, c.Hash, err)
				return nil
			}
			// A content fault — the pipeline itself, or the engine's
			// judgment of it. Advancing is right: retrying a bad
			// definition forever would wedge the watermark.
			log.Printf("ci: %s %.12s: %v", repo, c.Hash, err)
		}
		if err := st.SetCIWatermark(repo, c.Hash); err != nil {
			return err
		}
		if len(pipelines) > 0 {
			need().take()
		}
	}
	return nil
}

// scope says which pipelines a launch starts. A JSON pipeline's
// top-level "quarry_when" (quarry's key, namespaced so the engine can
// grow its own "when"; the engine ignores unknown keys) narrows where it
// runs: "landing" on the default branch and in landings' merges only,
// "default-branch" on check-ins of the default branch only.
type scope int

const (
	scopePush    scope = iota // a new check-in: landing pipelines only on the default branch
	scopeMissing              // a landing's merge: every pipeline with no run for it yet
	scopeAll                  // an asked-for re-run: everything
	scopeTag                  // a tag added: the workflows whose on.tag matches it, nothing else
)

// The values "quarry_when" may take.
const (
	whenLanding       = "landing"
	whenDefaultBranch = "default-branch"
)

// quarryWhen reads a JSON pipeline's "quarry_when". Any other value is
// an error: a misspelt "default-branch" silently running everywhere
// would put a deploy on every branch.
func quarryWhen(def []byte) (string, error) {
	var p struct {
		When string `json:"quarry_when"`
	}
	if json.Unmarshal(def, &p) != nil {
		return "", nil // the engine's registration says what is wrong with it
	}
	switch p.When {
	case "", whenLanding, whenDefaultBranch:
		return p.When, nil
	}
	return "", fmt.Errorf("quarry_when is %q; it may be %q or %q", p.When, whenLanding, whenDefaultBranch)
}

// launchEvent names what started a launch in `on:`'s words. A pair is a
// landing's merge, judged by its target branch, which is the branch the
// target tip is on. A new check-in is a push to its branch. A run asked
// for by hand is workflow_dispatch; it also runs whatever the check-in's
// push would have (see event.pushed). A tag added is a tag event on the
// tagged check-in.
func launchEvent(c Checkin, sc scope, r Repo) event {
	switch {
	case sc == scopeTag:
		return event{kind: "tag", branch: c.Branch, tag: c.Tag}
	case c.DefsAt != "":
		target := ""
		if _, tip, ok := splitPair(c.Hash); ok {
			target = r.BranchOf(tip)
		}
		return event{kind: "landing", branch: target}
	case sc == scopePush:
		return event{kind: "push", branch: c.Branch}
	}
	pushed := c.Branch
	if pushed == "" {
		pushed = r.BranchOf(c.Hash)
	}
	return event{kind: "workflow_dispatch", pushed: pushed}
}

// yamlQuarryWhen finds quarry_when as a top-level key of a ci.yml,
// where it is not honoured: refusing beats running everywhere a
// pipeline its author meant for landings only.
var yamlQuarryWhen = regexp.MustCompile(`(?m)^quarry_when\s*:`)

// room is what the deferral knows about the workers: how many more
// STEPS they will take right now, or that nobody can say. Unknown never
// defers — a forge whose engine does not list its workers (dagnats#628)
// behaves exactly as before.
type room struct {
	capacity int
	free     int
	known    bool
	e        *Engine
}

func (r *room) full() bool { return r.known && r.free <= 0 }

// take re-measures after a launch. A run holds as many steps as its
// pipeline runs at once — three for quarry's own — so reserving one
// slot per run let a drain start four runs into a worker with room for
// one (measured on 7ead79c34e8f: four waiting branches all launched in
// the same second). The engine already lists the new run's steps as
// queued, so asking again is exact where a guess per run is not.
func (r *room) take() {
	if !r.known {
		return
	}
	busy, err := r.e.Occupied(time.Now())
	if err != nil {
		r.free-- // the engine stopped answering: at least one is gone
		return
	}
	r.free = r.capacity - busy
}

// slots asks the engine for capacity (the sum of every worker's
// max_tasks) and for what occupies it (Engine.Occupied: steps, not
// runs). Either answer missing means unknown. The forge's own ci_run
// cache is not asked: its "running" rows include runs the engine will
// never schedule, which made the first version defer every branch.
func slots(e *Engine, st Store) room {
	workers, err := e.Workers()
	if err != nil || len(workers) == 0 {
		return room{}
	}
	capacity := 0
	now := time.Now()
	for _, w := range workers {
		// A registration the engine has not heard from lately is a
		// worker that restarted: the deploy restarts it, and for a
		// while the engine lists both, doubling the capacity.
		if t, err := time.Parse(time.RFC3339Nano, w.LastSeen); err == nil && now.Sub(t) > workerStale {
			continue
		}
		capacity += w.MaxTasks
	}
	if capacity <= 0 {
		return room{}
	}
	busy, err := e.Occupied(now)
	if err != nil {
		return room{}
	}
	return room{capacity: capacity, free: capacity - busy, known: true, e: e}
}

// defers decides whether a fresh check-in waits (029c4730): only when
// the worker is full, only on a branch, never on trunk — the line a
// deploy builds from — and never on a branch somebody has proposed,
// because a landing is exactly what the wait exists to protect.
func defers(rm room, c Checkin, r Repo) bool {
	if !rm.full() || c.Branch == "" || c.Branch == r.DefaultBranch() {
		return false
	}
	return !r.HasOpenLanding(c.Branch)
}

// Drain releases a repository's waiting check-ins without a new
// check-in to prompt it. Trigger drains only when the store changes, so
// on a quiet repository a wait outlived the room that ended it
// (quarry#296); a timer calls this instead. It asks the workers about
// room only when something is waiting.
func Drain(e *Engine, st Store, repo string, r Repo, cloneURL string) {
	if !e.Enabled() {
		return
	}
	var rm *room
	drain(e, st, repo, r, cloneURL, func() *room {
		if rm == nil {
			x := slots(e, st)
			rm = &x
		}
		return rm
	})
}

// draining serialises drains of one repository: the fold's Trigger and
// the timer's Drain can meet, and both would launch the same waiter.
var draining sync.Map // repo -> *sync.Mutex

// drain launches waiting check-ins, longest wait first, while there is
// room. A check-in whose branch has since been proposed goes regardless
// of room, since a landing is waiting on it.
func drain(e *Engine, st Store, repo string, r Repo, cloneURL string, need func() *room) {
	mu, _ := draining.LoadOrStore(repo, &sync.Mutex{})
	mu.(*sync.Mutex).Lock()
	defer mu.(*sync.Mutex).Unlock()
	waiting, err := st.Deferred(repo)
	if err != nil || len(waiting) == 0 {
		return
	}
	for _, c := range waiting {
		pipelines, err := pipelinesAt(r, c)
		if err != nil {
			log.Printf("ci: %s %.12s: waited, then: %v", repo, c.Hash, err)
			continue
		}
		if len(pipelines) == 0 {
			_ = st.Undefer(repo, c.Hash) // nothing to run after all
			continue
		}
		if need().full() && !r.HasOpenLanding(c.Branch) {
			continue
		}
		if err := launch(e, st, repo, r, c, cloneURL, pipelines, scopePush); err != nil {
			log.Printf("ci: %s %.12s: waited, then: %v", repo, c.Hash, err)
			continue
		}
		need().take()
	}
}

// pipelinesAt lists a check-in's pipeline files, or the transient
// reason it could not: a store that answers no files at all is not a
// check-in with none, and the watermark must not pass it.
func pipelinesAt(r Repo, c Checkin) ([]string, error) {
	files, err := r.Files(c.defsAt())
	if err != nil {
		return nil, transient(err)
	}
	if len(files) == 0 {
		return nil, transient(fmt.Errorf("the store answered no files at all for this check-in"))
	}
	var pipelines []string
	for _, f := range files {
		if IsPipeline(f) {
			pipelines = append(pipelines, f)
		}
	}
	return pipelines, nil
}

// RunAt launches a check-in's pipelines regardless of the watermark —
// the re-run path (RFC 0009 §triggers). An empty checkin means the tip.
func RunAt(e *Engine, st Store, repo string, r Repo, cloneURL, checkin string) error {
	if !e.Enabled() {
		return fmt.Errorf("ci: no engine configured")
	}
	var c Checkin
	if checkin == "" {
		tip, err := r.NewCheckins("", 1)
		if err != nil {
			return err
		}
		if len(tip) == 0 {
			return fmt.Errorf("ci: %s has no check-ins", repo)
		}
		c = tip[len(tip)-1]
	} else {
		c = Checkin{Hash: checkin}
	}
	pipelines, err := pipelinesAt(r, c)
	if err != nil {
		return err
	}
	return launch(e, st, repo, r, c, cloneURL, pipelines, scopeAll)
}

// RunPairAt starts the pipelines a premerge pair has no verdict for:
// the tree accepting branchTip onto targetTip would write, fetched by
// the runner from the forge's archive of the pair (RFC 0009 §premerge).
// The definitions are the branch tip's, the same ones its own run uses.
// Every pipeline runs, "quarry_when": "landing" ones included: a pair
// exists only for a proposed branch.
//
// ran is false when the branch tip has no pipelines at all, which is
// the answer for every later ask about that tip too.
func RunPairAt(e *Engine, st Store, repo string, r Repo, cloneURL, branch, branchTip, targetTip string) (ran bool, err error) {
	if !e.Enabled() {
		return false, fmt.Errorf("ci: no engine configured")
	}
	c := Checkin{Hash: Pair(branchTip, targetTip), Branch: branch, DefsAt: branchTip}
	pipelines, err := pipelinesAt(r, c)
	if err != nil {
		return false, err
	}
	if len(pipelines) == 0 {
		return false, nil
	}
	return true, launch(e, st, repo, r, c, cloneURL, pipelines, scopeMissing)
}

// RerunPairAt runs every pipeline on a landing's merge again, whatever
// already ran for it: a Re-run somebody asked for.
func RerunPairAt(e *Engine, st Store, repo string, r Repo, cloneURL, branch, branchTip, targetTip string) error {
	if !e.Enabled() {
		return fmt.Errorf("ci: no engine configured")
	}
	c := Checkin{Hash: Pair(branchTip, targetTip), Branch: branch, DefsAt: branchTip}
	pipelines, err := pipelinesAt(r, c)
	if err != nil {
		return err
	}
	return launch(e, st, repo, r, c, cloneURL, pipelines, scopeAll)
}

func launch(e *Engine, st Store, repo string, r Repo, c Checkin, cloneURL string, pipelines []string, sc scope) error {
	if len(pipelines) == 0 {
		return nil // no ci/ directory: the common case, silently fine
	}
	var ran []string
	if sc == scopeMissing {
		var err error
		if ran, err = st.RunPipelines(repo, c.Hash); err != nil {
			return transient(err)
		}
	}
	// A landing pipeline runs on the default branch and in a landing's
	// merge (RunPairAt, scopeMissing): a proposed branch's checks are its
	// merge, as a pull_request workflow runs on a pull request's test
	// merge, so its tip does not run them a second time.
	onLine := c.Branch != "" && c.Branch == r.DefaultBranch()
	// A "default-branch" pipeline (a deploy) runs on the default branch
	// and nowhere else, by any door: never in a merge, never on a branch
	// tip, and a re-run by hand only of a default-branch check-in. That
	// re-run carries no Branch (so it supersedes nothing), which is why
	// the check-in's branch is looked up rather than read off c.
	onDefault := func() bool {
		if c.DefsAt != "" {
			return false // a pair: a proposal, not the line
		}
		if c.Branch != "" {
			return onLine
		}
		return r.BranchOf(c.Hash) == r.DefaultBranch()
	}
	// A content refusal is one pipeline's: the rest still start, and it
	// is reported after them. A transient failure returns at once so the
	// watermark holds and the whole check-in is tried again.
	var refused []error
	// A refused pipeline is recorded as a failing check where the
	// check-in's and the landing's panels read checks, so a workflow
	// quarry will not run is never mistaken for one with nothing to
	// say ("All checks have passed"). A pair's lands on its branch tip,
	// where external checks count for the pair.
	refuse := func(p string, err error) {
		refused = append(refused, err)
		subject := c.Hash
		if c.DefsAt != "" {
			subject = c.DefsAt
		}
		if rerr := st.Refuse(repo, subject, p, err.Error()); rerr != nil {
			log.Printf("ci: %s %.12s: recording %s's refusal: %v", repo, subject, p, rerr)
		}
	}
	for _, p := range pipelines {
		if slices.Contains(ran, p) {
			continue // a verdict is here or on its way; nothing to read
		}
		if sc == scopeTag && strings.HasSuffix(p, ".json") {
			continue // no on: to name a tag; quarry_when speaks of pushes and landings
		}
		def, err := r.Read(p, c.defsAt())
		if err != nil {
			return transient(fmt.Errorf("read %s: %w", p, err))
		}
		isJSON := strings.HasSuffix(p, ".json")
		if !isJSON && yamlQuarryWhen.Match(def) {
			refuse(p, fmt.Errorf("%s: quarry_when is read from JSON pipelines only; move this pipeline to %s.json or drop the key", p, strings.TrimSuffix(strings.TrimSuffix(p, ".yml"), ".yaml")))
			continue
		}
		if isJSON {
			when, err := quarryWhen(def)
			if err != nil {
				refuse(p, fmt.Errorf("%s: %w", p, err))
				continue
			}
			if sc == scopePush && when == whenLanding && !onLine {
				continue
			}
			if when == whenDefaultBranch && !onDefault() {
				continue
			}
		}
		// Registered from the definition AT THIS CHECK-IN, so a pipeline
		// edit takes effect for the check-in that made it and not before.
		// A ci.yml is named by its file stem — the spec language has no
		// name field of its own — which is the same posture JSON
		// pipelines already take on a shared engine: the register just
		// before every Start keeps each run on its own definition.
		// Registered under a name that names the REPOSITORY as well as
		// the file, because the engine is shared and a file stem is not
		// unique across repositories. See ScopedName.
		stem := strings.TrimSuffix(strings.TrimSuffix(path.Base(p), ".yml"), ".yaml")
		stem = strings.TrimSuffix(stem, ".json")
		name := ScopedName(repo, stem)
		var wf Workflow
		var on json.RawMessage
		if strings.HasSuffix(p, ".json") {
			wf, err = e.RegisterNamed(name, def)
		} else {
			wf, on, err = e.RegisterYAML(name, def, GroupFor(repo))
		}
		if err != nil {
			if engineUnreachable(err) {
				return transient(err)
			}
			refuse(p, err) // the engine answered: this pipeline's refusal alone
			continue
		}
		// A jobs: workflow says when it runs (RFC 0019 §on); the engine
		// hands on: back uninterpreted, because only the forge knows what
		// a landing or a re-run is.
		if len(on) > 0 || sc == scopeTag {
			ok, err := runsOn(on, launchEvent(c, sc, r))
			if err != nil {
				refuse(p, fmt.Errorf("%s: %w", p, err))
				continue
			}
			if !ok {
				continue
			}
		}
		labels := map[string]string{"repo": repo, "checkin": c.Hash, "pipeline": p}
		if sc == scopeTag {
			// No branch label: a push supersedes by branch, and a release
			// in flight is not a stale build of its branch.
			labels["subject"] = "tag"
			labels["tag"] = c.Tag
		} else if c.Branch != "" {
			labels["branch"] = c.Branch
			// A new check-in on a branch makes the branch's still-running
			// builds stale; stop them (dagnats#629, best-effort). That
			// includes its pair runs, which judged the old tip. A pair
			// run stops only older pair runs: the branch's own tip run
			// is still the verdict on the tip.
			stale := map[string]string{"repo": repo, "branch": c.Branch, "pipeline": p}
			if c.DefsAt != "" {
				labels["subject"] = "pair"
				stale["subject"] = "pair"
			}
			if n, err := e.Supersede(wf.Name, stale); err == nil && n > 0 {
				log.Printf("ci: %s %s: superseded %d run(s)", repo, c.Branch, n)
			}
		}
		input := map[string]any{
			"repo":     repo,
			"checkin":  c.Hash,
			"branch":   c.Branch,
			"date":     c.Date,
			"user":     c.User,
			"comment":  c.Comment,
			"cloneUrl": cloneURL,
			"pipeline": p,
		}
		if sc == scopeTag {
			input["tag"] = c.Tag // a job's steps see it as QUARRY_TAG
		}
		runID, err := e.Start(wf.Name, input, labels)
		if err != nil {
			if engineUnreachable(err) {
				return transient(err)
			}
			refuse(p, err)
			continue
		}
		if err := st.AddRun(runID, repo, c.Hash, wf.Name, p, "running"); err != nil {
			// The run is launched but unrecorded; losing the mapping
			// by advancing would orphan it.
			return transient(err)
		}
		log.Printf("ci: %s %.12s -> %s run %s", repo, c.Hash, wf.Name, runID)
	}
	// Started, by whichever door: the wait, if there was one, is over.
	// A tag's launch is not that door: the check-in still owes its push.
	if sc == scopeTag {
		return errors.Join(refused...)
	}
	if err := st.Undefer(repo, c.Hash); err != nil {
		log.Printf("ci: %s %.12s: forgetting the wait: %v", repo, c.Hash, err)
	}
	return errors.Join(refused...)
}