workflow

package
v0.11.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 9, 2026 License: MIT Imports: 7 Imported by: 0

Documentation

Overview

Package workflow is the domain model of a flow and its graph.

The domain does not know what YAML is. Translating the file into these structs lives in internal/application/workflow -- so the file format can change without touching the invariants.

Index

Constants

View Source
const (
	WhenAllSuccess = "all_success" // the default: nothing has failed, and my dependencies succeeded
	WhenAnyFailed  = "any_failed"  // at least one of my dependencies failed
	WhenAllDone    = "all_done"    // all of my dependencies are finished, however they ended
)

The trigger rules, as a CLOSED vocabulary validated at publish.

WhenAllSuccess is the default and it is LOCAL: it asks about this step's own dependencies and nothing else. A failure in an unrelated branch does not stop this one, which is Airflow's rule and what anybody arriving from it expects.

It was not always: this engine used to abort the whole graph at the first failure, and that had a reason -- carrying on after an error produced a partial result that looked complete, and a pipeline ran 28 days late without anyone seeing it. The protection that replaces it is that the run still fails, the graph shows which steps were skipped and why, and the alert still goes out. What is given up is that an unrelated branch now writes its data on a run that failed elsewhere.

View Source
const (
	OnGiveUp  = "give_up" // the default: only when the run runs out of attempts
	OnAttempt = "attempt" // every failed attempt
)

When an on_error fires.

The default is the quiet one, and the reason is whose night it is: a step that fails four times and passes on the fifth would send four messages under the other default, and the cost of that choice falls on whoever is asleep.

View Source
const (
	ChannelSlack = "SLACK"
)

The alert channels a workflow may name, as a CLOSED vocabulary.

It lives in the domain because it is what a YAML is allowed to declare, and it is validated at PUBLISH: an unknown channel is refused when the workflow is published, naming what is valid, rather than discovered on the night the alert was needed -- which is the only night it matters.

Adding a name here without something that delivers to it is how a workflow gets to declare a destination that silently goes nowhere, so the two move together. internal/alerts asserts that they agree.

Variables

This section is empty.

Functions

func AlertChannels added in v0.8.0

func AlertChannels() []string

AlertChannels lists every destination a workflow may name.

func IsEmpty added in v0.8.0

func IsEmpty(v any) bool

Edge links two nodes: From runs before To. IsEmpty decides whether a published value counts as absent, for `unless_empty:`.

The list is JavaScript's falsiness minus the surprises, and it is short on purpose: false, zero, an empty string, null, an empty list and an empty object. Everything else is present.

What it deliberately does NOT do is parse strings. "false" as a STRING is a non-empty string and therefore present, because a step that published the four characters f-a-l-s-e published something, and guessing that it meant a boolean is how a rule starts having opinions its author cannot see. Publish a real boolean.

func TriggerRules added in v0.8.0

func TriggerRules() []string

TriggerRules lists what a `when:` may say.

Types

type Edge

type Edge struct {
	From string
	To   string

	// Label is what this dependency MEANS, shown on the arrow.
	//
	//	depends_on:
	//	  - {step: determine_load_type, label: changed existing data}
	//
	// Empty is the normal case and draws nothing. It exists for the branch: a
	// step with two outgoing arrows and no labels is a diagram that requires
	// opening the source to read, which is the one thing a graph is for.
	//
	// Additive in the stored document, like Runtime and Tools: a Workflow is
	// written whole as JSON with no tags, so an older engine ignores the field
	// and this one reading an older document gets "".
	Label string
}

type Kind

type Kind string

Kind distinguishes how the graph was declared. `chain` is syntactic sugar: the parser turns it into edges before it gets here, so the execution engine only ever knows a DAG. One engine, two ways of writing.

const (
	KindChain Kind = "chain"
	KindDAG   Kind = "dag"
)

type Node

