Documentation
¶
Overview ¶
Package notificationattempt persists a durable audit log of notification write attempts and their lifecycle so that `amq trace` and `amq doctor --ops` can report whether a wake attempted to notify an agent and what the outcome was.
The ledger NEVER blocks delivery: if persisting the prepared record fails, the wake injects anyway and the failure is recorded for trace to surface. An audit log that blocks the thing it audits is worse than no audit log.
Design (lead review 2026-08-30, three hard requirements):
TRUE append-only (O_APPEND), not read-modify-write. The prototype read the whole journal, concatenated, and called WriteFileAtomic — O(n) per notification on the wake hot path, and a lost-update race under concurrency (two notifications both read current, both append, one record vanishes silently). O_APPEND gives kernel-level atomic appends on local filesystems: concurrent writers each append a full line and neither loses data. A stable sidecar lock gates rotation and readers; shared append locks do not serialize ordinary writers.
ONE log, not two. The prototype used separate prepared/result files rotated independently; whichever crossed the size cap first dropped its old records while the other kept its partners, orphaning results that trace would render as "attempted, never completed" — a false failure report for the exact scenario this ledger exists to diagnose. In a single append-only log, a result is always written AFTER its prepared, so rotation (a size-capped move to .1) drops the prepared first and can never orphan a surviving result. The phase field distinguishes them.
trace distinguishes "no attempt recorded" from "recording failed". If the prepared write itself fails and the wake injects anyway (correct), the journal has a hole. An operator debugging a missed doorbell must not conclude the wake never tried. Prepare returns a writeErr that the caller carries; trace surfaces a distinct leg wording for "we failed to keep the record" vs "we have no record".
Index ¶
- Constants
- Variables
- func IsRotationOnly(err error) bool
- type Attempt
- type Lifecycle
- type Record
- type RotationError
- type Writer
- func (w *Writer) Begin(messageIDs []string, mode string) (*Lifecycle, error)
- func (w *Writer) Prepare(messageIDs []string, mode string) (record Record, writeErr error)
- func (w *Writer) Result(prepared Record, outcome, detail string) error
- func (w *Writer) Transition(lifecycle *Lifecycle, state, detail string) error
- func (w *Writer) WriteFailure(attemptID string, messageIDs []string, mode string, writeErr error) error
Constants ¶
const ( // SchemaVersion is the current integer schema version of a Record. Version // 2 adds ordered lifecycle state events while keeping the v1 prepared/result // records readable. Do not remove the version check: an unversioned reader // is how a schema change silently corrupts history. SchemaVersion = 2 PhasePrepared = "prepared" PhaseResult = "result" OutcomeWritten = "written" OutcomeFailed = "failed" // StateIndeterminate: a prepared record exists with no matching result. // The wake may still be in flight, or it may have crashed between prepare // and result. Trace renders this as "attempted; outcome unknown". StateIndeterminate = "indeterminate" // StateWriteFailed: the prepared write itself failed (the ledger could not // persist the record). The wake injected anyway (the ledger never blocks // delivery). Trace renders this as "attempted; the attempt record could // not be persisted" — distinct from StateIndeterminate ("no result yet") // and from an empty journal ("no attempt recorded"). StateWriteFailed = "write_failed" // Lifecycle states are deliberately small. They describe AMQ's durable // attempt state, not provider-side presentation or consumption. StateAttempt = "attempt" StateDeferred = "deferred" StateRetried = "retried" StateAccepted = "accepted" StateFailed = "failed" StateInvalid = "invalid" LogFilename = "notification-attempts.jsonl" RotatedSuffix = ".1" )
const LedgerSupported = true
LedgerSupported reports whether this platform provides the flock primitive required to coordinate journal rotation.
Variables ¶
var ErrNoJournal = errors.New("notification attempt journal does not exist")
ErrNoJournal is returned by helpers that need to distinguish "the journal does not exist" from "the journal exists but is empty". List does not use it (empty = no evidence), but trace may when deciding leg wording.
Functions ¶
func IsRotationOnly ¶ added in v0.77.1
IsRotationOnly reports whether err means the append succeeded and only the journal rotation failed.
Types ¶
type Attempt ¶
type Attempt struct {
State string `json:"state"`
Prepared Record `json:"prepared"`
Result *Record `json:"result,omitempty"`
History []Record `json:"history,omitempty"`
}
Attempt is the joined view of a prepared record and its optional result, used by trace and doctor. State is derived from the ordered lifecycle for a v2 attempt, or from the v1 result outcome for a legacy attempt.
func List ¶
List reads the notification log for an agent and returns the joined prepared→result attempts, optionally filtered by messageID. A missing journal returns (nil, nil) — no attempts recorded is empty evidence, not an error. This is the correct fail-mode for trace's "no evidence" leg.
func ListDeliveryRoot ¶
func ListDeliveryRoot(root *fsq.DeliveryRoot, agent, messageID string) ([]Attempt, error)
ListDeliveryRoot is like List but accepts an already-opened DeliveryRoot.
type Lifecycle ¶
type Lifecycle struct {
AttemptID string
MessageIDs []string
Agent string
Mode string
State string
Sequence uint64
}
Lifecycle is the in-process handle for one durable notification attempt. The handle keeps one AttemptID across deferred/retried delivery and is not itself a source of authority; the append-only journal is.
type Record ¶
type Record struct {
Schema int `json:"schema"`
AttemptID string `json:"attempt_id"`
Phase string `json:"phase"`
MessageIDs []string `json:"message_ids"`
Agent string `json:"agent"`
Mode string `json:"mode"`
RecordedAt string `json:"recorded_at"`
Outcome string `json:"outcome,omitempty"`
Detail string `json:"detail,omitempty"`
State string `json:"state,omitempty"`
Sequence uint64 `json:"sequence,omitempty"`
}
Record is one append-only journal entry. The Phase field distinguishes a prepared record (before injection) from a result record (after injection). Version 1 result records carry Outcome (written/failed). Version 2 lifecycle records carry State and Sequence instead.
type RotationError ¶ added in v0.77.1
type RotationError struct {
Err error
}
RotationError reports that the RECORD WAS PERSISTED but the size-capped journal rotation around it failed or was refused. Callers distinguishing "record lost" from "record kept, journal over its cap" must errors.As for this type: treating a rotation-only error as a lost record fabricates a failure result for an attempt whose prepared record exists (and the two then join into a false `failed` attempt in trace).
func (*RotationError) Error ¶ added in v0.77.1
func (e *RotationError) Error() string
func (*RotationError) Unwrap ¶ added in v0.77.1
func (e *RotationError) Unwrap() error
type Writer ¶
type Writer struct {
// contains filtered or unexported fields
}
Writer appends prepared/result records to the per-agent notification log. Each append opens the file with O_APPEND (kernel-level atomic append on local filesystems), writes one JSON line, and closes. A stable sidecar lock gates rotation and readers without serializing ordinary appenders.
func NewWriterWithMaxBytes ¶ added in v0.77.1
NewWriterWithMaxBytes is NewWriter with an explicit journal size cap.
func (*Writer) Begin ¶
Begin appends the initial v2 lifecycle event for one notification attempt. The returned handle must be used for every later state transition so a deferred attempt keeps the same AttemptID and cohort.
func (*Writer) Prepare ¶
Prepare appends a prepared record and returns it. If the append fails, it returns a zero Record, a non-nil writeErr, and the attempt ID + message IDs the caller intended to record — so the caller can still pass an identity to Result (which will record a result with outcome=failed and the write error in Detail), and trace can surface "recording failed" rather than "no attempt recorded". The ledger never blocks delivery: the caller injects regardless of writeErr.
func (*Writer) Result ¶
Result appends a result record for a prepared attempt. If prepared is the zero Record (because Prepare's write failed), the caller MUST pass the attempt ID and message IDs it intended to record; Result reconstructs a minimal prepared identity so the result is still joinable. outcome must be OutcomeWritten or OutcomeFailed.
func (*Writer) Transition ¶
Transition appends one ordered v2 lifecycle state. Invalid transitions are rejected before writing, so a terminal state cannot silently be reopened.
func (*Writer) WriteFailure ¶ added in v0.77.1
func (w *Writer) WriteFailure(attemptID string, messageIDs []string, mode string, writeErr error) error
WriteFailure records that an attempt was made but its prepared record could not be persisted (requirement 3: trace must distinguish "recording failed" from "no attempt recorded"). It reconstructs the minimal prepared identity from the attempt id the failed Prepare/Begin returned and appends a failed result carrying the write error, so List surfaces the orphan as a write-failed attempt with its mode and normalized ids intact.