harmonytask

package
v1.28.6 Latest Latest
Warning

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

Go to latest
Published: Aug 28, 2026 License: Apache-2.0, MIT Imports: 31 Imported by: 0

Documentation

Overview

Package harmonytask implements a pure (no task logic), distributed task manager. This clean interface lets task implementers avoid scheduling and cluster coordination; they only implement work units.

Tasks are small pieces of work split out by hardware limits, parallelism, reliability, or other reasons. The task system runs work the node can do up to resource limits. Ordering priority may starve lower priorities, so within a priority class prefer the oldest tasks first.

The hot path is event-driven scheduling with peer-to-peer coordination: nodes tell each other about new work, preempt-cost handshakes, and task starts over HTTP so the cluster reacts in milliseconds without waiting for a database round-trip for every change. The database still drives authoritative claims (UPDATE ... SKIP LOCKED) and holds the queue. A background DB poller remains as a safety net and for periodic housekeeping (e.g. POLL_RARELY), and the poller goroutine also runs heavier queries such as precomputing CanAccept caches and node cordon/restart flags, so not all work is “push-driven.”

The task system tries to run any work the node can do up to resource limits. As queues build, it can reserve resources for soft-claimed (reserved) tasks so higher-priority work is not starved and clusters can respect run order.

Polling alone would be too database-heavy for steady-state coordination, so nodes contact each other to share work and announce starts; the authoritative claim still happens in the database.

ex: prio: prio.P0 | prio.LessThan('x', 'y', 'z') | prio.PipelineOrder('a1', 'b2', 'c3')

The scheduler is single-threaded on the decision path, so CanAccept() and resource accounting do not need mutexes between it and tryWork.

Architecture Overview

The system is built around three cooperating layers:

  1. **Scheduler** (scheduler.go) — A single-threaded event loop that maintains an in-memory map of available tasks and decides when to attempt work. Events arrive from local task additions, peer notifications, task completions, and a background DB poller. The single-threaded design avoids mutex contention on the hot path and keeps CanAccept() versus resource accounting consistent.

  2. **Peering** (peering.go) — On startup each node connects to every known peer (from harmony_machines) over HTTP. Peers exchange JSON messages for verbs such as newTask, started, and preemptCost. That replaces the old “poll the DB every few seconds per task type” steady state with push-style notifications, cutting average task-start latency for latency-sensitive pipelines.

  3. **TaskEngine** (harmonytask.go) — The public API and glue. It owns the handler registry, the scheduler channel, and the peering instance. AddTaskByName writes to the DB, emits a scheduler event, and the scheduler broadcasts to peers without blocking the caller on network I/O.

Key Design Decisions

**Peers and events first; polling second.** Historically the stack polled the DB often for every task type (O(nodes × task_types) queries per interval). Normal operation is now driven by local adds, peer notifications, and task completions. DB polling is a fallback (e.g. POLL_RARELY ≈ 30s) so nodes eventually discover work if a notification is missed. Steady-state DB load drops sharply; polling did not disappear entirely because the poller still backs discovery and other periodic work.

**Offloading work from the scheduler thread.** The scheduler thread must stay responsive. Heavy work runs elsewhere:

  • DB queries run on a background poller goroutine.
  • CanAccept() is pre-computed on the poller and cached on handlers (e.g. SetAcceptCache with mutex).
  • Node flag checks (cordon/restart) run on the poller goroutine.
  • Task execution runs in per-task goroutines.
  • Peer HTTP calls are fire-and-forget from goroutines.

**Bundling rapid events.** When many tasks land at once (e.g. a batch of sector jobs), a bundler coalesces them into one scheduling attempt after a short quiet period (~10ms), avoiding redundant CanAccept + claim cycles per row.

**Reservations.** A node can reserve the next task of a type so that when the current task finishes, the reserved one can start without competing for capacity — important for time-sensitive pipelines (e.g. WindowPost).

NOTE: The reservation protocol is not fully realized cluster-wide. If every node reserves one task of the same type independently, the cluster can hold capacity on N nodes for work only one node will run. A future extension may coordinate reservations across peers.

Mental Model

