executor

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jun 7, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

Package executor provides the step-level execution engine for saga instances.

Executor drives a single step of a saga definition: it runs the step function with retry/backoff, manages per-step timeouts, and maintains the instance lease via periodic heartbeats. It does NOT own the saga state machine (that belongs to the Coordinator in runtime/saga) and does NOT write journal events (it returns a Result that the caller persists).

Heartbeater interface

Executor accepts a narrow Heartbeater interface rather than the full journal.Journal type. This satisfies SAGA-JOURNAL-HOLDER-SEAL-01: the executor package never imports kernel/saga/journal. Any journal.Journal implementation structurally satisfies Heartbeater.

StepFunc call funnel (SAGA-STEP-RUN-OUTSIDE-TX-01 A1)

All ksaga.StepFunc invocations occur exclusively inside the unexported safeRun helper. Execute passes step.Run as a parameter to safeRun; it never calls step.Run(…) directly. This satisfies the A1 invariant checked by the archtest.

Index

Constants

View Source
const (
	// DefaultHeartbeatInterval is the lease renewal cadence when
	// WithHeartbeatInterval is unset.
	DefaultHeartbeatInterval = 10 * time.Second
	// DefaultLeaseDuration is the lease TTL passed to Heartbeat when
	// WithLeaseDuration is unset.
	DefaultLeaseDuration = 30 * time.Second
	// HeartbeatLeaseSafetyFactor is the minimum factor by which LeaseDuration
	// must exceed HeartbeatInterval so at least one heartbeat fires before
	// lease expiry: heartbeatInterval * HeartbeatLeaseSafetyFactor < leaseDuration.
	// Exported so the Coordinator can reference the same constant without
	// duplicating the value (cross-package single source of truth, #1210 C9).
	HeartbeatLeaseSafetyFactor = 2
	// DefaultObserverCallDeadline bounds each Observer method invocation when
	// WithObserverCallDeadline is unset. The Observer contract documents that
	// implementations MUST NOT block, but it is a Soft contract — a faulty
	// metrics adapter that blocks indefinitely (network stall, lock contention)
	// would otherwise wedge the heartbeat goroutine on the stale-lease branch
	// and stall RunWithHeartbeat / Execute on the subsequent goroutine join.
	// Bounded waits keep the executor fail-closed even when the observer
	// violates its contract (#1210 round-3 F2).
	DefaultObserverCallDeadline = 5 * time.Second
)

Executor default lease parameters and the internal cancel-cause sentinels.

View Source
const (
	// DefaultMaxAttempts is the total number of Run invocations (including the
	// first) used when neither the step nor the definition specifies MaxAttempts.
	DefaultMaxAttempts = 1
	// DefaultBaseInterval is the first retry backoff base when neither the step
	// nor the definition specifies BaseInterval.
	DefaultBaseInterval = 100 * time.Millisecond
	// DefaultMaxInterval is the backoff cap when neither the step nor the
	// definition specifies MaxInterval.
	DefaultMaxInterval = 30 * time.Second
)

Package-level defaults for resolved retry policy. These constants are the fallback values used by resolvePolicy when neither the step nor the definition sets a field (all zeros ⇒ inherit).

NOTE: DefaultMaxAttempts of 1 means a single attempt with no retry. There is no "unlimited" option — retries are bounded, then compensation runs.

Variables

This section is empty.

Functions

func IsLeaseLost

func IsLeaseLost(err error) bool

IsLeaseLost reports whether err signals that the instance lease was lost during a RunWithHeartbeat invocation (another coordinator took over). Callers driving compensation walks use this to distinguish "another leader took over — stop quietly" from real fn errors.

Implementation note: uses errors.Is on an unexported sentinel (errLeaseLost). Only errors returned from RunWithHeartbeat can satisfy IsLeaseLost. Execute does NOT route lease-loss through error — it returns Result{Outcome: OutcomeLeaseLost}; IsLeaseLost is therefore not applicable to Result.Err. Issue #1181 design decision: lease-loss proactively cancels the running step via context cancelCause(errLeaseLost) — step authors must select on ctx.Done() to honor the cancellation.

