Skip to main content

Spool

Struct Spool 

Source
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

Source

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.

Source

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.

Source

pub fn default_root() -> Result<PathBuf>

The console’s production spool location.

Test: exercised indirectly by WebhookIngress::from_env.

Source

pub fn root(&self) -> &Path

Directory this spool writes into.

Source

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.

Source

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_newsync_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.

Source

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.

Source

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.

Source

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.

Source

pub fn exhausted_root(&self) -> PathBuf

Directory holding entries console has given up relaying.

Source

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.

Source

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.

Source

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.

Source

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§

Source§

impl Clone for Spool

Source§

fn clone(&self) -> Spool

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for Spool

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more

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> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T> FromRef<T> for T
where T: Clone,

Source§

fn from_ref(input: &T) -> T

Converts to this type from a reference to the input type.
Source§

impl<A, B, T> HttpServerConnExec<A, B> for T
where B: Body,

Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> IntoEither for T

Source§

fn into_either(self, into_left: bool) -> Either<Self, Self>

Converts 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 more
Source§

fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
where F: FnOnce(&Self) -> bool,

Converts 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
Source§

impl<T> PolicyExt for T
where T: ?Sized,

Source§

fn and<P, B, E>(self, other: P) -> And<T, P>
where T: Sized + Policy<B, E>, P: Policy<B, E>,

Create a new Policy that returns Action::Follow only if self and other return Action::Follow. Read more
Source§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where T: Sized + Policy<B, E>, P: Policy<B, E>,

Create a new Policy that returns Action::Follow if either self or other returns Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = !

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more