eventrouter

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: 15 Imported by: 0

Documentation

Overview

Package eventrouter provides a Router that separates event subscription declaration from execution. Cells declare handlers via AddContractHandler; the Router owns goroutine lifecycle, setup-error detection, and graceful shutdown.

ref: ThreeDotsLabs/watermill message/router.go — AddContractHandler/Run/Close pattern. Adopted: declaration-then-run split, Running() readiness signal, WaitGroup goroutine tracking. Deviated: no publish-side in AddContractHandler (GoCell publishes via outbox.Writer, not Router); startup readiness uses an explicit Ready signal from the Subscriber interface rather than a timeout heuristic — aligned with Uber fx OnStart synchronous return semantics.

Run lifecycle (4 phases):

Phase 1: serially call Subscriber.Setup(sub) for each handler — blocking topology declaration.
Phase 2: launch one Subscribe goroutine per handler concurrently.
Phase 3: wait on Subscriber.Ready(sub) for every handler (explicit signal, no timeout).
Phase 4: block until ctx cancel or a runtime subscription error.

Index

Constants

View Source
const (
	// SetupErrorReasonSetupError is emitted when Subscriber.Setup returns an
	// error (Phase 1 failure).
	SetupErrorReasonSetupError = "setup_error"

	// SetupErrorReasonReadyTimeout is emitted when one or more subscriptions
	// fail to signal Ready within the configured readyTimeout (Phase 3).
	SetupErrorReasonReadyTimeout = "ready_timeout"

	// SetupErrorReasonPanic is emitted when a subscription goroutine panics
	// inside runSubscribe (Phase 2 unrecoverable failure).
	SetupErrorReasonPanic = "panic"

	// SetupErrorReasonSubscribeFailure is emitted when a subscription
	// goroutine's SubscribeEntry returns an error before the router reaches
	// Running() — i.e. the failure surfaces during Phase 3 await-ready, so
	// it is a setup-phase fault, not a runtime fault.
	//
	// Wave 2 (PR #593 review fix-up): introduced to anchor the
	// phase-classified reason at the Phase 3 consumer point of setupErr,
	// removing the producer-side `select <-r.running` flicker that mixed
	// this failure with RuntimeErrorReasonRuntimeFault.
	SetupErrorReasonSubscribeFailure = "subscribe_failure"
)

SetupErrorReason* are the closed-set values passed to EventCollector.RecordSetupError. The ADR for the metrics-gaugevec-funnel (docs/architecture/202605191500-adr-metrics-gaugevec-funnel.md) enumerates all three reasons; adding a new reason requires updating that ADR and every collector implementation.

View Source
const DefaultReadyTimeout = 30 * time.Second

DefaultReadyTimeout bounds Phase 3 of Run() so a subscriber that never signals Ready (broker reconnect storm, mis-configured topology) does not block bootstrap indefinitely. 30s aligns with Uber fx StartTimeout default. Set to a non-positive value via WithReadyTimeout to disable the bound.

Variables

This section is empty.

Functions

func NewContractTracingSubscriber

func NewContractTracingSubscriber(inner outbox.Subscriber, tr wrapper.Tracer, opts ...TracingSubscriberOption) outbox.Subscriber

NewContractTracingSubscriber decorates an outbox.Subscriber so every contract-bound delivery attempt gets one span that ends after final broker settlement. It delegates lifecycle methods unchanged and wraps Subscribe handlers with wrapper.WrapSubscriber at subscription registration time.

Subscribe-time invalid Subscription metadata surfaces as an error from the returned Subscriber.Subscribe call (single-source validation via outbox.Subscription.Validate); previous behavior was a panic from the removed wrapper.MustWrapSubscriber helper.

Setup AND Subscribe both gate on Subscription.Validate so the lifecycle boundary is consistent: a malformed Subscription cannot pre-create topology only to fail later at Subscribe. Ready returns a chan and cannot signal validation errors; callers that drive lifecycle directly must call Setup first to surface validation failures (Watermill / Kratos pattern: registration-time validation owned by the decorator).

Types

type EventCollector

type EventCollector interface {
	IncSubscriptionActive(ctx context.Context, cellID string)
	DecSubscriptionActive(ctx context.Context, cellID string)
	RecordSetupError(ctx context.Context, cellID, topic, reason string)
	ObserveReadyWait(ctx context.Context, cellID string, d time.Duration)
	// RecordRuntimeError records a runtime-phase fault for the given cell+topic.
	// reason is a closed-set value drawn from RuntimeErrorReason* constants;
	// the typed parameter prevents callers from drifting to free-form strings.
	// Phase 4 owner — any failure detected after Running() closes is recorded
	// here as runtime_fault. Phase 1–3 failures live on the setup metric via
	// RecordSetupError (PR #593 review fix-up P2#3).
	RecordRuntimeError(ctx context.Context, cellID, topic string, reason RuntimeErrorReason)
}

