Nomad — Patterns#

Concurrency patterns#

Worker Pool (Scheduler Workers)#

  • Usage: The canonical worker pool in nomad/worker.go. Server.setupWorkers() spawns N Worker goroutines (configurable, default 10). Each worker loops calling evalBroker.Dequeue(), processes one evaluation via the scheduler, and submits the resulting plan back.
  • Example: nomad/worker.goWorker.run() loop with blocking dequeue; nomad/eval_broker.goEvalBroker.Dequeue() with timeout and priority queue
  • Assessment: Textbook worker-pool pattern. The broker decouples producers (Raft FSM creating evals) from consumers (scheduler workers). Gracefully disabled on non-leaders via broker.SetEnabled(false).

Done-Channel Shutdown Pattern#

  • Usage: 414 goroutine launches (go func), 307 close() calls, 935 select statements. Pervasive use of shutdownCh chan struct{} as a broadcast shutdown signal. Every long-running goroutine selects on this channel.
  • Example: client/lib/streamframer/framer.go:83shutdownCh chan struct{} closed by Destroy(); client/servers/manager.go:147shutdownCh passed from client to sub-components
  • Assessment: Classic Go idiom executed consistently. A single close broadcasts to all waiting goroutines without data races. The approach predates context.Context dominance but is idiomatic for the era.

Context Cancellation#

  • Usage: 964 context.Context usages throughout. Newer code (CSI, task identity, event streaming) uses context.Context for cancellation propagation; older code relies on shutdownCh.
  • Example: nomad/drainer/watch_nodes.go:216BlockingQuery(impl, minIndex, ctx) uses context for cancellation; client/widmgr/signer.go:90 — uses context in blocking RPC
  • Assessment: Two coexisting cancellation mechanisms (shutdownCh and context.Context) reflecting evolution over time. Not a problem in practice but shows the age of different subsystems.

Blocking Query / Watch Semantics#

  • Usage: All read RPCs accept MinQueryIndex in the request struct and block at the state store level until go-memdb’s watch set triggers. Used by clients polling for allocation assignments, HTTP API long-polling, and internal watchers.
  • Example: client/client.go:2585resp.Index <= req.MinQueryIndex re-blocks; nomad/job_endpoint.go:465blockingOptions{queryOpts: args.QueryOptions, queryFn: func(ws memdb.WatchSet, state *state.StateStore) error {...}}; scheduler/structs/interfaces.go:47Nodes(ws memdb.WatchSet) (memdb.ResultIterator, error) passes watch set down to state
  • Assessment: Elegant polling-efficient watch system. The memdb.WatchSet is passed through the call chain so that any touched index record registers a watcher. When any state changes the RPC unblocks and returns the new index. Avoids both polling overhead and WebSocket complexity for most consumers.

helper/group — WaitGroup + Context#

  • Usage: helper/group/group.go — custom Group type wrapping sync.WaitGroup with Go(f func()), AddCh(ch), Wait(), and WaitWithContext(ctx) methods. Cited as inspired by x/sync/errgroup but simpler (no error propagation).
  • Example: helper/group/group.go:38WaitWithContext races between all goroutines completing and ctx.Done()
  • Assessment: Useful when you want structured goroutine teardown but don’t need error collection. Honest about its scope — a deliberate simplification.

errgroup (x/sync)#

  • Usage: Used selectively in command/agent/event_endpoint.go for the event streaming endpoint, where multiple concurrent goroutines need coordinated error reporting.
  • Example: command/agent/event_endpoint.go:160errs, errCtx := errgroup.WithContext(ctx) for parallel subscription fan-out
  • Assessment: Appropriate and targeted use. Not over-applied.

Sync Primitives Summary#

  • Total occurrences: 412 (sync.Mutex, sync.RWMutex, sync.Once, sync.WaitGroup, sync.Map, atomic.*)
  • sync.WaitGroup: 77 uses — goroutine lifecycle tracking
  • sync.Once: 11 uses — lazy initialization (e.g., singleton clients)
  • sync.RWMutex: dominant in structs with read-heavy access (state caches, server config)
  • Assessment: Appropriate density for a distributed system of this scale. RWMutex is preferred where reads dominate writes (fingerprint cache, plugin registry).

