Documentation
¶
Overview ¶
Package outbox defines interfaces for the transactional outbox pattern: Writer (insert within a transaction), Emitter (write-or-direct abstraction), Relay (poll-and-publish), Publisher (fire-and-forget), and Subscriber (consume).
Scope: L2 (OutboxFact) — local transaction + outbox publish; and L3 (WorkflowEventual) — cross-cell eventual consistency via event fanout.
L4 (DeviceLatent) command queue semantics live in kernel/command/; outbox/ is scoped to L2 (OutboxFact) and L3 (WorkflowEventual) event fanout.
Implementations live in adapters/ (e.g., adapters/postgres, adapters/rabbitmq).
ref: ThreeDotsLabs/watermill message/ -- Message unified model, Publisher/Subscriber interfaces
Package outbox defines interfaces for the transactional outbox pattern. Implementations live in adapters/ (e.g., adapters/postgres).
ref: ThreeDotsLabs/watermill message/ -- Message unified model, Publisher/Subscriber interfaces
Index ¶
- Constants
- Variables
- func CheckNotNoop(mode DurabilityMode, cellID string, deps ...any) error
- func DemoCellTxManager() persistence.CellTxManager
- func Emit[T any](ctx context.Context, clk clock.Clock, emitter Emitter, topic string, payload T) error
- func ExponentialDelay(base, maxDelay time.Duration, attempt int) time.Duration
- func MarshalEnvelope(entry Entry) ([]byte, error)
- func MustNewEntryID() string
- func NewEntryID() (string, error)
- func NotifySettlement(ctx context.Context, outcome DeliveryOutcome, entry Entry, ...)
- func ReportDurable(e Emitter) bool
- func Transition(from, to State) error
- func ValidateMode(mode DurabilityMode) error
- func WriteBatchFallback(ctx context.Context, w Writer, entries []Entry) error
- type BatchWriter
- type CellEmitter
- type CellEmitterInputs
- type CellPublisher
- type CellWriter
- type ClaimPolicy
- type ConsumerBase
- type ConsumerBaseConfig
- type ConsumerObserver
- type DeliveryOutcome
- type DemoTxRunner
- type DirectEmitter
- type DirectEmitterOption
- type DirectPublishFailureMode
- type DiscardPublisher
- type Disposition
- type DurabilityMode
- type DurabilityReporter
- type Emitter
- type EmitterConfig
- type EmitterOutcome
- type Entry
- func (e Entry) AggregateID() string
- func (e Entry) AggregateType() string
- func (e Entry) CreatedAt() time.Time
- func (e Entry) EventID() string
- func (e Entry) EventType() string
- func (e Entry) FailurePolicy() FailurePolicy
- func (e Entry) ID() string
- func (e Entry) Metadata() map[string]string
- func (e Entry) Observability() ObservabilityMetadata
- func (e Entry) OccurredAt() time.Time
- func (e Entry) Payload() []byte
- func (e Entry) Principal() PrincipalMetadata
- func (e Entry) RestoreContext(ctx context.Context) context.Context
- func (e Entry) RoutingTopic() string
- func (e Entry) Stream() string
- func (e Entry) Topic() string
- func (e Entry) Validate() error
- type EntryHandler
- type EntryOption
- func WithAggregateID(id string) EntryOption
- func WithAggregateType(t string) EntryOption
- func WithCreatedAt(t time.Time) EntryOption
- func WithFailurePolicy(p FailurePolicy) EntryOption
- func WithID(id string) EntryOption
- func WithMetadata(m map[string]string) EntryOption
- func WithOccurredAt(t time.Time) EntryOption
- func WithTopic(t string) EntryOption
- type EntryScan
- type FailurePolicy
- type HandleResult
- type NoopRelayCollector
- type NoopWriter
- type Nooper
- type NopConsumerObserver
- type ObservabilityMetadata
- type PermanentError
- type PollCycleResult
- type PrincipalMetadata
- type ProviderRelayCollectorConfig
- type Publisher
- type Relay
- type RelayCollector
- type SerialInOrderGuarantor
- type Settlement
- type SettlementObservation
- type SettlementObserver
- type SettlementObserverFunc
- type SettlementResult
- type State
- type Subscriber
- type SubscriberHandler
- type SubscriberIntakeStopper
- type SubscriberWithMiddleware
- func (s *SubscriberWithMiddleware) Close(ctx context.Context) error
- func (s *SubscriberWithMiddleware) Ready(sub Subscription) <-chan struct{}
- func (s *SubscriberWithMiddleware) Setup(ctx context.Context, sub Subscription) error
- func (s *SubscriberWithMiddleware) StopIntake(ctx context.Context) error
- func (s *SubscriberWithMiddleware) SubscribeEntry(ctx context.Context, sub Subscription, h EntryHandler) error
- type Subscription
- type SubscriptionMiddleware
- type Writer
- type WriterEmitter
Constants ¶
const ( // ConsumerRejectReasonHandlerReject indicates the business handler // explicitly returned DispositionReject (permanent failure / DLX). ConsumerRejectReasonHandlerReject = "handler_reject" // ConsumerRejectReasonRetryExhausted indicates the retry budget was // exhausted without the handler returning Ack or Reject. ConsumerBase // converts to DispositionReject so the broker routes to DLX. ConsumerRejectReasonRetryExhausted = "retry_exhausted" )
ConsumerRejectReason* are the closed-set values passed to ConsumerObserver.ObserveReject. Adding a new reason requires touching every observer implementation (cross-PR conformance) — keep the set small and well-defined.
const EntryIDPrefix = "evt-"
EntryIDPrefix is the prefix for all outbox entry IDs — distinguishes entry IDs from other UUID-based IDs (audit entries, sessions, etc.) in logs.
const EnvelopeSchemaV1 = "v1"
EnvelopeSchemaV1 is the canonical schema version for outbox wire envelopes.
const MaxObservabilityTotalSize = 4 * idutil.MaxMetadataIDLen
MaxObservabilityTotalSize is a sizing-ceiling reference for downstream adapters that allocate buffers proportional to the worst-case total length of all observability fields combined. With four ID-shaped fields each capped at idutil.MaxMetadataIDLen (256B), the worst-case is 1024B; adapters/postgres uses 4× this value for the JSONB column allocation.
Validate() does NOT enforce this aggregate bound: TraceParent is constrained to a fixed 55B W3C string and the three ID fields are each per-field-capped at MaxMetadataIDLen, so the reachable maximum is 3×256 + 55 = 823 < 1024. Per-field limits already cover the worst case at write time; the aggregate cap is unreachable and intentionally not enforced.
const ( // MaxPayloadBytes caps Entry.Payload length. A bug or malicious producer // that emits multi-MB JSON would otherwise inflate relay batch memory // (BatchSize × payload bytes) and PG TOAST / replication overhead. 1 MiB // matches Apache Kafka's default `message.max.bytes` and gives audit // snapshots / large business events comfortable headroom while keeping a // single relay batch under tens of MB at the default BatchSize=100. // ref: Apache Kafka message.max.bytes default 1 MiB. MaxPayloadBytes = 1 << 20 )
const MaxPrincipalTotalSize = 4 * idutil.MaxMetadataIDLen
MaxPrincipalTotalSize is a sizing-ceiling reference for downstream adapters that allocate buffers proportional to the worst-case total length of all principal fields combined. With four ID-shaped fields each capped at idutil.MaxMetadataIDLen (256B), the worst-case is 1024B; adapters/postgres uses a multiple of this value for the JSONB column scan cap. Mirror of MaxObservabilityTotalSize — the two families are sized identically (4 SafeID fields each). Validate() enforces per-field bounds, not this aggregate.
const ProcessReasonRetryExhausted = "retry_exhausted"
ProcessReasonRetryExhausted marks a DeliveryOutcome whose Reject was produced by ConsumerBase exhausting its retry budget (as opposed to a handler's explicit Reject). It is the sole value ConsumerBase injects into DeliveryOutcome.ProcessReason. Subscriber settle loops classify the final SettlementResult from it via RejectSettlementResult — see that helper.
const WarnDirectPublishFailOpen = "outbox: direct publish failed (fail-open) — event dropped"
WarnDirectPublishFailOpen is the slog.Warn message emitted when a DirectEmitter in DirectPublishFailOpen mode swallows a publisher failure. Tests assert on this constant to lock the observable signal — AI-robust Medium (single-source literal, typed reference). Counter equivalent: outbox_emit_failopen_dropped_total (registered in NewDirectEmitter).
Variables ¶
var DefaultRelayBatchBuckets = []float64{1, 5, 10, 25, 50, 100, 200, 500}
DefaultRelayBatchBuckets are histogram buckets for batch size (1–500).
var DefaultRelayPollBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}
DefaultRelayPollBuckets are histogram buckets for poll phase duration (5ms–10s). Matches the range the original adapters/prometheus impl used before migration; preserved here so Grafana dashboards continue to look natural.
var ErrDegraded = errcode.New(errcode.KindUnavailable, errcode.ErrOutboxDegraded, "degraded")
ErrDegraded is the canonical sentinel returned by DirectEmitter.Probes to signal "operational but degraded" — the emitter is still serving requests but the fail-open drop ratio has exceeded the configured threshold, meaning events are silently lost at an elevated rate.
The /readyz aggregator detects ErrDegraded via errors.Is and maps it to HTTP 200 + body status="degraded" rather than 503, so K8s readinessProbe does not evict the pod for soft-failure signals.
Callers should reach this sentinel directly via outbox.ErrDegraded. The kernel/cell alias was removed in PR #615 (G-10 kernel/cell decompose) so that the cell package no longer imports kernel/outbox in production code.
ref: envoyproxy/envoy admin /ready — DEGRADED returns 200, distinguishing "soft failure, do not evict" from "hard failure, drain traffic".
var ErrObserverAlreadyAttached = errcode.New(errcode.KindInvalid, errcode.ErrValidationFailed,
"outbox: ConsumerObserver already attached; AttachObserver may only be called once per ConsumerBase")
ErrObserverAlreadyAttached is returned by ConsumerBase.AttachObserver when an observer has already been attached. Re-attaching would silently swap the in-use observer and lose previous emissions; the wiring-fail-fast pattern (runtime-api.md §Option 范式分层) requires it surface as an error.
var ErrUnknownEnvelopeVersion = errcode.New(errcode.KindInvalid, errcode.ErrEnvelopeSchema,
"outbox: unknown envelope schema version")
ErrUnknownEnvelopeVersion is returned when a wire message carries an unrecognized or absent schemaVersion field.
var ReservedMetadataKeys = []string{
"trace_id",
"traceparent",
"trace_state",
"tracestate",
"span_id",
"request_id",
"correlation_id",
"actor_id",
"subject_id",
"tenant_id",
"session_id",
}
ReservedMetadataKeys lists keys that the kernel observability and principal bridges own exclusively. Producers writing these into Entry.Metadata is a programming error: the typed Entry.Observability / Entry.Principal fields are the canonical home for trace/request/correlation and actor/subject/tenant/ session identity, and Entry.Validate rejects entries that try to forge them via the producer-owned Metadata namespace.
The list is exhaustive — these are the keys the kernel's observability/trace and principal bridges reserve. Note that not every reserved key round-trips through ctx: span_id / trace_state / tracestate have no ObservabilityMetadata field today, but are reserved for namespace hygiene as members of the W3C trace-context family the bridge owns. Adding a new bridge key requires extending this list (frozen by archtest OUTBOX-RESERVED-METADATA-KEYS-FROZEN-01 against an independent want-set; each key's rejection is exercised by TestEntry_Validate_RejectsReservedMetadataKeys).
occurred_at is deliberately NOT reserved: it belongs to neither bridge family. It is a domain event-time scalar carried by its own dedicated sealed Entry field (occurredAt), so a producer's Metadata["occurred_at"] is inert against the real field — reserving it would be a category error, not defense. See ADR 202605281200-1042 §5.
Functions ¶
func CheckNotNoop ¶
func CheckNotNoop(mode DurabilityMode, cellID string, deps ...any) error
CheckNotNoop returns an error if mode is invalid, or if any dep implements Nooper and mode is DurabilityDurable. In DurabilityDemo mode, all deps are accepted. nil deps are silently skipped (nil checks belong in the caller).
func DemoCellTxManager ¶
func DemoCellTxManager() persistence.CellTxManager
DemoCellTxManager returns a sealed persistence.CellTxManager backed by DemoTxRunner. Cell.Init uses this when the composition root has not provided a real CellTxManager (publisher-only demo assemblies).
The returned value still implements Nooper (via the wrapper's transparent Noop pass-through), so outbox.CheckNotNoop rejects it under DurabilityDurable — demo fallbacks can never silently slip into a durable assembly.
This factory is the kernel-internal demo entry point; it pairs with composition-root wraps (persistence.WrapForCell) for production wiring. The wrap call is restricted to this file by archtest CELL-RAW-INFRA-WRAPPER-LOCATION-01.
func Emit ¶
func Emit[T any](ctx context.Context, clk clock.Clock, emitter Emitter, topic string, payload T) error
Emit marshals payload to JSON and constructs + emits an Entry for the given topic via NewEntry (the sole sealed constructor) and emitter.Emit.
Replaces the hand-written "json.Marshal → Entry{} → Emit" pattern at producer call sites: one line instead of four, and the signature mechanically rules out silent marshal-error drops (_, _ := json.Marshal). clk is the mandatory positional clock dependency threaded into NewEntry (which stamps createdAt/occurredAt and injects observability + principal from ctx). The helper is transport-only — callers remain responsible for any surrounding transaction or atomicity scope their persistence path requires.
Error contract: Emit does not log on failure. Callers MUST either return the error (so the surrounding HTTP handler / consumer / runPersist path logs it at the correct correlation-ID scope) or log it themselves before swallowing. Silently discarding the returned error defeats the S41 guard this helper exists to enforce.
ref: ThreeDotsLabs/watermill components/cqrs/marshaler.go — typed struct at publisher, reflection-based marshal at call site. GoCell keeps the helper minimal (no CQRS command/event taxonomy, no header shim); callers that need AggregateID / Metadata / FailurePolicy call NewEntry directly with the corresponding EntryOption and then emitter.Emit.
func ExponentialDelay ¶
ExponentialDelay computes base * 2^attempt with overflow protection, capped at maxDelay. Used by both claimWithRetry and retryLoop.
This is the single source of truth for exponential-backoff delay computation; adapters (e.g., rabbitmq) should call this function instead of maintaining their own copies.
func MarshalEnvelope ¶
MarshalEnvelope serializes an Entry into the canonical v1 wire envelope.
Callers are responsible for setting entry.CreatedAt before calling MarshalEnvelope; this function is pure (no clock interaction). The PG outbox writer pre-fills CreatedAt from now() at INSERT time, the relay reads it back from the row, and DirectEmitter sets it from its injected clock.Clock — all paths populate CreatedAt before reaching this function.
Producer-side fail-fast: every ID-shaped field is parsed through idutil.ParseSafeID so an unsafe in-memory Entry surfaces an error at write time rather than poisoning downstream consumers (defense in depth against accidental Entry{ID: rawUnsafe} construction outside the trusted MustNewEntryID path).
func MustNewEntryID ¶
func MustNewEntryID() string
MustNewEntryID is the panic-on-error variant of NewEntryID. The underlying entropy read only fails on broken OS entropy, which is non-recoverable, so most call sites use this Must variant; outbox writers that want to surface the error to upstream tx callers should use NewEntryID directly.
func NewEntryID ¶
NewEntryID returns a new outbox.Entry.ID value in the canonical format "evt-<hex32>" (RFC 4122 v4-style UUID, serialized as 32 hex chars without dashes). Centralized so the prefix stays consistent across all slices that write to the outbox.
Uses crypto/rand directly (not github.com/google/uuid) because kernel/ may only depend on the Go standard library per CLAUDE.md's layering rule.
Returns the wrapped entropy read error if the OS entropy source is broken; callers that prefer fail-fast wiring can use MustNewEntryID.
func NotifySettlement ¶
func NotifySettlement( ctx context.Context, outcome DeliveryOutcome, entry Entry, disposition Disposition, settlement SettlementResult, err error, )
NotifySettlement emits a settlement observation to every observer attached to the outcome. It is a no-op when no observer is present.
func ReportDurable ¶
ReportDurable returns the durability status of an Emitter, defaulting to false when the implementation does not expose DurabilityReporter. Intended for Cell boundaries that accept a directly-injected Emitter via WithEmitter but still need to decide L2/L0 slice upgrades based on durability.
func Transition ¶
Transition validates a state transition from → to and returns an error if it is not allowed. Pure validation; it does NOT mutate any state.
Usage in the relay settlement loop ¶
The relay calls Transition at its three settlement decision points as a Mark↔target cross-check: a function that calls MarkPublished must also call Transition(_, StatePublished), and similarly for MarkDead/StateDead and MarkRetry/StatePending. This pairing is enforced statically by OUTBOX-STATE-TRANSITION-GUARD-01. Because the arguments are constants, these calls cannot fail at runtime against the current transition table — their purpose is machine-readable pairing documentation, not runtime correctness. The relay still propagates the returned error as a drift safety net: if a legal edge is later removed from stateTransitions, the mismatch surfaces loudly (relay returns the error) instead of silently diverging.
Store transitions without Transition calls ¶
ClaimPending (pending→claiming) and ReclaimStale (claiming→pending/dead) have no Mark* pairing to cross-check. Their correctness is enforced by SQL CAS predicates, conformance behavior tests, and LITERAL-BAN.
ref: kernel/saga/status.go::Transition (same pattern and name).
func ValidateMode ¶
func ValidateMode(mode DurabilityMode) error
ValidateMode returns an error if mode is not a known DurabilityMode. Use at assembly-start boundaries to reject misconfiguration early.
func WriteBatchFallback ¶
WriteBatchFallback writes entries using the Writer interface, falling back to sequential Write calls if the writer does not implement BatchWriter.
Validation scope: WriteBatchFallback runs Entry.Validate() on all entries upfront (topic + payload checks). Writer-specific validation (e.g. UUID format, transaction presence) is the responsibility of the Writer/BatchWriter implementation and may run independently.
The caller MUST ensure ctx carries an active transaction. Atomicity depends on the transaction scope: if all writes happen within the same transaction, a failure rolls back everything. WriteBatchFallback itself does not manage transactions.
An empty entries slice is a no-op and returns nil.
Types ¶
type BatchWriter ¶
type BatchWriter interface {
Writer
// WriteBatch persists multiple outbox entries atomically within the
// caller's transaction scope. Implementations MUST validate entries
// independently (defense-in-depth) even when called via
// WriteBatchFallback, which only runs Entry.Validate(). If validation
// fails for any entry, no entries are written.
//
// An empty entries slice is a no-op and returns nil.
//
// Implementations SHOULD use a single batch INSERT for efficiency.
// The context MUST carry the caller's transaction (same requirement
// as Writer.Write). All-or-nothing: either all entries are written
// or none are (transaction rollback on failure).
WriteBatch(ctx context.Context, entries []Entry) error
}
BatchWriter extends Writer with batch write support. Implementations that support batch operations SHOULD implement this interface for atomic multi-entry writes within a single transaction.
If an implementation does not support BatchWriter, callers can use WriteBatchFallback which auto-detects batch support and falls back to sequential Write calls.
ref: ThreeDotsLabs/watermill message/pubsub.go -- Publish(topic, ...msgs) variadic pattern. GoCell uses a separate interface instead to preserve the existing Writer contract and enable optimized multi-row INSERT.
type CellEmitter ¶
type CellEmitter interface {
Emitter
DurabilityReporter
healthz.ProbeSet
// contains filtered or unexported methods
}
CellEmitter is the only Emitter-shaped type that cells/<x> public With* Options (Cell.WithEmitter + every slice WithEmitter) may accept. The unexported sealedCellEmitter() method makes it unimplementable outside this package — kernel/outbox is the sole entry point via WrapEmitterForCell, which composition roots and the kernel-internal ResolveCellEmitter funnel call.
Unlike CellPublisher / CellWriter, CellEmitter additionally embeds DurabilityReporter and healthz.ProbeSet. Embedding the bare Emitter interface in internalCellEmitter promotes only Emit, so the inner *WriterEmitter.Durable() and *DirectEmitter.Probes() would be hidden behind the wrapper. Listing both in the CellEmitter contract makes the forwarding methods on internalCellEmitter COMPILE-MANDATORY (AI-HARD): omit Durable() or Probes() and internalCellEmitter no longer satisfies CellEmitter, so WrapEmitterForCell fails to compile. Without this a missing forward would silently break L2 durability detection (ResolveCellEmitter) and emitter health-probe registration (cell.RegisterEmitterHealthProbes).
AI-robust 评级:
- 字段/赋值层 + Durable()/Probes() forwarding:Hard(sealed marker 使外部 不可表达 internalCellEmitter 字面量;interface 嵌入使内部不可省略 forwarding)
- 公开 With* Option 签名层:Medium(CELL-RAW-INFRA-PUBLIC-OPTION-PARAM-01)
- Noop() 透传:Hard(SEALED-MARKER-NOOP-TRANSPARENCY-01 typed auto-discovery)
CellEmitter embeds Emitter so cells pass the wrapped value directly to slice service constructors / Emit call sites — those accept Emitter and the embedded interface satisfies them transparently, so internal API does not change.
ref: docs/architecture/202605101900-adr-cell-raw-infra-sealed-marker.md §D1
func DemoCellEmitter ¶
func DemoCellEmitter() CellEmitter
DemoCellEmitter returns a sealed CellEmitter backed by NewNoopEmitter (a NoopWriter-backed WriterEmitter). It is the emitter-leg analog of DemoCellTxManager: composition roots and test builders that previously passed a raw outbox.NewNoopEmitter() to a cell/slice WithEmitter now pass DemoCellEmitter() so the public Option accepts the sealed CellEmitter.
The wrapped emitter is non-durable (Durable()==false via NoopWriter), so a durable assembly that forgets a real emitter still surfaces the error at ResolveCellEmitter rather than silently losing L2 atomicity.
The wrap call is restricted to this file by archtest CELL-RAW-INFRA-WRAPPER-LOCATION-01.
func NewDirectCellEmitter ¶
func NewDirectCellEmitter( p Publisher, mode DirectPublishFailureMode, mp metrics.Provider, clk clock.Clock, cellID string, opts ...DirectEmitterOption, ) (CellEmitter, error)
NewDirectCellEmitter returns a sealed CellEmitter backed by a DirectEmitter, for cells that publish directly with no transactional outbox by design — e.g. L4 DeviceLatent cells (KG-07: no outbox writer / txRunner). It composes NewDirectEmitter + WrapEmitterForCell so a public WithEmitter Option receives a sealed CellEmitter.
Use this instead of ResolveCellEmitter for L4 direct-publish: unlike ResolveCellEmitter it carries NO cellvocab.L2 non-durable atomicity Warn. For L4 direct publish the absence of an outbox writer/txRunner is the intended production mode, not a demo degradation, so the L2 warning would be misleading. This is the "typed function choice" sibling of ResolveCellEmitter (mode-driven L1/L2/L3 resolution): the direct-publish-by-design semantic is expressed by the API name, not by a runtime suppression flag.
The durable-publisher guard (outbox.CheckNotNoop) is the caller's responsibility before this path; this constructor does not re-derive durability mode. The wrapped DirectEmitter is non-durable (Durable()==false) and forwards its fail-open Probes() through the sealed wrapper.
The wrap call is restricted to this file by archtest CELL-RAW-INFRA-WRAPPER-LOCATION-01.
func ResolveCellEmitter ¶
func ResolveCellEmitter(clk clock.Clock, in CellEmitterInputs) (CellEmitter, error)
ResolveCellEmitter is the Cell-side wrapper around ResolveEmitter that enforces the contract shared by accesscore/auditcore/configcore:
- PreResolved (WithEmitter) and raw deps (WithOutboxDeps) are mutually exclusive — both set returns ErrCellInvalidConfig.
- PreResolved + durable mode requires a durable emitter; otherwise returns ErrCellMissingOutbox.
- Otherwise delegate to ResolveEmitter.
- When the resolved emitter is non-durable and the Cell's consistency level is cellvocab.L2 or higher, emit a Warn explaining the degraded atomicity guarantee. The log carries cell, consistency_level, durability_mode.
clk is the mandatory clock passed through to ResolveEmitter for DirectEmitter CreatedAt stamping. Pass clock.Real() in production and clockmock.New(...) in tests. When PreResolved is set, clk is still validated via MustHaveClock to catch wiring mistakes early; it is not consumed in that path.
Returns a sealed CellEmitter: the PreResolved emitter is already sealed, and the WithOutboxDeps path wraps the kernel-built emitter via WrapEmitterForCell so cells store CellEmitter uniformly. Callers read the resolved durability via the returned emitter's Durable() for any composition-root decision.
ref: outbox.ResolveEmitter — the primitive this wraps.
func WrapEmitterForCell ¶
func WrapEmitterForCell(e Emitter) CellEmitter
WrapEmitterForCell is the sole authorized path for handing an Emitter to a cell's With* Option. Returns nil when e is bare-nil OR a typed-nil interface (e.g. `var e *DirectEmitter`) so caller-side typed-nil detection keeps working in accumulative builder options — mirror of WrapPublisherForCell.
Allowed callers (enforced by archtest CELL-RAW-INFRA-WRAPPER-LOCATION-01):
- cmd/* composition roots
- examples/<demo>/main.go, examples/<demo>/app.go, examples/<demo>/run.go
- *_test.go in any layer
- kernel/outbox/cell_marker.go (this file)
- kernel/outbox/mode_resolver.go (ResolveCellEmitter resolution funnel, which wraps the kernel-built DirectEmitter/WriterEmitter so cells hold a sealed CellEmitter uniformly)
type CellEmitterInputs ¶
type CellEmitterInputs struct {
EmitterConfig
// PreResolved is the sealed emitter set directly via Cell.WithEmitter(e)
// (wrapped at the composition root via WrapEmitterForCell). When non-nil,
// ResolveCellEmitter skips ResolveEmitter and validates that durable mode
// requires a durable PreResolved (PreResolved.Durable()==true).
PreResolved CellEmitter
// ConsistencyLevel is the owning Cell's consistency level; used to decide
// whether the cellvocab.L2 non-durable Warn log fires.
ConsistencyLevel cellvocab.Level
}
CellEmitterInputs bundles the Cell-side inputs for ResolveCellEmitter. Embeds EmitterConfig and adds the two knobs shared by every Cell's Init-time emitter resolution: the pre-resolved emitter (WithEmitter) and the Cell's consistency level (for the cellvocab.L2 non-durable Warn).
type CellPublisher ¶
type CellPublisher interface {
Publisher
// contains filtered or unexported methods
}
CellPublisher is the only Publisher-shaped type that cells/<x>/cell.go public With* Options may accept. The unexported sealedCellPublisher() method makes CellPublisher unimplementable outside this package — kernel/outbox is the sole entry point via WrapPublisherForCell, which composition roots must call.
AI-robust 评级:
- 字段/赋值层:Hard(sealed marker,外部不可表达 internalCellPublisher 字面量)
- 公开 With* Option 签名层:Medium(CELL-RAW-INFRA-PUBLIC-OPTION-PARAM-01 archtest type-aware 守)
双重防线,参见 ADR 202605101900-adr-cell-raw-infra-sealed-marker §D2。
CellPublisher embeds Publisher so cells can pass the wrapped value directly to outbox.NewDirectEmitter etc. — those constructors accept Publisher and the embedded interface satisfies them transparently, so internal kernel/outbox API does not change.
ref: docs/architecture/202605101900-adr-cell-raw-infra-sealed-marker.md §D1
func WrapPublisherForCell ¶
func WrapPublisherForCell(p Publisher) CellPublisher
WrapPublisherForCell is the sole authorized path for handing a Publisher to a cell's With* Option. Returns nil when p is bare-nil OR a typed-nil interface (e.g. `var p *amqpPublisher`) so caller-side typed-nil detection keeps working in accumulative builder options. Without IsNilInterface the wrapper would emit a non-nil sealed value hiding the inner nil, silently bypassing Init() fail-fast guards and panicking on the first Publish call.
Allowed callers (enforced by archtest CELL-RAW-INFRA-WRAPPER-LOCATION-01):
- cmd/* composition roots
- examples/<demo>/main.go, examples/<demo>/app.go, and examples/<demo>/run.go composition roots (run.go is the hand-written half of the K#10 main+run split)
- *_test.go in any layer
- kernel/outbox/cell_marker.go (this file)
type CellWriter ¶
type CellWriter interface {
Writer
// contains filtered or unexported methods
}
CellWriter mirrors CellPublisher for the outbox.Writer side: cells/<x> public With* Options accept CellWriter; raw Writer is sealed off behind WrapWriterForCell, callable only from composition roots.
ref: docs/architecture/202605101900-adr-cell-raw-infra-sealed-marker.md §D1
func WrapWriterForCell ¶
func WrapWriterForCell(w Writer) CellWriter
WrapWriterForCell mirrors WrapPublisherForCell for the Writer side. Bare-nil and typed-nil are both rejected via validation.IsNilInterface.
type ClaimPolicy ¶
type ClaimPolicy uint8
ClaimPolicy controls ConsumerBase behavior when Claimer.Claim() fails. The zero value (ClaimPolicyFailClosed) is the safe default.
const ( // ClaimPolicyFailClosed (default zero-value): retry Claim with exponential // backoff. Safe from duplicates, but consumption stops until the idempotency // backend recovers. ClaimPolicyFailClosed ClaimPolicy = iota // ClaimPolicyFailOpen: single Claim attempt; on error, proceed without // idempotency receipt. Avoids total consumer stall, but risks duplicate // processing during outage. ClaimPolicyFailOpen )
func (ClaimPolicy) String ¶
func (p ClaimPolicy) String() string
String returns the lowercase kebab-case name of the ClaimPolicy. Unknown values render as "unknown(N)". The zero value (ClaimPolicyFailClosed) is the safe-by-default Go convention: any unset ClaimPolicy field automatically uses the stricter fail-closed path.
func (ClaimPolicy) Valid ¶
func (p ClaimPolicy) Valid() bool
Valid returns true if the ClaimPolicy is a recognized enum value.
type ConsumerBase ¶
type ConsumerBase struct {
// contains filtered or unexported fields
}
ConsumerBase wraps an EntryHandler with two-phase idempotency (Claim/Commit/Release) and exponential backoff retry. DLQ routing is handled by the broker via DLX (DispositionReject triggers Nack requeue=false).
Settlement flows to the Subscriber delivery loop via the second return value of SubscriberHandler: ConsumerBase.Wrap returns (DeliveryOutcome, Settlement) so that Commit/Release can be called after broker Ack/Nack without leaking idempotency types into business code. Business handlers only return the slim HandleResult; ConsumerBase lifts it to DeliveryOutcome, injecting ProcessReason (e.g. "retry_exhausted") as needed.
Lives in kernel/outbox rather than adapters/rabbitmq because the logic is broker-agnostic — it only depends on kernel/idempotency + outbox types — and is wired by runtime/bootstrap alongside any transport that speaks the Subscriber interface. Adapters (rabbitmq, nats, kafka) reuse this middleware unchanged.
Consumer: cg-{ConsumerGroup}-{topic} Idempotency key: {ConsumerGroup}:{event-id}, TTL 24h ACK timing: after business logic returns DispositionAck Retry: transient errors -> retry+backoff / permanent errors -> DispositionReject → DLX
ref: ThreeDotsLabs/watermill message/router.go — router-level retry/poison/dedup ref: MassTransit UseMessageRetry — pipeline middleware at receive endpoint ref: NATS JetStream consumer_config AckWait+MaxDeliver+BackOff — subscriber config
func NewConsumerBase ¶
func NewConsumerBase(claimer idempotency.Claimer, config ConsumerBaseConfig, clk clock.Clock) (*ConsumerBase, error)
NewConsumerBase creates a ConsumerBase using the two-phase Claimer interface. Returns an error if ConsumerBaseConfig contains invalid values (e.g., unknown ClaimPolicy). The returned Receipt is threaded through HandleResult so that the Subscriber can Commit/Release after broker Ack/Nack.
clk is the time source for backoff sleeps. It must be non-nil; pass clock.Real() in production and clockmock.New() in tests.
ref: nats-go Connect() (*Conn, error), watermill-amqp NewSubscriber() (*Subscriber, error) — constructors return error, never panic.
func (*ConsumerBase) AttachObserver ¶
func (cb *ConsumerBase) AttachObserver(o ConsumerObserver) error
AttachObserver wires a ConsumerObserver that receives notifications on every terminal Reject disposition (handler-explicit reject or retry-budget exhaustion). AttachObserver may be called at most once per ConsumerBase; repeat calls return ErrObserverAlreadyAttached (wiring fail-fast per runtime-api.md §Option 范式分层). Bare-nil and typed-nil observers are rejected with ErrValidationFailed.
AttachObserver is the single public injection path for ConsumerObserver. The internal observer field is private and is not exposed via ConsumerBaseConfig — ConsumerBase is typically constructed at composition root (cmd/corebundle) before the metrics provider is wired in bootstrap phase 5, so constructor injection is not viable. bootstrap.autoWireOutboxRejectCollector calls AttachObserver in phase 6 (before subscriptions start consuming); business code typically does not call this directly.
ref: Temporal MetricsHandler observer-injected pattern
func (*ConsumerBase) IsConstructed ¶
func (cb *ConsumerBase) IsConstructed() bool
IsConstructed reports whether the ConsumerBase came from NewConsumerBase (true) rather than from a zero-value struct literal (false). Production wiring uses this to fail fast when a literal `&ConsumerBase{}` is fed into runtime/bootstrap.WithConsumerBase, preventing a silent retryLoop=0 path.
func (*ConsumerBase) Wrap ¶
func (cb *ConsumerBase) Wrap(sub Subscription, handler EntryHandler) SubscriberHandler
Wrap returns a SubscriberHandler that wraps the given business handler with two-phase Claim/Commit/Release idempotency and retry with exponential backoff.
The idempotency key is constructed as "{sub.ConsumerGroup}:{entry.id}", ensuring cross-cell fanout correctness: each cell's ConsumerGroup forms a separate namespace so ClaimDone in one cell does not silence another.
The Receipt is threaded through DeliveryOutcome -- ConsumerBase never calls Commit/Release itself; that is the delivery loop's job after broker Ack/Nack.
Fail-open (ClaimPolicyFailOpen): single Claim attempt; on error, proceed without idempotency -- avoids total consumer stall, but risks duplicate processing during outage.
Fail-closed (ClaimPolicyFailClosed, default zero-value): all Claim attempts go through claimWithRetry (including the first), so every failure is followed by exponential backoff + jitter. Safe from duplicates, but all consumption stops until the idempotency backend recovers.
Rules:
- handler returns DispositionAck -> pass through as Ack
- handler returns DispositionRequeue -> pass through as Requeue
- handler returns DispositionReject -> pass through as Reject
- handler returns error with non-Ack disposition -> retry with backoff
- DispositionReject (handler-explicit) -> Reject (broker routes to DLX)
- retry budget exhausted -> Reject with ProcessReason="retry_exhausted"
- ctx canceled / shutdown -> Requeue
Wrap lifts a business EntryHandler into a SubscriberHandler that includes idempotency claim/release and retry logic. The returned SubscriberHandler is passed to Subscriber.Subscribe (not EntryHandler) so Settlement can be delivered to the Subscriber without leaking idempotency types into business code.
Settlement is nil when ConsumerBase has no idempotency state: fail-open claim error, ClaimDone (already processed), or ClaimBusy (in progress). Subscribers MUST nil-check Settlement before calling Commit/Release.
type ConsumerBaseConfig ¶
type ConsumerBaseConfig struct {
// RetryCount is the maximum number of retries for transient errors.
// Default: 3.
RetryCount int
// RetryBaseDelay is the initial delay for exponential backoff retries.
// Default: 1s.
RetryBaseDelay time.Duration
// IdempotencyTTL is the TTL for idempotency keys (done-key TTL for Claimer).
// Default: 24h (idempotency.DefaultTTL).
IdempotencyTTL time.Duration
// LeaseTTL is the processing-lease TTL for the Claimer backend.
// If a consumer crashes mid-processing, the lease expires after this
// duration, allowing another consumer to re-claim.
// Default: 5m (idempotency.DefaultLeaseTTL). Only used with Claimer.
LeaseTTL time.Duration
// ClaimPolicy controls behavior when Claimer.Claim() fails due to
// infrastructure errors (e.g., Redis down). See ClaimPolicyFailClosed
// (default zero-value) and ClaimPolicyFailOpen for details.
ClaimPolicy ClaimPolicy
// ClaimRetryCount is the max number of Claim() attempts on the fail-closed
// path before returning DispositionRequeue to the broker.
// Default: falls back to RetryCount (3).
ClaimRetryCount int
// ClaimRetryBaseDelay is the initial backoff delay between Claim() retries.
// Default: falls back to RetryBaseDelay (1s).
ClaimRetryBaseDelay time.Duration
// MaxRetryDelay caps the exponential backoff delay for both claimWithRetry
// and retryLoop, preventing unbounded growth with large retry counts.
// Default: 30s.
MaxRetryDelay time.Duration
// LeaseRenewalInterval is how often the lease renewal goroutine calls
// receipt.Extend while the handler is running. Zero falls back to
// LeaseTTL/3 so the lease is renewed well before it expires.
// Set to a negative value to disable lease renewal entirely.
LeaseRenewalInterval time.Duration
}
ConsumerBaseConfig configures ConsumerBase behavior.
func (*ConsumerBaseConfig) SetDefaults ¶
func (c *ConsumerBaseConfig) SetDefaults()
SetDefaults populates zero-valued fields with safe defaults. Called automatically by NewConsumerBase; exported so callers constructing the config outside of NewConsumerBase (e.g., test harnesses verifying default values) can invoke it directly.
type ConsumerObserver ¶
type ConsumerObserver interface {
ObserveReject(ctx context.Context, cellID, topic, consumerGroup, reason string)
}
ConsumerObserver receives notifications from ConsumerBase when a delivery reaches a terminal Reject disposition. Implementations are provided by runtime/observability/metrics (OutboxRejectCollector) so kernel/outbox stays free of metric backend imports.
ObserveReject is called exactly once per terminal Reject (handler-explicit reject OR retry-budget exhaustion); cellID / topic / consumerGroup are the observability dimensions, reason is a closed-set string drawn from ConsumerRejectReason* constants.
Implementers should NOT include consumerGroup in metric label sets — it is provided here for slog/tracing dimensions but adding it as a Prometheus label expands cardinality multiplicatively (every consumer group × topic × cell × reason combination becomes a unique time-series). See runtime/observability/metrics.OutboxRejectCollector for the sanctioned label set {cell, topic, reason}.
Note: PendingDepthObserver is a SEPARATE interface defined in runtime/outbox (not here) covering the Relay path's pending-depth Gauge. The two interfaces serve different subsystems and are wired independently.
ref: kernel/outbox/relay_collector.go — RelayCollector is the analogous interface for the Relay path; same kernel-defined-runtime-implemented pattern keeps adapter-backend imports out of kernel.
type DeliveryOutcome ¶
type DeliveryOutcome struct {
Disposition Disposition
Err error // optional: logged/observed; nil for success
// ProcessReason is a low-cardinality kernel/process classification, such
// as "retry_exhausted". Injected by ConsumerBase, never set by business
// handlers.
ProcessReason string
// SettlementObservers are notified by subscribers after final broker
// settlement. Appended by subscriber-layer middleware (e.g.
// WrapConfigEventSubscriber) after ConsumerBase has resolved
// retry/lease decisions.
SettlementObservers []SettlementObserver
}
DeliveryOutcome is the subscriber-layer carrier produced by ConsumerBase.Wrap. It extends the slim HandleResult with kernel-injected ProcessReason and SettlementObservers so the subscriber (rabbitmq / eventbus / mqtt) can perform post-settlement notification without any of those concerns leaking into business handler code.
Business handlers return HandleResult. ConsumerBase.Wrap lifts HandleResult → DeliveryOutcome, injecting ProcessReason (e.g. "retry_exhausted") before returning SubscriberHandler to the subscriber layer.
type DemoTxRunner ¶
type DemoTxRunner struct{}
DemoTxRunner is the cell-boundary pass-through TxRunner installed at Cell Init() when the composition root has not provided a real persistence.TxRunner (publisher-only demo assemblies). It implements Nooper, so CheckNotNoop rejects it under DurabilityDurable mode — demo callers that forget to wire a real TxRunner surface an error at Init() time instead of silently losing cellvocab.L2 atomicity guarantees.
func (DemoTxRunner) Noop ¶
func (DemoTxRunner) Noop() bool
Noop reports DemoTxRunner as a no-op runner for CheckNotNoop guards.
type DirectEmitter ¶
type DirectEmitter struct {
// contains filtered or unexported fields
}
DirectEmitter emits by wrapping entries in the v1 wire envelope and calling Publisher.Publish directly.
func NewDirectEmitter ¶
func NewDirectEmitter( p Publisher, mode DirectPublishFailureMode, mp metrics.Provider, clk clock.Clock, cellID string, opts ...DirectEmitterOption, ) (*DirectEmitter, error)
NewDirectEmitter adapts a Publisher into an Emitter that publishes v1 wire envelopes. cellID is the owning Cell's ID and is used as the "cell" label on the fail-open dropped counter; it must be non-empty (empty string returns errcode.ErrValidationFailed). mp is a required metrics.Provider used to register the fail-open dropped counter (fqName after Namespace injection: gocell_outbox_emit_failopen_dropped_total); pass metrics.NopProvider{} in tests or demos where no backend is wired. A nil mp returns an errcode error. clk is the mandatory positional clock dependency (CLOCK-POSITIONAL-INJECTION-01); it is validated via MustHaveClock at construction. DirectEmitter no longer stamps timestamps — NewEntry is the single source of createdAt/occurredAt — but the dependency is retained so every emitter is constructed clock-aware. Pass clock.Real() in production and clockmock.New(...) in tests.
Use WithLogger to override the default slog.Default() logger. Use WithFailOpenRateThreshold to set the drop-ratio threshold for the Probes checker (default 5%; 0 disables).
func (*DirectEmitter) Durable ¶
func (*DirectEmitter) Durable() bool
Durable always returns false for DirectEmitter: direct publishing bypasses the transactional outbox and therefore carries no durability guarantee by design.
func (*DirectEmitter) Emit ¶
func (e *DirectEmitter) Emit(ctx context.Context, entry Entry) error
Emit validates the entry, marshals the v1 wire envelope, and publishes synchronously. The entry's createdAt/occurredAt stamps and observability + principal identity were already set by NewEntry at construction (the single injection trust boundary); Emit re-runs Validate as defense in depth before touching the wire. When publish fails, the per-entry FailurePolicy (or the construction-time default) decides between fail-closed (return the wrapped error) and fail-open (log + increment the gocell_outbox_emit_failopen_dropped_total counter and return nil so the caller's request path is not blocked on broker availability).
func (*DirectEmitter) Probes ¶
func (e *DirectEmitter) Probes() []healthz.Probe
Probes returns the fail-open rate probe for this emitter.
The probe name "outbox_failopen_rate_<cellID>" is snake_case per the PROBENAME-SEALED-FUNNEL-01 convention (a framework rate probe, so no "_ready" suffix). The composed name flows through the typed healthz.EmitterFailOpenProbeName(cellID) constructor at NewDirectEmitter time; a hyphen or other illegal character in cellID fails construction (no runtime drift possible — the value-shape check is funneled into the same NewProbeName validator used at every other construction site).
type DirectEmitterOption ¶
type DirectEmitterOption func(*directEmitterOptions)
DirectEmitterOption configures NewDirectEmitter.
func WithFailOpenRateThreshold ¶
func WithFailOpenRateThreshold(ratio float64) DirectEmitterOption
WithFailOpenRateThreshold sets the drop ratio threshold above which the emitter's Probes checker reports outbox.ErrDegraded.
Default is 0.05 (5%) — the tracker is enabled by default because fail-open drop monitoring is framework infrastructure responsibility, not a per-cell opt-in (CLAUDE.md "生产配置禁止静默降级"). Pass WithFailOpenRateThreshold(0) to explicitly disable.
The implicit time window is the interval between two /readyz probes (typically 10-30s under K8s readinessProbe).
ref: kernel/outbox/failopen_tracker.go for ratio semantics.
func WithLogger ¶
func WithLogger(l *slog.Logger) DirectEmitterOption
WithLogger overrides slog.Default() for this DirectEmitter's structured logs.
type DirectPublishFailureMode ¶
type DirectPublishFailureMode int
DirectPublishFailureMode controls direct publisher error handling.
const ( DirectPublishFailClosed DirectPublishFailureMode = iota + 1 DirectPublishFailOpen )
type DiscardPublisher ¶
type DiscardPublisher struct {
// Logger is used for discard warnings. If nil, slog.Default() is used.
Logger *slog.Logger
// contains filtered or unexported fields
}
DiscardPublisher is an explicit publisher sink for tests and demos. Unlike NoopWriter, it affects direct-publish flows rather than durable outbox writes. It is an explicit opt-in sink, not a default runtime fallback.
Use DiscardPublisher when:
- Unit testing Cells that require an outbox.Publisher dependency
- Running demo/example code without a message broker
Publish logs a structured warning and discards the payload. The warning ensures discard behavior is visible in logs.
ref: go-logr zero-value safe (if sink == nil), slog DiscardHandler
func (*DiscardPublisher) Close ¶
func (d *DiscardPublisher) Close(_ context.Context) error
Close implements Publisher. DiscardPublisher holds no resources; always nil.
func (*DiscardPublisher) DiscardCount ¶
func (d *DiscardPublisher) DiscardCount() uint64
DiscardCount returns the total number of messages discarded. Returns 0 on a nil receiver.
func (*DiscardPublisher) Noop ¶
func (*DiscardPublisher) Noop() bool
Noop implements cell.Nooper. CheckNotNoop rejects DiscardPublisher in durable mode.
type Disposition ¶
type Disposition uint8
Disposition describes the broker-level action for a consumed message.
- DispositionAck: message processed successfully (or duplicate); broker may discard.
- DispositionRequeue: temporary failure / shutdown; broker should redeliver.
- DispositionReject: permanent failure; broker routes to dead-letter exchange.
const ( // DispositionAck indicates the message was processed successfully (or is a // safe-to-skip duplicate); broker may discard. // // IMPORTANT: iota+1 ensures the zero value (0) is NOT a valid Disposition. // A forgotten/uninitialised HandleResult.Disposition will NOT silently ACK. DispositionAck Disposition = iota + 1 // = 1 DispositionRequeue // NACK+requeue -- transient / shutdown DispositionReject // NACK+no-requeue -- permanent failure -> DLX )
func (Disposition) String ¶
func (d Disposition) String() string
String returns a human-readable label for the Disposition. The zero value returns "invalid" to surface forgotten/uninitialised fields.
func (Disposition) Valid ¶
func (d Disposition) Valid() bool
Valid reports whether d is a recognized Disposition value. The zero value is deliberately invalid to catch forgotten/uninitialised fields.
type DurabilityMode ¶
type DurabilityMode int
DurabilityMode declares whether an assembly runs in demo or durable mode. Cells use this to reject noop/test implementations at Init() time, preventing "pseudo-success" assemblies in production.
The zero value is intentionally invalid (unset), forcing callers to explicitly choose DurabilityDemo or DurabilityDurable. ref: Vault StoredKeysInvalid=0, gRPC InvalidSecurityLevel=0, net/http SameSite iota+1
const ( // DurabilityDemo allows noop implementations (NoopWriter, NoopTxRunner, // DiscardPublisher). Used by examples/ and unit tests. DurabilityDemo DurabilityMode = iota + 1 // DurabilityDurable rejects noop implementations at Init() time. // Used by production assemblies (e.g., cmd/corebundle). DurabilityDurable )
func (DurabilityMode) String ¶
func (m DurabilityMode) String() string
String returns "demo", "durable", or "unset".
type DurabilityReporter ¶
type DurabilityReporter interface {
Durable() bool
}
DurabilityReporter is an optional interface Emitter implementations may expose so callers (typically Cell boundaries) can query whether this emitter is backed by durable (transactional outbox) sinks. Emitters that do not implement DurabilityReporter are treated as non-durable by callers — the safe default for direct-publish and noop paths.
ref: kernel/cell.EmitterOutcome.Durable — the primary consumer; Cells use the reported value to decide whether optional slices (e.g. rbacassign) upgrade from L0 to L2. ref: github.com/ThreeDotsLabs/watermill message/router.go — `disabledPublisher` pattern; an explicit typed indicator lets callers branch on capability without runtime type switches.
type Emitter ¶
Emitter emits an outbox entry either by writing it to a durable outbox or by directly publishing its canonical wire envelope.
Implementations may optionally satisfy DurabilityReporter to expose whether their sink is backed by durable (transactional outbox) storage. Callers that need to decide L2/L0 slice upgrades should use ReportDurable, which returns false for any Emitter that does not implement the optional interface.
func NewNoopEmitter ¶
func NewNoopEmitter() Emitter
NewNoopEmitter returns an Emitter backed by NoopWriter.
type EmitterConfig ¶
type EmitterConfig struct {
CellID string
Mode DurabilityMode
Publisher Publisher
OutboxWriter Writer
TxRunner persistence.TxRunner
Logger *slog.Logger
DirectPublishMode DirectPublishFailureMode
// MetricsProvider is REQUIRED when DurabilityDemo mode resolves to a DirectEmitter.
MetricsProvider metrics.Provider
}
EmitterConfig bundles the inputs needed to resolve an outbox.Emitter for a Cell according to DurabilityMode rules.
ResolveEmitter accepts nil TxRunner and treats it as absent (no pairing with a real writer is possible). Callers in Durable mode must inject a real TxRunner explicitly; Demo mode tolerates nil and any Nooper implementation.
MetricsProvider is required for DirectEmitter resolution paths. Pass metrics.NopProvider{} explicitly in tests.
The clock is NOT a field here — it is a mandatory positional parameter to ResolveEmitter (see ADR 202605270000-adr-clock-positional-injection-funnel).
ref: outbox.CheckNotNoop — sibling durability guard.
type EmitterOutcome ¶
EmitterOutcome reports the resolved emitter and whether it is durable (backed by a real writer+txRunner). Cells use Durable to decide whether optional slices (e.g., rbacassign) can activate L2 outbox publication.
func ResolveEmitter ¶
func ResolveEmitter(clk clock.Clock, cfg EmitterConfig) (EmitterOutcome, error)
ResolveEmitter picks the right outbox.Emitter based on durability mode, publisher, writer, and txRunner. Semantics mirror the pre-existing per-cell resolveOutboxDeps+resolveDemoEmitter pair that accesscore/ configcore/auditcore each carried.
clk is the mandatory clock injected into DirectEmitter for CreatedAt stamping. Pass clock.Real() in production and clockmock.New(...) in tests.
Durable mode: requires real writer+txRunner (non-noop); nil/noop → error.
Demo mode: prefers DirectEmitter when publisher is present and writer is absent-or-noop. Falls back to WriterEmitter when writer is present (paired with txRunner — both together form a valid demo sink). Both absent → error.
ref: outbox.CheckNotNoop — sibling durability guard. ref: github.com/ThreeDotsLabs/watermill message/router.go — disabledPublisher pattern.
type Entry ¶
type Entry struct {
// contains filtered or unexported fields
}
Entry represents a single outbox record to be published.
Entry is sealed construction (issue #1229): all fields are unexported and the only ways to obtain an Entry are NewEntry (the producer constructor), UnmarshalEnvelope (the wire-decode funnel), and EntryScan.ToEntry (the storage-adapter reconstruction funnel) — all three in package kernel/outbox. A composite literal `outbox.Entry{...}` outside this package is a compile error (type-system Hard), which is the point: OccurredAt is always clock-derived, Observability and Principal are always ctx-injected at the trust boundary, and no producer can forge or omit them. Consumers read state through the value-receiver getters below.
ref: OpenTelemetry SpanContext / OIDC Principal — typed carriers of trace and principal identity, kept distinct from producer-owned business Metadata.
func NewEntry ¶
func NewEntry(clk clock.Clock, ctx context.Context, eventType string, payload []byte, opts ...EntryOption) (Entry, error)
NewEntry is the sole producer constructor for an outbox Entry. It stamps createdAt = occurredAt = clk.Now().UTC() (overridable via WithCreatedAt / WithOccurredAt), generates a fresh ID (overridable via WithID), applies the options, injects observability + principal identity from ctx (the single injection trust boundary — there is no producer-facing inject API), and validates. clk is the mandatory positional clock dependency (CLOCK-POSITIONAL-INJECTION-01); a typed-nil clk panics via MustHaveClock (programmer error).
Because Entry fields are unexported, NewEntry / UnmarshalEnvelope / EntryScan.ToEntry are the only ways to obtain an Entry — a composite literal outside kernel/outbox does not compile (OUTBOX-ENTRY-SEALED-CONSTRUCTION-01).
func UnmarshalEnvelope ¶
UnmarshalEnvelope decodes a v1 wire envelope into an Entry. Unsafe ID-shaped fields (newline, length overrun, etc.) are rejected during json.Unmarshal via idutil.SafeID.UnmarshalJSON — wrapped here as ErrEnvelopeSchema for consistent error classification across the schema-version / missing-field / unsafe-id rejection paths.
func (Entry) AggregateID ¶
AggregateID returns the owning domain-entity identifier (may be empty).
func (Entry) AggregateType ¶
AggregateType returns the owning domain-entity type (may be empty).
func (Entry) EventID ¶
EventID is the stream-unique identifier (cellvocab.ProjectionEvent). It is the entry id — the polymorphic carrier name for outbox.Entry.ID.
func (Entry) FailurePolicy ¶
func (e Entry) FailurePolicy() FailurePolicy
FailurePolicy returns the per-entry publisher failure policy (in-process control plane; not on the wire).
func (Entry) Metadata ¶
Metadata returns a defensive copy of the business metadata map so external mutation cannot reach the sealed entry state. Returns nil when no metadata is set.
func (Entry) Observability ¶
func (e Entry) Observability() ObservabilityMetadata
Observability returns the cross-async tracing identity carried by the entry.
func (Entry) OccurredAt ¶
OccurredAt returns the producer-domain event time.
func (Entry) Payload ¶
Payload returns the event payload. The returned slice aliases the entry's backing array (no copy, consumer hot path) — callers MUST NOT mutate it.
func (Entry) Principal ¶
func (e Entry) Principal() PrincipalMetadata
Principal returns the cross-async OAuth/OIDC principal identity carried by the entry. The returned value is itself immutable-by-construction (PrincipalMetadata exposes no setters).
func (Entry) RestoreContext ¶
RestoreContext installs the entry's observability + principal identity into ctx (cellvocab.ProjectionEvent). It is the single source of the "restore both envelope identity families into ctx" sequence used by the projection rebuild path and the live consumer delivery path (SubscriberWithMiddleware). Both RestoreToContext calls are idempotent and preserve any ambient transaction already on ctx.
func (Entry) RoutingTopic ¶
RoutingTopic returns the broker routing key for the entry. If topic is set, it is returned; otherwise eventType is used as fallback.
func (Entry) Stream ¶
Stream is the routing/topic equivalent (cellvocab.ProjectionEvent): the projection harness compares it against the subscribed spec topic. Identical to RoutingTopic; the distinct name reads correctly for a non-outbox carrier.
func (Entry) Topic ¶
Topic returns the explicit broker routing key (may be empty; use RoutingTopic for the EventType fallback).
func (Entry) Validate ¶
Validate checks that required fields (id, topic or eventType, payload, and a non-zero occurredAt) are present, that metadata is within size limits and free of reserved keys, and that observability + principal fields are well-formed. NewEntry calls Validate at construction; the wire-decode and reconstruction funnels (UnmarshalEnvelope, EntryScan.ToEntry) re-run it. (F-OB-03, META-SIZE-01, PR246-FU1 reserved-key invariant, issue #1229).
type EntryHandler ¶
type EntryHandler func(context.Context, Entry) HandleResult
EntryHandler is the business handler signature. Business handlers return a HandleResult that declares the intended broker disposition. Settlement is not visible to business handlers — it is delivered via SubscriberHandler, which is the Subscriber-layer interface (not business layer).
type EntryOption ¶
type EntryOption func(*Entry)
EntryOption customizes an Entry under construction. Options may set the aggregate identity, explicit topic, business metadata, failure policy, and (for domain-time / reconstruction needs) the createdAt / occurredAt stamps.
There is deliberately no WithPrincipal / WithObservability option: those two families are injected from the construction context by NewEntry alone, so a producer cannot forge or override them — the trust boundary is the ctx, not the call site.
func WithAggregateID ¶
func WithAggregateID(id string) EntryOption
WithAggregateID sets the owning domain-entity identifier.
func WithAggregateType ¶
func WithAggregateType(t string) EntryOption
WithAggregateType sets the owning domain-entity type.
func WithCreatedAt ¶
func WithCreatedAt(t time.Time) EntryOption
WithCreatedAt overrides the store/seal time. Rare — reserved for callers that must pin createdAt to a domain timestamp. The value is normalized to UTC.
func WithFailurePolicy ¶
func WithFailurePolicy(p FailurePolicy) EntryOption
WithFailurePolicy sets the per-entry publisher failure policy (DirectEmitter paths only; ignored by the transactional WriterEmitter).
func WithID ¶
func WithID(id string) EntryOption
WithID overrides the auto-generated entry ID. Reserved for reconstruction and tests that need a deterministic ID; producers should let NewEntry generate one.
func WithMetadata ¶
func WithMetadata(m map[string]string) EntryOption
WithMetadata sets business metadata. The map is cloned so later caller-side mutation cannot reach the sealed entry state. Reserved keys (ReservedMetadataKeys) are rejected by Validate. Note: "occurred_at" is NOT reserved, but writing it here is inert against the domain event time — use WithOccurredAt to override Entry.OccurredAt.
func WithOccurredAt ¶
func WithOccurredAt(t time.Time) EntryOption
WithOccurredAt overrides the producer-domain event time. By default NewEntry stamps occurredAt = createdAt = clock.Now(); pass this when the domain event time differs from the seal time (e.g. an order created earlier in the same request). The value is normalized to UTC.
func WithTopic ¶
func WithTopic(t string) EntryOption
WithTopic sets an explicit broker routing key. When unset, RoutingTopic falls back to the eventType.
type EntryScan ¶
type EntryScan struct {
ID string
AggregateID string
AggregateType string
EventType string
Topic string
Payload []byte
CreatedAt time.Time
OccurredAt time.Time
Metadata map[string]string
Observability ObservabilityMetadata
Principal PrincipalMetadata
FailurePolicy FailurePolicy
}
EntryScan is the sanctioned reconstruction funnel for storage adapters that rebuild an Entry from persisted columns (the relay claim path scans DB rows into it, then calls ToEntry). It exists because Entry fields are unexported (sealed construction, issue #1229): an adapter in another package cannot scan `rows.Scan(&entry.id, ...)` nor build a composite literal, so the kernel exposes this typed, validated rebuild path instead.
EntryScan deliberately carries exported fields — it is a scan target, not a producer surface. It has NO wire role (wireMessage remains the only json.Unmarshal funnel) and NO clock/ctx injection (the persisted row already carries createdAt/occurredAt/observability/principal). ToEntry runs the full Entry.Validate, so a corrupt row cannot reconstruct an invalid Entry.
EntryScan is asserted to be the only exported Entry-reconstruction mirror by archtest OUTBOX-ENTRY-SEALED-CONSTRUCTION-01; any second exported struct that reconstitutes an Entry would defeat the seal.
type FailurePolicy ¶
type FailurePolicy int
FailurePolicy expresses how an Emitter handles publisher-side failures for a particular Entry. Cells default their DirectEmitter to DirectPublishFailClosed (k8s apiserver audit model); individual entries opt into FailOpen for observational / non-critical sinks.
Reserved for direct-publish paths: WriterEmitter (transactional outbox) surfaces errors through the surrounding transaction and does not consult FailurePolicy — the field is ignored there by design.
const ( // FailurePolicyDefault falls through to Emitter ctor default. Zero value. FailurePolicyDefault FailurePolicy = iota // FailurePolicyFailOpen drops on publisher failure (log + counter), returns nil. FailurePolicyFailOpen // FailurePolicyFailClosed surfaces publisher failure to caller. FailurePolicyFailClosed )
func (FailurePolicy) Resolve ¶
func (p FailurePolicy) Resolve(ctorDefault DirectPublishFailureMode) DirectPublishFailureMode
Resolve returns the effective DirectPublishFailureMode, preferring the per-entry policy when set and falling through to the Emitter construction- time default when FailurePolicyDefault.
type HandleResult ¶
type HandleResult struct {
Disposition Disposition
Err error // optional: logged/observed; nil for success
}
HandleResult carries the business handler's processing outcome. Business handlers return HandleResult via EntryHandler — it contains only what the handler author decides: the disposition and an optional error.
ProcessReason and SettlementObservers are subscriber-layer concerns and live in DeliveryOutcome (the type ConsumerBase.Wrap produces). Business handlers never need to set those fields; ConsumerBase injects ProcessReason (e.g. "retry_exhausted") and the subscriber layer appends SettlementObservers.
func Ack ¶
func Ack() HandleResult
Ack returns a HandleResult declaring successful processing. ConsumerBase will Ack the broker delivery and Commit the idempotency Receipt.
ref: cloudevents/sdk-go/v2/protocol Receipt + ResultACK -- factory pattern for handler outcome types.
func Reject ¶
func Reject(err error) HandleResult
Reject returns a HandleResult declaring permanent failure. ConsumerBase will Nack(requeue=false) so the broker routes to DLX. Wrap err with outbox.NewPermanentError to tag it for logging/metrics; the disposition itself is what the broker observes — PermanentError is purely diagnostic.
Passing a nil err is accepted but reduces DLX observability — the downstream operator has no diagnostic to attach. Production handlers SHOULD pass a non-nil err (typically wrapped with NewPermanentError).
func Requeue ¶
func Requeue(err error) HandleResult
Requeue returns a HandleResult declaring transient failure. ConsumerBase will return the message to the broker for backoff retry; once the retry budget is exhausted the consumer escalates to Reject so the broker can route to DLX. err is optional — pass nil if the caller has no diagnostic to attach.
type NoopRelayCollector ¶
type NoopRelayCollector struct{}
NoopRelayCollector is a no-op implementation of RelayCollector. Used when metrics collection is disabled (nil Metrics in RelayConfig).
func (NoopRelayCollector) RecordBatchSize ¶
func (NoopRelayCollector) RecordBatchSize(_ context.Context, _ int)
func (NoopRelayCollector) RecordCleanup ¶
func (NoopRelayCollector) RecordCleanup(_ context.Context, _, _ int64)
func (NoopRelayCollector) RecordPollCycle ¶
func (NoopRelayCollector) RecordPollCycle(_ context.Context, _ PollCycleResult)
func (NoopRelayCollector) RecordReclaim ¶
func (NoopRelayCollector) RecordReclaim(_ context.Context, _ int64)
type NoopWriter ¶
type NoopWriter struct{}
NoopWriter is an explicit outbox writer sink for tests and demos. It validates entries like a real writer, then discards them instead of persisting anything. It is not a production durability mechanism.
Use NoopWriter when:
- Unit testing Cells that require an outbox.Writer dependency
- Running demo/example code without a database
NoopWriter still validates entries via Entry.Validate(), unlike a zero-value or nil writer. This catches schema errors during development.
func (NoopWriter) Noop ¶
func (NoopWriter) Noop() bool
Noop implements cell.Nooper. CheckNotNoop rejects NoopWriter in durable mode.
func (NoopWriter) Write ¶
func (NoopWriter) Write(_ context.Context, entry Entry) error
Write validates the entry, discards it, and returns nil.
func (NoopWriter) WriteBatch ¶
func (NoopWriter) WriteBatch(_ context.Context, entries []Entry) error
WriteBatch validates all entries, discards them, and returns nil.
type Nooper ¶
type Nooper interface {
Noop() bool
}
Nooper is a marker interface for test/demo-only implementations. Types that implement Nooper are rejected by CheckNotNoop when the assembly runs in DurabilityDurable mode.
Kernel noop types (outbox.NoopWriter, outbox.DiscardPublisher) implement this interface; cells in Demo mode may register their own Nooper TxRunners.
type NopConsumerObserver ¶
type NopConsumerObserver struct{}
NopConsumerObserver is the default ConsumerObserver used when no metrics provider is wired. ObserveReject is a no-op.
func (NopConsumerObserver) ObserveReject ¶
func (NopConsumerObserver) ObserveReject(_ context.Context, _, _, _, _ string)
ObserveReject is a no-op: NopConsumerObserver is the default observer when no metrics collector is wired. Discarding the observation is deliberate — the no-op exists so ConsumerBase can always call ObserveReject unconditionally without a nil guard.
type ObservabilityMetadata ¶
type ObservabilityMetadata struct {
TraceID idutil.SafeID `json:"traceId,omitempty"`
TraceParent string `json:"traceParent,omitempty"`
RequestID idutil.SafeID `json:"requestId,omitempty"`
CorrelationID idutil.SafeID `json:"correlationId,omitempty"`
}
ObservabilityMetadata carries cross-async tracing context that the gocell observability bridge owns. Producers MUST NOT populate these fields directly — the writer bridge (InjectObservabilityFromContext) fills them from context at persistence time. Consumer middleware (RestoreToContext) reads them back into handler context.
The typed field replaces the pre-PR246-FU1 string-key metadata bridge (Merge*/IsReserved*) which allowed producers to forge reserved keys via entry.Metadata["trace_id"] = "evil" before the observability layer got a chance to overwrite them. With a typed field the forgery surface is removed by construction — the observability system and the business metadata map no longer share a key namespace.
ref: OpenTelemetry SpanContext -- typed carrier of trace identity across transport boundaries, kept distinct from application attributes. Adopted: separate struct for system-owned fields vs. producer-owned Metadata map. Deviated: only 4 fields (no sampled flag, no traceFlags struct) to keep the async boundary narrow; TraceParent is the W3C canonical form.
TraceID/RequestID/CorrelationID use idutil.SafeID — UnmarshalJSON fail-closes on unsafe characters at wire boundary (CWE-117). TraceParent keeps `string`: its 55-byte W3C format has its own validator (validTraceParent) and is not in the IsSafeID character set.
func ContextObservability ¶
func ContextObservability(ctx context.Context) ObservabilityMetadata
ContextObservability reads reserved observability values from ctx and returns a populated ObservabilityMetadata. Missing keys stay empty. Falls back to a synthesized W3C traceparent from trace_id+span_id when ctx has no explicit traceparent (preserves pre-FU1 semantics).
func (ObservabilityMetadata) IsZero ¶
func (o ObservabilityMetadata) IsZero() bool
IsZero reports whether all fields are empty.
func (ObservabilityMetadata) RestoreToContext ¶
func (o ObservabilityMetadata) RestoreToContext(ctx context.Context) context.Context
RestoreToContext returns a new context populated with the non-empty fields of o. Existing non-empty ctx values WIN (idempotent restore — the consumer ctx may already carry trace identity from an inbound header on a synchronous spawn path; we do not stomp it). Values that fail safety validation (overlong, unsafe chars, invalid traceparent) are silently dropped.
Producer/consumer asymmetry — by design:
- InjectObservabilityFromContext OVERWRITES e.Observability with the producer-side context's identity (the writer is the source of truth for what the entry carries).
- RestoreToContext does NOT overwrite existing ctx values (the consumer ctx may legitimately have its own trace propagated by an outer middleware; the entry's identity is a fallback, not a mandate).
Restoration also does not synthesize a TraceParent from a TraceID-only metadata: synthesis is a producer-side fallback (ContextObservability builds it from ctx trace_id+span_id when no traceparent is set), but on the consumer side the wire-captured TraceParent is the canonical truth. If it's empty, no synthesis can recover the original parent-id.
func (ObservabilityMetadata) Validate ¶
func (o ObservabilityMetadata) Validate() error
Validate enforces per-field size + charset bounds. Each non-empty ID field (TraceID/RequestID/CorrelationID) must satisfy idutil.IsSafeID and len ≤ idutil.MaxMetadataIDLen via SafeID.Validate; TraceParent must be a valid W3C traceparent (fixed 55-byte format, checked via validTraceParent).
No aggregate size check: per-field caps already cover the worst case (see MaxObservabilityTotalSize doc).
Producers MUST call Validate (via Entry.Validate, which is called by every Writer.Write impl) so size violations surface at write time rather than as silent broker rejections or downstream OOMs.
type PermanentError ¶
type PermanentError struct {
Err error
}
PermanentError wraps an error to indicate it should not be retried and should be routed to the dead-letter queue. This is a domain concept alongside Disposition and HandleResult.
ref: Temporal SDK temporal.ApplicationError (NonRetryable flag in SDK core); Watermill delegates error classification to middleware -- GoCell makes it explicit at the kernel level so WrapLegacyHandler and InMemoryEventBus can detect it without depending on adapter-layer types.
func NewPermanentError ¶
func NewPermanentError(err error) *PermanentError
NewPermanentError wraps an error as a PermanentError.
func (*PermanentError) Error ¶
func (e *PermanentError) Error() string
func (*PermanentError) Unwrap ¶
func (e *PermanentError) Unwrap() error
type PollCycleResult ¶
type PollCycleResult struct {
// Published / Retried / Dead are the canonical outcomes from the publish
// and writeback phases. Skipped covers `MarkPublished updated=false`
// (the entry was reclaimed mid-flight before MarkPublished could win).
// Lost covers the same condition for failure writebacks (Mark{Retry,Dead}
// updated=false): the lease lost mid-flight while the publisher was
// reporting an error, so the failure must NOT be counted as retried/dead
// — the new lease owner will report the canonical outcome.
Published, Retried, Dead, Skipped, Lost int
ClaimDur, PublishDur, WriteBackDur time.Duration
}
PollCycleResult captures the outcome of a single relay poll cycle. Used by RelayCollector.RecordPollCycle to avoid a long parameter list and to support future extensions without breaking the interface.
type PrincipalMetadata ¶
type PrincipalMetadata struct {
ActorID idutil.SafeID `json:"actorId,omitempty"`
SubjectID idutil.SafeID `json:"subjectId,omitempty"`
TenantID idutil.SafeID `json:"tenantId,omitempty"`
SessionID idutil.SafeID `json:"sessionId,omitempty"`
}
PrincipalMetadata carries cross-async principal identity that the gocell observability bridge owns. Producers MUST NOT populate these fields directly — NewEntry fills them from the construction context (the single injection trust boundary). Consumer middleware (RestoreToContext) reads them back into handler context. It mirrors ObservabilityMetadata field-for -field (sealed-construction sibling, issue #1229).
OAuth/OIDC semantics:
- ActorID = impersonator — the principal actually triggering the action (token "act.sub" claim). In non-impersonation flows equals SubjectID.
- SubjectID = subject-of-record — the user the action is performed on behalf of (token "sub" claim).
- TenantID = tenant boundary identifier (multi-tenant deployments).
- SessionID = session identifier (server-side session binding).
The typed field prevents producers from forging principal IDs via entry.Metadata["actor_id"] = "evil" — the two namespaces are physically separate, and Entry.Validate rejects ReservedMetadataKeys to keep producers honest at write time. Mirror of ObservabilityMetadata's rationale for trace identity.
ref: OpenID Connect Core 1.0 §5.1 ("sub"), RFC 8693 §4.1 ("act") — typed carrier of principal identity, kept distinct from observability (trace) and business (metadata) namespaces.
All four fields use idutil.SafeID — UnmarshalJSON fail-closes on unsafe characters at wire boundary (CWE-117 defense).
The field set, JSON tags, and method set are frozen by archtest PRINCIPAL-SEALED-FIELD-FROZEN-01.
func ContextPrincipal ¶
func ContextPrincipal(ctx context.Context) PrincipalMetadata
ContextPrincipal reads reserved principal values from ctx and returns a populated PrincipalMetadata. Missing keys stay empty. Mirror of ContextObservability — ctx values were already validated upstream (runtime/auth middleware writes ctxkeys at the request trust boundary); cast to SafeID without re-checking; producer-side Validate (invoked by Entry.Validate at NewEntry) catches any drift before reaching wire.
TenantID provenance (scope fallback, #1618 F1): pre-auth emits that run inside a tenant.WithScope but carry no authenticated-principal ctxkeys.TenantID — login's event.session.created.v1 and setup's event.user.created.v1, both emitted inside accesscore scopedtx — would otherwise land with an empty principal tenant and be written by the audit appender into the tenant_id=” system chain, which migration 055's `OR tenant_id=”` RLS USING clause makes readable by EVERY tenant (a cross-tenant audit leak). The scope is the real resolved tenant for those operations, so we fall back to it when ctxkeys is absent. This mirrors the GUC source precedence in adapters/postgres tenantScopeForTx (scope → ctxkeys); the scope value's write provenance is itself Hard-funnel-locked (TENANT-TXSCOPE-WRITE-CALLER-01), so principal-tenant inherits that trust. Post-auth emits set ctxkeys.TenantID (auth middleware), so the ctxkeys branch wins and behavior there is unchanged.
func (PrincipalMetadata) IsZero ¶
func (p PrincipalMetadata) IsZero() bool
IsZero reports whether all fields are empty.
func (PrincipalMetadata) RestoreToContext ¶
func (p PrincipalMetadata) RestoreToContext(ctx context.Context) context.Context
RestoreToContext returns a new context populated with the non-empty fields of p. Existing non-empty ctx values WIN (idempotent restore — the consumer ctx may already carry principal identity from an outbound header on a synchronous spawn path; we do not stomp it). Values that fail safety validation (overlong, unsafe chars) are silently dropped.
Producer/consumer asymmetry — by design (mirror of ObservabilityMetadata.RestoreToContext):
- NewEntry OVERWRITES e.principal with the construction context's identity (the producer is the source of truth for what the entry carries).
- RestoreToContext does NOT overwrite existing ctx values (the consumer ctx may legitimately carry its own principal propagated by an outer middleware; the entry's identity is a fallback).
The no-overwrite / idempotent contract above is for the generic / spawn path. The async consume path (SubscriberWithMiddleware.SubscribeEntry) pre-clears the ambient principal via clearAmbientPrincipal before calling RestoreContext, so the entry's wire principal (actor/subject/tenant/session) is authoritative and the no-overwrite guard never blocks it. This mirrors the projection rebuild detach boundary (kernel/projection.clearAmbientPrincipal).
func (PrincipalMetadata) Validate ¶
func (p PrincipalMetadata) Validate() error
Validate enforces per-field size + charset bounds. Each non-empty field must satisfy idutil.IsSafeID and len ≤ idutil.MaxMetadataIDLen via SafeID.Validate. Zero-value PrincipalMetadata returns nil (matches ObservabilityMetadata optional model).
Producers MUST call Validate (via Entry.Validate, which is called by every Writer.Write impl) so size violations surface at write time rather than as silent broker rejections or downstream OOMs.
type ProviderRelayCollectorConfig ¶
type ProviderRelayCollectorConfig struct {
// PollBuckets overrides DefaultRelayPollBuckets; zero value uses defaults.
PollBuckets []float64
// BatchBuckets overrides DefaultRelayBatchBuckets; zero value uses defaults.
BatchBuckets []float64
}
ProviderRelayCollectorConfig customizes metric naming / bucketing. Zero value is acceptable and produces defaults.
type Publisher ¶
type Publisher interface {
Publish(ctx context.Context, topic string, payload []byte) error
// Close terminates the publisher and releases resources. The ctx parameter
// allows callers to share a shutdown budget (e.g., bootstrap shutCtx).
Close(ctx context.Context) error
}
Publisher sends events to the message broker.
ref: uber-go/fx app.go Lifecycle.Append OnStop(ctx) — ContextCloser pattern adopted so bootstrap teardown can share a unified shutCtx budget across all managed publishers.
type RelayCollector ¶
type RelayCollector interface {
// RecordPollCycle records a completed poll cycle with outcome counts and
// per-phase durations. Called once per pollOnce invocation after writeBack.
RecordPollCycle(ctx context.Context, r PollCycleResult)
// RecordBatchSize records the number of entries claimed in a poll cycle.
// Called even when the batch is empty (size=0) to capture idle cycles.
RecordBatchSize(ctx context.Context, size int)
// RecordReclaim records the number of stale entries reclaimed back to
// pending (or dead-lettered). Called once per reclaimStale tick **only
// when count > 0** — idle reclaim sweeps are not observed (the metric
// is a recovery counter, not a tick frequency gauge). This contrasts
// with RecordBatchSize which is also called on size==0 cycles so
// dashboards can detect a totally idle relay.
RecordReclaim(ctx context.Context, count int64)
// RecordCleanup records the number of entries removed during periodic
// cleanup, split by original status (published vs dead-lettered).
RecordCleanup(ctx context.Context, publishedDeleted, deadDeleted int64)
}
RelayCollector records outbox relay operational metrics. Implementations must be safe for concurrent use. Zero counts are valid inputs; implementations should handle them gracefully (e.g. skip counter increments for zero values).
The interface is intentionally in kernel/outbox (not runtime/) so that adapters/postgres can depend on it without pulling in runtime/ packages.
ref: Temporal client.Options{MetricsHandler} — inject-at-construction pattern ref: Watermill components/metrics — publish_time_seconds, subscriber_messages_received_total ref: Debezium JMX — MilliSecondsBehindSource, max.batch.size, DLQ count
func NewProviderRelayCollector ¶
func NewProviderRelayCollector(p metrics.Provider, cellID string, opts ...ProviderRelayCollectorConfig) (RelayCollector, error)
NewProviderRelayCollector registers outbox relay metrics on p and returns a RelayCollector that records through them. Returns error when cellID is empty or when the Provider reports registration failure (typically duplicate metric names).
type SerialInOrderGuarantor ¶
type SerialInOrderGuarantor interface {
GuaranteesSerialInOrderDelivery() bool
}
SerialInOrderGuarantor is an optional Subscriber-implementer extension contract. Business handlers must never reference this interface.
A Subscriber that implements it and returns true ASSERTS that it delivers a single subscription's stream strictly serially and in order: at most one delivery is dispatched at a time and the next is handed off only after the previous handler returns. This is the precondition L3 CQRS projection subscriptions REQUIRE — projection exactly-once rests on a monotonic checkpoint (applyOne skips any event whose stream position ≤ the stored checkpoint), which is only SOUND under serial in-order delivery. Under concurrent delivery (e.g. the AMQP subscriber dispatching one goroutine per delivery with prefetch>1) a higher position can commit the checkpoint before a lower position is applied, silently dropping the lower event's distinct apply (a projection gap). See kernel/projection/doc.go "Ordering precondition" and ADR docs/architecture/202605261620-adr-cqrs-projection-lifecycle-harness.md §6 threat row 4.
Scope of the guarantee: it holds for a SINGLE subscriber on a given (consumerGroup, topic). A projection's consumer group is derived as "<cellID>-<projectionID>" and the bootstrap drain registers exactly one subscription for it, so the single-subscriber precondition is structurally satisfied. The guarantee does NOT extend to multiple competing subscribers sharing one consumer group (e.g. the in-memory bus round-robins across them on independent goroutines).
fail-closed by absence: a Subscriber that does NOT implement this interface is treated as NOT serial. The projection drain rejects wiring a projection onto such a transport. A concurrent transport therefore opts OUT simply by not implementing the method — and any future transport that forgets to implement it is auto-rejected for projections rather than silently unsafe.
Guarded by archtest PROJECTION-SERIAL-DELIVERY-ENFORCEMENT-01, which freezes this method set and pins the implementer set to exactly the qualifying transports. A new transport that implements this marker MUST also be added to that archtest's implementer golden (serialGuarantorSoleImpl), or sub-rule B fails CI — by design, so adding a serial transport is a deliberate update.
type Settlement ¶
Settlement is the broker-side commit/release handle that ConsumerBase delivers alongside HandleResult via SubscriberHandler. Subscriber implementations (adapters/rabbitmq, runtime/eventbus) call Commit before broker Ack and Release after broker Nack to finalize idempotency state.
Business handlers MUST NOT see Settlement — it is part of SubscriberHandler (framework Subscriber-side hand-off), not EntryHandler (business return signature). The compile-time separation supersedes the prior HANDLER-RECEIPT-WRITE-01 archtest gate, which guarded a HandleResult.Receipt field that no longer exists (see 029 #12 K#12 PR-V1-OUTBOX-RECEIPT-EXTRACT).
kernel/idempotency.Receipt implicitly satisfies Settlement: its Commit and Release methods match this interface, and its Extend method is hidden in the Settlement view (renewal stays inside ConsumerBase).
ref: nats-io/nats.go jetstream/message.go Msg interface — settle ops
(Ack/Nak/Term/InProgress) abstracted into a single interface
ref: IBM/sarama consumer_group.go ConsumeClaim(session ConsumerGroupSession,
claim ConsumerGroupClaim) — settle handle as explicit method parameter rather than a ctx side-channel
type SettlementObservation ¶
type SettlementObservation struct {
Entry Entry
Disposition Disposition
Result SettlementResult
ProcessReason string
Err error
}
SettlementObservation is emitted by subscribers after the final delivery settlement action is known.
type SettlementObserver ¶
type SettlementObserver interface {
ObserveSettlement(context.Context, SettlementObservation)
}
SettlementObserver records a post-settlement observation. Implementations must be non-blocking or bounded; subscriber delivery loops call observers on the hot path after broker settlement.
type SettlementObserverFunc ¶
type SettlementObserverFunc func(context.Context, SettlementObservation)
SettlementObserverFunc adapts a function into a SettlementObserver.
func (SettlementObserverFunc) ObserveSettlement ¶
func (f SettlementObserverFunc) ObserveSettlement(ctx context.Context, obs SettlementObservation)
type SettlementResult ¶
type SettlementResult string
SettlementResult describes whether the subscriber completed the final broker settlement action. It intentionally observes the boundary after Commit, Ack/Nack, and receipt release decisions, not the handler's process result.
const ( SettlementResultSuccess SettlementResult = "success" SettlementResultRetryExhausted SettlementResult = "retry_exhausted" SettlementResultCommitFailed SettlementResult = "commit_failed" SettlementResultAckFailed SettlementResult = "ack_failed" SettlementResultNackFailed SettlementResult = "nack_failed" )
func RejectSettlementResult ¶
func RejectSettlementResult(outcome DeliveryOutcome) SettlementResult
RejectSettlementResult classifies the SettlementResult of a Reject disposition from the DeliveryOutcome's ProcessReason. It is the single source every subscriber settle loop (rabbitmq / mqtt / eventbus) routes its Reject branch through, so retry-budget exhaustion is reported as SettlementResultRetryExhausted on every transport instead of drifting per adapter. A handler's explicit Reject (empty ProcessReason) stays SettlementResultSuccess — the broker settlement action itself succeeded; only the process outcome was a rejection.
type State ¶
type State uint8
State is the publication state of a transactional-outbox entry.
Single-source role ¶
State is the single Go-side source of the wire/DB status strings: String() produces the exact value bound into the `status` column, so adapters/postgres and runtime/outbox must use the enum rather than bare string literals (enforced by the OUTBOX-STATE-LITERAL-BAN-01 archtest).
Transition table ¶
stateTransitions documents the complete legal state graph and is validated by the OUTBOX-STATE-TRANSITION-COMPLETENESS-01 archtest (every State const must be a key). It is NOT the runtime enforcement source for SQL operations:
relay settlement (writeBackOne / handleFailedEntry): three paths where the relay explicitly decides claiming→published, claiming→dead, or claiming→pending. Each calls Transition as a Mark↔target cross-check: a function that calls MarkPublished must also Transition to StatePublished, catching copy-paste mismatches at CI time (OUTBOX-STATE-TRANSITION-GUARD-01). These calls are not correctness guards — the constant arguments mean they can never fail at runtime — but they serve as machine-readable pairing assertions.
store ClaimPending (pending→claiming) and ReclaimStale (claiming→pending/dead): no Mark* pairing exists for these transitions. Their correctness is enforced by SQL CAS predicates (WHERE status='claiming'/'pending'), conformance behavior tests, and LITERAL-BAN. Duplicating Transition calls there would add no pairing-check value and would be misleading.
SQL-text ↔ table-edge machine binding: SQL args are ...any, so the wire string cannot be type-constrained at the binding site. This is a true Medium ceiling, explicitly accepted; no fragile static SQL↔edge parser is attempted.
ref: kernel/saga/status.go (transition-table + Transition pattern).
const ( // StatePending: awaiting publish (initial state and the retry back-off // state). // // IMPORTANT: iota+1 ensures the zero value (0) is NOT a valid State, so a // forgotten/uninitialised State will not silently appear as pending. StatePending State = iota + 1 // = 1 StateClaiming // locked by a relay instance, publish in progress StatePublished // delivered to broker (terminal) StateDead // exceeded MaxAttempts, needs manual intervention (terminal) )
func ParseState ¶
ParseState parses a wire/DB status string into a State. Returns errcode.ErrValidationFailed for unrecognized input.
func (State) CanTransitionTo ¶
CanTransitionTo reports whether s can transition to target.
func (State) IsTerminal ¶
IsTerminal reports whether s is a terminal (final) state: published or dead.
func (State) String ¶
String returns the canonical wire/DB status string. This is the sole sanctioned producer of the status literal; see OUTBOX-STATE-LITERAL-BAN-01.
func (State) ValidTransitions ¶
ValidTransitions returns the states reachable from s, as a defensive copy. Returns nil for terminal states.
type Subscriber ¶
type Subscriber interface {
// Setup pre-declares broker topology (exchanges, queues, bindings) for the
// given subscription before Subscribe is called. Callers SHOULD await Ready
// before publishing to ensure messages are queued deterministically.
// In-memory implementations MUST return nil immediately.
Setup(ctx context.Context, sub Subscription) error
// Ready returns a channel that is closed when the subscription is ready to
// consume. In-memory implementations SHOULD return an already-closed channel.
Ready(sub Subscription) <-chan struct{}
// Subscribe registers a handler for the given subscription and blocks until
// ctx is canceled or an unrecoverable error occurs.
//
// Subscription.ConsumerGroup identifies the logical consumer group.
// Subscribers sharing the same group compete for messages (load-balanced);
// different groups each receive a full copy (fanout).
//
// handler is SubscriberHandler so the Subscriber can receive Settlement
// alongside DeliveryOutcome without idempotency types leaking into business
// code. Callers that hold an EntryHandler and want the full business
// pipeline (middleware chain + ConsumerBase idempotency) should use
// SubscriberWithMiddleware.SubscribeEntry instead of lifting manually.
Subscribe(ctx context.Context, sub Subscription, handler SubscriberHandler) error
// Close terminates all active subscriptions and releases resources.
// The ctx parameter allows callers to share a shutdown budget.
//
// ref: uber-go/fx app.go Lifecycle.Append OnStop(ctx context.Context) error
// ref: ThreeDotsLabs/watermill message/pubsub.go Subscriber.Close()
Close(ctx context.Context) error
}
Subscriber consumes events from a topic.
ref: ThreeDotsLabs/watermill message/pubsub.go Subscriber interface Adopted: Close() for clean shutdown; topic-based subscription model. Deviated: callback-based EntryHandler instead of channel-based (<-chan *Message) to align with GoCell's ConsumerBase pattern and simplify consumer lifecycle. Extended: Setup/Ready split mirrors Watermill Router's setup-before-run pattern, eliminating the 500ms startup-timeout heuristic in eventrouter (Commit 3).
ref: Kafka sarama ConsumerGroup -- consumerGroup isolates consumption; same group competes, different groups each get a full copy (fanout). ref: go-micro broker.SubscribeOptions.Queue -- same concept, different name.
type SubscriberHandler ¶
type SubscriberHandler func(ctx context.Context, entry Entry) (DeliveryOutcome, Settlement)
SubscriberHandler is the Subscriber-layer handler type that ConsumerBase.Wrap returns and Subscriber.Subscribe accepts. It extends EntryHandler with a Settlement return value so Subscriber implementations can call Settlement.Commit before broker Ack and Settlement.Release after broker Nack without any idempotency types leaking into business code.
The return type is DeliveryOutcome (not HandleResult) because ConsumerBase.Wrap injects kernel-level ProcessReason ("retry_exhausted") and subscriber-layer SettlementObservers that business handlers must not set. Business handlers return slim HandleResult; ConsumerBase lifts it to DeliveryOutcome.
Settlement may be nil when ConsumerBase has no idempotency state (fail-open claim error, ClaimDone, ClaimBusy short-circuit). Subscribers MUST nil-check before calling Commit/Release.
Business handlers use EntryHandler — they never see Settlement. The type separation provides compile-time enforcement without archtest gates.
ref: IBM/sarama consumer_group.go ConsumeClaim(session ConsumerGroupSession,
claim ConsumerGroupClaim) — settle handle as explicit method parameter
ref: nats-io/nats.go jetstream/message.go Msg interface (Ack/Nak/Term)
type SubscriberIntakeStopper ¶
SubscriberIntakeStopper is a Subscriber-implementer extension contract. Business handlers should never reference this interface.
Subscribers that implement it can stop accepting new deliveries while still processing in-flight ones, enabling a two-phase drain during shutdown (stop intake → wait in-flight handlers). Router.Close calls StopIntake on subscribers implementing this interface before canceling the run context.
Subscribers that do not implement this interface will fall back to the legacy single-phase close behavior (cancel context only).
StopIntake MUST be idempotent. It SHOULD be safe to call concurrently with in-flight Subscribe calls.
Errors are best-effort: callers (e.g., eventrouter.Router.Close) log a warning and proceed to context cancellation regardless. Return an error only for caller-observable failures that operators would want to see in logs.
ref: ThreeDotsLabs/watermill message/router.go (closingInProgressCh two-phase barrier) ref: uber-go/fx app.go shutdown semantics (run ctx vs stop ctx separation)
type SubscriberWithMiddleware ¶
type SubscriberWithMiddleware struct {
// contains filtered or unexported fields
}
SubscriberWithMiddleware wraps a Subscriber with a business middleware chain and a required ConsumerBase for idempotency/retry.
The SubscribeEntry method is the primary entry point for callers that hold an EntryHandler (e.g., eventrouter.Router). It orchestrates:
- Business middleware chain (EntryHandler → EntryHandler): applied via slices.Backward(s.Middleware) — [0] is outermost (first to see each delivery, last to return), [len-1] is innermost (adjacent to ConsumerBase). This is the opposite of chi/Kratos forward composition where [0] is applied last.
- ConsumerBase.Wrap (EntryHandler → SubscriberHandler): injects idempotency Claim/Commit/Release and retry logic. SubscribeEntry fails fast when ConsumerBase is nil.
- Observability restore (entry.Observability → ctx): built-in OUTERMOST wrapper applied inside Inner.Subscribe so every layer sees a ctx populated with trace_id/request_id/correlation_id.
- Inner.Subscribe: the actual broker subscription.
Observability restoration is a paired invariant with Entry.InjectObservability FromContext on the producer side — there is no kill-switch. Raw delivery without restore (integration testing only) should invoke Inner.Subscribe directly.
SubscriberWithMiddleware does NOT implement the Subscriber interface: it exposes only SubscribeEntry (EntryHandler) as the single public entry point. This prevents the lift→discard footgun where callers could assign *SubscriberWithMiddleware to outbox.Subscriber and bypass the business middleware chain. Adapter-layer tests that need raw SubscriberHandler delivery must call the inner subscriber directly.
Construction is funneled through NewSubscriberWithMiddleware: fields are unexported so struct literals cannot create an invalid (nil-inner / nil-ConsumerBase) instance. This mirrors the OUTBOX-SERVICE-01 ctor fail-fast pattern that 12 outbox-bound services already follow.
ref: ThreeDotsLabs/watermill message/router.go — handleMessage applies middleware then calls handler; Ack/Nack monopoly stays in router. ref: go-kratos/kratos — middleware chain on business Request/Reply; transport/grpc monopolizes gRPC status. ref: IBM/sarama consumer_group.go — ConsumeClaim owns MarkMessage; business handler in ConsumeClaim body never calls MarkMessage directly.
func NewSubscriberWithMiddleware ¶
func NewSubscriberWithMiddleware(inner Subscriber, cb *ConsumerBase, mw ...SubscriptionMiddleware) (*SubscriberWithMiddleware, error)
NewSubscriberWithMiddleware constructs a SubscriberWithMiddleware. inner and cb are required (nil → error); mw is optional. The constructor is the only path to a valid instance — fields are unexported so a struct literal cannot bypass the nil-guards. Methods (Setup / Ready / SubscribeEntry / Close / StopIntake) therefore delegate without runtime nil checks; the boundary is closed at construction time.
func (*SubscriberWithMiddleware) Close ¶
func (s *SubscriberWithMiddleware) Close(ctx context.Context) error
Close delegates to the inner subscriber, forwarding the ctx unchanged so the inner implementation can honor the shutdown budget.
func (*SubscriberWithMiddleware) Ready ¶
func (s *SubscriberWithMiddleware) Ready(sub Subscription) <-chan struct{}
Ready delegates to the inner subscriber.
func (*SubscriberWithMiddleware) Setup ¶
func (s *SubscriberWithMiddleware) Setup(ctx context.Context, sub Subscription) error
Setup delegates topology pre-declaration to the inner subscriber.
func (*SubscriberWithMiddleware) StopIntake ¶
func (s *SubscriberWithMiddleware) StopIntake(ctx context.Context) error
StopIntake forwards to the inner subscriber if it implements SubscriberIntakeStopper. Returns nil if it does not (graceful degradation). Safe to call multiple times (idempotent, assuming inner is).
func (*SubscriberWithMiddleware) SubscribeEntry ¶
func (s *SubscriberWithMiddleware) SubscribeEntry(ctx context.Context, sub Subscription, h EntryHandler) error
SubscribeEntry is the entry point for callers that hold an EntryHandler (e.g. eventrouter.Router). It applies the full pipeline: business middleware chain → ConsumerBase.Wrap → observability/principal restore → inner Subscribe.
The business middleware chain is applied via slices.Backward(s.middleware): middleware[0] is the outermost layer (first to wrap, last to return) and middleware[len-1] is the innermost layer (adjacent to ConsumerBase). This is the opposite of chi/Kratos forward composition where index 0 is applied last. Example with middleware = [A, B, C]: execution order is A → B → C → handler.
This method is intentionally not part of the Subscriber interface: it accepts the business-layer EntryHandler rather than the framework-layer SubscriberHandler, enforcing the boundary at the type level. eventrouter.Router calls SubscribeEntry directly; Subscriber adapters (rabbitmq, eventbus) implement Subscribe directly.
type Subscription ¶
type Subscription struct {
// Topic is the broker routing key. Required.
Topic string
// ConsumerGroup is the logical consumer group identity. Required.
// It forms the idempotency key namespace: "{ConsumerGroup}:{entry.ID}".
// Subscribers sharing the same ConsumerGroup compete for messages;
// different groups each receive a full copy (fanout).
ConsumerGroup string
// CellID is the observability owner label. Required.
//
// CellID is the single source of truth for the cell owning this
// subscription — metrics, slog fields, and trace span attributes use it
// to route subscriber telemetry to the right owner. The value is set by
// codegen (contractgen NewSubscription / cellgen cell.tmpl) from the
// cell's metadata.ID at compile time; there is no runtime fallback to
// ConsumerGroup. ConsumerGroup is a broker partition key + idempotency
// namespace, conceptually orthogonal to ownership.
//
// ref: ADR docs/architecture/202605111000-adr-subscription-cellid-mandatory.md
CellID string
// SliceID is an optional observability owner label. Codegen fills it
// from slice.yaml contractUsages[role=subscribe] via cellgen when a cell
// wants per-slice consumer metrics.
SliceID string
// ContractID/Kind/Transport identify the contract bound to this
// subscription. They are intentionally primitive strings rather than a
// contractspec.ContractSpec to keep kernel/outbox independent of
// kernel/wrapper (wrapper already imports outbox for EntryHandler).
//
// Runtime eventrouter fills these fields for AddContractHandler
// registrations so subscription middleware can install the contract span
// outside ConsumerBase while still after observability metadata restore.
ContractID string
ContractKind string
ContractTransport string
}
Subscription describes the full identity of a single subscription intent. It is the first-class object passed through the middleware chain, ensuring every layer (idempotency, observability, retry) sees Topic, ConsumerGroup, CellID, and SliceID without information loss at middleware boundaries.
ref: ThreeDotsLabs/watermill message/router.go handler context injection ref: MassTransit ConsumeContext — full identity traverses the entire pipeline
func (Subscription) IdempotencyNamespace ¶
func (s Subscription) IdempotencyNamespace() string
IdempotencyNamespace returns the namespace prefix for idempotency keys. Keys are constructed as "{IdempotencyNamespace}:{entry.ID}".
func (Subscription) ObservabilityID ¶
func (s Subscription) ObservabilityID() string
ObservabilityID returns the human-readable identifier used in logs and metrics.
Returns CellID unconditionally — Validate enforces CellID is non-empty, so the empty string only surfaces when the caller bypassed Validate (e.g. constructing a literal in a test fixture). There is no fallback to ConsumerGroup: substituting ConsumerGroup would mask a codegen defect (the cell metadata → reg.Subscribe positional parameter chain failed to populate the field), so the empty value is intentionally surfaced.
ref: ADR docs/architecture/202605111000-adr-subscription-cellid-mandatory.md
func (Subscription) Validate ¶
func (s Subscription) Validate() error
Validate returns an error when required fields are missing.
Subscription.Validate is the SINGLE source of truth for subscription-shape invariants. Subscribe-time decorators (e.g. ContractTracingSubscriber) call it once at the entry point instead of duplicating the checks downstream (N8 (c) — collapsed the previous parallel check inside MustWrapSubscriber).
type SubscriptionMiddleware ¶
type SubscriptionMiddleware func(sub Subscription, next EntryHandler) EntryHandler
SubscriptionMiddleware is the event-consumer middleware type. It carries the full Subscription identity (Topic + ConsumerGroup + CellID), so middleware can route, log, and observe per-subscription rather than per-topic.
Apply in order: [0] is outermost, [len-1] is innermost.
The next handler is EntryHandler (business signature). Middleware authors work entirely in the business domain and do not see Settlement — it is an internal concern of the Subscriber layer (adapters/rabbitmq, runtime/eventbus). ConsumerBase is field-injected into SubscriberWithMiddleware and applied as an explicit EntryHandler→SubscriberHandler conversion boundary after the business middleware chain, not as a SubscriptionMiddleware entry.
ref: ThreeDotsLabs/watermill message/router.go — HandlerMiddleware operates on Message→Message; Ack/Nack is the router's exclusive responsibility. ref: go-kratos/kratos middleware — middleware operates on Request→Reply; gRPC status (settle equivalent) is the transport's exclusive responsibility.
type Writer ¶
type Writer interface {
// Write persists an outbox entry atomically with the caller's business
// state. Write MUST be invoked from within an active transaction; the
// implementation extracts the tx from ctx via TxFromContext(ctx). Calling
// Write outside of a persistence.TxRunner.RunInTx scope is a programming
// error — implementations return an errcode error with KindInternal
// (e.g. adapters/postgres returns ErrAdapterPGNoTx) rather than silently
// writing without transactional guarantees.
//
// ref: nikolayk812/pgx-outbox writer.go -- explicit tx parameter +
// ErrTxNil guard for the same MUST-have-tx contract.
Write(ctx context.Context, entry Entry) error
}
Writer writes outbox entries within a transaction. The implementation MUST ensure the outbox write is atomic with the business state write (same DB transaction).
type WriterEmitter ¶
type WriterEmitter struct {
// contains filtered or unexported fields
}
WriterEmitter emits by writing entries to the transactional outbox.
func NewWriterEmitter ¶
func NewWriterEmitter(w Writer) (*WriterEmitter, error)
NewWriterEmitter adapts an outbox Writer into an Emitter.
func (*WriterEmitter) Durable ¶
func (e *WriterEmitter) Durable() bool
Durable reports whether this WriterEmitter is backed by a real (non-noop) outbox.Writer. NoopWriter and any writer that advertises Noop()==true are considered non-durable; anything else is durable.
func (*WriterEmitter) Emit ¶
func (e *WriterEmitter) Emit(ctx context.Context, entry Entry) error
Emit writes the entry to the underlying transactional outbox. The relay then publishes it asynchronously, so durability is decoupled from the caller's success path. Returns ErrCellMissingOutbox when the writer is nil — a programmer error that should surface at construction time.
Source Files
¶
- cell_marker.go
- consumer_base.go
- consumer_observer.go
- demo_tx_runner.go
- doc.go
- durability.go
- emit.go
- emitter.go
- entry_id.go
- envelope.go
- failopen_tracker.go
- metadata.go
- mode_resolver.go
- observability.go
- outbox.go
- principal.go
- reconstruct.go
- relay_collector.go
- relay_metrics.go
- result.go
- settlement.go
- state.go
- subscription.go
Directories
¶
| Path | Synopsis |
|---|---|
|
Package outboxtest provides a reusable conformance test suite for outbox.Publisher and outbox.Subscriber implementations.
|
Package outboxtest provides a reusable conformance test suite for outbox.Publisher and outbox.Subscriber implementations. |