Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Workflow

Workflow models the lifecycle of application objects as places and transitions. It supports both multi-place workflows and strict single-state machines, with weighted arcs, transition guards, YAML configuration, events, metadata, registries, diagnostics, and deterministic diagram exporters.

The package keeps definitions immutable and application state in ordinary Go values. Subjects can expose small state methods, use accessor functions, or provide a custom marking store. Event delivery is delegated to event-dispatcher-go.

Installation

Workflow requires Go 1.26 or newer.

go get github.com/lemric/workflow-go

Import the package with the workflow name:

import "github.com/lemric/workflow-go"

Quick start

The following state machine moves an article from draft to reviewed and then to published:

package main

import (
	"fmt"
	"log"

	"github.com/lemric/workflow-go"
)

type Article struct {
	state string
}

func (a *Article) WorkflowState() string {
	return a.state
}

func (a *Article) SetWorkflowState(state string, _ map[string]any) {
	a.state = state
}

func main() {
	draft, err := workflow.NewArc("draft", 1)
	if err != nil {
		log.Fatal(err)
	}
	reviewed, err := workflow.NewArc("reviewed", 1)
	if err != nil {
		log.Fatal(err)
	}
	published, err := workflow.NewArc("published", 1)
	if err != nil {
		log.Fatal(err)
	}

	submit, err := workflow.NewTransition("submit", []workflow.Arc{draft}, []workflow.Arc{reviewed})
	if err != nil {
		log.Fatal(err)
	}
	publish, err := workflow.NewTransition("publish", []workflow.Arc{reviewed}, []workflow.Arc{published})
	if err != nil {
		log.Fatal(err)
	}

	definition, err := workflow.NewDefinition(
		[]string{"draft", "reviewed", "published"},
		[]*workflow.Transition{submit, publish},
		workflow.WithInitialPlaces("draft"),
	)
	if err != nil {
		log.Fatal(err)
	}

	machine, err := workflow.NewStateMachine(definition, workflow.WithName("article"))
	if err != nil {
		log.Fatal(err)
	}

	article := &Article{state: "draft"}
	allowed, err := machine.Can(article, "submit")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(allowed)

	marking, err := machine.Apply(article, "submit", map[string]any{"actor": "editor"})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(article.state, marking.Has("reviewed"))

	// Output:
	// true
	// reviewed true
}

NewStateMachine uses the subject's WorkflowState and SetWorkflowState methods by default. Calling GetMarking initializes an empty subject from the definition's initial place.

Configuration from YAML

Applications may load the same model from a strict YAML document. Portable support names are explicitly bound to Go strategies; method marking stores can read a configured property through Go accessors or an exported field:

registry := workflow.NewRegistry()
bindings := workflow.NewConfigurationBindings()

if err := bindings.RegisterSupport(
	"order",
	workflow.TypeSupportStrategy[*Order](),
); err != nil {
	log.Fatal(err)
}

loader := workflow.NewYAMLLoader(registry, bindings)
if err := loader.Load("config/workflows.yaml"); err != nil {
	log.Fatal(err)
}

The loader uses lemric/yaml-go exclusively for strict, ordered YAML decoding and expression-language-go for configured guards. It supports weighted arcs and both top-level and nested framework.workflows forms. See YAML configuration for the full schema and a weighted-workflow example.

Memory bounds

Runtime inputs and retained package state have safe defaults designed for a 100 MiB service budget. YAML input is read through a bounded reader (1 MiB by default), nesting is limited, registries reject growth past their configured capacity, model constructors reject oversized graphs and markings before copying them, reflection/event-name caches retain a fixed amount, and TraceableWorkflow keeps only its newest 128 calls.

Use DefaultResourceLimits, NewRegistryWithLimits, and NewYAMLLoaderWithLimits to lower limits for a deployment. Limit failures unwrap to ErrResourceLimit; callers should treat them as backpressure/configuration errors. Go's GOMEMLIMIT=100MiB is also recommended when the complete process must target 100 MiB. It is a soft runtime limit, so no library can guarantee survival when caller-owned objects, custom listeners/stores, native allocations, or the rest of the application already exhaust the process budget.

Core model

The package revolves around five concepts:

  1. A place represents a condition or state.
  2. A transition consumes tokens from input places and creates tokens in output places.
  3. A definition is the immutable graph of places and transitions.
  4. A marking records the places currently occupied by a subject.
  5. A workflow evaluates and applies transitions while a marking store reads and writes subject state.

Use NewWorkflow when a subject may occupy several places or use weighted tokens. Use NewStateMachine when exactly one place may be active and every arc has weight one.

Feature overview

Capability Main API
Immutable graph definition NewDefinition, DefinitionBuilder
Multi-place workflow NewWorkflow
Single-state lifecycle NewStateMachine
Weighted input and output arcs Arc, NewTransition
Transition checks and execution Can, Apply, EnabledTransitions
Detailed rejection reasons TransitionBlockers, NotEnabledTransitionError
Custom state persistence MarkingStore, accessor stores
Guard rules and lifecycle events GuardEvent, WithDispatcher
Workflow selection Registry, SupportStrategy
Strict YAML configuration YAMLLoader, ConfigurationBindings
Workflow and node metadata MetadataStore
Diagram export Graphviz, Mermaid, PlantUML dumpers
Runtime inspection TraceableWorkflow, DiagnosticsCollector

Documentation

Error handling

Package errors work with errors.Is. Transition failures also expose structured details through errors.As:

marking, err := machine.Apply(article, "publish", nil)
if err != nil {
	var blocked *workflow.NotEnabledTransitionError
	if errors.As(err, &blocked) {
		for _, blocker := range blocked.Blockers.All() {
			log.Printf("%s: %s", blocker.Code, blocker.Message)
		}
	}
}
_ = marking

An undefined transition unwraps to ErrUndefinedTransition. A defined but unavailable transition unwraps to ErrNotEnabledTransition and carries its blocker list, subject, workflow, and copied context.

Testing and benchmarks

go test ./...
go test -race ./...
go vet ./...

The benchmark suite models 1000 configured workflows and a pool of one million subjects:

go test -run '^$' \
  -bench '^Benchmark(Decode|Load)1000WorkflowsFromYAML' \
  -benchtime=1x -benchmem -count=5 ./...

go test -run '^$' -bench '^BenchmarkConfigure1000Workflows$' \
  -benchtime=1000x -benchmem ./...

go test -run '^$' \
  -bench '^Benchmark(RegistryLookup|CanMillion|ApplyMillion)' \
  -benchtime=1000000x -benchmem ./...

For the YAML benchmarks, one operation is one complete 1000-workflow deployment load. For the runtime benchmarks, one operation is one configuration, lookup, check, or apply call. The production-sized benchtime values control how many individual runtime operations are executed, so their allocs/op remains a per-action metric.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages