Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 5 additions & 12 deletions apexchainx_calculator/src/calculation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ use soroban_sdk::{symbol_short, Address, Env, Symbol, Vec};

use crate::{
SLAConfig, SLAError, SLAResult, SLAStats, SeverityTelemetry, EVENT_DUP_INPUT, EVENT_SETTLE_INTENT,
EVENT_SLA_CALC, EVENT_VERSION, HISTORY_KEY, LAST_CALCULATION_TS_KEY, LAST_VIOLATION_TS_KEY,
MAX_HISTORY_SIZE, MAX_RECALCS_PER_OUTAGE, PAUSED_KEY, RETENTION_LIMIT_KEY, SEVERITY_CALC_COUNTS_KEY,
SEVERITY_VIOL_COUNTS_KEY, STATS_KEY, TOTAL_ENTRIES_KEY, TOTAL_PRUNED_KEY,
EVENT_SLA_CALC, EVENT_VERSION, HISTORY_KEY, HISTORY_LEN_KEY, LAST_CALCULATION_TS_KEY,
LAST_VIOLATION_TS_KEY, MAX_HISTORY_SIZE, MAX_RECALCS_PER_OUTAGE, PAUSED_KEY, RETENTION_LIMIT_KEY,
SEVERITY_CALC_COUNTS_KEY, SEVERITY_VIOL_COUNTS_KEY, STATS_KEY,
};

/// Calculate the SLA outcome for an outage event (delegated implementation).
Expand Down Expand Up @@ -137,17 +137,10 @@ pub fn calculate_sla(
trimmed.push_back(history.get(i).unwrap());
}
env.storage().instance().set(&HISTORY_KEY, &trimmed);
// #461 – automatic trim pruned exactly 1 entry (the oldest)
let prev_pruned: u32 = env
.storage()
.instance()
.get(&TOTAL_PRUNED_KEY)
.unwrap_or(0);
env.storage()
.instance()
.set(&TOTAL_PRUNED_KEY, &prev_pruned.saturating_add(1));
env.storage().instance().set(&HISTORY_LEN_KEY, &trimmed.len());
} else {
env.storage().instance().set(&HISTORY_KEY, &history);
env.storage().instance().set(&HISTORY_LEN_KEY, &history.len());
}

if result.status == symbol_short!("viol") {
Expand Down
6 changes: 5 additions & 1 deletion apexchainx_calculator/src/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use soroban_sdk::{Address, Env, Symbol, Vec};

use crate::{
HistoryPage, SLAError, SLAResult, EVENT_PRUNED, EVENT_PRUNED_AGE, EVENT_RET_LIM, EVENT_VERSION,
HISTORY_KEY, MAX_HISTORY_SIZE, RETENTION_LIMIT_KEY,
HISTORY_KEY, HISTORY_LEN_KEY, MAX_HISTORY_SIZE, RETENTION_LIMIT_KEY,
};

/// Upper bound on the number of entries a single pagination call may return.
Expand Down Expand Up @@ -59,7 +59,9 @@ pub fn prune_history(env: &Env, caller: &Address, keep_latest: u32) -> Result<()
new_history.push_back(history.get(i).unwrap());
}

// Issue #463: maintain cached history length alongside history
env.storage().instance().set(&HISTORY_KEY, &new_history);
env.storage().instance().set(&HISTORY_LEN_KEY, &new_history.len());
let kept = new_history.len();
env.events().publish(
(EVENT_PRUNED, EVENT_VERSION, caller.clone()),
Expand Down Expand Up @@ -104,7 +106,9 @@ pub fn prune_history_by_age(env: &Env, caller: &Address, min_age_seconds: u64) -

if removed > 0 {
let kept = new_history.len();
// Issue #463: maintain cached history length alongside history
env.storage().instance().set(&HISTORY_KEY, &new_history);
env.storage().instance().set(&HISTORY_LEN_KEY, &new_history.len());
env.events()
.publish((EVENT_PRUNED_AGE, EVENT_VERSION, caller.clone()), (removed, kept));
}
Expand Down
207 changes: 92 additions & 115 deletions apexchainx_calculator/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,11 @@ pub(crate) const LAST_VIOLATION_TS_KEY: Symbol = symbol_short!("VIOLTS");
/// Ordered list of historical SLAResult entries.
pub(crate) const HISTORY_KEY: Symbol = symbol_short!("HIST");

/// Cached count of history entries (maintained alongside HISTORY_KEY).
/// This allows get_full_audit_state to report history length without deserializing
/// the full vector, addressing issue #463 (one-shot bootstrap efficiency).
pub(crate) const HISTORY_LEN_KEY: Symbol = symbol_short!("HISTLEN");

/// Current on-chain storage schema version number.
// INVARIANT: Once written, the value at STORAGE_VERSION_KEY must only be
// incremented by migrate(). All other read paths treat this key as read-only.
Expand All @@ -191,7 +196,9 @@ pub(crate) const STORAGE_VERSION_KEY: Symbol = symbol_short!("VER");

/// The storage schema version this contract binary expects.
/// Incremented when breaking state changes are introduced.
pub(crate) const STORAGE_VERSION: u32 = 1;
// v2 adds HISTORY_LEN_KEY. This must stay in lockstep with migrate(): a
// deployed v1 contract has history but not the cached counter.
pub(crate) const STORAGE_VERSION: u32 = 2;

/// Version of the SLAResult schema exposed via get_result_schema().
/// Incremented when result encoding changes in a breaking way.
Expand Down Expand Up @@ -1231,9 +1238,8 @@ impl SLACalculatorContract {
env.storage()
.instance()
.set(&HISTORY_KEY, &Vec::<SLAResult>::new(&env));
// #461 – cumulative retention counters for get_retention_metrics
env.storage().instance().set(&TOTAL_PRUNED_KEY, &0u32);
env.storage().instance().set(&TOTAL_ENTRIES_KEY, &0u32);
// Issue #463: initialize cached history length
env.storage().instance().set(&HISTORY_LEN_KEY, &0u32);

let mut configs = Map::<Symbol, SLAConfig>::new(&env);
configs.set(
Expand Down Expand Up @@ -1322,12 +1328,16 @@ impl SLACalculatorContract {
inst.set(&HISTORY_KEY, &Vec::<SLAResult>::new(env));
}

// #461 – cumulative retention counters
if !inst.has(&TOTAL_PRUNED_KEY) {
inst.set(&TOTAL_PRUNED_KEY, &0u32);
}
if !inst.has(&TOTAL_ENTRIES_KEY) {
inst.set(&TOTAL_ENTRIES_KEY, &0u32);
// Issue #463: HISTORY_LEN_KEY caches the history length so
// `get_full_audit_state` can report it without materializing the full
// history vector. A contract migrated from a schema that predates this
// key must backfill it from the *actual* history length — which may be
// non-empty — rather than defaulting to 0, or the bootstrap read would
// under-report history size until the next write refreshes the cache.
// This one-time O(n) read runs during migration, not on the hot path.
if !inst.has(&HISTORY_LEN_KEY) {
let history: Vec<SLAResult> = inst.get(&HISTORY_KEY).unwrap_or_else(|| Vec::new(env));
inst.set(&HISTORY_LEN_KEY, &history.len());
}

if !inst.has(&CONFIG_KEY) {
Expand Down Expand Up @@ -1425,12 +1435,19 @@ impl SLACalculatorContract {
current = 1;
}

// v1 → v2 (placeholder for the next breaking state change):
// if current == 1 {
// // … transform state …
// env.storage().instance().set(&STORAGE_VERSION_KEY, &2u32);
// current = 2;
// }
// v1 → v2: backfill the cached history length introduced by #463.
// This is deliberately derived from the source-of-truth vector once
// during migration; read paths thereafter use the counter directly.
if current == 1 {
let history: Vec<SLAResult> = env
.storage()
.instance()
.get(&HISTORY_KEY)
.unwrap_or_else(|| Vec::new(&env));
env.storage().instance().set(&HISTORY_LEN_KEY, &history.len());
env.storage().instance().set(&STORAGE_VERSION_KEY, &2u32);
current = 2;
}

// Sanity: after all steps we must be at STORAGE_VERSION
if current != STORAGE_VERSION {
Expand Down Expand Up @@ -2074,12 +2091,8 @@ impl SLACalculatorContract {
.get(&STATS_KEY)
.ok_or(SLAError::NotInitialized)?;

let history: Vec<SLAResult> = env
.storage()
.instance()
.get(&HISTORY_KEY)
.unwrap_or_else(|| Vec::new(&env));
let history_len = history.len();
// Issue #463: use cached history length instead of materializing full vector
let history_len: u32 = env.storage().instance().get(&HISTORY_LEN_KEY).unwrap_or(0);

let result_schema = SLAResultSchema {
version: symbol_short!("v1"),
Expand Down Expand Up @@ -2681,9 +2694,9 @@ impl SLACalculatorContract {
for i in 1..history.len() {
trimmed.push_back(history.get(i).unwrap());
}
env.storage().instance().set(&HISTORY_KEY, &trimmed);
Self::update_history_and_cache(&env, &trimmed);
} else {
env.storage().instance().set(&HISTORY_KEY, &history);
Self::update_history_and_cache(&env, &history);
}

// Mutate stats and emit events depending on outcome
Expand All @@ -2708,7 +2721,17 @@ impl SLACalculatorContract {
/// Pure helper to generate the SLAResult deterministically.
/// `config_version_hash` binds the result to the exact config snapshot used
/// during evaluation. `recorded_at` is the ledger timestamp at call time
/// (0 in view/audit mode).
/// for both mutating and view paths (see issue #465 for audit-mode semantics).
///
/// # Timestamp Semantics (Issue #465)
///
/// - Mutating path (`calculate_sla`): `recorded_at` is the current ledger
/// timestamp; the result is stored to history.
/// - View path (`calculate_sla_view`): `recorded_at` is the current ledger
/// timestamp to ensure view results match the mutating path when executed
/// in the same ledger; the result is NOT stored.
/// - Replay path (`replay_calculate_sla`): Uses a provided historical
/// timestamp for audit purposes.
fn compute_result(
outage_id: Symbol,
mttr_minutes: u32,
Expand Down Expand Up @@ -3373,17 +3396,7 @@ impl SLACalculatorContract {
new_history.push_back(history.get(i).unwrap());
}

env.storage().instance().set(&HISTORY_KEY, &new_history);
// #461 – track cumulative pruned count
let prev_pruned: u32 = env
.storage()
.instance()
.get(&TOTAL_PRUNED_KEY)
.unwrap_or(0);
env.storage().instance().set(
&TOTAL_PRUNED_KEY,
&prev_pruned.saturating_add(remove_count),
);
Self::update_history_and_cache(&env, &new_history);
remove_count
} else {
0
Expand All @@ -3394,10 +3407,13 @@ impl SLACalculatorContract {
}

/// SC-063 – Prune history entries older than `min_age_seconds` before the
/// current ledger timestamp. Entries with `recorded_at == 0` (view-mode
/// results that were never stored with a real timestamp) are always kept.
/// Returns `Err(SLAError::InvalidInput)` if `min_age_seconds >= now`.
/// Admin-only. Emits a `pruned_a` event.
/// current ledger timestamp. Admin-only. Emits a `pruned_a` event.
///
/// # Timestamp Semantics (Issue #465)
///
/// All stored results carry `recorded_at` = the ledger timestamp at calculation
/// time. View-mode results (from `calculate_sla_view`) are never stored to history,
/// so the empty-edge case of `recorded_at == 0` does not occur in practice.
pub fn prune_history_by_age(env: Env, caller: Address, min_age_seconds: u64) -> Result<(), SLAError> {
Self::check_version(&env)?;
Self::require_admin(&env, &caller)?;
Expand Down Expand Up @@ -3428,17 +3444,7 @@ impl SLACalculatorContract {
}

if removed > 0 {
env.storage().instance().set(&HISTORY_KEY, &new_history);
// #461 – track cumulative pruned count
let prev_pruned: u32 = env
.storage()
.instance()
.get(&TOTAL_PRUNED_KEY)
.unwrap_or(0);
env.storage().instance().set(
&TOTAL_PRUNED_KEY,
&prev_pruned.saturating_add(removed),
);
Self::update_history_and_cache(&env, &new_history);
}
let kept = new_history.len();
env.events()
Expand Down Expand Up @@ -3479,25 +3485,12 @@ impl SLACalculatorContract {
/// See `docs/HISTORY_PAGINATION_POLICY.md` for the full policy.
pub fn get_history_page(env: Env, offset: u32, limit: u32) -> Result<Vec<SLAResult>, SLAError> {
Self::check_version(&env)?;
let limit = limit.min(MAX_PAGE_SIZE);
let history: Vec<SLAResult> = env
.storage()
.instance()
.get(&HISTORY_KEY)
.unwrap_or_else(|| Vec::new(&env));
let len = history.len();
let mut page = Vec::new(&env);
if offset >= len || limit == 0 {
return Ok(page);
}
// Saturating arithmetic: `offset + limit` could otherwise wrap for extreme
// `u32` inputs (e.g. offset near `u32::MAX`), silently slicing the wrong
// range. Saturation clamps the end index to the real history length, which
// is the correct behaviour for any page that asks for more than remains.
let end = offset.saturating_add(limit).min(len);
for i in offset..end {
page.push_back(history.get(i).unwrap());
}
let (_end, page) = Self::compute_page_slice(&env, &history, offset, limit);
Ok(page)
}

Expand All @@ -3517,27 +3510,13 @@ impl SLACalculatorContract {
/// `docs/HISTORY_PAGINATION_POLICY.md`.
pub fn get_history_page_with_meta(env: Env, offset: u32, limit: u32) -> Result<HistoryPage, SLAError> {
Self::check_version(&env)?;
let limit = limit.min(MAX_PAGE_SIZE);
let history: Vec<SLAResult> = env
.storage()
.instance()
.get(&HISTORY_KEY)
.unwrap_or_else(|| Vec::new(&env));
let total = history.len();
let mut items = Vec::new(&env);
// Saturating arithmetic mirrors `get_history_page`: clamp the end index
// to the real history length so extreme `u32` inputs can never wrap into
// a wrong slice. `end` also drives `has_more`: entries remain whenever
// the requested range stops short of the end of history and limit > 0.
let end = offset.saturating_add(limit).min(total);
if offset < total && limit != 0 {
for i in offset..end {
items.push_back(history.get(i).unwrap());
}
}
// When limit == 0, the page is empty by request, which signals end-of-history
// per the pagination policy. This ensures consistency with get_history_page.
let has_more = if limit == 0 { false } else { end < total };
let (end, items) = Self::compute_page_slice(&env, &history, offset, limit);
Ok(HistoryPage {
items,
total,
Expand Down Expand Up @@ -3651,7 +3630,7 @@ impl SLACalculatorContract {
for i in remove_count..len {
new_history.push_back(history.get(i).unwrap());
}
env.storage().instance().set(&HISTORY_KEY, &new_history);
Self::update_history_and_cache(&env, &new_history);
env.events()
.publish((EVENT_PRUNED, EVENT_VERSION, caller), (remove_count, limit));
}
Expand All @@ -3669,40 +3648,38 @@ impl SLACalculatorContract {
.unwrap_or(MAX_HISTORY_SIZE))
}

/// #461 – Returns retention health metrics for the history buffer.
///
/// Operators call this to understand how close the retained history is to
/// the configured limit, how many entries have been pruned (by admin,
/// age, or automatic trim), and the current retention ratio.
///
/// The `pruned_entries` and `total_entries` counters are cumulative and
/// fed by all pruning paths: `prune_history`, `prune_history_by_age`,
/// and the automatic trim in `calculate_sla`.
pub fn get_retention_metrics(
env: Env,
) -> Result<metrics::retention_stats::HistoryRetentionMetrics, SLAError> {
Self::check_version(&env)?;
let retention_limit: u32 = env
.storage()
.instance()
.get(&RETENTION_LIMIT_KEY)
.unwrap_or(MAX_HISTORY_SIZE);
let retained_entries: u32 = env
.storage()
.instance()
.get::<Symbol, Vec<SLAResult>>(&HISTORY_KEY)
.map_or(0, |h| h.len());
let pruned_entries: u32 = env
.storage()
.instance()
.get(&TOTAL_PRUNED_KEY)
.unwrap_or(0);
Ok(metrics::history_metrics::build_history_metrics(
STORAGE_VERSION,
retention_limit,
retained_entries,
pruned_entries,
))
/// Internal helper to update history and maintain cached length atomically.
/// Addresses issue #463: allows get_full_audit_state to report history_len
/// without deserializing the full vector, keeping "one-shot bootstrap" cheap.
fn update_history_and_cache(env: &Env, history: &Vec<SLAResult>) {
env.storage().instance().set(&HISTORY_KEY, history);
env.storage().instance().set(&HISTORY_LEN_KEY, &history.len());
}

/// Internal helper for pagination slice computation (issue #264).
/// Returns the clamped end index and the slice items for a page.
/// Encapsulates the pagination policy defined in HISTORY_PAGINATION_POLICY.md.
fn compute_page_slice(
env: &Env,
history: &Vec<SLAResult>,
offset: u32,
limit: u32,
) -> (u32, Vec<SLAResult>) {
let limit = limit.min(MAX_PAGE_SIZE);
let len = history.len();
let mut page = Vec::new(env);

if offset < len && limit > 0 {
// Saturating arithmetic: offset + limit could wrap for extreme u32 inputs.
// Saturation clamps to the real history length, ensuring correct slicing.
let end = offset.saturating_add(limit).min(len);
for i in offset..end {
page.push_back(history.get(i).unwrap());
}
(end, page)
} else {
(offset.min(len), page)
}
}

/// SC-021 – Migration state read helper
Expand Down
Loading
Loading