Types

type DriveResult

type DriveResult string

DriveResult is a typed enum classifying a single driveOne completion. The values are stable wire-facing strings used as the saga_drive_total{result} metric label. Value set frozen by SAGA-METRIC-LABEL-VALUES-FROZEN-01 (tools/archtest/saga_invariants_test.go).

const (
	// DriveOK means driveOne returned nil (instance advanced cleanly).
	DriveOK DriveResult = "ok"
	// DriveError means driveOne returned a non-nil error (stale lease, instance
	// gone, step failure surfaced to the coordinator, etc.).
	DriveError DriveResult = "error"
)

type Executor

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

Executor drives a single step of a saga.

func NewExecutor

func NewExecutor(hb Heartbeater, clk clock.Clock, opts ...Option) (*Executor, error)

NewExecutor constructs an Executor. hb and clk are required:

  • hb nil (or typed-nil) → errcode.KindInvalid
  • clk nil → panics via clock.MustHaveClock (programmer error)

Options are applied after defaults; post-option validation checks:

  • heartbeatInterval > 0
  • leaseDuration > 0
  • heartbeatInterval * 2 < leaseDuration (guarantees at least one heartbeat before expiry)

func (*Executor) Compensate

func (e *Executor) Compensate(
	ctx context.Context,
	inst *ksaga.Instance,
	leaseID idutil.SafeID,
	step ksaga.Step,
	committedState []byte,
) error

Compensate runs step.Compensate once (no retries, no step.Timeout). Returns nil when step.Compensate is nil (the step has nothing to undo). Panics inside the compensate function are recovered and returned as errors.