Things that block tasks:

  • Task type not registered on any running node
  • Max concurrency reached (per-type or shared limiters)
  • Resource exhaustion (CPU, RAM, GPU, storage)
  • CanAccept() refuses (task-specific logic, e.g. wrong miner)

Ways tasks start (event sources):

  • Peer notification: another node added or announced work (fast path)
  • Local addition: this process called AddTaskByName (immediate)
  • Task completion: freed resources trigger re-evaluation
  • DB poll fallback: background poller finds unclaimed work (safety net)

Ways tasks get added:

  • Adder() goroutine: each task type's listener (chain events, etc.)
  • IAmBored: idle capacity triggers speculative work creation
  • External: any code path calling AddTaskByName

How duplicate tasks are avoided:

  • Unique constraints on extra-info tables (task-specific)
  • SKIP LOCKED in the claim query prevents double-claiming

Database Tables

harmony_task: The queue of uncompleted work. Rows are INSERT'd by AddTaskByName (owner_id=NULL) and claimed via UPDATE SET owner_id with SKIP LOCKED. Completed tasks are DELETE'd; failed tasks have owner_id reset to NULL with retries incremented.

harmony_task_history: Audit log of completed and permanently-failed tasks. Grows continuously and needs periodic cleanup.

harmony_machines / harmony_machine_details: Node registry managed by lib/harmony/resources. Used for peer discovery, resource tracking, and the cordon/restart flag mechanism.

Usage

  1. Implement TaskInterface for each task type.
  2. Pass all active implementations to New().
  3. The engine handles scheduling, peering, retries, and cleanup.

Index

Constants

This section is empty.

Variables

View Source
var Registry = map[string]TaskInterface{}

About the Registry This registry exists for the benefit of "static methods" of TaskInterface extensions. For example, GetSPID(db, taskID) (int, err) is a static method that can be called

from any task that has a GetSPID method. This is useful for the web UI to
be able to indicate the SpID for a task.

Reg is a task registry full of nil implementations. Even if NOT running, a nil task should be registered here.

View Source
var TaskMeasures = struct {
	Uptime            *stats.Int64Measure
	TasksStarted      *stats.Int64Measure
	TasksCompleted    *stats.Int64Measure
	TasksFailed       *stats.Int64Measure
	TaskDuration      *stats.Float64Measure
	TaskScheduledWait *stats.Float64Measure
	ActiveTasks       *stats.Int64Measure
	CpuUsage          *stats.Float64Measure
	GpuUsage          *stats.Float64Measure
	RamUsage          *stats.Float64Measure
	PollerIterations  *stats.Int64Measure
	AddedTasks        *stats.Int64Measure
}{
	Uptime:            stats.Int64(pre+"uptime", "Total uptime of the node in seconds.", stats.UnitSeconds),
	TasksStarted:      stats.Int64(pre+"tasks_started", "Total number of tasks started.", stats.UnitDimensionless),
	TasksCompleted:    stats.Int64(pre+"tasks_completed", "Total number of tasks completed successfully.", stats.UnitDimensionless),
	TasksFailed:       stats.Int64(pre+"tasks_failed", "Total number of tasks that failed.", stats.UnitDimensionless),
	TaskDuration:      stats.Float64(pre+"task_duration_seconds", "The histogram of task durations in seconds.", stats.UnitSeconds),
	TaskScheduledWait: stats.Float64(pre+"task_scheduled_wait_seconds", "The histogram of task wait times from posting or previous attempt completion to work start in seconds.", stats.UnitSeconds),
	ActiveTasks:       stats.Int64(pre+"active_tasks", "Current number of active tasks.", stats.UnitDimensionless),
	CpuUsage:          stats.Float64(pre+"cpu_usage", "Percentage of CPU in use.", stats.UnitDimensionless),
	GpuUsage:          stats.Float64(pre+"gpu_usage", "Percentage of GPU in use.", stats.UnitDimensionless),
	RamUsage:          stats.Float64(pre+"ram_usage", "Percentage of RAM in use.", stats.UnitDimensionless),
	PollerIterations:  stats.Int64(pre+"poller_iterations", "Total number of poller iterations.", stats.UnitDimensionless),
	AddedTasks:        stats.Int64(pre+"added_tasks", "Total number of tasks added.", stats.UnitDimensionless),
}

