Workflow Orchestration Library for Go

repository·main·Indexed 42 days ago

https://github.com/luno/workflow

A type-safe, event-driven orchestration library for Go designed for building robust, distributed state machines. It features compile-time safety via Go generics, automatic retries, and an infrastructure-agnostic design with pluggable adapters for Kafka, SQL, and in-memory storage. It implements the Transactional Outbox Pattern to ensure exactly-once event processing and provides a flexible builder API for defining state transitions and external connectors.

Tokens
83.8K
Snippets
259
Records
304
Agent score
53%

What's inside Workflow

  1. What is a Step in Workflow?

    main

    A Step is the fundamental building block of a workflow. It is a function that implements business logic by processing the current workflow state.

    Each step:

    1. Receives the current Run state (containing your business data).
    2. Executes logic (e.g., database updates, API calls).
    3. Returns the next Status to transition to, or an error to trigger a retry.

    Steps are type-safe, using a ConsumerFunc signature that binds a specific data type and status type to the step logic.

    type ConsumerFunc[Type any, Status StatusType] func(
        ctx context.Context,
        r *Run[Type, Status]
    ) (Status, error)
  2. What is a Workflow and how is it defined?

    main

    A Workflow is a type-safe, event-driven, and durable definition of how data flows through a series of steps to accomplish a business process. In Workflow, workflows are defined using Go generics to ensure that both the data type and the status enum are checked at compile time.

    Key characteristics:

    • Type-safe: Uses Go generics for data and status validation.
    • Event-driven: Steps communicate via events, allowing for distributed execution.
    • Durable: All state changes are persisted to survive restarts.
    • Scalable: Uses role-based scheduling for horizontal scaling.
    // A workflow processes data of type Order through OrderStatus states
    type Workflow[Order, OrderStatus] interface {
        Trigger(ctx context.Context, foreignID string, opts ...TriggerOption) (string, error)
        Await(ctx context.Context, foreignID, runID string, status OrderStatus) (*Run[Order, OrderStatus], error)
        Callback(ctx context.Context, foreignID string, status OrderStatus, payload io.Reader) error
        Run(ctx context.Context)
        Stop()
    }
  3. What is a Run and how does its lifecycle work?

    main

    A Run is an individual instance of a workflow processing specific data. Calling Trigger() creates a new Run.

    A Run contains your business data (Object), a ForeignID (your unique identifier for the business entity), and a RunID (unique to that specific run).

    type Run[Type any, Status StatusType] struct {
        WorkflowName string
        ForeignID    string    // Your unique identifier for this business entity
        RunID        string    // Unique identifier for this specific run
        RunState     RunState  // Lifecycle state (Initiated, Running, Completed, etc.)
        Status       Status    // Your workflow-specific status
        Object       *Type     // Your business data
        CreatedAt    time.Time
        UpdatedAt    time.Time
    }
  4. Understand option inheritance and overrides

    main

    The workflow system uses a hierarchical configuration model. Settings follow this priority (from lowest to highest):

    1. Global Defaults: Set via workflow.WithDefaultOptions() during .Build(). These apply to all steps.
    2. Step-Specific Overrides: Set via .WithOptions() on an individual step. These override any global defaults for that specific step.

    This allows you to define a sane baseline for the whole workflow while fine-tuning critical or high-latency steps individually.

  5. How Workflow's type-safety works

    main

    Workflow uses Go generics to provide compile-time guarantees for your workflow data. When defining a step function, the *workflow.Run[T, S] parameter ensures that r.Object is typed as your specific business object T, and the return value must match your status type S. This prevents runtime type mismatches during state transitions.

    // Your IDE knows exactly what data flows where
    func processPayment(ctx context.Context, r *workflow.Run[Order, OrderStatus]) (OrderStatus, error) {
        // r.Object is typed as *Order, OrderStatus is your enum
        // Compiler catches mismatches before they cause runtime errors
    }
  6. How do Events and Event Flow work?

    main

    An Event is the mechanism by which workflow steps communicate. When a step completes and returns a new Status, the workflow persists the Run, publishes an event to the EventStreamer, and the next step's consumer receives that event to process the Run.

    This architecture provides:

    • Loose coupling: Steps do not call each other directly.
    • Reliability: Events are persisted and retried on failure.
    • Observability: All state changes are observable.
    • Scalability: Steps can run on different machines.
    type Event struct {
        ID        int64             // Unique event identifier
        ForeignID string            // Links to workflow instance
        Type      int               // Status the workflow moved to
        Headers   map[Header]string // Metadata
        CreatedAt time.Time         // Event timestamp
    }
  7. Understand hook limitations

    main

    When designing systems that rely on hooks, be aware of the following constraints:

    1. Eventual Consistency: Hooks are asynchronous and may experience delays.
    2. No Ordering Guarantees: If multiple hooks are registered, they may execute in any order.
    3. No Return Values: Hooks cannot modify the workflow outcome or return values to the workflow engine.
    4. Memory Usage: Hook consumers are responsible for maintaining their own event processing state.
    5. Error Isolation: Errors occurring within a hook do not cause the parent workflow to fail, though they may accumulate.
  8. How to define a Workflow as a State Machine

    main

    Workflows are implemented as state machines where States are your Status enum values, Transitions are defined by your step functions, and Data (the Object) flows through the states.

    Unlike traditional state machines, Workflow state machines support cycles (returning to previous states), carry data that is modified during transitions, and are distributed and durable.

    b := workflow.NewBuilder[Order, OrderStatus]("order-processing")
    
    // Define valid transitions
    b.AddStep(OrderCreated, processPayment, PaymentProcessed, PaymentFailed)
    b.AddStep(PaymentProcessed, reserveInventory, InventoryReserved, InventoryFailed)
    b.AddStep(InventoryReserved, fulfillOrder, OrderFulfilled)
    
    // Cycles are allowed - orders can be retried
    b.AddStep(PaymentFailed, retryPayment, PaymentProcessed, OrderCancelled)
  9. How Role-Based Scheduling and Sharding work

    main

    Role-Based Scheduling ensures that only one instance of a specific process runs at a time in a distributed environment. Each step creates a unique role string following the pattern: workflow-name-status-consumer-shard-of-total-shards.

    Sharding

    To handle high-throughput steps, you can shard across multiple processes using WithOptions(workflow.ParallelCount(n)). This distributes events across shards using consistent hashing on the ForeignID.

    b.AddStep(OrderCreated, processPayment, PaymentProcessed).
        WithOptions(workflow.ParallelCount(5)) // Run 5 parallel instances
  10. How Workflow's architecture works

    main

    Workflow is an event-driven, distributed-first orchestration system designed to be infrastructure-agnostic through an adapter-based architecture.

    Core Principles:

    • Event-Driven: All component communication occurs via events, providing loose coupling and reliability.
    • Distributed-First: Designed to run across multiple instances with built-in coordination.
    • Type-Safe: Uses Go's type system for compile-time correctness.
    • Adapter-Based: Uses pluggable adapters for EventStreamer, RecordStore, TimeoutStore, Logger, and WebUI to remain infrastructure-agnostic.
    • Durable: State changes are persisted with transactional guarantees using a Transactional Outbox pattern.

    High-Level Components:

    1. Workflow Engine: Manages definitions, runtime state, step execution, and error recovery.
    2. Role Scheduler: Coordinates distributed execution, ensuring only one instance of a specific role runs at a time and handling failover.
    3. Adapters: Abstract the underlying infrastructure (e.g., Kafka for streaming, SQL for storage).

    Data Flow:

    1. Trigger: A client calls Trigger(), which creates a Run record in the RecordStore and publishes an event to the EventStreamer.
    2. Processing: A Step Consumer receives an event, loads the state from the RecordStore, executes business logic, updates the state, and publishes a new event.
    3. Transactional Outbox: To ensure exactly-once processing, state updates and event writes are performed within a single database transaction. An outboxProcessor then asynchronously publishes these pending events.
    EventStreamer → Step Consumer → Business Logic → State Update → New Event
  11. How Workflow's infrastructure-agnostic design works

    main

    Workflow is designed to be infrastructure agnostic by using pluggable adapters. You can start with in-memory adapters for development and switch to production-grade infrastructure (like Kafka, SQL, and Rink) by simply changing the arguments passed to Build().

    // Development: Everything in-memory
    ```go
    wf := b.Build(memstreamer.New(), memrecordstore.New(), memrolescheduler.New())

    // Production: Battle-tested infrastructure

    wf := b.Build(kafkastreamer.New(), sqlstore.New(), rinkrolescheduler.New())
  12. How timeouts work in Workflow

    main

    The timeout system allows you to execute logic after a specified duration has elapsed. It is used for delays, SLA monitoring, automatic escalations, and time-based business rules. The system relies on two main components:

    1. TimerFunc: A function that determines when a timeout should expire by returning a time.Time.
    2. TimeoutFunc: A function that executes what happens when the timeout expires.

    Timeout Lifecycle

    1. Creation: When a workflow record reaches a status with timeouts, the auto-inserter creates timeout records.
    2. Storage: Timeout records are persisted with expiration times in a TimeoutStore.
    3. Polling: Timeout pollers continuously check for expired timeouts.
    4. Execution: Expired timeouts trigger their TimeoutFunc.
    5. Completion: Successfully processed timeouts are marked as completed.
    b.AddTimeout(StatusWaiting,
        func(ctx context.Context, r *workflow.Run[Order, Status], now time.Time) (time.Time, error) {
            // Set timeout for 1 hour from now
            return now.Add(time.Hour), nil
        },
        func(ctx context.Context, r *workflow.Run[Order, Status], now time.Time) (Status, error) {
            // Handle timeout - escalate the order
            r.Object.Priority = "HIGH"
            return StatusEscalated, nil
        },
        StatusEscalated,
    )