type Node struct {
	ID     string
	Run    string
	Action string
	With   map[string]any

	// Image overrides the workflow's. Empty inherits.
	Image string

	// Resources sizes this step's pod. The gain from separating per step is
	// concrete: a Go fetcher fits in 64Mi while the dbt next to it asks for
	// 1Gi, and under a single image both would pay the larger of the two.
	Resources Resources

	// Runtime and Tools say what this step runs in. Both empty is the normal
	// case: the engine infers from Run and Image, and only what the author
	// DECLARED lands here.
	//
	// They are additive in the stored document. A Workflow is written whole as
	// JSON into workflows.definicao and runs.definicao with no tags, so the Go
	// field name is the key -- an older engine ignores what it does not know,
	// and this one reading an older document gets the zero value, which means
	// "not declared" and falls through to the inference. Nothing to migrate.
	// See TestTheParamKeysAreTheOnDiskFormat for the case where that is NOT
	// true.
	Runtime string
	Tools   []string

	// UnlessEmpty names a context key that decides whether this step runs.
	//
	//	- id: transform
	//	  depends_on: [extract]
	//	  unless_empty: extract.has_rows
	//
	// A KEY, not an expression. The tempting design is
	// `when: "{{ context.extract.rows > 0 }}"`, and a mini expression language
	// is a large commitment: a parser, a type system to say what `>` means
	// across a JSON `any`, a security story because the expression comes out of
	// a YAML somebody else wrote, and error messages that point into a string.
	// Every orchestrator that has one has a bug tracker full of it.
	//
	// Here the STEP decides and publishes a boolean, so the decision lives in
	// the language its author already writes and is testable with their own
	// test framework. The engine reads one key and asks whether it is empty.
	UnlessEmpty string

	// Uses names another workflow whose steps take this one's place.
	//
	//	- id: mlops
	//	  uses: ml_training
	//	  depends_on: [prepare]
	//
	// Expanded at PUBLISH, not at run time: the child's steps become part of
	// this graph, prefixed with this step's id, and this node disappears.
	//
	// The alternative -- a step that triggers a child RUN and waits -- is the
	// design that deadlocked Airflow, and this engine has the same ingredient:
	// Runner.Slots is a per-process semaphore, so a parent holding a slot while
	// waiting for a child that needs slots from the same pool hangs. It appears
	// only under load, which is to say in production.
	//
	// Expansion gives up a child run with its own id and its own history. One
	// run, one graph, one pool.
	Uses string

	// Group draws this step inside a named, collapsible box on the graph.
	//
	//	- id: extract_orders
	//	  group: sales_data_reporting
	//
	// VISUAL only, and that is a decision rather than a shortcut. Airflow's
	// TaskGroups also PREFIX the ids inside them, so `extract` in a group
	// becomes `sales.extract` -- which would change every `depends_on`, every
	// context key and every task_runs row in an existing workflow, for a
	// feature whose whole value is that a big graph is readable. Namespacing
	// can be added later; it is a strict addition to this.
	//
	// A group is a LABEL, not a container: steps keep their global ids, a group
	// may span levels, and nothing about execution changes.
	Group string

	// ForEach maps this step over a list published by a step above it.
	//
	//	- id: load
	//	  depends_on: [extract]
	//	  for_each: extract.partitions
	//
	// The step runs once per element, each with a row, a retry and an exit code
	// of its own. The DAG's SHAPE does not change -- it is still one node with
	// one set of edges, and only the number of rows under it varies -- which is
	// what makes this cheap: graph.Levels never sees it.
	//
	// The fan-out is bounded for free. The list travels in the context, and the
	// context has a 4096-byte ceiling inherited from the kubelet, so a workflow
	// cannot ask for ten thousand pods without first finding a way to say so in
	// four kilobytes. That is a limit worth keeping rather than working around.
	ForEach string

	// Marker says this step does nothing and exists to be a point in the graph.
	//
	//	- id: start
	//	  marker: true
	//
	// It is the EmptyOperator every orchestrator ends up with, and it is not
	// decoration: an `end` that depends on everything turns "did the whole
	// thing finish?" into one node instead of six arrows to follow. Without it,
	// Validate refuses a step with neither `run` nor `action` -- correctly,
	// because that is almost always a mistake, and the two cases must not
	// collapse into one.
	Marker bool

	// When is the trigger rule: under what state of its dependencies this step
	// runs at all. Empty is WhenAllSuccess, which is what every workflow
	// published before this existed means.
	//
	//	- id: notify_failure
	//	  depends_on: [extract]
	//	  when: any_failed
	When string

	// OnError declares that this step announces its own failures.
	//
	//	on_error:
	//	  type: SLACK
	//
	// The DESTINATION is not here and never will be. A webhook is a credential
	// -- whoever holds it posts in the channel as if they were the platform --
	// and a workflow file is written by somebody who is not necessarily allowed
	// to choose where the company's alerts go. The YAML says WHETHER and HOW;
	// the installation says WHERE, through the same environment variable it
	// already uses. It is the argument that made BREVIS_POD_ALLOWED_SECRETS a
	// list the installation controls rather than something the YAML picks.
	//
	// Absent is the normal case: the run-level alert already fires when a run
	// gives up, with no block repeated in any file.
	OnError *OnError

	// Shell decides how the command enters the container. Nil means with a
	// shell, which is what `run:` suggests ("python fetch.py"). False passes the
	// argv directly, for a distroless image -- where `sh -c` would fail with
	// "no such file or directory", an error that says nothing about the
	// cause.
	Shell *bool

	// Env are this step's environment variables, with a literal value in the
	// file. They override the workflow's, name by name.
	//
	//	env:
	//	  BREVIS_LOG_LEVEL: info
	Env map[string]string

	// Secrets are variables whose VALUE never appears in the file. The key is
	// the variable's name; the value is where to find it, as `secret/key`.
	//
	//	secrets:
	//	  GABRIEL_SESSION_COOKIE: gabriel-session/cookie
	//
	// Two keys and not one, on purpose. With a single one, the shortest path to
	// making it work would be pasting the secret into the YAML -- and the YAML
	// is in git. `env:` accepts a literal, `secrets:` does not.
	//
	// Where the coordinate resolves depends on the executor, and the asymmetry
	// is deliberate:
	//
	//   Kubernetes  valueFrom.secretKeyRef{name: gabriel-session, key: cookie}
	//   local       the variable of the same name in the engine's own
	//               environment, and missing is an ERROR -- not an empty string
	//
	// In either case the engine passes it on without reading: the value enters
	// no log, no database and no rendered command.
	Secrets map[string]string
}

