journal

package
v0.1.0 Latest Latest
Warning

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

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

Documentation

Overview

Package journal provides the durable, append-only Journal for L3 (WorkflowEventual) saga orchestration: the interface the runtime Coordinator (PR-03) and the PostgreSQL store (PR-04) build on, plus an in-memory implementation (MemJournal) for tests and development. The reusable conformance suite that every implementation must pass lives in the sibling package kernel/saga/sagajournaltest.

Two truths, one interface (hybrid model)

A Journal keeps two consistent views of each saga instance:

  • an append-only event log — the source of truth for history and replay;
  • a coordination projection (status, current event version, lease) — the source of truth for who is driving the instance and where it is.

An implementation mutates the projection in the same critical section / transaction as the corresponding event append, so the two cannot drift. The projection is intentionally coarse-grained: it tracks the Pending → Running → Compensating → terminal lifecycle but NOT the fine-grained step cursor (saga.Instance.CurrentStep), which would require the definition's step count the Journal does not hold. A caller reconstructs the exact cursor by folding the event log from Load.

Event model (fully event-sourced, 9 kinds)

Every lifecycle move has a corresponding EventKind, so the append-only log alone replays the complete history — including WHICH terminal state was reached and WHEN the rollback phase began. There are three categories:

  • Forward step events (KindStepStarted / KindStepCompleted / KindStepFailed): carry a StepName; advance Pending→Running on the first occurrence; legal no-op while Running; REJECTED while Compensating.

  • KindCompensationStarted: saga-scoped (no StepName); appended by the Coordinator when it DECIDES to compensate, before any compensation runs, so a leader handoff mid-rollback reads Compensating, not Running. Advances Running→Compensating; Pending→Compensating is illegal.

  • KindStepCompensated: carry a StepName; legal ONLY while Compensating (status unchanged); rejected in any other phase.

  • Terminal kinds (KindSagaSucceeded / KindSagaFailed / KindSagaCompensated / KindSagaExpired): appended ONLY via MarkTerminal, atomically with the projection's terminal status flip. The terminal status is encoded in the kind itself, so the log alone replays the final state — no separate terminal-status field is needed.

The projection is a deterministic fold of these kinds in order; an out-of-phase event is rejected without mutation (fail-closed).

Flow

Enqueue ──► ClaimPending ──► Append* ──► MarkTerminal
(producer)   (leader sweep)   (leader)    (leader, terminal)

Enqueue           lease-free open enrollment; instance starts Pending, version 0.
ClaimPending      leases a batch of non-terminal instances under one batch lease.
Append            records a step or phase event under the lease:
                    step events (StepStarted/Completed/Failed) → Pending→Running,
                      no-op while Running, rejected while Compensating;
                    KindCompensationStarted → Running→Compensating;
                    KindStepCompensated → no-op while Compensating, rejected elsewhere.
MarkTerminal      commits a terminal status + the matching terminal event kind
                  atomically, releases lease.

Phase ASCII flow:

  Pending ──(forward step)──► Running ──(CompensationStarted)──► Compensating
     │                           │                                     │
     │ MarkTerminal              │ MarkTerminal                        │ MarkTerminal
     │ (Failed/Expired)          │ (Succeeded/Failed/Expired)          │ (Compensated/Failed/Expired)
     ▼                           ▼                                     ▼
  [KindSagaFailed/Expired]   [KindSagaSucceeded/Failed/Expired]   [KindSagaCompensated/Failed/Expired]

Lease fencing (asymmetric returns)

Every leader-driven mutator (Append, Heartbeat, MarkTerminal) is CAS-fenced by the batch leaseID from ClaimPending. The fencing return is deliberately asymmetric: Append returns a KindConflict error on a stale lease (a zombie leader must not silently corrupt the version stream), whereas Heartbeat and MarkTerminal return ok=false with a nil error (losing a lease is an expected race during leader handoff, not a fault). An expired lease is treated as lost: the next ClaimPending sweep re-leases the instance.

Single sanctioned holder (forward note)

Only the runtime Coordinator (PR-03) is meant to hold a Journal field; the SAGA-JOURNAL-HOLDER-SEAL-01 archtest seals this once the Coordinator exists. Enqueue is the producer-facing entry point; whether to split a narrower producer interface is deferred to PR-03, when a real producer exists.

References

ref: runtime/audit/ledger/mem_store.go (in-repo) — append-only log + clock- injected mem store + defensive copies. ref: runtime/outbox/outboxtest/store_conformance.go (in-repo) — lease-fencing, stale-lease, and leader-handoff conformance template. ref: docs/architecture/202605051600-adr-pg-outbox-fencing.md — lease_id CAS. ref: eventuate-foundation/eventuate-client-java saga-orchestration — event- sourced saga history.

Index

Constants

View Source
const MaxPayloadBytes = 64 * 1024

MaxPayloadBytes caps Event.Payload size at append time so a misbehaving caller cannot push GB-scale JSON blobs into the event log. Sized to 64 KiB to match outbox per-entry payload caps; raise via ADR if a saga workload proves it insufficient.

Variables

This section is empty.

Functions

func NewDuplicateInstanceError

func NewDuplicateInstanceError(instanceID idutil.SafeID) error

NewDuplicateInstanceError reports that Enqueue was called for an instance ID that already exists.

func NewEventPhaseError

func NewEventPhaseError(instanceID idutil.SafeID, kind EventKind, status saga.Status) error

NewEventPhaseError reports that an event kind was appended in a status that does not permit it (e.g. a forward step while Compensating, or KindStepCompensated while not Compensating). The projection is a fold of the event log, so an out-of-phase event would desynchronise status from history.

func NewInstanceNotFoundError

func NewInstanceNotFoundError(instanceID idutil.SafeID) error

NewInstanceNotFoundError reports that the addressed saga instance was never enqueued. Returned by Load / Append / Heartbeat / MarkTerminal.

func NewInvalidTerminalStatusError

func NewInvalidTerminalStatusError(instanceID idutil.SafeID, from, to saga.Status) error

NewInvalidTerminalStatusError reports that MarkTerminal was given a non-terminal status, or one the instance's current status cannot legally transition to.

func NewNegativeGlobalSeqError

func NewNegativeGlobalSeqError(afterGlobalSeq int64) error

NewNegativeGlobalSeqError reports that GlobalReader.LoadSince received a negative afterGlobalSeq cursor (a checkpoint position is never negative; 0 means "scan from the beginning").

func NewNonPositiveBatchSizeError

func NewNonPositiveBatchSizeError(batchSize int) error

NewNonPositiveBatchSizeError reports that ClaimPending received a batchSize ≤ 0.

func NewNonPositiveLeaseDurationError

func NewNonPositiveLeaseDurationError(d time.Duration) error

NewNonPositiveLeaseDurationError reports that ClaimPending / Heartbeat received a leaseDuration ≤ 0 (which would mint an immediately-expired or boundary lease).

func NewNonPositiveLimitError

func NewNonPositiveLimitError(limit int) error

NewNonPositiveLimitError reports that GlobalReader.LoadSince received a limit ≤ 0 (a non-positive page size cannot bound a scan).

func NewStaleLeaseError

func NewStaleLeaseError(instanceID, leaseID idutil.SafeID) error

NewStaleLeaseError reports that a lease-fenced Append presented a leaseID that no longer owns the instance (lost / expired / superseded). A zombie leader must not corrupt the event stream, so Append surfaces this rather than dropping the write silently.

Types

type ClaimedInstance

type ClaimedInstance struct {
	Instance saga.Instance
	LeaseID  idutil.SafeID
}

ClaimedInstance is a saga.Instance projection plus the fencing token the caller MUST echo to every lease-fenced mutator (Append, Heartbeat, MarkTerminal) for that instance. The LeaseID is minted by the ClaimPending sweep that returned the instance; once the lease is lost — reclaimed after expiry or superseded by a newer claim — every fenced operation presenting this LeaseID becomes a no-op (Heartbeat/MarkTerminal return ok=false) or a conflict error (Append).

Tip: pass ci.Instance.ID as instanceID and ci.LeaseID as leaseID to the fenced mutators (both are idutil.SafeID; order matters).

type Enqueuer

type Enqueuer interface {
	Enqueue(ctx context.Context, instance saga.Instance) error
}