Note: Authors needing a per-step compensation timeout should wrap with context.WithTimeout inside their CompensateFunc. Compensate is not bounded by step.Timeout (deliberate, mirrors Temporal's lack of CompensateTimeout). The only bound is the saga-level ctx (parent deadline) plus the heartbeat goroutine's lease-loss cancel (~heartbeatInterval granularity).

leaseID is the fence token identifying the current coordinator's claim. Unlike Execute it is NOT forwarded to any Heartbeat call (Compensate runs no heartbeat); it is carried only into the compensation log lines so an operator can correlate a compensation entry back to the ClaimPending cycle that drove it.

func (*Executor) Execute

func (e *Executor) Execute(
	ctx context.Context,
	inst *ksaga.Instance,
	leaseID idutil.SafeID,
	step ksaga.Step,
	defPolicy ksaga.RetryPolicy,
	prevState []byte,
) Result

Execute runs the given step against inst, retrying according to the effective retry policy (step.RetryPolicy → defPolicy → package defaults). Each attempt is guarded by per-step timeout (step.Timeout > 0) or the inherited ctx deadline (step.Timeout == 0).

A single heartbeat goroutine spans the WHOLE Execute call — the step run AND every retry backoff — so the lease cannot be dropped during a long backoff (C1/F5). If a heartbeat observes a stale lease (ok=false), it cancels runCtx with errLeaseLost, which both unblocks the running step / backoff and routes the result to OutcomeLeaseLost (C1/F4).

runCtx is cancelable-with-cause so the terminal Outcome distinguishes the four end reasons (see classifyCanceled): lease lost, parent shutdown cancel, parent (saga-level) deadline, and per-step timeout.

leaseID is the fence token that identifies the current coordinator's claim; it is forwarded to every Heartbeat call.

The returned Result carries Outcome, NewState (on success), Err (on failure), and Attempts (total invocations).

func (*Executor) RunWithHeartbeat

func (e *Executor) RunWithHeartbeat(
	ctx context.Context,
	inst *ksaga.Instance,
	leaseID idutil.SafeID,
	fn func(ctx context.Context) error,
) error

RunWithHeartbeat invokes fn under an active heartbeat goroutine that renews the instance lease via the executor's Heartbeater for the lifetime of fn. The heartbeat goroutine is created and joined inside this call.

If a heartbeat tick observes a stale lease (ok=false), fn's ctx is canceled with the lease-lost sentinel and RunWithHeartbeat returns an error satisfying IsLeaseLost. If fn returns first, the heartbeat is stopped and fn's error is returned unchanged (lease-loss never overrides a real fn error).

Used by Coordinator.runCompensation to keep the lease alive during a long reverse-compensation walk. Internally, Execute uses runHeartbeat directly because its retry loop needs more nuanced control over the goroutine — both paths share runHeartbeat as the heartbeat-goroutine body.

Return-value precedence (correctness invariant):

  • If fn returns a real domain error (not nil / context.Canceled / context.DeadlineExceeded), it is returned as-is. Lease-loss never overrides a real fn error — domain failures must surface.
  • If fn returns nil OR a ctx-derived error (context.Canceled / context.DeadlineExceeded) AND a heartbeat concurrently observed a stale lease, the lease-lost sentinel overrides the return. Callers detect this via IsLeaseLost.
  • Otherwise fn's return is propagated unchanged.

ref: temporalio/sdk-go internal_task_handlers.go (per-activity heartbeat goroutine spanning the whole activity invocation).

type HeartbeatFailureReason

type HeartbeatFailureReason string

HeartbeatFailureReason is a typed enum of why a heartbeat tick reported failure. The values are stable wire-facing strings used as metric labels (saga_heartbeat_failed_total{reason}) and slog fields.

const (
	// HeartbeatFailureInfraError means Heartbeater returned a non-nil err
	// (transient infra fault — e.g., DB timeout, network blip). The heartbeat
	// goroutine logs Warn and continues to the next tick (fail-open transient).
	HeartbeatFailureInfraError HeartbeatFailureReason = "infra_error"
	// HeartbeatFailureStaleLease means Heartbeater returned ok=false: another
	// coordinator has taken over the instance. The heartbeat goroutine logs
	// Warn, invokes the onStale callback (cancels the run context with
	// errLeaseLost), and returns.
	HeartbeatFailureStaleLease HeartbeatFailureReason = "stale_lease"
)

type Heartbeater

type Heartbeater interface {
	// Heartbeat attempts to renew the lease for the given instance.
	// Returns ok=true when the lease was successfully renewed; ok=false when
	// the lease is stale (another coordinator took over). err indicates an
	// infrastructure failure (retry is appropriate).
	Heartbeat(ctx context.Context, instanceID, leaseID idutil.SafeID, leaseDuration time.Duration) (ok bool, err error)
}

Heartbeater extends a saga instance's lease by calling Heartbeat periodically. The interface matches kernel/saga/journal.Heartbeater (and the Heartbeat method on the full journal.Journal) exactly, so any journal implementation satisfies it structurally without this package importing journal.

This is deliberately a SEPARATE declaration from journal.Heartbeater, not an alias or re-use: kernel/ cannot depend on runtime/, and this package must never import kernel/saga/journal (below), so the two structurally-identical interfaces are an intentional layering artifact. journal.Heartbeater exists only to compose the full journal.Journal; this one is the executor's own dependency surface.

INVARIANT: executor never imports kernel/saga/journal — it uses this narrow interface instead. Enforced by the saga-executor-no-journal-import depguard rule in .golangci.yml (path-level import ban) and complemented by SAGA-JOURNAL-HOLDER-SEAL-01 (no journal.* field may be persisted here either).

Caller contract: the Executor stops the heartbeat loop when ok=false is returned; implementations should guarantee that ok=false is idempotent (i.e., repeated observation after the lease is lost is safe).

type LeaderSkipReason

type LeaderSkipReason string

LeaderSkipReason is a typed enum of why the leader-elect gate skipped an instance (acquireLead returned lead=false). The values are stable wire-facing strings used as the saga_leader_elect_skip_total{reason} metric label and as slog fields. Value set frozen by SAGA-METRIC-LABEL-VALUES-FROZEN-01 (tools/archtest/saga_invariants_test.go).

const (
	// LeaderSkipContended means another coordinator holds the per-instance
	// distlock (ErrDistlockTimeout) — expected in multi-process deployments.
	LeaderSkipContended LeaderSkipReason = "contended"
	// LeaderSkipCtxCanceled means the acquire was aborted by ctx cancellation or
	// deadline (normal Stop()/shutdown of the tick loop).
	LeaderSkipCtxCanceled LeaderSkipReason = "ctx_canceled"
	// LeaderSkipBackendError means a distlock backend I/O fault prevented the
	// acquire — this is the lock-acquire failure rate (operationally interesting).
	LeaderSkipBackendError LeaderSkipReason = "backend_error"
)

type NopObserver

type NopObserver struct{}

NopObserver is the zero-cost default Observer. All methods are no-ops.

func (NopObserver) ObserveDrive

func (NopObserver) ObserveDrive(_ context.Context, _ string, _ DriveResult)

ObserveDrive implements Observer.

func (NopObserver) ObserveHeartbeatFailure

func (NopObserver) ObserveHeartbeatFailure(_ context.Context, _ idutil.SafeID, _ idutil.SafeID, _ HeartbeatFailureReason)

ObserveHeartbeatFailure implements Observer.

func (NopObserver) ObserveLeaderSkip

func (NopObserver) ObserveLeaderSkip(_ context.Context, _ string, _ LeaderSkipReason)

ObserveLeaderSkip implements Observer.

func (NopObserver) ObserveOutcome

func (NopObserver) ObserveOutcome(_ context.Context, _ idutil.SafeID, _ idutil.SafeID, _, _ string, _ Outcome, _ int)

ObserveOutcome implements Observer.

func (NopObserver) ObserveRetry

func (NopObserver) ObserveRetry(_ context.Context, _ idutil.SafeID, _ idutil.SafeID, _, _ string)

ObserveRetry implements Observer.

func (NopObserver) ObserveTick

func (NopObserver) ObserveTick(_ context.Context, _ TickResult)

ObserveTick implements Observer.

type Observer

type Observer interface {
	// ObserveOutcome is called exactly once per Execute call, at the terminal
	// Result. attempts is Result.Attempts (total invocations).
	// instanceID and leaseID identify the specific instance execution for
	// audit / tracing — see Observer godoc for cardinality guidance.
	ObserveOutcome(
		ctx context.Context,
		instanceID, leaseID idutil.SafeID,
		definitionID, stepName string,
		outcome Outcome,
		attempts int,
	)

	// ObserveRetry is called between attempts, before the backoff Sleep starts.
	// Not called for the first attempt (only when attempt N > 1 is about to run).
	// instanceID and leaseID identify the specific instance execution for
	// audit / tracing — see Observer godoc for cardinality guidance.
	ObserveRetry(
		ctx context.Context,
		instanceID, leaseID idutil.SafeID,
		definitionID, stepName string,
	)

	// ObserveHeartbeatFailure is called on each heartbeat tick that fails or
	// observes a stale lease. reason is one of HeartbeatFailureReason constants.
	// instanceID and leaseID identify the specific instance execution for
	// audit / tracing — see Observer godoc for cardinality guidance.
	ObserveHeartbeatFailure(ctx context.Context, instanceID idutil.SafeID, leaseID idutil.SafeID, reason HeartbeatFailureReason)

	// ObserveTick is called once per Coordinator ClaimPending cycle that runs
	// (the stopping-drain early return is not counted). result classifies the
	// cycle: claimed (≥1 instance), empty (0 claimed), or error (ClaimPending
	// failed). Coordinator-emitted — definition_id is not available pre-claim.
	ObserveTick(ctx context.Context, result TickResult)

	// ObserveDrive is called once per driveOne completion with the per-instance
	// outcome: ok (driveOne returned nil) or error (non-nil). Coordinator-emitted;
	// definition_id is the bounded label dimension (per-instance IDs excluded).
	ObserveDrive(ctx context.Context, definitionID string, result DriveResult)

	// ObserveLeaderSkip is called each time the leader-elect gate skips an
	// instance this tick (acquireLead could not confirm leadership). reason is
	// one of LeaderSkipReason constants. Coordinator-emitted; backend_error is
	// the distlock lock-acquire failure rate, contended is normal multi-process
	// contention. definition_id is the bounded label dimension.
	ObserveLeaderSkip(ctx context.Context, definitionID string, reason LeaderSkipReason)
}

Observer is the saga-wide best-effort observability sink (metrics counters, tracing, audit). It carries two families of events:

  • Executor-emitted (per-step hot path): ObserveOutcome / ObserveRetry / ObserveHeartbeatFailure — called by the Executor on every Execute outcome / retry attempt / failed heartbeat tick.
  • Coordinator-emitted (per-tick / per-drive / per-skip): ObserveTick / ObserveDrive / ObserveLeaderSkip — called by runtime/saga.Coordinator on each ClaimPending cycle, each driveOne, and each leader-elect skip. The contract (interface) lives here for cohesion: the Executor is the lower layer the Coordinator imports, so a single saga observability sink can satisfy both producers (the Coordinator forwards this same value into the internally-constructed Executor via WithObserver).

All methods MUST be non-blocking and must tolerate canceled contexts — they are called on hot paths and must never affect saga correctness.

Concurrency: a single Observer value is shared across every saga instance and is invoked CONCURRENTLY. A Coordinator tick drives its claimed instances in parallel (one goroutine each, bounded by ClaimBatchSize, #983), so both the Coordinator-emitted ObserveDrive and the Executor-emitted per-step methods (ObserveOutcome / ObserveRetry / ObserveHeartbeatFailure) may run for several instances at the same time — the same method on different instances, and different methods, can overlap. Implementations MUST be safe for concurrent use (e.g. atomic counters / mutex-guarded state). The metrics SagaCollector satisfies this: its instruments are concurrency-safe via the OTel SDK.

SHOULD NOT panic; both producers wrap each Observer call in a defer/recover guard so a misbehaving observer logs Warn and execution continues — observability is best-effort and never affects correctness. Recovery is provided by Executor.safeObserveOutcome / safeObserveRetry / safeObserveHeartbeatFailure helpers (executor.go) and Coordinator.safeObserve (coordinator.go).

instanceID and leaseID are carried on the Executor-emitted methods for audit / tracing purposes (e.g. structured log correlation, future audit sink). Metric collectors SHOULD NOT use instanceID or leaseID as label dimensions — they are high-cardinality per-instance values that would cause cardinality explosion in time-series stores. Use definition_id / step_name / outcome / reason / result for metric labels and carry instanceID / leaseID only in log fields or trace span attributes.

ref: temporalio/sdk-go internal_task_handlers.go — server-emitted activity metric envelope (best-effort, non-blocking sink).

type Option

type Option func(*Executor)

Option is a functional option for Executor construction.

func WithHeartbeatInterval

func WithHeartbeatInterval(d time.Duration) Option

WithHeartbeatInterval is a direct-assign option. Zero or negative values are stored and rejected by NewExecutor's post-option validation (direct-assign, no nil guard).

func WithLeaseDuration

func WithLeaseDuration(d time.Duration) Option

WithLeaseDuration is a direct-assign option. Zero or negative values are stored and rejected by NewExecutor's post-option validation (direct-assign, no nil guard).

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger is a cumulative builder option. Category: builder-noop. Sets the structured logger; nil is silently ignored and the constructor default (slog.Default()) is kept. Safe to call multiple times; last non-nil value wins.

func WithObserver

func WithObserver(o Observer) Option

WithObserver is a cumulative builder option. Category: builder-noop. Sets the Observer that receives outcome / retry / heartbeat-failure events. A nil observer is silently ignored; the constructor's default (NopObserver) is kept. Safe to call multiple times; last non-nil value wins.

Builder-noop choice rationale (.claude/rules/gocell/runtime-api.md): Observer is an optional best-effort sink, not a wiring-required dependency. NopObserver is a correct zero-value default. New composition roots may attach a metrics collector without forcing every test to inject one. Typed-nil (e.g. a nil *SagaCollector) is rejected via validation.IsNilInterface (#1181 F8) so a downstream method call never dereferences a nil receiver.

func WithObserverCallDeadline

func WithObserverCallDeadline(d time.Duration) Option

WithObserverCallDeadline overrides the per-Observer-call bounded wait (default DefaultObserverCallDeadline = 5s). Non-positive values are silently ignored — the constructor default is kept. Primarily a test seam: package tests use a short deadline so a deliberately blocking observer surfaces the timeout in milliseconds rather than seconds. Production callers should rely on the default; the only reason to extend it is an observer with a known-bounded but slow remote dependency (rare). #1210 round-3 F2.

func WithTracer

func WithTracer(t wrapper.Tracer) Option

WithTracer is a cumulative builder option. Category: builder-noop. Sets the Tracer used for per-step Execute / Compensate spans (`saga.executor.step.run` / `saga.executor.step.compensate`). A nil tracer is silently ignored; the constructor default (wrapper.NoopTracer{}) is kept. Safe to call multiple times; last non-nil value wins.

Builder-noop choice rationale (.claude/rules/gocell/runtime-api.md): Tracer is an optional adapter wiring, not a fail-fast requirement — NoopTracer is a correct zero-allocation default for tests and dev mode. Typed-nil (e.g. (*adapters/otel.Tracer)(nil)) is rejected via validation.IsNilInterface (#1181 F9) so a downstream tracer.Start call never dereferences a nil receiver.

type Outcome

type Outcome uint8

Outcome classifies the result of an Execute call. The zero value is intentionally invalid — callers must check for known Outcome constants, not treat zero as a sentinel.

const (
	// OutcomeSucceeded means the step's Run returned a nil error.
	OutcomeSucceeded Outcome = iota + 1
	// OutcomeFailed means retry attempts were exhausted with a non-nil error.
	// This is an execution FACT only — the executor does NOT decide whether to
	// compensate. The Coordinator chooses Compensating vs Failed from committed
	// step history (kernel/saga/status.go).
	OutcomeFailed
	// OutcomeExpired means a deadline elapsed: either the per-step Step.Timeout
	// or a parent (saga-level Definition.Timeout) deadline. Terminal.
	OutcomeExpired
	// OutcomeCanceled means the parent context was explicitly canceled
	// (orchestrator shutdown/abort) — NOT a business expiry. The Coordinator
	// should leave the instance for re-claim, not terminate it.
	OutcomeCanceled
	// OutcomeLeaseLost means a heartbeat observed ok=false: another coordinator
	// now owns the lease. The running step was canceled.
	OutcomeLeaseLost
)

func (Outcome) String

func (o Outcome) String() string

String implements fmt.Stringer for readable log output.

type Result

type Result struct {
	// Outcome classifies how Execute ended.
	Outcome Outcome
	// NewState is the opaque JSON state returned by the step's Run function
	// on success (nil on failure/expiry).
	NewState []byte
	// Err is the last error encountered (nil on OutcomeSucceeded).
	Err error
	// Attempts is the total number of Run invocations made.
	Attempts int
}

Result is the value returned by Execute.

type TickResult

type TickResult string

TickResult is a typed enum classifying a Coordinator ClaimPending cycle. The values are stable wire-facing strings used as the saga_tick_total{result} metric label. Value set frozen by SAGA-METRIC-LABEL-VALUES-FROZEN-01 (tools/archtest/saga_invariants_test.go).

const (
	// TickClaimed means ClaimPending returned ≥1 instance this cycle.
	TickClaimed TickResult = "claimed"
	// TickEmpty means ClaimPending returned 0 instances (idle tick).
	TickEmpty TickResult = "empty"
	// TickError means ClaimPending itself failed (journal/backend fault).
	TickError TickResult = "error"
)

Jump to

Keyboard shortcuts

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