TaskMeasures groups all harmonytask metrics.

Functions

func MetaValue added in v1.28.3

func MetaValue[T any](ctx context.Context, key any) (T, bool)

MetaValue reads a value from the context built for task completion callbacks.

func Reg added in v1.23.0

func Reg(t TaskInterface) bool

func SetMeta added in v1.28.3

func SetMeta(ctx context.Context, key, value any)

SetMeta records a key-value pair for OnTaskComplete callbacks after Do returns. key must be comparable (e.g. a package-level typed sentinel in the task package). No-op if ctx was not created by the task engine or value is nil.

func SingletonTaskAdder

func SingletonTaskAdder(minInterval time.Duration, task TaskInterface) func(AddTaskFunc) error

Types

type AddTaskFunc

type AddTaskFunc func(extraInfo func(TaskID, *harmonydb.Tx) (shouldCommit bool, seriousError error))

AddTaskFunc is responsible for adding a task's details "extra info" to the DB. It should return true if the task should be added, false if it was already there. This is typically accomplished with a "unique" index on your detals table that would cause the insert to fail. The error indicates that instead of a conflict (which we should ignore) that we actually have a serious problem that needs to be logged with context.

type PeerConnection added in v1.28.3

type PeerConnection interface {
	SendMessage(message []byte) error
	ReceiveMessage() ([]byte, error)
	Close() error
}

PeerConnection represents a bidirectional communication channel with a single peer. Messages are opaque byte slices (JSON-encoded PeerMessage).

type PeerConnectorInterface added in v1.28.3

type PeerConnectorInterface interface {
	ConnectToPeer(peerID string) (PeerConnection, error)
	SetOnConnect(onConnect func(peerAddr string, conn PeerConnection))
}

PeerConnectorInterface abstracts the transport layer for peer connections. Production uses HTTP POST (lib/harmony_peer_http); tests use in-memory channel pipes (pipetest). This abstraction lets the peering logic be transport-agnostic and fully testable without network I/O.

type PeerMessage added in v1.28.3

type PeerMessage struct {
	Verb   string    `json:"verb"`
	TaskID TaskID    `json:"taskID,omitempty"`
	Other  taskOther `json:"other,omitempty"`
}

PeerMessage is the JSON envelope for all peer-to-peer messages. The protocol is simple by design: each message is a self-contained JSON object sent over the transport (HTTP POST in production, channel pipes in tests). The three-field structure keeps serialization overhead minimal; verb-specific fields live in taskOther (identity uses hostAndPort; preempt uses cost; newTask uses retries and posted; others use taskType alone).

type PipeNetwork added in v1.28.3

type PipeNetwork struct {
	// contains filtered or unexported fields
}

PipeNetwork is an in-memory peer network for testing.

func NewPipeNetwork added in v1.28.3

func NewPipeNetwork() *PipeNetwork

NewPipeNetwork creates a new in-memory peer network.

func (*PipeNetwork) NewNode added in v1.28.3

func (n *PipeNetwork) NewNode(addr string) *PipeNode

NewNode registers a node in the pipe network.

type PipeNode added in v1.28.3

type PipeNode struct {
	// contains filtered or unexported fields
}

PipeNode implements PeerConnectorInterface over the in-memory pipe network.

func (*PipeNode) ConnectToPeer added in v1.28.3

func (n *PipeNode) ConnectToPeer(peerAddr string) (PeerConnection, error)

ConnectToPeer creates a pipe pair and triggers the remote node's onConnect.

func (*PipeNode) SetOnConnect added in v1.28.3

func (n *PipeNode) SetOnConnect(fn func(string, PeerConnection))

SetOnConnect sets the callback for incoming peer connections.

type TaskCompleteFunc added in v1.28.3

type TaskCompleteFunc func(ctx context.Context, taskID TaskID, success bool)

TaskCompleteFunc runs in the task goroutine after recordCompletion commits. ctx carries KV pairs the task stashed via SetMeta during Do. success is true when Do returned done=true.

type TaskEngine

type TaskEngine struct {
	// contains filtered or unexported fields
}

TaskEngine is the central coordinator for distributed task scheduling. It owns the handler registry, the event-driven scheduler, and the peering layer. All scheduling decisions flow through the schedulerChannel as events.

The fields are partitioned into three named sub-structs so a reader can tell at a glance how each field is safe to touch:

  • cfg: immutable after New(); read from anywhere without locking.
  • atomics: atomic.Value / atomic.Bool; read from anywhere via atomic methods.
  • state: pointers to internal/* registries whose mutexes and protected data are reachable only through typed, locking method calls.

The top-level fields (handlers, taskMap, peering, schedulerChannel) are themselves immutable after New(); they carry their own concurrency discipline either via delegation (peering) or via the single-threaded scheduler-goroutine invariant (schedulerChannel consumer).

func New

func New(
	db *harmonydb.DB,
	impls []TaskInterface,
	hostnameAndPort string,
	peerConnector PeerConnectorInterface,
	inspector resources.ResourceInspector) (*TaskEngine, error)

New creates a TaskEngine that manages the given task implementations. The engine is task-agnostic: it handles scheduling, resource tracking, peering, and retries while delegating all domain logic to TaskInterface.

peerConnector may be nil for tests or single-node setups that do not open peer connections; outbound peering is then a no-op.

Startup sequence:

  1. Register this machine's resources in the DB.
  2. Build handler registry and validate task names.
  3. Size schedulerChannel for resurrection, then start peering (connect to known cluster nodes for event propagation). Peering must not start before the channel reaches its final value: inbound peers send into it.
  4. Resurrect any tasks this machine owned before a restart — these are re-fed to considerWork so in-progress pipelines resume immediately without waiting for a DB poll cycle.
  5. Launch Adder goroutines for each task type (external event listeners).
  6. Start the scheduler event loop and background poller.

func NewWithReg added in v1.28.3

func NewWithReg(
	db *harmonydb.DB,
	impls []TaskInterface,
	hostnameAndPort string,
	peerConnector PeerConnectorInterface,
	reg *resources.Reg) (*TaskEngine, error)

NewWithReg is like New but uses an existing *resources.Reg (from resources.Register or RegisterWithResources).

func (*TaskEngine) AddTaskByName added in v1.28.3

func (e *TaskEngine) AddTaskByName(name string, extra func(TaskID, *harmonydb.Tx) (bool, error))

AddTaskByName is the single entry point for creating new tasks in the system. It performs a transactional DB insert (owner_id=NULL), then emits a schedulerSourceAdded event so the scheduler immediately considers the new work without waiting for a DB poll cycle.

The event emission is done in a goroutine to avoid blocking the caller (which may be an Adder listener or IAmBored callback) on channel backpressure. The scheduler will broadcast this task to peers via TellOthers(newTask).

Duplicate detection relies on the caller's extra func: if the transaction violates a unique constraint, the task already exists and is silently skipped. Serialization errors are retried with exponential backoff.

func (*TaskEngine) GracefullyTerminate

func (e *TaskEngine) GracefullyTerminate()

GracefullyTerminate hangs until time-sensitive and uninterruptible work has drained, then returns so the process can exit. It cancels the engine context (no new claims) and sets draining so Uninterruptible tasks fail stillOwned() before their next checkpoint (e.g. send lock acquire). Tasks already past that checkpoint are not cancelled; shutdown waits for their Active count.

func (*TaskEngine) Host added in v1.24.3

func (e *TaskEngine) Host() string

func (*TaskEngine) OnTaskComplete added in v1.28.3

func (e *TaskEngine) OnTaskComplete(taskType string, fn TaskCompleteFunc)

OnTaskComplete registers a callback invoked after a successful task completion (Do returned done=true) and after the harmony_task row is finalized in the DB. Multiple callbacks per task type are allowed; they run in registration order. Callbacks must not block for long; they run on the task goroutine.

func (*TaskEngine) OwnerID added in v1.28.3

func (e *TaskEngine) OwnerID() int

OwnerID returns the machine ID assigned to this TaskEngine.

func (*TaskEngine) Resources

func (e *TaskEngine) Resources() resources.Resources

Resources returns the resources available in the TaskEngine's registry.

func (*TaskEngine) ResourcesAvailable

func (e *TaskEngine) ResourcesAvailable() resources.Resources

ResourcesAvailable determines what resources are still unassigned.

func (*TaskEngine) RestartTaskByID added in v1.28.3

func (e *TaskEngine) RestartTaskByID(id TaskID, name string, postedTime time.Time) error

RestartTaskByID re-inserts a previously-failed task into harmony_task using its original ID, then notifies the scheduler so it can claim the work immediately without waiting for a DB poll cycle. If the task is already pending or running (unique-constraint violation), it is silently skipped.

func (*TaskEngine) RunningCount added in v1.27.3

func (e *TaskEngine) RunningCount(name string) int

func (*TaskEngine) TestONLY_SeedAcceptCache added in v1.28.3

func (e *TaskEngine) TestONLY_SeedAcceptCache(taskType string, ids []int64)

TestONLY_SeedAcceptCache injects pre-computed CanAccept IDs for a task type (integration tests for accept-cache miss vs refuse behavior).

func (*TaskEngine) TestONLY_SetPollDuration added in v1.28.3

func (e *TaskEngine) TestONLY_SetPollDuration(d time.Duration)

TestONLY_SetPollDuration overrides the DB polling interval (useful for tests).

func (*TaskEngine) TestONLY_TimeSensitiveSchedulerStarts added in v1.28.3

func (e *TaskEngine) TestONLY_TimeSensitiveSchedulerStarts() uint64

TestONLY_TimeSensitiveSchedulerStarts returns how often the scheduler handled schedulerSourceStartTimeSensitive (preempt + claim) for integration tests.

type TaskID

type TaskID int

type TaskInterface

type TaskInterface interface {
	// Do the task assigned. Call stillOwned before making single-writer-only
	// changes to ensure the work has not been stolen.
	// This is the ONLY function that should attempt to do the work, and must
	// ONLY be called by harmonytask.
	// Indicate if the task no-longer needs scheduling with done=true including
	// cases where it's past the deadline.
	// Optional: call SetMeta(ctx, key, value) with task-specific keys so
	// TaskEngine.OnTaskComplete callbacks receive metadata after a successful run.
	Do(ctx context.Context, taskID TaskID, stillOwned func() bool) (done bool, err error)

	// CanAccept should return if the task can run on this machine. It should
	// return null if the task type is not allowed on this machine.
	// It should select the task it most wants to accomplish.
	// It is also responsible for determining & reserving disk space (including scratch).
	CanAccept([]TaskID, *TaskEngine) ([]TaskID, error)

	// TypeDetails() returns static details about how this task behaves and
	// how this machine will run it. Read once at the beginning.
	TypeDetails() TaskTypeDetails

	// This listener will consume all external sources continuously for work.
	// Do() may also be called from a backlog of work. This must not
	// start doing the work (it still must be scheduled).
	// Note: Task de-duplication should happen in ExtraInfoFunc by
	//  returning false, typically by determining from the tx that the work
	//  exists already. The easy way is to have a unique joint index
	//  across all fields that will be common.
	// Adder should typically only add its own task type, but multiple
	//   is possible for when 1 trigger starts 2 things.
	// Usage Example:
	// func (b *BazType)Adder(addTask AddTaskFunc) {
	//	  for {
	//      bazMaker := <- bazChannel
	//	    addTask("baz", func(t harmonytask.TaskID, txn db.Transaction) (bool, error) {
	//	       _, err := txn.Exec(`INSERT INTO bazInfoTable (taskID, qix, mot)
	//			  VALUES ($1,$2,$3)`, id, bazMaker.qix, bazMaker.mot)
	//         if err != nil {
	//				scream(err)
	//	 		 	return false
	//		   }
	// 		   return true
	//		})
	//	  }
	// }
	Adder(AddTaskFunc)
}

TaskInterface must be implemented in order to have a task used by harmonytask.

type TaskTypeDetails

type TaskTypeDetails struct {
	// Max returns how many tasks this machine can run of this type.
	// Nil (default)/Zero or less means unrestricted.
	// Counters can either be independent when created with Max, or shared between tasks with SharedMax.Make()
	Max taskhelp.Limiter

	// Name is the task name to be added to the task list.
	Name string

	// Peak costs to Do() the task.
	Cost resources.Resources

	// Max Failure count before the job is dropped.
	// 0 = retry forever
	MaxFailures uint

	// RetryWait is the time to wait before retrying a failed task.
	// It is called with the number of retries so far.
	// If nil, it will retry immediately.
	RetryWait func(retries int) time.Duration

	// IAmBored is called (when populated) when there's capacity but no work.
	// Invoked after the waterfall considers known tasks, and on a short idle
	// tick (idleTryInterval) that does not poll/claim existing DB work.
	// Rate-limit inside the callback (passcall.Every / SingletonTaskAdder).
	// Tasks added will be proposed to CanAccept() on this machine.
	// Ex: make new CC sectors, clean-up, or retrying pipelines that failed in later states.
	//
	// This is starved on busy machines, so use it to gather "above and beyond" work only.
	IAmBored func(AddTaskFunc) error

	// CanYield is true if the task should yield when the node is not schedulable.
	// This is implied for background tasks.
	CanYield bool

	// SchedulingOverrides is a map of task names which, when running while the node is not schedulable,
	// allow this task to continue being scheduled. This is useful in pipelines where a long-running
	// task would block a short-running task from being scheduled, blocking other related pipelines on
	// other machines.
	SchedulingOverrides map[string]bool

	// TimeSensitive tasks skip event bundling, can preempt other work when
	// capacity is exhausted, run first in the scheduling waterfall, and block
	// GracefullyTerminate until they finish. They are also not preemptable.
	// Use for deadline-driven work (e.g. WindowPost/WinningPost). For tasks that
	// must not be cancelled mid-flight but should not get that priority or
	// preemption power, set Uninterruptible instead.
	TimeSensitive bool

	// Uninterruptible tasks are never selected as preemption victims and block
	// GracefullyTerminate until Active reaches zero. During drain, stillOwned()
	// returns false so they can exit before irreversible work (e.g. before
	// taking a send lock); once past that checkpoint they are allowed to
	// finish. Unlike TimeSensitive, this does not grant scheduling priority,
	// skip bundling, or the ability to preempt others.
	Uninterruptible bool

	// May Follow is a list of task names whose completion may trigger this task to be scheduled.
	// This does not cause triggering, instead it reduces pipeline latency.
	// Longest paths' end are ran earliest when the oldest task is in that pipeline.
	MayFollow []string
}

Directories

Path Synopsis
internal
acceptcache
Package acceptcache stores the result of a CanAccept() call so the scheduler can reuse it on the next considerWork cycle without paying the (potentially expensive) cost of re-evaluating CanAccept.
Package acceptcache stores the result of a CanAccept() call so the scheduler can reuse it on the next considerWork cycle without paying the (potentially expensive) cost of re-evaluating CanAccept.
peerregistry
Package peerregistry manages the in-memory routing table from task-type names to peer connections.
Package peerregistry manages the in-memory routing table from task-type names to peer connections.
preemptbids
Package preemptbids brokers cross-node preempt-cost responses for a time-sensitive scheduling decision.
Package preemptbids brokers cross-node preempt-cost responses for a time-sensitive scheduling decision.
runnowflags
Package runnowflags is a small, concurrent registry of per-task-name "run now" flags.
Package runnowflags is a small, concurrent registry of per-task-name "run now" flags.
runregistry
Package runregistry tracks the set of currently-executing tasks on a single node.
Package runregistry tracks the set of currently-executing tasks on a single node.
Package pipetest provides in-memory PeerConnectorInterface / PeerConnection implementations for integration testing.
Package pipetest provides in-memory PeerConnectorInterface / PeerConnection implementations for integration testing.

Jump to

Keyboard shortcuts

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