Skip to content

middleware: new middleware package built on top of state#90

Open
ecordell wants to merge 1 commit into
authzed:mainfrom
ecordell:middleware2
Open

middleware: new middleware package built on top of state#90
ecordell wants to merge 1 commit into
authzed:mainfrom
ecordell:middleware2

Conversation

@ecordell

@ecordell ecordell commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Adds a middleware layer to the state pipeline and a new state/middleware package with a couple of ready-made implementations.

Middleware

  • Middleware (func(NewStep) NewStep) with an ambient registration mechanism: WithAmbientMiddleware composes middleware into context (first-registered outermost), and AmbientDispatch(steps...) opts a pipeline in, firing the ambient middleware once per listed step. Middleware registered mid-pipeline applies to subsequent steps, and granularity is explicit: pass steps individually for per-step firing, or a composite to wrap it as a unit.
  • MakeMiddleware(before, after) builds middleware from hook pairs with bracket semantics: the after-hook is guaranteed to fire on every outcome the program survives: continue, terminal (queue.Done), cancellation, or a recovered panic.
  • Recovery-scoped panic release: middleware.Recover marks its dynamic extent via state.WithPanicRecovery; brackets inside a marked scope close during panic unwinding (without touching the panic), so spans/timers release before the error handler runs. Outside a recovery scope, panics propagate untouched with no hooks fired. Parallel clears the marker for branches since recovery can't cross goroutines.
  • Named(name, step) / StepName(ctx) annotate steps for observability, with a race-safe, outermost-wins capture slot (WithStepNameCapture/CapturedStepName) so middleware can read names even from terminal steps.

state/middleware

  • Log(logger) logs each step's name and duration via slog.
  • Recover moved from state; wraps a step with panic recovery, routing a *state.PanicError through the error handler.

API changes

  • state.Recovermiddleware.Recover; ParallelWith(wrapper, steps...)Parallel(Map(wrapper, steps...)...).
  • queue.Done/queue.Requeue are now package vars (use queue.Done, not queue.Done()); queue.OnError removed, makes sense to branch on context directly.
  • ctxkey bumped to tagged v0.1.0.

Example

Ambient middleware is registered on the context with WithAmbientMiddleware and
applied by wrapping the pipeline in AmbientDispatch. Middleware fires once per
step. AmbientDispatch is the opt-in point, so pipelines that don't use it are
completely unaffected.

// Register middleware once, at controller setup. First registered is outermost.
ctx = state.WithAmbientMiddleware(ctx, middleware.Log(slog.Default())) // log is a pre-made middleware

// MakeMiddleware is a helper to write correct middleware, for example for tracing:
ctx = state.WithAmbientMiddleware(ctx, state.MakeMiddleware(. 
    func(ctx context.Context) context.Context {
        return startSpan(ctx) // before each step
    },
    func(ctx context.Context) context.Context {
        endSpan(ctx) // after each step 
        return ctx
    },
))

// The middleware is "ambient" because it's in the `ctx` and will apply to any machine that runs with that context
// but you still need to explicitly opt in by wrapping the pipeline in AmbientDispatch:
state.Run(ctx, state.AmbientDispatch(
    state.Named("validate", c.validateSecret),
    state.Named("ensureDeployment", c.ensureDeployment),
    state.Named("updateStatus", c.updateStatus),
))

With middleware.Log, each step produces a log line like:

level=INFO msg="step executed" step=ensureDeployment duration_ms=12.4

Middleware can also be registered mid-pipeline; it applies to all subsequent
steps in the same AmbientDispatch call:

state.Run(ctx, state.AmbientDispatch(
    state.Do(func(ctx context.Context) context.Context {
        // e.g. attach a logger scoped to the resource being reconciled
        return state.WithAmbientMiddleware(ctx, middleware.Log(loggerFor(ctx)))
    }),
    c.stepOne, // logged
    c.stepTwo, // logged
))

middleware.Recover is the exception — it's applied per-step rather than
ambiently, for steps that may panic:

state.Parallel(state.Map(middleware.Recover, step1, step2, step3)...)

@ecordell
ecordell force-pushed the middleware2 branch 2 times, most recently from 6a79f89 to 1de2fa1 Compare July 17, 2026 15:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant