pub struct Spool { /* private fields */ }Expand description
The on-disk spool rooted at one directory.
Why: trusty_common::resolve_data_dir("trusty-console") is already the
console’s canonical state location (lib.rs:476), so the spool is a
subdirectory of it rather than a new location convention.
What: a path plus the durable write sequence. Cheap to clone.
Test: every spool_* case in webhook/tests.rs.
Implementations§
Source§impl Spool
impl Spool
Sourcepub fn at(root: impl Into<PathBuf>) -> Self
pub fn at(root: impl Into<PathBuf>) -> Self
Bind a spool to root without touching the filesystem.
Why: lets a caller construct the spool before deciding whether it can be
created, and lets a test point one at a path that will fail on write.
What: stores the path. No I/O, and the spool is NOT marked opened — an
absent directory under this constructor is genuinely “never written”.
Test: spool_persist_fails_when_the_root_is_not_a_directory.
Sourcepub fn open(root: impl Into<PathBuf>) -> Result<Self, SpoolError>
pub fn open(root: impl Into<PathBuf>) -> Result<Self, SpoolError>
Bind a spool to root, creating it at 0700.
What: creates the directory and records that it existed, which is what
makes a later ENOENT a failure rather than an empty listing.
Test: spool_open_creates_the_directory_at_0700,
health_reports_error_when_the_spool_directory_is_gone.
Sourcepub fn default_root() -> Result<PathBuf>
pub fn default_root() -> Result<PathBuf>
The console’s production spool location.
Test: exercised indirectly by WebhookIngress::from_env.
Sourcepub fn entry_path(&self, entry: &SpoolEntry) -> PathBuf
pub fn entry_path(&self, entry: &SpoolEntry) -> PathBuf
Path an entry occupies, derived from its receipt time and delivery id.
Why: the leading zero-padded millisecond timestamp makes a lexical sort
an age sort, so the oldest-pending scan does not have to parse every
file to find the oldest one. Public because Spool::record_attempt
rewrites the same path and the tests assert on it.
What: <received_at_unix_ms:013>-<sanitised delivery id>.json. The id is
reduced to [A-Za-z0-9_-] and truncated so a hostile header value
cannot traverse out of the spool directory or overflow NAME_MAX.
Test: spool_entry_path_sanitises_a_hostile_delivery_id.
Sourcepub fn persist_new(&self, entry: &SpoolEntry) -> Result<PathBuf, SpoolError>
pub fn persist_new(&self, entry: &SpoolEntry) -> Result<PathBuf, SpoolError>
Write a NEW entry durably, refusing to overwrite an existing one.
Why: this call returning Ok is the ONLY thing that licenses console to
send GitHub a 202. ADR-0034 §2: “Console returns 202 only after
the delivery … is written and fsync’d to a spool.” Refusing to clobber
matters because the entry already at that path may be a delivery console
has already acknowledged; overwriting it would destroy work GitHub will
never re-send. (Defensive: GitHub always sends X-GitHub-Delivery, so
two deliveries only collide when the header is absent and they land in
the same millisecond.)
What: encode → write temp with create_new → sync_all the file →
hard_link into place → unlink the temp → sync_all the directory.
hard_link rather than rename because rename silently replaces the
destination while link fails with EEXIST — the atomic refusal this
needs. The file fsync makes the bytes durable; the directory fsync makes
the name durable, without which a crash can leave the entry
unreachable even though its data reached the platter.
§Errors
Any SpoolError. Every one means the delivery is not recorded and the
caller must return 5xx without acknowledging.
Test: spool_persists_and_reloads_an_entry_byte_exact,
spool_persist_fails_when_the_root_is_not_a_directory,
spool_persist_new_refuses_to_clobber_an_existing_entry.
Sourcepub fn persist_update(&self, entry: &SpoolEntry) -> Result<PathBuf, SpoolError>
pub fn persist_update(&self, entry: &SpoolEntry) -> Result<PathBuf, SpoolError>
Rewrite an entry that already exists, replacing it atomically.
Why: Spool::record_attempt needs clobber semantics — updating the
attempt count IS overwriting the previous version of the same delivery.
Split from Spool::persist_new so the two intents cannot be confused
at a call site.
What: identical to persist_new except it commits with rename, which
replaces the destination.
Test: spool_record_attempt_increments_durably,
spool_persist_update_fails_when_the_final_path_is_a_directory.
Sourcepub fn record_attempt(
&self,
entry: &mut SpoolEntry,
reason: String,
now_unix_ms: u64,
) -> Result<PathBuf, SpoolError>
pub fn record_attempt( &self, entry: &mut SpoolEntry, reason: String, now_unix_ms: u64, ) -> Result<PathBuf, SpoolError>
Record one failed relay attempt against a spooled entry.
Why: ADR-0034 §2 — “Relay failure … leaves the spool entry pending
with an incremented attempt count. It is never deleted on failure.”
The durable count is what a stuck delivery is diagnosed from; a
tracing::warn! is explicitly forbidden as the sole record.
What: bumps attempts, stores reason and the attempt time, and
rewrites the entry through Spool::persist_update. Mutates entry in
place so the caller sees the new count.
The rewrite copies the whole entry, body included. That cost is bounded
by super::BackoffPolicy, which spaces retries exponentially and stops
them entirely at max_attempts — without it a permanently unrelayable
delivery would rewrite its own body plus two fsyncs every sweep tick,
forever.
Test: spool_record_attempt_increments_durably,
relay_failure_leaves_a_pending_entry_with_an_incremented_attempt_count.
Sourcepub fn remove_acked(&self, path: &Path) -> Result<(), SpoolError>
pub fn remove_acked(&self, path: &Path) -> Result<(), SpoolError>
Delete an entry the target has explicitly acknowledged.
Why: the only deletion path in this module, reachable only from a
RelayOutcome::Acked. “The connection succeeded” is deliberately not
enough — ADR-0034 §2 makes the explicit ack the sole delete trigger, and
treating a successful connect as a successful delivery is the same
silent loss one layer down.
What: remove_file, then fsyncs the directory so the deletion is as
durable as the creation was. A missing file is not an error — a
concurrent sweep may already have removed it.
Test: spool_remove_acked_deletes_the_entry,
spool_remove_acked_tolerates_an_already_removed_entry.
Sourcepub fn exhausted_root(&self) -> PathBuf
pub fn exhausted_root(&self) -> PathBuf
Directory holding entries console has given up relaying.
Sourcepub fn quarantine(&self, path: &Path) -> Result<PathBuf, SpoolError>
pub fn quarantine(&self, path: &Path) -> Result<PathBuf, SpoolError>
Move an entry console will not retry into exhausted/.
Why: an exhausted entry left in the live directory is read and decoded
by every sweep and every metrics request, forever — the spool becomes
unboundedly expensive to scan precisely because nothing can be relayed.
It also pins the oldest-pending diagnostics to itself, so a genuinely
new stuck delivery moves no field an operator or alert rule watches.
Moving it aside keeps the delivery (it is still an unacknowledged
webhook) while taking it off both hot paths.
What: rename into EXHAUSTED_DIR_NAME, then fsync both
directories so the move survives a crash. Returns the new path.
Test: spool_quarantine_moves_an_entry_out_of_the_live_set,
sweep_quarantines_an_exhausted_entry_and_stops_paying_for_it.
Sourcepub fn load(&self, path: &Path) -> Result<SpoolEntry, SpoolError>
pub fn load(&self, path: &Path) -> Result<SpoolEntry, SpoolError>
Decode one entry by path.
Why: the health scan needs attempts and last_error for exactly one
entry — the oldest live one. Decoding just that one keeps the metrics
request O(1) in decodes rather than O(spool).
Test: spool_scan_metadata_avoids_decoding_and_load_reads_one.
Sourcepub fn scan_metadata(&self) -> Result<SpoolMetadata, SpoolError>
pub fn scan_metadata(&self) -> Result<SpoolMetadata, SpoolError>
Filename-only census of both the live and exhausted sets.
Why: Spool::entry_path encodes the receipt time in the filename
precisely so age can be read without opening anything, and until now
nothing used that — both hot paths decoded every file. A metrics request
needs counts and ages, which the names already carry.
What: read_dir on the live directory and on exhausted/, parsing
<received_at_unix_ms:013>-<delivery id>.json. No file is opened. A name
that does not parse is reported through unparsable rather than
dropped, for the same reason an undecodable entry is.
A missing live directory is an error for an opened spool, exactly as in
Spool::list_pending; a missing exhausted/ is simply empty, since it
is created lazily on the first quarantine.
Test: spool_scan_metadata_avoids_decoding_and_load_reads_one,
spool_scan_metadata_separates_live_from_exhausted.
Sourcepub fn list_pending(&self) -> Result<PendingListing, SpoolError>
pub fn list_pending(&self) -> Result<PendingListing, SpoolError>
Every entry currently pending, oldest first.
Why: both the retry sweep and the health scan need this, and both need
a failure to be distinguishable from an empty spool — an unreadable
spool reported as “nothing pending” is the fail-quiet shape again.
What: reads the live directory, skips temp files, the exhausted/
subdirectory, and anything that is not a .json entry, decodes each, and
sorts by receipt time. An entry that fails to decode is reported through
undecodable rather than dropped.
This decodes every live entry, which is why exhausted ones are moved out
of it — the live set is then bounded by the arrival rate over the retry
window rather than growing without limit. A caller that only needs counts
and ages should use Spool::scan_metadata, which opens nothing.
Test: spool_list_pending_orders_oldest_first,
spool_list_pending_reports_an_undecodable_entry,
spool_list_pending_ignores_the_exhausted_subdirectory.
Trait Implementations§
Auto Trait Implementations§
impl Freeze for Spool
impl RefUnwindSafe for Spool
impl Send for Spool
impl Sync for Spool
impl Unpin for Spool
impl UnsafeUnpin for Spool
impl UnwindSafe for Spool
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
Source§impl<T> CloneToUninit for Twhere
T: Clone,
impl<T> CloneToUninit for Twhere
T: Clone,
impl<A, B, T> HttpServerConnExec<A, B> for Twhere
B: Body,
Source§impl<T> Instrument for T
impl<T> Instrument for T
Source§fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
Source§fn in_current_span(self) -> Instrumented<Self> ⓘ
fn in_current_span(self) -> Instrumented<Self> ⓘ
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left is true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left(&self) returns true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read more