EventCollector receives EventRouter lifecycle observations. The concrete production impl is *runtime/observability/metrics.EventRouterCollector. Defined here as an interface so router_test.go can use a spy implementation without depending on Prometheus.

ref: ThreeDotsLabs/watermill router metrics middleware — subscription lifecycle counters and gauges matching router_messages_processed_total / router_handler_active.

type NopEventCollector

type NopEventCollector struct{}

NopEventCollector is the default when no collector is wired. All methods are no-ops so production code paths are always safe to call without a nil check.

func (NopEventCollector) DecSubscriptionActive

func (NopEventCollector) DecSubscriptionActive(_ context.Context, _ string)

DecSubscriptionActive is a no-op for NopEventCollector (default when no metrics backend is wired).

func (NopEventCollector) IncSubscriptionActive

func (NopEventCollector) IncSubscriptionActive(_ context.Context, _ string)

IncSubscriptionActive is a no-op for NopEventCollector (default when no metrics backend is wired).

func (NopEventCollector) ObserveReadyWait

func (NopEventCollector) ObserveReadyWait(_ context.Context, _ string, _ time.Duration)

ObserveReadyWait is a no-op for NopEventCollector (default when no metrics backend is wired).

func (NopEventCollector) RecordRuntimeError

func (NopEventCollector) RecordRuntimeError(_ context.Context, _, _ string, _ RuntimeErrorReason)

RecordRuntimeError is a no-op for NopEventCollector (default when no metrics backend is wired).

func (NopEventCollector) RecordSetupError

func (NopEventCollector) RecordSetupError(_ context.Context, _, _, _ string)

RecordSetupError is a no-op for NopEventCollector (default when no metrics backend is wired).

type Option

type Option func(*Router)

Option configures a Router.

func WithEventRouterCollector

func WithEventRouterCollector(c EventCollector) Option

WithEventRouterCollector wires an EventCollector into the Router so that subscription lifecycle events (setup errors, ready-wait durations, active counts) are recorded. Typed-nil inputs are silently ignored; the final fallback to NopEventCollector{} occurs at New() after all options are applied.

Follows the cumulative builder noop pattern (runtime-api.md §Option 范式分层): nil input ≠ "remove the collector", it means "no change this call".

func WithReadyTimeout

func WithReadyTimeout(d time.Duration) Option

WithReadyTimeout overrides the default ready-wait budget. A value <= 0 disables the timeout (waits indefinitely on Ready channels and ctx).

type Router

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

Router manages event subscription lifecycle. It is populated from RegistrySnapshot.Subscriptions drained by bootstrap phase6, and provides Run/Close for the execution phase.

Run MUST be called at most once. Calling Run a second time returns an error.

The subscriber field is *outbox.SubscriberWithMiddleware so that runSubscribe can call SubscribeEntry (EntryHandler → business middleware chain → ConsumerBase.Wrap → Inner.Subscribe). The EntryToSubscriberHandler lift function has been deleted; SubscribeEntry is the only public entry point, which is the structural guarantee that ConsumerBase is the explicit conversion boundary between business (EntryHandler) and broker (SubscriberHandler) layers.

ref: ThreeDotsLabs/watermill message/router.go — router holds *Router.handlers, not a generic middleware list: the router owns the composition.

func New

func New(sub *outbox.SubscriberWithMiddleware, clk clock.Clock, opts ...Option) *Router

New creates a Router that will use the given SubscriberWithMiddleware for all subscriptions. The subscriber field is typed as *outbox.SubscriberWithMiddleware so that runSubscribe can call SubscribeEntry and get the full business pipeline: business middleware chain → ConsumerBase.Wrap → Inner.Subscribe.

clk is required; pass clock.Real() at the composition root or clockmock.New(...) in tests. Panics on nil or typed-nil clock.

func (*Router) AddContractHandler

func (r *Router) AddContractHandler(
	spec contractspec.ContractSpec, handler outbox.EntryHandler, consumerGroup string, ownerCellID string, opts ...cell.SubscriptionOption,
) error

AddContractHandler registers a contract-first subscription intent. The Router stores the contract metadata on the Subscription; bootstrap decorates the concrete Subscriber with NewContractTracingSubscriber so delivery spans close after final broker settlement.

spec.Kind MUST be "event" and spec.Topic MUST be set. topic used for the Subscriber.Setup / Subscribe lifecycle is derived from spec.Topic — callers do not pass a separate topic string.

ownerCellID is the cell that owns this subscription — distinct from consumerGroup (which may include a role suffix like "accesscore-rbac-session-sync"). It is mandatory and populates Subscription.CellID directly; there is no fallback to consumerGroup because Registry.Subscribe forces cellID to flow positionally from cell metadata at codegen time (HARD contract).

Returns a non-nil error when handler is nil, consumerGroup is empty, ownerCellID is empty, or the spec is malformed; callers should propagate the error to the bootstrap phase6 subscription walker.