Enqueuer is the producer-facing narrow journal interface: saga enrollment only. A business producer (e.g. an HTTP slice that places orders) needs exactly Enqueue and none of the coordinator-only claim/append/commit surface, so it holds Enqueuer rather than JournalCore.

MemJournal and any PostgreSQL implementation satisfy Enqueuer automatically because both satisfy the full Journal (which embeds JournalCore, which contains Enqueue). No implementation change is required.

type Event

type Event struct {
	Version int64 // per-instance position, assigned by Append; zero on input
	// GlobalSeq is the cross-instance global position — monotonic over the whole
	// journal, NOT per-instance like Version — assigned by the Journal on append
	// (in-memory from a global counter; PG from a BIGINT IDENTITY column, PR-PG).
	// It is the stable total order [GlobalReader] scans by and the value a
	// projection cursor checkpoints on (see [GlobalEvent]). Additive (#1609
	// PR-02): callers leave it zero on input, and a backend that does not yet
	// maintain a global sequence (PG until PR-PG) leaves it zero on the way out.
	GlobalSeq int64
	Kind      EventKind     // required, must be Valid
	StepName  idutil.SafeID // required for step kinds; empty for CompensationStarted / terminal kinds
	Payload   []byte        // opaque JSON object-or-null; copied defensively by the Journal
	CreatedAt time.Time     // stamped by the Journal on Append
}

Event is one durable, append-only record in a saga instance's history. The owning instance is identified by the Append(instanceID, ...) argument, so it is deliberately not duplicated as a field here. Version is assigned by Journal.Append (monotonic per instance, starting at 1) and CreatedAt is stamped by the Journal from its injected clock; callers leave both zero on the way in.

func (Event) ValidateForAppend

func (e Event) ValidateForAppend() error

ValidateForAppend checks an Event submitted to Journal.Append. It is a pure validator (mutates nothing) enforcing:

  • Kind is Valid and is NOT a terminal kind (terminal events are written only by MarkTerminal, atomically with the projection's terminal flip);
  • a step kind carries a present, valid StepName;
  • Payload is a JSON object or null (or empty), within MaxPayloadBytes.

It does NOT check phase legality (whether the kind is permitted in the instance's current status) — the Journal enforces that under its lock, where the current status is known.

type EventKind

type EventKind uint8

EventKind classifies a durable saga journal event. The vocabulary is fully state-transition-bearing: every lifecycle move a saga makes has a corresponding event kind, so the append-only log alone replays the complete history — including WHICH terminal state was reached and WHEN the rollback phase began. The instance Status projection is a deterministic fold of these kinds, never a separate source of truth.

IMPORTANT: iota+1 ensures the zero value (0) is NOT a valid kind, so a forgotten/uninitialised Event.Kind never silently appears valid — the same discipline as saga.Status.

const (
	// Step-level events — appended via Append under the holder's lease; each
	// carries a StepName.
	KindStepStarted     EventKind = iota + 1 // step_started
	KindStepCompleted                        // step_completed
	KindStepFailed                           // step_failed
	KindStepCompensated                      // step_compensated — a compensation step result; legal only while Compensating

	// KindCompensationStarted marks the saga entering the rollback phase
	// (Running → Compensating). Appended via Append; saga-scoped (no StepName).
	// The Coordinator appends it when it DECIDES to compensate, before any
	// compensation runs — so a leader handoff mid-rollback reads Compensating,
	// not Running.
	KindCompensationStarted // compensation_started

	// Terminal events — appended ONLY via MarkTerminal, atomically with the
	// projection's terminal status flip. The terminal status is encoded in the
	// kind itself, so the log alone replays the final state.
	KindSagaSucceeded   // saga_succeeded
	KindSagaFailed      // saga_failed
	KindSagaCompensated // saga_compensated
	KindSagaExpired     // saga_expired

	// KindStepCompensationFailed — a compensation step's CompensateFunc returned
	// a non-nil error. Legal only while Compensating; status unchanged. Issued
	// per-step (carries StepName) and distinct from KindStepFailed (which is a
	// forward Step.Run failure during Running phase). Appended at iota position
	// 10 (after the terminal kinds) so existing kind wire values 1..9 stay
	// stable — adding a new kind in the middle would re-number persisted rows.
	// Introduced by #1181 to give reverse-compensation failures a wire-distinct
	// vocabulary (previously runCompensation co-opted KindStepFailed, which
	// the Compensating-phase projection rejected).
	KindStepCompensationFailed // step_compensation_failed

	// KindSagaCompensationFailed is the terminal journal event for
	// saga.StatusCompensationFailed. Written exclusively via MarkTerminal when
	// the compensation phase completed but at least one CompensateFunc returned a
	// non-nil error. Wire value 11 (appended after KindStepCompensationFailed at
	// position 10) to keep existing kind values 1..10 stable.
	// Introduced by #1210 to give the "rollback failed" terminal state a
	// dedicated event kind distinct from KindSagaFailed ("forward failed").
	KindSagaCompensationFailed // saga_compensation_failed
)

func TerminalEventKind

func TerminalEventKind(s saga.Status) (EventKind, bool)

TerminalEventKind maps a terminal saga.Status to the event kind MarkTerminal records for it, so the log encodes the final state. ok is false for a non-terminal status.

func (EventKind) IsTerminal

func (k EventKind) IsTerminal() bool

IsTerminal reports whether k is a terminal saga event (written only by MarkTerminal). Exported so the PR-04 PG store and the conformance suite share one definition.

func (EventKind) String

func (k EventKind) String() string

String returns a human-readable label for k. The snake_case labels map 1:1 to the persisted saga_events.kind text column (PR-04).

func (EventKind) Valid

func (k EventKind) Valid() bool

Valid reports whether k is a recognized EventKind.

type GlobalEvent

type GlobalEvent struct {
	GlobalSeq  int64         // == Event.GlobalSeq; the stable total-order position (cursor reads this)
	InstanceID idutil.SafeID // the saga instance this event belongs to
	Event      Event         // the durable event (Event.GlobalSeq == GlobalSeq)
}

GlobalEvent is one event returned by GlobalReader, carrying the global position and the owning instance ID alongside the Event itself. InstanceID is surfaced here because Event deliberately omits its owning instance (it is addressed by the Append argument in the per-instance API); a cross-instance scan must report it.

GlobalSeq is intentionally exposed at the envelope level (and mirrors the inner Event.GlobalSeq — the two are always equal) so a tailing consumer can read its cursor position without descending into the embedded Event, the same convention as a Kafka ConsumerRecord.offset or an outbox seq on the record wrapper. The downstream PR-03 SagaJournalSource cursor MUST read GlobalEvent.GlobalSeq (the public envelope path), not GlobalEvent.Event.GlobalSeq.

type GlobalReader

type GlobalReader interface {
	// LoadSince returns events whose GlobalSeq is strictly greater than
	// afterGlobalSeq (exclusive lower bound = projection-checkpoint cursor
	// semantics), in ascending GlobalSeq order, at most limit events. Passing
	// afterGlobalSeq=0 scans from the beginning.
	//
	// Caught-up signal: a cursor equal to or greater than HeadSeq returns an
	// empty slice and a NIL error (so does an empty store) — a tailing loop polls
	// this to detect "no new events" and back off, distinguishing it from the
	// error cases purely by the nil error. limit MUST be > 0 and afterGlobalSeq
	// MUST be >= 0; a non-positive limit or negative cursor returns a KindInvalid
	// error (fail-closed, like ClaimPending's batchSize).
	LoadSince(ctx context.Context, afterGlobalSeq int64, limit int) ([]GlobalEvent, error)

	// HeadSeq returns the highest GlobalSeq currently assigned across all
	// instances — the upper bound a projection has yet to consume. An empty
	// journal returns 0 (no event ever carries GlobalSeq 0; the counter starts
	// at 1), so a fresh projection cursor of 0 means "caught up" iff HeadSeq==0.
	HeadSeq(ctx context.Context) (int64, error)
}

GlobalReader is the projection-facing global scan interface (#1609 PR-02): a cross-instance, append-order read of the event log, distinct from Reader (per-instance Load). A model-A CQRS projection (Axon Tracking Event Processor style) replays and tails the whole journal through it — the only way to observe saga terminal states (succeeded / compensated / failed), which are written ONLY to saga_events and never emitted to the outbox.

It is deliberately NOT folded into JournalCore: the single-sanctioned-holder seal (SAGA-JOURNAL-HOLDER-SEAL-01) restricts JournalCore field holders to the Coordinator, whereas the PR-04 projection Tailer must hold a global reader without driving sagas. The narrow type is therefore a CONSUMER view (it lets the Tailer hold a 2-method reader, structurally unable to claim/append/commit) — NOT a contract for read-only backends. Every GlobalReader implementation is itself the append-only saga_events store and hence a full Journal (MemJournal today; PGJournal at PR-PG); there is no store-less, read-only-only implementation in this design.

Events are ordered by Event.GlobalSeq, a stable total order across all instances. MemJournal assigns it from an in-process counter; the PG backend assigns it from a BIGINT IDENTITY column (PR-PG, deferred — until then PG does not implement GlobalReader). Every GlobalReader implementation is enrolled in the sagajournaltest.RunGlobalReaderConformance suite, enforced by archtest SAGA-GLOBALREADER-CONFORMANCE-ENROLL-01.

Position 0 is reserved: a conforming implementation never returns an event with GlobalSeq==0 (the counter starts at 1). The zero value only ever appears on events written by a backend that does not yet maintain a global sequence (none today — PG, the only such backend, does not implement GlobalReader until PR-PG). Consumers therefore use 0 as the "from the beginning" cursor and HeadSeq==0 as the empty-journal sentinel; they must never checkpoint a real position of 0.

type Heartbeater

type Heartbeater interface {
	// Heartbeat extends the lease on a single claimed instance to
	// now+leaseDuration. leaseDuration MUST be > 0; a non-positive value returns a
	// KindInvalid error. It is lease-fenced: ok is false (with a nil error) when
	// leaseID no longer owns the instance, signaling the holder to stop driving
	// it. A never-enqueued instance also returns ok=false (nil error); callers
	// cannot distinguish it from a stale lease.
	Heartbeat(ctx context.Context, instanceID, leaseID idutil.SafeID, leaseDuration time.Duration) (ok bool, err error)
}

Heartbeater is the single lease-renewal method split out of Journal (#1209).

runtime/saga/executor owns the only sanctioned Heartbeat caller (the per-step heartbeat goroutine) and declares its OWN structurally-identical Heartbeater interface rather than importing this one: kernel/ cannot depend on runtime/, and the executor must never import kernel/saga/journal (SAGA-JOURNAL-HOLDER-SEAL-01). This kernel-side Heartbeater exists solely to compose the full Journal so that the PG / in-memory implementations satisfy it via interface embedding.

type Journal

type Journal interface {
	JournalCore
	Heartbeater
}

Journal is the append-only durable event log plus lease-coordinated instance projection that L3 saga orchestration is built on. Two truths live behind one interface: the append-only event log is the source of truth for history/replay, while the projection (status, current version, lease) is the source of truth for coordination. An implementation keeps the two consistent by mutating the projection in the same critical section / transaction as the event append.

The interface is storage-dialect-neutral: no *sql.Tx or driver type appears in any signature, so the in-memory and PostgreSQL implementations satisfy the exact same contract and the same conformance suite (see kernel/saga/sagajournaltest).

Interface split: JournalCore + Heartbeater (#1209)

Journal is the union of two narrower interfaces:

  • JournalCore — enrollment, the append-only log, claim/projection, and terminal commit: everything a coordinator needs EXCEPT lease renewal.
  • Heartbeater — the single Heartbeat lease-renewal method.

runtime/saga.Coordinator holds only JournalCore, so a centralized heartbeat loop becomes a compile error — the persisted field cannot express Heartbeat. Per-step lease renewal is funneled through runtime/saga/executor, which receives the full Journal value transiently at construction (NewExecutor). See archtests SAGA-COORDINATOR-NO-HEARTBEAT-LOOP-01 and SAGA-JOURNAL-HOLDER-SEAL-01. PG and in-memory implementations satisfy the full Journal, hence both halves.

Time

Unlike the pure saga state machine (saga.AdvanceSaga takes an explicit now), the Journal owns lease-expiry arithmetic, so it sources time from a clock injected at construction rather than from a per-call now argument.

Lease fencing

Enqueue is lease-free open enrollment: producers create instances before any leader exists. Every mutator that runs under a leader (Append, Heartbeat, MarkTerminal) is CAS-fenced by leaseID. The fencing return convention is deliberately asymmetric:

  • Append returns a conflict error on a stale lease, because silently dropping a step event would desynchronise the leader's view of the instance version. A zombie leader must fail loudly.
  • Heartbeat and MarkTerminal return ok=false (no error) on a stale lease, because losing a lease is an expected race during leader handoff, not a fault — the caller simply stops driving that instance.

Single sanctioned holder

Only runtime/saga.Coordinator is intended to hold a JournalCore field; no struct in runtime/saga may persist the Heartbeat-bearing full Journal (or a bare Heartbeater) as a field. Both rules are enforced by the SAGA-JOURNAL-HOLDER-SEAL-01 archtest. Enqueue is the producer-facing entry point; a narrower producer interface may be split out if a real producer ever needs less than JournalCore.

type JournalCore

type JournalCore interface {
	// Enqueue enrolls a new saga instance for orchestration. The instance MUST
	// pass saga.Instance.ValidateNew (Pending, CurrentStep 0, no timestamps
	// beyond StartedAt). Enqueue is lease-free: it writes the projection with
	// current version 0 and no lease, and writes no event (events describe step
	// execution, which has not begun). Re-enqueuing an existing ID returns a
	// KindConflict error with code errcode.ErrSagaDuplicateInstance.
	//
	// Load after Enqueue returns an empty, non-nil slice (see Load); a consumer
	// detects a not-yet-started instance by the empty event log.
	Enqueue(ctx context.Context, instance saga.Instance) error

	// Append durably records one Event for the instance under the holder's lease
	// and returns the per-instance version assigned to it (monotonic, starting
	// at 1). It is lease-fenced: leaseID MUST match the instance's current lease,
	// otherwise nothing is recorded and a KindConflict error is returned. The
	// event is validated via Event.ValidateForAppend (terminal event kinds are
	// rejected here — terminal status is committed via MarkTerminal). The Event's
	// Version and CreatedAt are assigned by the Journal; any caller-supplied
	// values are ignored.
	//
	// Append advances the projection's non-terminal Status as a deterministic fold
	// of the event kind, reusing the saga state machine (saga.AdvanceSaga) so every
	// move is transition-validated and an out-of-phase event is rejected without
	// mutation:
	//   - a forward step event (StepStarted/StepCompleted/StepFailed) moves a
	//     Pending instance to Running; on a Running instance it is a no-op; while
	//     Compensating it is rejected.
	//   - KindCompensationStarted moves Running → Compensating (the Coordinator
	//     appends it when it DECIDES to compensate, BEFORE any compensation runs,
	//     so a handoff mid-rollback reads Compensating, not Running); from Pending
	//     it is rejected.
	//   - KindStepCompensated is legal only while Compensating (status unchanged);
	//     elsewhere it is rejected.
	//   - KindStepCompensationFailed is legal only while Compensating (status
	//     unchanged, like KindStepCompensated); elsewhere it is rejected. It records
	//     that a per-step CompensateFunc returned a non-nil error — distinct from
	//     KindStepFailed (a forward-phase failure during Running).
	// Terminal event kinds are rejected by ValidateForAppend — terminal status is
	// committed only via MarkTerminal, which encodes the terminal state in the
	// event kind. Append also does NOT maintain the step cursor
	// (Instance.CurrentStep); see ClaimPending.
	//
	// An unknown instance returns KindNotFound with code errcode.ErrSagaNotFound;
	// a stale lease returns KindConflict with code errcode.ErrSagaStaleLease.
	// A terminal instance (whose lease was released by MarkTerminal) also
	// returns KindConflict with ErrSagaStaleLease — the projection holds no
	// lease, so the caller's leaseID cannot match. Callers needing to
	// distinguish "expired lease" from "already terminal" must consult Load.
	Append(ctx context.Context, instanceID, leaseID idutil.SafeID, event Event) (version int64, err error)

	// Load returns the full ordered event history for an instance (version
	// ascending), for replay / state reconstruction. A never-enqueued instance
	// returns a KindNotFound error with code errcode.ErrSagaNotFound; an
	// enqueued instance with no events yet returns an empty, non-nil slice.
	Load(ctx context.Context, instanceID idutil.SafeID) ([]Event, error)

	// ClaimPending atomically leases up to batchSize non-terminal instances that
	// are currently unleased or whose lease has expired, sets each one's lease to
	// expire at now+leaseDuration, and returns them stamped with a single batch
	// LeaseID that the caller MUST echo on every subsequent fenced call for each
	// claimed instance. When nothing is claimable it returns an empty slice, the
	// zero LeaseID, and a nil error.
	//
	// batchSize and leaseDuration MUST both be positive; a non-positive value
	// returns a KindInvalid error.
	//
	// The returned Instance carries the coordination projection: Status (the
	// coarse-grained Pending/Running/Compensating lifecycle the Journal maintains)
	// and lease, but NOT the fine-grained step cursor — Instance.CurrentStep is
	// not advanced by the Journal (it would require the definition's step count,
	// which the Journal does not hold) and remains 0. A caller resuming an
	// instance reconstructs the exact cursor by folding the event log from Load.
	//
	// Instance.Status is the current projection value (maintained by
	// Append/MarkTerminal) and is guaranteed up-to-date; callers MAY trust it
	// directly while still folding Load for the exact step cursor.
	ClaimPending(ctx context.Context, batchSize int, leaseDuration time.Duration) (claimed []ClaimedInstance, leaseID idutil.SafeID, err error)

	// MarkTerminal transitions the instance projection to finalStatus and appends
	// the matching terminal event atomically, so the log alone replays which
	// terminal state was reached. Terminal event kinds per finalStatus:
	//   - StatusSucceeded     → KindSagaSucceeded     (all steps committed)
	//   - StatusFailed        → KindSagaFailed        (forward failure, no rollback)
	//   - StatusCompensated   → KindSagaCompensated   (rollback completed cleanly)
	//   - StatusExpired       → KindSagaExpired       (overall timeout elapsed)
	//   - StatusCompensationFailed → KindSagaCompensationFailed (rollback itself
	//     failed; reachable only from StatusCompensating)
	//
	// finalStatus MUST be a terminal saga.Status reachable from the current
	// status (validated via saga.AdvanceSaga). It is lease-fenced: ok is false
	// (with a nil error) when leaseID no longer owns the instance. On success
	// the lease is released. A never-enqueued instance also returns ok=false
	// (nil error); callers cannot distinguish it from a stale lease.
	MarkTerminal(ctx context.Context, instanceID, leaseID idutil.SafeID, finalStatus saga.Status) (ok bool, err error)

	// RepoReady is a differentiated readiness check (kernel/healthz.RepoProber):
	// SQL-backed implementations exercise the saga_instances relation directly so
	// schema/migration drift surfaces independently of a pool-level ping;
	// in-memory implementations return nil.
	//
	// Probe registration (name + cellgen path) is the responsibility of the
	// Coordinator cell that holds the Journal (PR-03); a Journal implementation
	// only provides the RepoProber method and does not self-register.
	RepoReady(ctx context.Context) error
}

JournalCore is the Heartbeat-free core of Journal: enrollment, the append-only event log, claim/projection, and terminal commit. It is the interface runtime/saga.Coordinator persists — deliberately WITHOUT Heartbeat, so a centralized heartbeat loop cannot be built on a stored JournalCore field (the call would not type-check). See the "Interface split" section on Journal and archtest SAGA-COORDINATOR-NO-HEARTBEAT-LOOP-01.

Only runtime/saga.Coordinator may hold a JournalCore field, enforced by the SAGA-JOURNAL-HOLDER-SEAL-01 archtest. Business producers and query-side slices should hold narrower interfaces instead:

  • Enqueuer — saga enrollment only (e.g. an HTTP slice that places orders).
  • Reader — event-log read only (e.g. a status-query slice).
  • ProducerReader — enrollment + read, for cells that need both without the coordinator-only claim/append/commit surface.

Note: SAGA-JOURNAL-HOLDER-SEAL-01 currently scans only runtime/saga for JournalCore field holders. Business cells (examples/, cells/) holding JournalCore are not yet machine-rejected (tracked in gh #1415).

type MemJournal

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

MemJournal is an in-memory Journal implementation for tests and development. It is not a production substrate — the PostgreSQL-backed implementation (PR-04) owns the production path. Single sync.Mutex serializes all state mutations; this is acceptable at dev/test scale.

Two truths are kept consistent under the lock:

  • The event log (events map) is append-only source of truth for history.
  • The projection (instanceRow) is the authoritative coordination view: current status, event version counter, and lease fencing token.

func NewMemJournal

func NewMemJournal(clk clock.Clock) (*MemJournal, error)

NewMemJournal constructs a MemJournal. clk is a required dependency; a nil or typed-nil Clock panics via clock.MustHaveClock (programmer error, analogous to MustHaveClock convention across kernel/ wiring points). Returns (*MemJournal, nil) on success; the nil error satisfies the Journal-constructor convention even though the only failure mode panics.

func (*MemJournal) Append

func (m *MemJournal) Append(_ context.Context, instanceID, leaseID idutil.SafeID, event Event) (int64, error)

Append implements Journal.Append.

func (*MemJournal) ClaimPending

func (m *MemJournal) ClaimPending(_ context.Context, batchSize int, leaseDuration time.Duration) ([]ClaimedInstance, idutil.SafeID, error)

ClaimPending implements Journal.ClaimPending.

func (*MemJournal) Enqueue

func (m *MemJournal) Enqueue(_ context.Context, instance saga.Instance) error

Enqueue implements Journal.Enqueue.

func (*MemJournal) HeadSeq

func (m *MemJournal) HeadSeq(_ context.Context) (int64, error)

HeadSeq implements GlobalReader.HeadSeq: the highest GlobalSeq assigned so far (== len(globalLog), dense 1..N), or 0 for an empty journal.

func (*MemJournal) Heartbeat

func (m *MemJournal) Heartbeat(_ context.Context, instanceID, leaseID idutil.SafeID, leaseDuration time.Duration) (bool, error)

Heartbeat implements Journal.Heartbeat.

func (*MemJournal) Load

func (m *MemJournal) Load(_ context.Context, instanceID idutil.SafeID) ([]Event, error)

Load implements Journal.Load.

func (*MemJournal) LoadSince

func (m *MemJournal) LoadSince(_ context.Context, afterGlobalSeq int64, limit int) ([]GlobalEvent, error)

LoadSince implements GlobalReader.LoadSince. GlobalSeq is assigned densely from 1 (globalLog[i].GlobalSeq == i+1), so the exclusive lower bound afterGlobalSeq maps directly to slice index afterGlobalSeq.

func (*MemJournal) MarkTerminal

func (m *MemJournal) MarkTerminal(_ context.Context, instanceID, leaseID idutil.SafeID, finalStatus saga.Status) (bool, error)

MarkTerminal implements Journal.MarkTerminal.

func (*MemJournal) RepoReady

func (m *MemJournal) RepoReady(_ context.Context) error

RepoReady implements healthz.RepoProber. The in-memory store has no differentiated failure domain; it always returns nil.

type ProducerReader

type ProducerReader interface {
	Enqueuer
	Reader
}

ProducerReader composes the two narrow capabilities a business cell legitimately needs — enrollment (Enqueue) and event-log read (Load) — WITHOUT the coordinator-only lease/claim/commit methods (ClaimPending/Append/MarkTerminal/Heartbeat). The single-sanctioned-holder rule (only runtime/saga.Coordinator holds JournalCore) is unaffected: business cells now hold ProducerReader, structurally unable to drive a saga.

MemJournal and any PostgreSQL implementation satisfy ProducerReader automatically (they satisfy the full Journal which is a superset).

type Reader

type Reader interface {
	Load(ctx context.Context, instanceID idutil.SafeID) ([]Event, error)
}

Reader is the query-facing narrow journal interface: event-log read only. A status-query slice folds the event log via Load and needs nothing else.

MemJournal and any PostgreSQL implementation satisfy Reader automatically because both satisfy the full Journal (which embeds JournalCore, which contains Load). No implementation change is required.

Jump to

Keyboard shortcuts

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