Leader-Only Activation Pattern#

  • Usage: EvalBroker.SetEnabled(bool) is called by monitorLeadership() in nomad/server.go. When the server loses leadership, SetEnabled(false) makes Dequeue() return an error, causing all workers to pause. On leadership gain, SetEnabled(true) resumes processing.
  • Example: nomad/eval_broker.goenabled bool field guarded by mutex; nomad/server.gomonitorLeadership() goroutine watching Raft LeaderCh()
  • Assessment: Clean and race-free. A binary enable/disable signal propagated through existing channel infrastructure. The same pattern applies to the PeriodicDispatcher and deployment watcher.

Rate Limiting#

  • Usage: golang.org/x/time/rate used in the HTTP API server (command/agent/http.go:321rate.NewLimiter(10, 100) for certain admin endpoints). DeploymentQueryRateLimit configured in server config to throttle polling.
  • Assessment: Targeted, not overused.

Error handling#

  • Style: Mixed — fmt.Errorf with %w wrapping dominates (627 occurrences), supplemented by sentinel errors and custom types.
  • Error types defined: Many domain-specific custom types:
    • client/structs/structs.go:18RpcError (carries gRPC code for network errors)
    • client/state/db_error.go:25ErrDB (persistent state errors)
    • scheduler/generic_sched.go:39SetStatusError (scheduling failures with structured fields)
    • client/allocrunner/taskrunner/errors.go:28hookError (lifecycle hook failures)
    • api/variables.go:500ErrCASConflict (compare-and-swap conflict errors for the API client)
    • command/agent/http.go:673codedError (HTTP status code + message for the REST layer)
  • Wrapping approach: fmt.Errorf("%w", err) is the dominant form (627 uses). errors.Is/errors.As used 94 times for unwrapping structured errors.
  • Examples:
    • client/fingerprint/fingerprint.go:86fmt.Errorf("unknown fingerprint '%s'", name) (leaf error, no wrapping needed)
    • client/state/db_error.go — custom ErrDB implements Error() and Unwrap() to preserve bolt error while adding context
    • scheduler/generic_sched.go:39SetStatusError allows the worker to distinguish “eval status update failed” from other scheduling errors

Configuration pattern#

  • Approach: Hierarchical config structs with explicit merge functions. No functional options for top-level configuration. Some functional options used in lower-level helpers.
  • Functional options usage: Limited and targeted:
    • client/state/interface.go:174WriteOption func(*WriteOptions) for BoltDB write batching (WithBatchMode())
    • scheduler/reconciler/reconcile_cluster.go:45AllocReconcilerOption func(*AllocReconciler) for configuring the reconciler in tests
    • helper/winsvc/event.go:37EventOption for Windows event log entries
  • Main config pattern: command/agent/config.go defines a large ServerConfig struct. command/agent/config_parse.go parses HCL2. AgentConfig.merge(other) applies layering (file → CLI flags). The merged config is converted into package-specific config types (e.g., convertServerConfig()nomad.Config).
  • Example of a typical component: nomad.NewServer(config *nomad.Config, logger hclog.Logger) — all configuration passed as a typed struct; no option functions.

Dependency injection#

  • Approach: Manual constructor wiring throughout. No DI framework.
  • Evidence:
    • nomad/server.goNewServer(config, logger) constructs every sub-component explicitly: newEvalBroker(...), NewBlockedEvals(...), newPlanQueue(...), then passes them as arguments or embeds them
    • scheduler/ receives scheduler.State and scheduler.Planner interfaces as constructor arguments to NewScheduler() — pure interface injection without a container
    • client.NewClient(config, logger) — constructs AllocRunner, PluginManager, Fingerprinter instances explicitly and stores them in the Client struct
  • Assessment: Manual DI is entirely appropriate at this scale. The package boundaries are clear enough that a DI container would add complexity without benefit. Interface injection for the scheduler enables clean unit testing without a full server.

Other notable patterns#

Hook Pattern (Lifecycle Hooks)#

  • Where: client/allocrunner/interfaces/task_lifecycle.go and runner_lifecycle.go define a rich set of named hook interfaces: TaskPrestartHook, TaskPoststartHook, TaskPreKillHook, TaskExitedHook, TaskUpdateHook, TaskStopHook, RunnerPrerunHook, RunnerPostrunHook, RunnerDestroyHook, ShutdownHook.
  • Mechanism: AllocRunner and TaskRunner hold []interface{} slices of hooks. At each lifecycle event they iterate the list and type-assert to the appropriate hook interface to call the relevant method. Hooks that don’t implement a given interface are silently skipped.
  • Examples: Volume mounting, identity injection, service registration, and log collection are all implemented as hooks rather than hard-coded into the runner
  • Assessment: Clean extension mechanism that keeps TaskRunner from growing into a monolith. New lifecycle behaviors can be added without modifying the runner core. The use of interface{} + type assertion is idiomatic pre-generics Go.

Registry Pattern#

  • Where: client/dynamicplugins/registry.goRegistry interface + registry implementation. Maps (pluginType, name) to PluginDispenser functions. The scheduler uses scheduler.BuiltinSchedulers — a map[string]Factory — to look up scheduler implementations by name.
  • Example: client/dynamicplugins/registry.go:54NewRegistry(state, dispensers map[string]PluginDispenser) accepts a map of plugin type → factory function; nomad/worker.go — looks up scheduler by eval.Type in BuiltinSchedulers
  • Assessment: Standard service-locator for plugins. The indirection through function values (not just types) allows dispensers to carry configuration via closures.

Init() Subprocess Dispatch Trick#

  • Where: main.go blank-imports several packages that register init() functions. Each checks os.Args[0] or environment variables to detect if it should execute as a subprocess.
  • Purpose: One binary serves as CLI, server, client agent, executor, logmon, docker_logger, template renderer. Subprocess roles (executor, logmon) exit before the full CLI framework loads — keeping memory overhead minimal.
  • Assessment: Unusual and worth highlighting. It’s a deliberate trade-off: operational simplicity (single binary) at the cost of startup path complexity. Third-party readers may find this surprising.

Functional Options (Limited)#

  • Nomad does not use functional options as its primary API design. They appear in narrow scopes:
    • Test helpers: scheduler/reconciler uses options to configure reconciler tests
    • BoltDB write mode: WithBatchMode() for write batching
    • Windows event log helpers
  • Assessment: Not a first-class pattern here. The project favors explicit typed config structs for public APIs.

Generics (Go 1.18+, Limited)#

  • Usage: Present but conservative — used in utility functions only:
    • helper/funcs.go:44Copyable[T any] interface and IsSubset[T comparable](larger, smaller []T) for slice operations
    • lib/lang/stack.go:10Stack[T any] generic stack data structure
    • command/operator_debug.gowriteResponseStreamOrErrorToFile[T any] for debug output generalization
    • client/fingerprint/network.go:354LessFunc[T any] for sortable resource lists
  • Assessment: Appropriate restraint. Generics adopted for data structures and utility functions where the type parameter genuinely eliminates duplication, not for domain logic.

Table-Driven Testing (Production Side)#

  • Table-driven structures appear in production code primarily in the e2e/ package and in the scheduler’s feasibility checkers — e.g., constraint evaluation loops that iterate over a table of constraint rules. This is technically data-driven logic rather than testing-style tables, but the structural similarity is notable.

Type Switches#

  • 53 explicit .(type) type switch usages, mainly in:
    • scheduler/feasible/feasible.go — constraint value comparison across multiple operand types
    • client/taskenv/util.go — environment variable interpolation
    • api/acl.go — JSON deserialization where duration fields can be string or numeric
  • Assessment: Used sparingly and appropriately — always at deserialization or polymorphic evaluation boundaries, not as a substitute for interfaces.

Streaming RPC Handler Registry#

  • Where: nomad/structs/streaming_rpc.go:32StreamingRpcRegistry holds a map[string]StreamingRpcHandler. Streaming operations (log tailing, allocation exec, event streams) are registered by name and dispatched over a multiplexed TCP connection (yamux).
  • Assessment: Separating streaming RPCs from the regular net/rpc layer is architecturally sound — streaming semantics (long-lived, bidirectional) don’t fit net/rpc’s request/response model well.