Node is a unit of work. Exactly one form of execution must be filled in -- `Run` (a command) or `Action` (a typed action with parameters).

func (Node) UsaShell

func (n Node) UsaShell() bool

UsaShell says whether the command enters through `sh -c`.

func (Node) WhenOf added in v0.8.0

func (n Node) WhenOf() string

WhenOf is the rule this step actually runs under, with the default applied.

type OnError added in v0.8.0

type OnError struct {
	// Type is the channel. Required: an on_error with no type is a step that
	// asks to be announced somewhere unspecified.
	Type string

	// When is OnGiveUp (default) or OnAttempt.
	When string
}

OnError is a step's declaration that it announces its failures.

func (*OnError) Fires added in v0.8.0

func (o *OnError) Fires(gaveUp bool) bool

Fires reports whether this declaration wants an alert now.

type Param

type Param struct {
	Name        string    `json:"Nome"`
	Type        ParamType `json:"Tipo"`
	Default     string    `json:"Default"`
	Description string    `json:"Descricao"`

	// Enum restricts the accepted values. Empty = any that passes the type.
	Enum []string `json:"Enum"`

	// Pattern is a regular expression the value has to match. It exists for the
	// author to WIDEN what the `string` type accepts by default — see
	// safeCharacters.
	Pattern string `json:"Pattern"`
}

Param is a run parameter: what changes between two triggers of the SAME workflow without editing the file.

It was the largest distance between this engine and Kestra/Leoflow. Without params there is no backfill (`load_full=true`) and no window reprocessing, and eight of the data repository's 51 flows could not even be converted — their command carries `{{ inputs.start_date }}` and the like. The tags below are the ON-DISK FORMAT, and they keep the Portuguese field names the Go struct used to have.

A Workflow is stored whole as JSON in `workflows.definicao` and `runs.definicao`, with no tags of its own -- so the Go field NAME was the key. Every workflow published before this rename holds `{"Nome":"date", "Tipo":"string","Descricao":"..."}`, and json.Unmarshal ignores a key it does not know: reading one with the fields renamed and untagged gives a param with no name, no type and no description, with no error and no log. The trigger form would render an empty field and the validation would refuse a value the author declared as valid.

Pinning the keys costs three tags and keeps both directions working: an engine on either side of this commit reads and writes the same document.

func (Param) Accepts added in v0.7.0

func (p Param) Accepts(value string) error

Accepts validates a VALUE against the declaration.

func (Param) Validate added in v0.7.0

func (p Param) Validate() error

Validate checks the param's declaration, not the value.

type ParamType added in v0.7.0

type ParamType string
const (
	ParamString  ParamType = "string"
	ParamBool    ParamType = "boolean"
	ParamInteger ParamType = "integer"
)

type Resources

type Resources struct {
	CPU         string
	Memory      string
	CPULimit    string
	MemoryLimit string
}

Resources are a pod's requests and limits, in Kubernetes' own format ("200m", "1Gi"). Text and not a number on purpose: the format belongs to Kubernetes, and converting to a unit of our own would only create a second vocabulary for the same thing.

func (Resources) Empty added in v0.7.0

func (r Resources) Empty() bool

Empty says whether nothing was declared -- the pod then starts without `resources`, inheriting the namespace's LimitRange.

func (Resources) WithDefaults added in v0.7.0

func (r Resources) WithDefaults(p Resources) Resources

WithDefaults fills what the step did not declare from the workflow's. Inheriting field by field, rather than the whole block, lets a step ask for more memory alone without losing the default CPU.

type Workflow

type Workflow struct {
	Slug     string
	Name     string
	Kind     Kind
	Schedule string // cron; vazio = so disparo manual

	// Tags classify the workflow for search and filtering in the UI. They are
	// free-form labels from the YAML's author, not domain: nothing in the
	// engine depends on them.
	Tags []string

	// Image is the steps' default runtime. In Kubernetes EVERY step becomes a
	// pod, and the image is what defines what that pod knows how to do: a dbt
	// step brings up the dbt image, a Go binary brings up a 10 MB one.
	// Declaring it here avoids repeating the same line in ten steps; a step
	// overrides it when it needs a different runtime.
	Image string

	// Resources is the default CPU and memory request, for the same reason.
	Resources Resources

	// Env are environment variables every step receives. A literal value, which
	// is why they are NOT for secrets: the YAML is in git.
	Env map[string]string

	// Secrets are variables whose value the engine neither sees nor stores. See Node.
	Secrets map[string]string

	// Params are the values that change between two dispatches of the same
	// workflow -- `load_full`, a date window, a limit. See param.go.
	Params []Param

	// MaxActive caps simultaneous runs OF THIS workflow. Zero means no limit.
	//
	// It differs from the global step ceiling: that one protects the CLUSTER,
	// this one protects the DATA. A `*/15` that takes 20 minutes overlaps
	// itself, and two `dbt build` on the same model at once fight over the same
	// table.
	MaxActive int

	Nodes []Node
	Edges []Edge
}

Workflow is a flow's definition. Immutable once published: §22 of the plan requires a Run to keep a snapshot of the version that produced it.

func (Workflow) EnvDe

func (w Workflow) EnvDe(n Node) map[string]string

EnvDe resolves a step's effective literal variables: the workflow's, with the step's on top.

func (Workflow) ImageFor added in v0.7.0

func (w Workflow) ImageFor(n Node) string

ImageFor resolves a step's effective image.

func (Workflow) Resolver

func (w Workflow) Resolver(given map[string]string) (map[string]string, error)

Resolver merges the supplied values with the defaults and validates all of them.

An unknown key is an ERROR, not silence: `--param lod_full=true` with a typo would run the workflow with the default and nobody would notice the backfill did not happen.

func (Workflow) ResourcesFor added in v0.7.0

func (w Workflow) ResourcesFor(n Node) Resources

ResourcesFor resolves a step's effective resources.

func (Workflow) SecretsDe

func (w Workflow) SecretsDe(n Node) map[string]string

SecretsDe resolves a step's effective secrets, by the same rule.

func (Workflow) Validate

func (w Workflow) Validate() error

Validate applies the invariants §5 of the plan requires before saving. Order matters: duplicate IDs and missing dependencies are checked before the cycle, because a graph with a dangling edge cannot be walked.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL