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
- type Attempt
- type Lifecycle
- type Record
- 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
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 ¶
This section is empty.
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 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 (*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.