Documentation
¶
Overview ¶
Package projection declares the kernel-level contracts for the CQRS projection lifecycle harness: the business event→state Apply hook, the CheckpointStore offset abstraction, the MemCursor / MemReplaySource demo helpers, the functional Option seam, and the rebuild-lifecycle Phase enum.
Scope ¶
L3 (WorkflowEventual): a projection consumes a cell's event stream and maintains a derived read-model. The harness owns the mechanical parts — checkpoint/offset persistence, exactly-once delivery to Apply, rebuild orchestration, metrics, readyz — so business cells only write the Apply function body and (optionally) an OnReset hook. The read-model schema and the Apply body itself stay business-owned (GAP-8 seal; see ADR §4).
Delivery status (PR-03 landed) ¶
PR-01 delivered Coordinator + Subscribe + consume loop + checkpoint write (kernel/projection/coordinator.go), MemCheckpointStore, and the projectiontest conformance harness. PR-02 delivered the postgres CheckpointStore adapter (adapters/postgres). PR-03 has delivered the rebuild state machine (Phase enum, Rebuild/Close lifecycle), MemReplaySource + ReplaySource interface, metrics, readyz probes (Probes() returning store-ready + lag probes), and the PROJECTION-REPLAY-SOURCE-CONFORMANCE-ENROLL-01 archtest. Remaining items:
- PR-04a (landed): the reg.RegisterProjection record-only seam + the bootstrap projection drain — the single sanctioned Coordinator.Subscribe callsite is now runtime/bootstrap/phases_projection.go (Option A, ADR §Amendment 2026-05-31). cellgen emits record-only reg.RegisterProjection, never Coordinator.Subscribe (raw infra may not reach cell code).
- PR-04b: cellgen kind:projection derivation of reg.RegisterProjection.
- PR-04c: production journal-backed Cursor + ReplaySource.
- PR-04e (landed; migrated to the operator admin plane by #1505): the framework-owned HTTP rebuild endpoint is POST /admin/v1/projection/<cell>/<name>/rebuild on the loopback cell.AdminListener, gated by an auth.AuthOperator operator-credential ListenerAuth (no caller-cell allowlist). It was originally framed for the InternalListener (POST /internal/v1/<cell>/projection/<name>/rebuild, service-token + caller-cell); #1505 moved it because a rebuild is an operator→system action, not cell→cell (see ADR 202606041200-1505 and 202605261620 §Amendment 2026-06-04). It stays a framework-owned RouteGroup (no contract.yaml, never trips DEAD-CONTRACT-01).
Subscribe's frozen forward signature (projectionID moved to NewCoordinator in PR-03):
func (c *Coordinator) Subscribe( ctx context.Context, spec contractspec.ContractSpec, apply Apply, opts ...Option, ) error
Ambient transaction model ¶
Apply and CheckpointStore take ctx only and obtain the transaction ambiently via persistence.TxFromContext — the established GoCell idiom (mirrors outbox.Writer.Write and is enforced by PG-REPO-AMBIENT-TX-01). The Coordinator wraps each event in persistence.TxRunner.RunInTx so the Apply mutation and the checkpoint SaveOffset commit in one transaction (exactly-once). Decided in ADR §3 Q1/Q2 against an explicit tx-handle parameter.
Ordering precondition (exactly-once requires serial in-order delivery) ¶
The exactly-once guarantee rests on a cumulative monotonic checkpoint: applyOne skips any event whose Cursor position is ≤ the stored checkpoint. This is only SOUND when the projection's stream is delivered strictly serially and in order. Under concurrent delivery (the production AMQP subscriber dispatches one goroutine per delivery, prefetch defaulting to 10) or broker redelivery-reorder, a higher position can commit the checkpoint before a lower position is applied; the lower event's distinct Apply is then silently skipped (pos ≤ checkpoint), leaving a projection gap. A single consumer GROUP does NOT by itself provide this ordering — it only prevents cross-cell fanout.
This serial in-order delivery is now ENFORCED at the bootstrap projection drain (PR-04d, #1369): a transport opts in by implementing outbox.SerialInOrderGuarantor and returning true; the drain rejects wiring a projection onto any subscriber that does not (fail-closed-by-absence). Only runtime/eventbus.InMemoryEventBus qualifies today (single-goroutine consume); AMQP/MQTT (concurrent dispatch) fail fast rather than silently dropping positions. This intra-consumer-group ordering precondition is distinct from the multi-pod boundary below. See ADR §6 threat row 4 + §Amendment 2026-06-02.
v1 operational boundaries ¶
- Single-pod only. v1 has no distributed claim: running two pods that consume the same projection without external leader election is UNSAFE — both advance the same checkpoint and double-apply. Multi-pod safety is the upper layer's responsibility (cmd/* leader election). The PG checkpoint schema reserves an `owner` column (ref: Axon token_entry.owner) for a v1.1 pessimistic claim, but v1 never reads or writes it. See ADR §3 Q5.
- Full rebuild only — no snapshot / partial replay in v1 (ADR §3 Q4).
Governance (see each archtest file for ratings) ¶
- PROJECTION-STATE-PHASE-FROZEN-01 — freezes the Phase enum membership. Archtest: tools/archtest/projection_state_phase_frozen_test.go. Green from PR-00.
- PROJECTION-APPLY-HOOK-FUNNEL-01 — Subscribe callable only from cellgen-derived wiring or allowlisted callers. Archtest: tools/archtest/projection_apply_hook_funnel_test.go. PR-01 vacuous-active (zero external callsites); PR-04 load-bearing.
- PROJECTION-CHECKPOINT-TX-BOUND-01 — SaveOffset must use the ambient tx, no raw db handle. Archtest: tools/archtest/projection_checkpoint_tx_bound_test.go. PR-01 vacuous-active (MemCheckpointStore has no pool); PR-02 load-bearing.
- PROJECTION-CHECKPOINT-CONFORMANCE-ENROLL-01 — every CheckpointStore impl must enroll in projectiontest.RunCheckpointConformance. Archtest: tools/archtest/projection_checkpoint_conformance_enroll_test.go. Green from PR-01 (MemCheckpointStore enrolled).
- PROJECTION-SERIAL-DELIVERY-ENFORCEMENT-01 — a projection may only be carried by a transport implementing outbox.SerialInOrderGuarantor (true); the bootstrap drain fail-fasts otherwise. Marker freeze + exact implementer set {InMemoryEventBus} + single guard callsite. Archtest: tools/archtest/projection_serial_delivery_enforcement_test.go. Green from PR-04d (#1369).
ref: docs/architecture/202605261620-adr-cqrs-projection-lifecycle-harness.md ref: AxonFramework TokenStore / @ResetHandler — checkpoint-in-tx + 4-phase rebuild. ref: JasperFx/marten async-daemon — IDocumentOperations apply shape. ref: ThreeDotsLabs/watermill components/cqrs — EventProcessor baseline.
Index ¶
- Constants
- Variables
- func InstallSystemPrincipal(ctx context.Context) context.Context
- type Apply
- type CheckpointStore
- type Coordinator
- func (c *Coordinator) Close(ctx context.Context) error
- func (c *Coordinator) Phase() Phase
- func (c *Coordinator) Probes() ([]healthz.Probe, error)
- func (c *Coordinator) Rebuild(ctx context.Context) error
- func (c *Coordinator) Snapshot(ctx context.Context) (Snapshot, error)
- func (c *Coordinator) Subscribe(ctx context.Context, spec contractspec.ContractSpec, apply Apply, ...) error
- type CoordinatorConfig
- type Cursor
- type MemCheckpointStore
- type MemCursor
- type MemReplaySource
- type Metrics
- type OnReset
- type Option
- type Phase
- type ProjectionEvent
- type ReplaySource
- type Snapshot
- type SubscribeRegistrar
Constants ¶
const SystemPrincipalActor = "system"
SystemPrincipalActor is the actor identity installed in a context by InstallSystemPrincipal. Saga journal replay events run under this sentinel so the business Apply function never sees an unauthenticated empty principal or a forwarded admin identity from the Rebuild trigger context.
Value "system" is consistent with the framework-internal sentinel used elsewhere (e.g. reconcile worker identity) — a stable, recognizable string that audit log consumers can filter on.
Variables ¶
var ErrRebuildInProgress = errcode.New(errcode.KindConflict, errcode.ErrConflict,
"projection.Rebuild: rebuild already in progress; only one rebuild may run at a time")
ErrRebuildInProgress is returned by Rebuild when a rebuild is already running. Callers should map this to HTTP 409 Conflict.
Functions ¶
func InstallSystemPrincipal ¶
InstallSystemPrincipal overwrites ALL four principal ctx keys (actor/subject/ tenant/session) unconditionally: it sets actor and subject to SystemPrincipalActor and clears tenant and session to the empty string.
This is the OVERWRITE variant of principal restore — contrast with outbox.PrincipalMetadata.RestoreToContext which is no-overwrite (existing ctx values win). The overwrite semantic is intentional for the saga journal path: saga events carry no per-event principal identity, so the carrier must positively assert a known system identity rather than leaving the ambient one (which might be the triggering admin's) in place.
Caller allowlist (PROJECTION-SYSTEM-PRINCIPAL-INSTALL-CALLER-01) ¶
Only kernel/saga/sagaprojection/source.go is sanctioned to call this function. Any other callsite fails the archtest in CI. See the archtest for the AI-robust rating and Hard-upgrade tracking (gh #1702).
Types ¶
type Apply ¶
type Apply = cellvocab.ProjectionApply
Apply is the business event→state projection hook: given a consumed ProjectionEvent, mutate the read-model. The transaction is ambient — Apply obtains it via persistence.TxFromContext(ctx) exactly like outbox.Writer.Write, because the Coordinator invokes Apply inside persistence.TxRunner.RunInTx so the read-model mutation and the checkpoint advance commit atomically (exactly-once delivery; the harness never calls Apply twice for the same offset).
Apply MUST NOT open its own transaction or connection. A transient failure returns a plain error (the Coordinator requeues). A permanent failure returns an error wrapping outbox.NewPermanentError(err); the Coordinator classifies it as DispositionReject and routes to the DLX — the same vocabulary as the ConsumerBase handler convention (see .claude/rules/gocell/eventbus.md). Decided in ADR §3 Q2 against the eventhorizon read-modify-write entity shape and the explicit tx-handle parameter.
It is an alias of cellvocab.ProjectionApply (also aliased by kernel/cell.ProjectionApply), the single underlying type that lets the bootstrap drain pass a cell-recorded hook to Coordinator.Subscribe with no named-type conversion.
ref: JasperFx/marten async-daemon IDocumentOperations apply shape.
type CheckpointStore ¶
type CheckpointStore interface {
// LoadOffset returns the last committed offset for the projection, or 0 if
// none has been recorded yet (cold start).
LoadOffset(ctx context.Context, cellID, projectionID string) (int64, error)
// SaveOffset advances the projection's committed offset within the ambient
// transaction carried by ctx.
SaveOffset(ctx context.Context, cellID, projectionID string, offset int64) error
}
CheckpointStore persists a projection's consumed offset. It is the framework's own offset table — it does NOT touch any business read-model schema (the CellTx-offset design keeps the harness clear of the GAP-8 seal; ADR §4).
The offset is an opaque, monotonically increasing cursor over the projection's input stream. Its concrete mapping to a stream position is owned by the replay source defined in PR-01 (the harness compares a replayed event's position against the stored checkpoint to skip already-applied events) — it is NOT an outbox.Entry field (Entry carries no sequence number today). LoadOffset returns 0 for an unknown (cellID, projectionID) pair (cold start = offset 0).
Both methods are ambient-tx: SaveOffset participates in the caller's transaction via persistence.TxFromContext(ctx) (no raw db handle — enforced by PROJECTION-CHECKPOINT-TX-BOUND-01), so the offset advance commits together with the Apply mutation.
Implementations: mem (PR-01) + postgres (PR-02); both verified by the shared projectiontest.RunCheckpointConformance template (PR-01). Decided in ADR §3 Q1 (caller-provided tx + harness-internal SaveOffset, mirroring outbox.Writer and Axon's JdbcTokenStore same-tx commit).
type Coordinator ¶
type Coordinator struct {
// contains filtered or unexported fields
}
Coordinator wires an event subscription to an Apply function, managing exactly-once delivery via a CheckpointStore. Each consumed event is processed inside a TxRunner.RunInTx so that the Apply mutation and the checkpoint advance commit atomically.
Per-projection model (PR-03) ¶
Each Coordinator is per-projection (projectionID moves from Subscribe to NewCoordinator). Subscribe may only be called once; a second call returns an error. This is a Hard constraint enforced by a subscribed atomic.Bool CAS. A cell hosting N projections creates N Coordinators.
Gate mechanism (PR-03) ¶
During a rebuild, the live event handler parks on an unbuffered channel gate (liveGate). An OPEN gate is a CLOSED channel (reads pass immediately); a SHUT gate is a fresh open (non-closed) channel (reads block). Only the single rebuild goroutine calls shutGate/openGate.
ref: JasperFx/marten async-daemon ProjectionDaemon. ref: AxonFramework TrackingEventProcessor.
func NewCoordinator ¶
func NewCoordinator(clk clock.Clock, cfg CoordinatorConfig) (*Coordinator, error)
NewCoordinator constructs a per-projection Coordinator. clk is the first parameter per CLOCK-POSITIONAL-INJECTION-01; all other dependencies are supplied via cfg.
When cfg.Metrics is non-nil its label set is validated via preflight during construction, so a metric-label misconfiguration fails fast at startup rather than panicking on the first runtime metric update.
Required dependencies are validated with hand-written nil guards (using validation.IsNilInterface) rather than gocell:"required" codegen — kernel/ has no codegen dependency (established kernel-layer pattern).
ref: ADR docs/architecture/202605261620-adr-cqrs-projection-lifecycle-harness.md ref: kernel/outbox.NewConsumerBase (required deps + config struct + positional clk)
func (*Coordinator) Close ¶
func (c *Coordinator) Close(ctx context.Context) error
Close cancels any in-flight rebuild and waits for it to finish. It then closes the done channel. Close is idempotent and safe to call concurrently from multiple goroutines; only the first call closes the done channel (subsequent calls are no-ops). ctx controls the wait timeout.
func (*Coordinator) Phase ¶
func (c *Coordinator) Phase() Phase
Phase returns the current lifecycle phase of the Coordinator. Best-effort snapshot: may be stale by the time the caller acts on it.
func (*Coordinator) Probes ¶
func (c *Coordinator) Probes() ([]healthz.Probe, error)
Probes returns the two healthz.Probe values for this projection, mirroring the ProbeSet pattern used by adapters/postgres.Pool.Probes():
"<cell>_projection_<proj>_store_ready": dependency-availability probe. Healthy when both replay.Head and store.LoadOffset succeed. Unhealthy when either errors (storage unreachable).
"<cell>_projection_<proj>_lag": operational-health probe (no _ready suffix — same convention as outbox_relay_*). Healthy when pending events = 0 (idle), nothing applied yet (startup grace), or lag ≤ threshold. Unhealthy when pending > 0 AND lag > projectionLagThresholdSeconds.
Both probes compute their state on demand (no background ticker), consistent with the plan's "metrics computed on probe/snapshot reads" decision.
The split follows observability.md: storage reachability (dependency availability) and read-model staleness (operational health) are distinct failure domains with different on-call responses.
func (*Coordinator) Rebuild ¶
func (c *Coordinator) Rebuild(ctx context.Context) error
Rebuild triggers a background full rebuild of the projection's read-model. It transitions the Coordinator through the 4-phase lifecycle:
Stop → Reset → Replay → Catchup → Live
Rebuild returns immediately (nil) if the CAS admission succeeds; the caller should respond 202 Accepted. If a rebuild is already running, Rebuild returns ErrRebuildInProgress (caller maps to 409 Conflict).
Subscribe must have been called before Rebuild — the projection must have a registered apply function and spec before rebuild can start.
The rebuild goroutine is detached from the request's cancellation and deadline (via context.WithoutCancel) but inherits its values — request_id / trace_id / correlation_id / cell_id — so the async lifecycle logs correlate to the request that admitted the rebuild. It is canceled only by Close (which sets rebuildCancel), never by the triggering request completing.
func (*Coordinator) Snapshot ¶
func (c *Coordinator) Snapshot(ctx context.Context) (Snapshot, error)
Snapshot returns the Coordinator's current lifecycle phase plus a best-effort pending-events / replay-lag reading. Phase is read from the live atomic (Coordinator.Phase, never errors). PendingEvents and ReplayLagSeconds are derived from the replay head, stored checkpoint, and last-applied domain time via computeLagPending — the same single-source computation the lag readyz probe uses (so the wire snapshot can never diverge from the probe). On a store/replay read error the Snapshot carries the valid Phase with zeroed pending/lag and the error is returned for the caller to log.
func (*Coordinator) Subscribe ¶
func (c *Coordinator) Subscribe( ctx context.Context, spec contractspec.ContractSpec, apply Apply, opts ...Option, ) error
Subscribe registers the event subscription for this Coordinator's projection. Subscribe must be called exactly once; a second call returns an error (v1 single-input-stream Hard constraint: one Coordinator, one input stream).
spec must be an event-kind contract spec (spec.Kind must be "event"). apply must be non-nil. opts are applied to subscribeOptions.
The consumerGroup is derived as cellID + "-" + projectionID and is not caller-configurable (each projection has exactly one consumer group).
After a successful Subscribe, apply and onReset (from opts) are captured for reuse by Rebuild.
type CoordinatorConfig ¶
type CoordinatorConfig struct {
Registrar SubscribeRegistrar
CellID string
ProjectionID string
TxRunner persistence.TxRunner
Store CheckpointStore
Cursor Cursor
Replay ReplaySource
Tracer wrapper.Tracer
Metrics *Metrics // optional; nil = instruments disabled
}
CoordinatorConfig bundles the Coordinator's dependencies. It mirrors the kernel many-dependency convention NewConsumerBase(claimer, ConsumerBaseConfig, clk): required deps live in a config struct validated with hand-written nil guards, while clk is a separate positional parameter to NewCoordinator.
clk is deliberately NOT a field here: CLOCK-POSITIONAL-INJECTION-01 mandates a positional clock parameter and forbids a Clock field on input config structs.
Metrics is optional (nil disables all instruments). Every other field is required; a nil/empty value is rejected by NewCoordinator. Grouping the deps in a struct (rather than 9 positional params) removes the silent-swap hazard of the two adjacent string fields CellID/ProjectionID.
ref: open-source consensus — Watermill cqrs.EventProcessorConfig, Axon TrackingEventProcessor.Builder; kernel kernel/outbox.ConsumerBaseConfig.
type Cursor ¶
type Cursor interface {
Position(entry ProjectionEvent) (int64, error)
}
Cursor maps a consumed event to its monotonic stream position. The ProjectionEvent carrier exposes no sequence field — the position is supplied by the replay source.
Position invariants (required of every implementation) ¶
Monotonic: within the same (cellID, projectionID) stream, Position is non-decreasing across events delivered in order. DISTINCT events MUST get STRICTLY INCREASING positions — if two distinct events shared a position, the Coordinator's pos <= checkpoint guard would silently skip the second after the first commits the checkpoint (a projection gap), so a coarser cursor is unsound. The ONLY valid equality is RE-DELIVERY of an already-applied event, which returns its same previously-assigned position; that guard handles idempotent re-delivery. (RunCursorConformance asserts strict increase across distinct seeded entries accordingly.)
1-based: every valid event position is ≥ 1. The value 0 is reserved to mean "no checkpoint / cold start" (the default returned by CheckpointStore on first read). A Cursor must never return 0 for a real event.
Gap-allowed: the position sequence may have gaps (e.g. jumping from 3 to 7). The Coordinator applies both ends of the gap as they arrive; events for the missing positions 4–6 will be skipped if they arrive later because their position is ≤ the checkpoint. This is expected and correct — gaps arise when the event source does not emit every sequence number.
Resolution error semantics: an error returned by Position is treated as transient by default (Coordinator requeues the event for retry). Wrap the error in outbox.NewPermanentError to signal that the event is unrecoverable and should be routed to the dead-letter exchange.
PR-01 shipped this interface and the MemCursor test fake; the production outbox-journal-backed cursor (adapters/postgres.PGProjectionCursor, reading outbox_entries.seq) landed in PR-04c (#1368). Note the v1 limitation: positions come from the transient outbox relay, so the cursor/replay are gated to dev/preview until a durable projection journal lands (#1504).
ref: Axon TrackingToken (position is a property of the token store / stream).
type MemCheckpointStore ¶
type MemCheckpointStore struct {
// contains filtered or unexported fields
}
MemCheckpointStore is an in-process CheckpointStore backed by a plain map. It is suitable for tests and demos; it does NOT participate in any database transaction (ctx is accepted but unused — ambient-tx binding is the caller's concern). Durability is process-lifetime only.
Thread-safe: concurrent SaveOffset / LoadOffset calls are protected by a RWMutex.
func NewMemCheckpointStore ¶
func NewMemCheckpointStore() *MemCheckpointStore
NewMemCheckpointStore returns a ready-to-use MemCheckpointStore.
func (*MemCheckpointStore) LoadOffset ¶
func (m *MemCheckpointStore) LoadOffset(_ context.Context, cellID, projectionID string) (int64, error)
LoadOffset returns the committed offset for (cellID, projectionID), or 0 if no offset has been recorded yet (cold start). ctx is accepted but unused.
func (*MemCheckpointStore) SaveOffset ¶
func (m *MemCheckpointStore) SaveOffset(_ context.Context, cellID, projectionID string, offset int64) error
SaveOffset persists offset for (cellID, projectionID). ctx is accepted but unused — MemCheckpointStore is not tx-bound.
type MemCursor ¶
type MemCursor struct {
// contains filtered or unexported fields
}
MemCursor is an in-process Cursor paired with a MemReplaySource: it resolves an entry's monotonic position by looking it up in the source. Suitable for tests and demos only.
Position returns the 1-based insertion index of the entry in the source (same value as MemReplaySource.Position). If the entry is not present in the paired source, Position returns 0 and a permanent error — retry cannot fix a missing source entry.
ref: Axon TrackingToken (position is a property of the token store / stream).
func NewMemCursor ¶
func NewMemCursor(src *MemReplaySource) (*MemCursor, error)
NewMemCursor returns a MemCursor backed by src. src is required: a nil source is rejected here (fail-fast) rather than deferred to the first Position call.
type MemReplaySource ¶
type MemReplaySource struct {
// contains filtered or unexported fields
}
MemReplaySource is an in-process append-only ReplaySource backed by a plain slice. It is suitable for tests and demos only; it does NOT participate in any database transaction and provides no durability.
Position scheme ¶
Position is 1-based insertion index: the first Append call assigns position 1, the second assigns position 2, etc. This scheme is deterministic, gap-free, and agrees with the paired test memCursor.Position(e) implementation that uses positionOf(e) to look up the 1-based index.
Event identity ¶
Position resolution matches events by their EventID (the ProjectionEvent carrier's identity accessor), which the carrier contract requires to be unique across the whole replay source — so the lookup is collision-free even when test events are created in the same nanosecond and does not rely on any pointer or timestamp identity.
Thread-safe: all methods are protected by a RWMutex.
func NewMemReplaySource ¶
func NewMemReplaySource() *MemReplaySource
NewMemReplaySource returns a ready-to-use MemReplaySource.
func (*MemReplaySource) Append ¶
func (m *MemReplaySource) Append(entry ProjectionEvent)
Append adds entry to the source at the next 1-based insertion position. This is a test helper — production code does not call Append directly.
func (*MemReplaySource) Head ¶
func (m *MemReplaySource) Head(_ context.Context) (int64, error)
Head returns the number of entries (= highest available 1-based position), or 0 if empty. ctx is accepted but unused (satisfies ReplaySource interface).
func (*MemReplaySource) Position ¶
func (m *MemReplaySource) Position(e ProjectionEvent) int64
Position returns the 1-based insertion index of entry in the source by its EventID, or 0 if not found. Used by the projectiontest conformance helper and test cursors which need a stable cross-package API. EventID is unique per event so this lookup is collision-free even for same-nanosecond events.
func (*MemReplaySource) Replay ¶
func (m *MemReplaySource) Replay(ctx context.Context, fromOffset int64, fn func(ProjectionEvent) error) error
Replay iterates entries with 1-based position > fromOffset in insertion order, calling fn for each. Returns fn's error immediately if non-nil. ctx.Done is checked at the start of each iteration to respect cancellation.
type Metrics ¶
type Metrics struct {
// ReplayLag is a gauge tracking the event lag in seconds:
// lag = now − lastApplied.OccurredAt(). Labels: {cell, projection}.
ReplayLag kernelmetrics.GaugeVec
// RebuildDuration is a histogram observing rebuild wall-clock duration in
// seconds. Labels: {cell, projection}.
RebuildDuration kernelmetrics.HistogramVec
// PendingEvents is a gauge tracking replay.Head() − checkpoint: the number
// of events not yet reflected in the projection. Labels: {cell, projection}.
PendingEvents kernelmetrics.GaugeVec
}
Metrics holds the optional pre-bound instruments the Coordinator records to. A nil *Metrics (or nil individual fields) disables that instrument. Build via RegisterMetrics and inject into NewCoordinator as an optional parameter (nil = instruments disabled, cloned from kernel/reconcile/metrics.go pattern).
func RegisterMetrics ¶
func RegisterMetrics(p kernelmetrics.Provider) (*Metrics, error)
RegisterMetrics registers the three projection instruments on p with canonical names, labels, and buckets, returning them bundled. Call at the composition root and inject the result into NewCoordinator.
type OnReset ¶
type OnReset = cellvocab.ProjectionResetHook
OnReset is the business hook invoked during a projection rebuild Reset phase. It allows the projection owner to clear its read-model state (e.g. TRUNCATE a view table) before the harness resets the checkpoint offset to 0 and begins replay. The transaction is ambient — OnReset obtains it via persistence.TxFromContext(ctx), exactly like Apply. Both OnReset and the SaveOffset(0) call share the same transaction; if OnReset returns an error, the whole transaction rolls back and the read-model is left untouched.
Passing nil is valid (projection has no read-model table to clear — offset reset alone suffices). OnReset is set via WithOnReset option on Subscribe.
It is an alias of cellvocab.ProjectionResetHook (also aliased by kernel/cell.ProjectionResetHook).
ref: AxonFramework @ResetHandler — called during TrackingEventProcessor reset to let the projection clear application state before replay.
type Option ¶
type Option func(*subscribeOptions)
Option configures a projection subscription. It is the frozen functional-option seam consumed by Coordinator.Subscribe (PR-01); concrete option constructors (e.g. starting offset, fail-open policy) are added alongside the Coordinator. Declared here so the Subscribe API surface is fixed by the ADR rather than drifting when the implementation lands.
Available option constructors:
- WithOnReset — registers an OnReset hook invoked during the rebuild Reset phase.
Passing no opts (an empty or nil slice) is valid.
func WithOnReset ¶
WithOnReset configures the OnReset hook for the projection. When a rebuild is triggered, the hook is called inside the Reset transaction so the read-model can be cleared atomically with the checkpoint offset reset to 0. Passing nil is valid (no-op; offset is still reset to 0).
type Phase ¶
type Phase uint8
Phase is the lifecycle state of a projection, exposed by the Coordinator (PR-03) so operators (readyz / metrics) and business read paths can observe where a projection is. The four rebuild phases mirror Axon's processor lifecycle (Stop → Reset → Replay → Catch-up); PhaseLive is the steady-state value outside a rebuild.
Business read-503 opt-in ¶
rebuild does NOT block business reads by default — stale reads during rebuild are the industry consensus (Axon / Marten / Commanded). A business read path MAY consult Phase() and return 503 of its own accord; the harness never forces it. PhaseLive means the read-model is fresh and serving; any other phase means a rebuild is in progress. See ADR §5.
Phase() is a best-effort point-in-time snapshot, not a freshness lock: a read path that observes PhaseLive and then queries the read-model has no atomic ordering against a rebuild starting between the two calls. Business code requiring strict freshness must maintain its own read quiescence.
Frozen membership ¶
The const set is frozen by archtest PROJECTION-STATE-PHASE-FROZEN-01 (the operational contract for readyz/metrics/503 must not drift silently). The rebuild state machine + transition table that drive these phases land in PR-03.
ref: kernel/outbox/state.go (enum + String pattern). ref: AxonFramework EventProcessor lifecycle (Stop/Reset/Replay/Catch-up).
const ( // PhaseLive: steady-state consuming, read-model fresh and serving. // // IMPORTANT: iota+1 makes the zero value (0) an invalid Phase, so a // forgotten/uninitialised Phase cannot silently appear as PhaseLive. PhaseLive Phase = iota + 1 // = 1 // PhaseStopped: rebuild step 1 — consume loop halted, checkpoint claim // released so the reset/replay can rewrite the offset row safely. PhaseStopped // PhaseReset: rebuild step 2 — business OnReset hook (TRUNCATE/DROP its // read-model) + harness resets the checkpoint offset to 0, one CellTx. PhaseReset // PhaseReplay: rebuild step 3 — replaying the event stream from offset 0, // each event in its own CellTx (Apply + checkpoint). PhaseReplay // PhaseCatchup: rebuild step 4 — replay reached the head offset captured at // rebuild start; now consuming newly arriving events until caught up, then // transition back to PhaseLive. PhaseCatchup )
type ProjectionEvent ¶
type ProjectionEvent = cellvocab.ProjectionEvent
ProjectionEvent is the minimal typed read-only carrier the harness applies: ReplaySource.Replay's callback, Cursor.Position, and Apply all carry a ProjectionEvent rather than the concrete outbox.Entry, so the outbox event source and a saga-journal event source (EPIC #1609 PR-03) share one typed funnel. It is an alias of cellvocab.ProjectionEvent — the carrier type lives in the cellvocab leaf because kernel/projection imports kernel/cell (whose ProjectionApply mirror references the same carrier), so the type must live in a package both import without a cycle. See cellvocab.ProjectionEvent for the method contract and the not-sealed rationale; carrier-shape enforcement is archtest PROJECTION-EVENT-CARRIER-TYPED-01.
type ReplaySource ¶
type ReplaySource interface {
// Replay iterates events with position strictly greater than fromOffset in
// ascending position order, calling fn for each. fromOffset==0 replays all
// events. If fn returns an error, Replay stops immediately and returns that
// error; events already passed to fn are NOT retried.
Replay(ctx context.Context, fromOffset int64, fn func(ProjectionEvent) error) error
// Head returns the highest available position in the source, or 0 if the
// source is empty. Head is used to determine the rebuild cutoff and to
// compute the pending_events metric (Head − checkpoint).
Head(ctx context.Context) (int64, error)
}
ReplaySource is the read-model event store interface consumed by a Coordinator rebuild. It delivers past events in deterministic ascending position order so the rebuild can apply them exactly once from offset 0 (or any resume point).
Position scheme ¶
Positions are 1-based monotonically increasing integers. Position 0 means "no events / cold start" (same sentinel as CheckpointStore.LoadOffset cold start). Each event returned by Replay has a position > fromOffset, and positions across multiple Replay calls on the same source are stable (deterministic, not ephemeral).
Transaction semantics ¶
ReplaySource.Replay is NOT transactional: it iterates and calls fn for each qualifying event. The Coordinator wraps each fn call in RunInTx so that the Apply and SaveOffset commit atomically. Replay does not open or join any ambient transaction — it is the caller's responsibility to manage tx boundaries around fn.
Replay duration bound (v1) ¶
In v1, Replay is bounded only by ctx cancellation: there is no max-entries or max-duration parameter. Operators bound replay duration by setting a timeout on the rebuild context (the ctx passed to Rebuild, which is forwarded as the runRebuild ctx). Per-batch limiting is deferred to a future version when large-history projections require it.
The carrier is the typed ProjectionEvent interface (EPIC #1609 PR-01): the outbox-backed source (adapters/postgres.PGProjectionReplaySource) and a future saga-journal source both satisfy it; neither leaks the concrete outbox.Entry.
ref: AxonFramework EventStore — ordered event stream replay by position/token. ref: JasperFx/marten IDocumentSession.Events.QueryAllRawEvents — append-only event store replay.
type Snapshot ¶
Snapshot is a point-in-time view of a projection's lifecycle and replay lag, returned by the rebuild control-plane endpoint as the 202 response body ({phase, pendingEvents, replayLagSeconds}).
Phase is always populated (read from the in-memory atomic — no I/O, never fails). PendingEvents and ReplayLagSeconds are best-effort: on a checkpoint-store / replay read error they are zero and Snapshot returns a non-nil error. Callers (the rebuild handler) log the error but MUST NOT fail an already-admitted rebuild over a degraded snapshot read — the rebuild has been accepted regardless of whether its current lag could be read.
ReplayLagSeconds == 0 is ambiguous by design and must be read together with PendingEvents: it means either "caught up" (PendingEvents == 0) OR "lag unknown" (PendingEvents > 0 but nothing applied yet — cold start / fresh rebuild, so there is no last-applied time to measure from). The lag readyz probe distinguishes these (it leaves the lag gauge unwritten during the startup-grace case rather than reporting a false-healthy zero).
type SubscribeRegistrar ¶
type SubscribeRegistrar interface {
Subscribe(
spec contractspec.ContractSpec,
handler outbox.EntryHandler,
consumerGroup string,
cellID string,
opts ...cell.SubscriptionOption,
) error
}
SubscribeRegistrar is the minimal cell.Registrar surface the Coordinator needs: it registers the projection's wrapped event subscription. The Coordinator holds this narrow interface rather than the full cell.Registrar so that (a) the dependency is honest (the Coordinator only ever calls Subscribe) and (b) a holder injected at wiring time — e.g. the bootstrap projection drain's capture adapter — need not stub the other Registrar methods (no nil-embed foot-gun). cell.Registrar satisfies this interface structurally.
The signature mirrors cell.Registrar.Subscribe exactly so a *cell.RegistryRecorder (or the drain's capture adapter) is assignable without conversion.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package projectiontest provides conformance test helpers for projection.CheckpointStore, projection.ReplaySource, and projection.Cursor implementations.
|
Package projectiontest provides conformance test helpers for projection.CheckpointStore, projection.ReplaySource, and projection.Cursor implementations. |