ref: ThreeDotsLabs/watermill router.AddHandler handlerName / NATS subscription metadata. ref: ADR docs/architecture/202605111000-adr-subscription-cellid-mandatory.md

func (*Router) AddSubscriptionValidator

func (r *Router) AddSubscriptionValidator(v cell.SubscriptionValidator)

AddSubscriptionValidator registers a validator that is invoked for every subsequent AddContractHandler call. Validators run outside the Router lock so a slow or panicking validator does not hold the subscription store. A nil validator is silently ignored.

func (*Router) Close

func (r *Router) Close(ctx context.Context) error

Close shuts down the Router in three phases:

  1. StopIntake (optional): if the subscriber implements outbox.SubscriberIntakeStopper, StopIntake(ctx) is called first. This signals the broker to stop delivering new messages (basic.cancel) while in-flight handlers continue to run. Errors from StopIntake are logged as warnings and do not abort the shutdown sequence.

  2. Cancel: the internal runCtx subtree is canceled, unblocking Subscribe goroutines that are waiting on context cancellation.

  3. Drain: wg.Wait() blocks until all goroutines exit. If ctx expires before drain completes, Close returns ctx.Err().

ref: ThreeDotsLabs/watermill message/router.go — closingInProgressCh two-phase barrier. ref: uber-go/fx app.go — run ctx vs stop ctx separation.

func (*Router) HandlerCount

func (r *Router) HandlerCount() int

HandlerCount returns the number of registered handlers.

func (*Router) Health

func (r *Router) Health() error

Health reports whether the router is ready to serve subscriptions. It returns nil only after startup completes successfully and before a setup or runtime failure has been recorded. After graceful shutdown it returns a distinguishable "shutting down" error.

func (*Router) Run

func (r *Router) Run(ctx context.Context) error

Run starts all registered subscriptions and blocks until ctx is canceled or an unrecoverable subscription error occurs.

Run MUST be called at most once; a second call returns errAlreadyRunning.

Lifecycle (4 phases):

Phase 1: serially call Subscriber.Setup(sub) — blocking topology declaration.
         Any Setup error causes Run to return immediately without starting subscriptions.
Phase 2: launch one Subscribe goroutine per handler concurrently.
Phase 3: wait on Subscriber.Ready(sub) for every handler.
         Running() is closed only after ALL Ready channels close.
Phase 4: block until ctx cancel or a runtime subscription error.

On context cancellation, Run waits for all goroutines to finish before returning.

func (*Router) Running

func (r *Router) Running() <-chan struct{}

Running returns a channel that is closed when all subscriptions have successfully started consuming. Callers can use this to wait for the Router to be ready (e.g., in bootstrap).

Note: if Run returns a setup error, Running() is never closed. Callers should also monitor the error from Run.

type RuntimeErrorReason

type RuntimeErrorReason string

RuntimeErrorReason is the type-safe closed-set for RecordRuntimeError's reason argument. Using a named type prevents callers from passing arbitrary strings and documents the full value set at the type level.

const (
	// RuntimeErrorReasonRuntimeFault is emitted for any runtime fault
	// detected in Phase 4 (after Running() closes).
	RuntimeErrorReasonRuntimeFault RuntimeErrorReason = "runtime_fault"
)

RuntimeErrorReason* are the closed-set values passed to EventCollector.RecordRuntimeError. Runtime errors occur after startup (Phase 4) and are semantically distinct from setup errors (Phase 1-3).

PR #593 review fix-up (P1#1 + P2#3): the reason set is intentionally single-valued. Phase 3 setup-phase failures (SubscribeEntry returning an error before Running(), or ready timeout) are owned by the setup metric only; the previous RuntimeErrorReasonSubscribeFailure / ReadyWaitTimeout reasons were a phase-boundary violation that produced double-counting and reason flicker. The Phase 4 consumer always records runtime_fault — the router is running, so the underlying cause is by definition a runtime fault.

type SubscriberWrapperFunc

type SubscriberWrapperFunc func(sub outbox.Subscription, next outbox.SubscriberHandler) outbox.SubscriberHandler

SubscriberWrapperFunc is applied at Subscribe time after wrapper.WrapSubscriber. It allows composition-root layers to inject per-subscription instrumentation (e.g. settlement observers) at the SubscriberHandler layer without requiring eventrouter to import observability packages.

type TracingSubscriberOption

type TracingSubscriberOption func(*contractTracingSubscriber)

TracingSubscriberOption configures a contractTracingSubscriber.

func WithSubscriberWrapper

func WithSubscriberWrapper(fn SubscriberWrapperFunc) TracingSubscriberOption

WithSubscriberWrapper adds a SubscriberHandler-layer wrapper applied after wrapper.WrapSubscriber inside Subscribe. The wrapper receives the validated Subscription and the tracing-wrapped handler; it may append DeliveryOutcome.SettlementObservers or perform other SubscriberHandler-layer instrumentation.

Jump to

Keyboard shortcuts

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