pub enum ActivityLog {
Redb {
db: Arc<Database>,
next_id: Arc<AtomicU64>,
},
Discard,
}Expand description
Thread-safe handle to the persisted activity log.
Why: held on AppState so every emitting handler (HTTP, MCP, Hook) can
record an entry without re-opening the database. redb’s Database
already supports concurrent access internally; an Arc clone is cheap
and lets the type satisfy AppState: Clone. The Discard variant
(issue #225) keeps the daemon usable when no writable directory is
available (read-only containers, locked-down sandboxes) by silently
dropping every append and returning empty reads — the activity log is
documented as best-effort, so falling back to a no-op is the contract
the rest of the daemon already assumes.
What: an enum with two variants — Redb wraps a backing redb database
plus an AtomicU64 next-id counter initialised from the table’s current
max key (the counter survives clones because it lives behind the same
Arc); Discard is a zero-state variant that drops appends and returns
empty reads / zero counts, used when both the primary data root and the
tempdir fallback are unwritable.
Test: appends_assign_monotonic_ids covers Redb;
discard_variant_drops_writes_and_returns_empty_reads covers Discard.
Variants§
Redb
redb-backed activity log — the production path.
Discard
No-op fallback used when no writable directory is available.
Why: callers should never branch on whether the log is functional;
every method on this variant returns a successful empty result so
state.emit stays best-effort and the dashboard simply shows an
empty feed.
What: zero-sized variant — appends are dropped, count returns 0,
list returns an empty vec.
Test: discard_variant_drops_writes_and_returns_empty_reads.
Implementations§
Source§impl ActivityLog
impl ActivityLog
Sourcepub fn open(data_root: &Path) -> Result<Self>
pub fn open(data_root: &Path) -> Result<Self>
Open (or create) the activity log at <data_root>/activity.redb.
Why: the daemon may be started against a fresh data dir, so the
helper must tolerate the file not existing. On an existing file we
initialise next_id from the max key already present so ids stay
monotonic across daemon restarts.
What: ensures the data dir exists, opens the database, creates the
activity table if absent, and seeds next_id from last_key().
Always returns the Redb variant on success; use
ActivityLog::discard() to construct the no-op fallback explicitly.
Test: activity_log_open_creates_db_file,
next_id_resumes_from_max_after_reopen.
Sourcepub fn discard() -> Self
pub fn discard() -> Self
Construct a no-op activity log that drops every write (issue #225).
Why: when neither the primary data root nor the tempdir fallback is
writable, the daemon must still come up. Returning this variant from
open_activity_log_with_fallback keeps the call sites identical —
append, count, and list all stay infallible-ish (they return
Ok but do nothing) so callers do not need to branch on whether the
log is real.
What: returns ActivityLog::Discard — a zero-sized enum variant.
Test: discard_variant_drops_writes_and_returns_empty_reads.
Sourcepub fn is_discard(&self) -> bool
pub fn is_discard(&self) -> bool
True when this is the Discard (no-op) variant.
Why: exposed for tests and for any future code that wants to surface
the degraded state in a health endpoint without taking a hard
dependency on the enum shape.
What: returns true for ActivityLog::Discard, false otherwise.
Test: discard_variant_drops_writes_and_returns_empty_reads.
Sourcepub fn alloc_id(&self) -> u64
pub fn alloc_id(&self) -> u64
Pre-allocate the next sequential id without writing anything.
Why: AppState::emit offloads the redb write to spawn_blocking
(issue #232). When multiple events are emitted in rapid succession the
blocking-pool workers may execute in any order, so if ID assignment
happens inside the closure the persisted ordering no longer matches
the emission order. Calling alloc_id() synchronously in the emitting
thread (before the spawn) reserves the slot in sequence; the closure
then calls append_with_id with that pre-allocated id.
What: atomically increments next_id with Ordering::SeqCst and
returns the old value (the reserved id). Returns 0 for the Discard
variant (consistent with append_with_id’s no-op behaviour).
Test: ordering invariant covered by
web::tests::activity_endpoint_lists_recent_emits.
Sourcepub fn append_with_id(
&self,
id: u64,
source: ActivitySource,
palace_id: Option<String>,
event_type: impl Into<String>,
payload: impl Serialize,
) -> Result<u64>
pub fn append_with_id( &self, id: u64, source: ActivitySource, palace_id: Option<String>, event_type: impl Into<String>, payload: impl Serialize, ) -> Result<u64>
Append a new entry using a caller-supplied id and return it.
Why: companion to alloc_id — the caller reserves an id in the
emitting thread so the id sequence matches emission order even when
the actual write is deferred to a blocking-pool thread. Callers that
do not need ordering guarantees may still call append, which calls
alloc_id internally.
What: identical to append except it skips the fetch_add and uses
the supplied id directly. On the Discard variant, returns Ok(0).
Test: appends_assign_monotonic_ids (via append);
web::tests::activity_endpoint_lists_recent_emits (ordering path).
Sourcepub fn append(
&self,
source: ActivitySource,
palace_id: Option<String>,
event_type: impl Into<String>,
payload: impl Serialize,
) -> Result<u64>
pub fn append( &self, source: ActivitySource, palace_id: Option<String>, event_type: impl Into<String>, payload: impl Serialize, ) -> Result<u64>
Append a new entry and return the assigned id.
Why: every mutating handler calls this so the feed has a complete
history. Append also triggers FIFO eviction when the row count
exceeds MAX_ENTRIES so the table footprint stays bounded.
What: on the Redb variant, allocates an id via alloc_id, serialises
the entry with serde_json (small overhead, but keeps the schema
human-readable for redb’s dump and our own debug tooling), writes
it under the allocated id, and prunes the oldest rows past the cap. On
the Discard variant, returns Ok(0) without touching any state.
Note: callers that need the id assigned in the emitting thread (e.g.
AppState::emit which defers the write to spawn_blocking) should
call alloc_id() + append_with_id() instead.
Test: appends_assign_monotonic_ids,
appends_evict_oldest_when_capped,
discard_variant_drops_writes_and_returns_empty_reads.
Sourcepub fn prune(&self) -> Result<()>
pub fn prune(&self) -> Result<()>
Drop oldest rows until the table is at or below MAX_ENTRIES.
Why: keep the on-disk footprint bounded. Called from append so the
cap is enforced on every write; tests can also call it directly.
What: counts rows, computes the overflow, and removes the lowest-id
rows in batches of [EVICTION_BATCH]. On the Discard variant,
returns immediately — there is nothing to evict.
Test: appends_evict_oldest_when_capped.
Sourcepub fn count(&self) -> Result<u64>
pub fn count(&self) -> Result<u64>
Number of entries currently in the table.
Why: exposed for tests and the prune loop; also handy for the
GET /api/v1/activity response so the UI can render a total count.
What: opens a read transaction and calls redb’s Table::len on the
Redb variant; returns 0 for the Discard variant.
Test: appends_evict_oldest_when_capped,
discard_variant_drops_writes_and_returns_empty_reads.
Sourcepub fn list(
&self,
filter: &ActivityFilter,
limit: usize,
offset: usize,
) -> Result<Vec<ActivityEntry>>
pub fn list( &self, filter: &ActivityFilter, limit: usize, offset: usize, ) -> Result<Vec<ActivityEntry>>
List entries newest-first with optional filters and paging.
Why: backs GET /api/v1/activity. Newest-first ordering matches the
dashboard’s mental model — the most recent event sits at the top of
the feed.
What: walks the table in reverse-key order, applies the filters in
memory (the dataset is bounded at MAX_ENTRIES, so a linear scan
is the simplest correct strategy), and returns at most limit rows
starting at offset. limit is clamped at the call site by the
handler; this method does not clamp so tests can exercise edge cases.
On the Discard variant, returns an empty vec.
Test: list_returns_newest_first,
list_filters_by_source_palace_and_time,
discard_variant_drops_writes_and_returns_empty_reads.
Trait Implementations§
Source§impl Clone for ActivityLog
impl Clone for ActivityLog
Source§fn clone(&self) -> ActivityLog
fn clone(&self) -> ActivityLog
1.0.0 (const: unstable) · Source§fn clone_from(&mut self, source: &Self)
fn clone_from(&mut self, source: &Self)
source. Read moreAuto Trait Implementations§
impl Freeze for ActivityLog
impl !RefUnwindSafe for ActivityLog
impl Send for ActivityLog
impl Sync for ActivityLog
impl Unpin for ActivityLog
impl UnsafeUnpin for ActivityLog
impl !UnwindSafe for ActivityLog
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,
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