tasking

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 11, 2026 License: MIT Imports: 10 Imported by: 0

README

tasking — reliable async tasks & DAG workflows for Go

tasking is an embeddable Go library for running asynchronous background tasks and multi-step DAG workflows reliably — with retries, timeouts, cancellation, and a subscribable notification stream — surviving process restarts and worker crashes.

It is a library embedded by other applications: it orchestrates execution, but it does not own multi-tenancy or access control — those belong to the embedding application. Each component's DESIGN.md records the rationale for that boundary.

Use cases

  • Background jobs — submit a unit of work and have it run reliably: immediate or scheduled one-shot, with per-attempt retry (exponential backoff), a per-attempt timeout, and cancellation.
  • Multi-step pipelines — express work as a DAG of steps with dependency edges; the engine fans out steps that can run in parallel, dispatches each step once its parents finish, enforces a workflow-wide deadline, and lets you revive a failed workflow or cancel one (e.g. render → thumbnail → publish).
  • Reacting to state changes — subscribe to task/workflow lifecycle events over Redis pub/sub.

Intentionally out of scope (belongs to the embedding application): multi-tenancy isolation, and access control. Periodic/recurring tasks are also not yet supported (only immediate and scheduled one-shot). See each component's DESIGN.md.

How the pieces fit

flowchart TD
    subgraph app["your application"]
      WF["workflow engine<br>(DAG orchestration + state)"]
      TASK["task engine<br>(execution: retry / timeout / cancel)"]
      NOTIFY["notify<br>(Redis pub/sub over the audit log)"]
    end
    DB[("db<br>durable state +<br>SystemEventAudit log")]

    WF -- "every step runs as a task<br>(__EXECUTE_WORKFLOW_STEP__)" --> TASK
    WF --> DB
    TASK --> DB
    DB -- "un-broadcast audit rows" --> NOTIFY
    NOTIFY -- "step results (creator channel)" --> WF

The workflow engine owns DAG orchestration and state; the task engine owns the execution of each individual step — per-attempt retry and timeout are the task engine's job. Every workflow step, whatever its type, is executed as an ordinary task under the reserved name __EXECUTE_WORKFLOW_STEP__, so the workflow engine inherits the task engine's reliability for free. Both engines write durable, creator-tagged SystemEventAudit rows in the same transaction as each state change; notify turns those rows into a best-effort Redis pub/sub stream — and the workflow scheduler is itself a notify subscriber, which is how step results reach it.

Shared reliability model. Across all three components, the database is the source of truth. Every IPC message is a best-effort poke that lets a component act sooner than its periodic maintenance sweep would; lose a poke and work is delayed, never lost. IPC rides crash-safe reliable Redis queues, handlers are idempotent, and unprocessable messages are quarantined rather than crash-looped.

Components

Component Purpose Docs
Task Engine (task) Reliable async execution of a single unit of work README · DESIGN
Workflow Engine (workflow) DAG orchestration of multi-step workflows over the task engine README · DESIGN
Notifications (notify) Best-effort Redis pub/sub stream over the durable audit log README · DESIGN

Underpinning all three: the db package (db.Client and transaction support — persistence and the SystemEventAudit log) and the models package (configuration structs, wire types, and the engine's state enums).

Sitting above them, the root tasking package provides one-call constructors for the wiring below — NewPersistenceClient, NewTaskWorkerReceiver, NewTaskScheduler, NewTaskClient, DefineTaskQueueForWorkflowStep, NewWorkflowScheduler, NewWorkflowClient, and NewNotificationProducer. Each fills in the standard Redis IPC and executor factories; drop to the per-package constructors (task.NewReceiver, workflow.NewClient, …) when you need to substitute one, as tests do.

Wiring it together

A single embedding application typically stands up all three components, using the root package's constructors. The snippet below is otherwise complete — errors are discarded into _ purely to keep the composition readable.

The composition point to notice is DefineTaskQueueForWorkflowStep: it builds the workflow Step Runner and hands it back as an ordinary task queue config, running under the reserved task name __EXECUTE_WORKFLOW_STEP__. That queue joins your own in one []models.TaskQueueConfig, which is how the workflow engine inherits the task engine for execution, retry and timeout. Processors are supplied declaratively — each is declared on its task, inside the queue that runs it — so there is no runtime registration call: the receiver builds each queue's task name → processor map from the config and hands it to that queue's executor.

// --- One persistence client, shared by every component in this process ---
dbClient, _ := tasking.NewPersistenceClient(postgres.Open(dsn), logger.Warn)

// --- Workflow step handlers: one per step Type ---
stepHandlers := map[string]models.WorkflowStepProcessor{
    "render-html":     renderHandler{},
    "make-thumbnails": thumbnailHandler{},
    "push-cdn":        cdnHandler{},
}

// --- Where workflow plugs into task: this builds the Step Runner and returns an ordinary task
//     queue which runs it under the reserved name __EXECUTE_WORKFLOW_STEP__ ---
workflowQueue, _ := tasking.DefineTaskQueueForWorkflowStep(
    "workflow-queue", 4 /* workers */, 16 /* buffered requests */, dbClient, stepHandlers,
)

// --- Your own queues, plus the workflow one. Task names must be unique across the whole set ---
taskQueues := []models.TaskQueueConfig{
    {
        Name:           "default-queue",
        Workers:        4,
        BufferRequests: 16,
        SupportedTasks: []models.TaskDefinition{
            {
                TaskName:  "resize-image",
                Retry:     models.RetryParam{InitialDelaySec: 5, MaxRetries: 3},
                Processor: resizeProcessor{},
            },
        },
    },
    workflowQueue,
}

// --- Task Receiver: runs the tasks. A nil OnFatal keeps the default (log and terminate) ---
receiver, _ := tasking.NewTaskWorkerReceiver(ctx, models.TaskReceiverConfig{
    Name: "worker-01", SchedulerQueue: "task-scheduler", TaskQueues: taskQueues,
}, dbClient, redisClient, nil)
_ = receiver.Initialize(ctx, nil) // MUST run before Start: reconciles buffered work after a crash
_ = receiver.Start(ctx)
defer receiver.Stop(ctx)

// --- Task Scheduler: the single writer of task state. It gets the SAME taskQueues — that is what
//     tells it which queue to poke for __EXECUTE_WORKFLOW_STEP__ and for your own task names ---
taskScheduler, _ := tasking.NewTaskScheduler(ctx, models.TaskSchedulerConfig{
    MaintenanceTimerIntSecs: 30, SchedulerQueue: "task-scheduler", TaskQueues: taskQueues,
}, dbClient, redisClient, nil)
_ = taskScheduler.Start(ctx)
defer taskScheduler.Stop(ctx)

// --- notify Producer: broadcasts audit rows. EmitCreator is REQUIRED for workflow feedback — the
//     workflow scheduler subscribes on notify:creator:<engine-creator>, and without it every step
//     sits in RUNNING until its deadline expires ---
producer, _ := tasking.NewNotificationProducer(ctx, models.NotificationProducerConfig{
    PollIntervalSecs: 5, BatchSize: 100, EmitCreator: true,
}, dbClient, redisClient)
_ = producer.Start(ctx)
defer producer.Stop(ctx)

// --- Workflow Scheduler: single writer of workflow state. It builds its own task client (to
//     dispatch steps) and notify consumer (to receive their results) internally ---
wfScheduler, _ := tasking.NewWorkflowScheduler(
    ctx,
    models.WorkflowSchedulerConfig{
        MaintenanceTimerIntSecs: 30, SchedulerQueue: "workflow-scheduler",
    },
    "task-scheduler", // the task scheduler's queue, where step tasks are submitted
    dbClient, redisClient, nil,
)
_ = wfScheduler.Start(ctx)
defer wfScheduler.Stop(ctx)

// --- Submit workflows. The step Types listed here must match the handler map above ---
wfClient, _ := tasking.NewWorkflowClient(
    ctx, "my-app", "my-app",
    models.WorkflowClientConfig{SchedulerQueue: "workflow-scheduler"},
    []string{"render-html", "make-thumbnails", "push-cdn"},
    dbClient, redisClient,
)
wf, _ := wfClient.DefineAndRunWorkflow(ctx, workflow.DefineWorkflowParams{ /* ... */ }, nil)
_ = wf

// --- Submit ordinary tasks. Only needed if your app dispatches non-workflow work; the workflow
//     scheduler does not use this client ---
taskClient, _ := tasking.NewTaskClient(
    ctx, "my-app", "my-app",
    models.TaskClientConfig{SchedulerQueue: "task-scheduler", TaskQueues: taskQueues},
    dbClient, redisClient,
)
_ = taskClient

Routing. Because a workflow step is an ordinary task, the queue returned by DefineTaskQueueForWorkflowStep must reach both the receiver's TaskReceiverConfig and the scheduler's TaskSchedulerConfig — the receiver runs the step tasks, the scheduler decides which queue to dispatch them to. Give it to the receiver alone and steps are defined but never routed, so workflows stall with nothing reported. See task/README.md and workflow/README.md for the full flow.

Requirements & getting started

  • Module: github.com/alwitt/tasking (Go 1.26+).
  • PostgreSQL-compatible database — durable state and the SystemEventAudit log, via the db package.
  • Redis — IPC message queues (engine coordination) and pub/sub (notifications).

Build and test targets live in the Makefile.

License

Released under the MIT License.

Documentation

Overview

Package tasking - an embeddable Go library for running asynchronous background tasks and multi-step DAG workflows

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func DefineTaskQueueForWorkflowStep

func DefineTaskQueueForWorkflowStep(
	requestQueue string,
	workers int,
	requestBufferLen int,
	dbClient db.Client,
	workflowStepHandlers map[string]models.WorkflowStepProcessor,
) (models.TaskQueueConfig, error)

DefineTaskQueueForWorkflowStep helper function to define a Task Engine queue config for executing workflow steps through the Task Engine.

Combine the returned config with the rest of your queue configs, and give the combined set to *both* the task receiver (models.TaskReceiverConfig) and the task scheduler (models.TaskSchedulerConfig): the receiver needs it to run step tasks, and the scheduler needs it to know which queue to dispatch them to. Giving it to the receiver alone leaves step tasks defined but never routed, so workflows stall with nothing reported.

Do not also declare models.WorkflowExecutionTaskName in a queue of your own. Task names must be unique across a config, so the duplicate is rejected when the component is constructed.

@param requestQueue string - task queue name for sending workflow step execution requests
@param workers int - number of workers to process tasks from the queue in parallel
@param requestBufferLen int - number of requests a task receiver can locally buffer for execution
@param dbClient db.Client - persistence client; see NewPersistenceClient
@param workflowStepHandlers map[string]models.WorkflowStepProcessor - step Type to processor
    mapping the workflow step runner supports. Fixed at construction; must contain no nil
    processors.
@returns new task engine queue config

func NewNotificationProducer

func NewNotificationProducer(
	parentCtx context.Context,
	producerConfig models.NotificationProducerConfig,
	dbClient db.Client,
	redisClient goutilsRedis.Client,
) (notify.Producer, error)

NewNotificationProducer helper function to define a notification producer - the component which polls the audit log for un-broadcast events and publishes them over Redis pub/sub.

Set producerConfig.EmitCreator when running the Workflow Engine. The workflow scheduler learns a step's outcome by subscribing to the engine's creator channel, and the producer only publishes creator channels when that flag is set, so leaving it false strands every step in RUNNING until its deadline expires.

@param parentCtx context.Context - the parent execution context of the producer
@param producerConfig models.NotificationProducerConfig - notification producer config
@param dbClient db.Client - persistence client; see NewPersistenceClient
@param redisClient goutilsRedis.Client - REDIS client
@returns new notification producer

func NewPersistenceClient

func NewPersistenceClient(dialector gorm.Dialector, dbLogLevel logger.LogLevel) (db.Client, error)

NewPersistenceClient helper function to define a tasking persistence client.

Define one client and pass it to every component in the process. A client owns a connection pool, and each component holds on to the one it is given for its lifetime, so building a client per component opens several pools against the same database to no purpose.

@param dbDialector gorm.Dialector - GORM dialector
@param dbLogLevel logger.LogLevel - SQL log level
@returns new persistence client

func NewTaskClient

func NewTaskClient(
	parentCtx context.Context,
	clientName string,
	taskCreator string,
	clientConfig models.TaskClientConfig,
	dbClient db.Client,
	redisClient goutilsRedis.Client,
) (task.Client, error)

NewTaskClient helper function to define a Task Engine client

@param parentCtx context.Context - the parent execution context of the client
@param clientName string - client name
@param taskCreator string - task creator name. Bind tasks created by this client to this name
@param clientConfig models.TaskClientConfig - task client config
@param dbClient db.Client - persistence client; see NewPersistenceClient
@param redisClient goutilsRedis.Client - REDIS client
@returns new task client

func NewTaskScheduler

func NewTaskScheduler(
	parentCtx context.Context,
	schedulerConfig models.TaskSchedulerConfig,
	dbClient db.Client,
	redisClient goutilsRedis.Client,
	onFatalCB models.OnFatalCB,
) (task.Scheduler, error)

NewTaskScheduler helper function to define a Task Engine worker scheduler

@param parentCtx context.Context - the parent execution context of the scheduler
@param schedulerConfig models.TaskSchedulerConfig - task scheduler config
@param dbClient db.Client - persistence client; see NewPersistenceClient
@param redisClient goutilsRedis.Client - REDIS client
@param onFatalCB models.OnFatalCB - when set, is invoked (at most once) when a queue-processing
    goroutine hits an unrecoverable fault instead of terminating the process. reporter identifies
    the faulting queue thread; err carries the cause (with code position in its chain); timestamp
    is when it was detected. When nil, the default logs and calls log.Fatal, preserving prior
    behavior. The goroutine exits after the callback runs.
@returns new task scheduler

func NewTaskWorkerReceiver

func NewTaskWorkerReceiver(
	parentCtx context.Context,
	receiverConfig models.TaskReceiverConfig,
	dbClient db.Client,
	redisClient goutilsRedis.Client,
	onFatalCB models.OnFatalCB,
) (task.Receiver, error)

NewTaskWorkerReceiver helper function to define a Task Engine Worker request receiver

@param parentCtx context.Context - the parent execution context of the receiver
@param receiverConfig models.TaskReceiverConfig - task receiver config
@param dbClient db.Client - persistence client; see NewPersistenceClient
@param redisClient goutilsRedis.Client - REDIS client
@param onFatalCB models.OnFatalCB - when set, is invoked (at most once) when a queue-processing
    goroutine hits an unrecoverable fault instead of terminating the process. reporter identifies
    the faulting queue thread; err carries the cause (with code position in its chain); timestamp
    is when it was detected. When nil, the default logs and calls log.Fatal, preserving prior
    behavior. The goroutine exits after the callback runs.
@returns new task receiver

func NewWorkflowClient

func NewWorkflowClient(
	parentCtx context.Context,
	clientName string,
	workflowCreator string,
	clientConfig models.WorkflowClientConfig,
	knownStepTypes []string,
	dbClient db.Client,
	redisClient goutilsRedis.Client,
) (workflow.Client, error)

NewWorkflowClient helper function to define a Workflow Engine client

knownStepTypes must list the same step Types the Step Runner was built with - the handler map given to DefineTaskQueueForWorkflowStep. DefineWorkflow rejects a workflow containing a step of any other Type up front, rather than letting it fail mid-run, so a Type missing here is refused at submission and a Type listed here without a matching handler fails when the step is executed.

@param parentCtx context.Context - the parent execution context of the client
@param clientName string - client name
@param workflowCreator string - workflow creator name. Bind workflows created by this client to
    this name
@param clientConfig models.WorkflowClientConfig - workflow client config
@param knownStepTypes []string - the step Types with a registered handler. Must be non-empty
@param dbClient db.Client - persistence client; see NewPersistenceClient
@param redisClient goutilsRedis.Client - REDIS client
@returns new workflow client

func NewWorkflowScheduler

func NewWorkflowScheduler(
	parentCtx context.Context,
	workflowConfig models.WorkflowSchedulerConfig,
	taskSchedulerQueue string,
	dbClient db.Client,
	redisClient goutilsRedis.Client,
	onFatalCB models.OnFatalCB,
) (workflow.Scheduler, error)

NewWorkflowScheduler helper function to define new Workflow Engine scheduler.

@param parentCtx context.Context - the parent execution context of the scheduler
@param workflowConfig models.WorkflowSchedulerConfig - workflow scheduler config
@param taskSchedulerQueue string - task engine scheduler request queue name
@param dbClient db.Client - persistence client; see NewPersistenceClient
@param redisClient goutilsRedis.Client - REDIS client
@param onFatalCB models.OnFatalCB - when set, is invoked (at most once) when a queue-processing
    goroutine hits an unrecoverable fault instead of terminating the process. reporter identifies
    the faulting queue thread; err carries the cause (with code position in its chain); timestamp
    is when it was detected. When nil, the default logs and calls log.Fatal, preserving prior
    behavior. The goroutine exits after the callback runs.
@returns new workflow scheduler

Types

This section is empty.

Directories

Path Synopsis
Package common - common utility structs and functions
Package common - common utility structs and functions
Package db - database controllers for system persistence
Package db - database controllers for system persistence
mocks
db
Package models - system data models
Package models - system data models
Package notify - subscribable notification framework for the task engine.
Package notify - subscribable notification framework for the task engine.
Package task - task processing engine
Package task - task processing engine
Package test - various support components used in unit-testing.
Package test - various support components used in unit-testing.
utils
atlas-migrate command
Package main - Atlas GORM migration support binary
Package main - Atlas GORM migration support binary
Package workflow - DAG workflow engine layered on the task engine
Package workflow - DAG workflow engine layered on the task engine

Jump to

Keyboard shortcuts

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