Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
internal severity list invariant is ever broken the call surfaces a
deterministic `InvalidSeverity` error rather than an unrecoverable host trap.
### Added
- `get_history_page_with_meta` — paginated history read that returns a `HistoryPage` struct (`items`, `total`, `has_more`) so consumers can detect end-of-history and total size in one read without a separate `get_history` call; `get_history_page` is unchanged (#380)
- Per-severity telemetry counter saturation regression coverage — documented the `u32` lane saturation behavior in the `record_severity_telemetry` code docs and `docs/CONTRACT_MAINTENANCE_POLICY.md`, and added `test_severity_telemetry_counters_saturate_at_u32_max` to verify counters saturate at `u32::MAX` instead of wrapping (release) or panicking (debug) (#387)
- `docs/CONTRACT_SHAPE_CHANGE_CHECKLIST.md` — release-readiness checklist for PRs that touch storage keys, `STORAGE_VERSION`, event topic constants, or event payload fields; cross-referenced from `CONTRIBUTING.md` as SC-100
- **[SC-509] SLAError Addition Workflow** (#253) — comprehensive contributor guide for adding, deprecating, or reviewing `SLAError` variants without breaking backend adapter logic. See `docs/sla-error-additions-guide.md`.
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,15 @@ contract is paused — and never modify on-chain state or emit events.
| Config views | `get_config`, `get_config_snapshot`, `get_config_version_hash`, `list_configs`, `get_last_config_update`, `get_config_bundle` |
| Custom severity views | `get_custom_severity`, `get_custom_config_snapshot` |
| Stats & telemetry | `get_stats`, `get_economic_exposure`, `get_severity_telemetry` |
| History views | `get_history`, `get_history_page`, `get_history_by_outage`, `get_latest_by_outage` |
| History views | `get_history`, `get_history_page`, `get_history_page_with_meta`, `get_history_by_outage`, `get_latest_by_outage` |

`get_history_page` follows the documented [History Pagination
Policy](docs/HISTORY_PAGINATION_POLICY.md) (issue #263): offset-based,
oldest-first, empty-page end-of-history signalling, and saturating
`offset + limit` arithmetic so extreme `u32` values can never overflow into
a wrong slice.
a wrong slice. `get_history_page_with_meta` returns the same page plus
`total` and `has_more` metadata so consumers can page without a separate
`get_history` call.
| Role queries | `get_admin`, `get_operator`, `get_pending_admin`, `get_pending_operator` |
| Introspection | `get_result_schema`, `get_failure_schema`, `get_contract_metadata`, `get_full_audit_state` |
| Retention helpers | `get_retention_limit`, `get_config_count`, `get_storage_version` |
Expand Down
41 changes: 39 additions & 2 deletions apexchainx_calculator/src/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
use soroban_sdk::{Address, Env, Symbol, Vec};

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

/// Returns the full SLA calculation history.
Expand Down Expand Up @@ -135,6 +135,43 @@ pub fn get_history_page(env: &Env, offset: u32, limit: u32) -> Result<Vec<SLARes
Ok(page)
}

/// Returns a paginated slice of the SLA history with pagination metadata.
///
/// This is a metadata-carrying companion to [`get_history_page`]. The `items`
/// slice is identical to what `get_history_page` returns for the same
/// `(offset, limit)`; `total` is the full history length and `has_more` is
/// `true` when the requested range ends before the end of history.
///
/// Pagination semantics (offset-based, oldest-first, saturating
/// `offset + limit`, empty page when `offset >= len` or `limit == 0`) are
/// identical to [`get_history_page`] — see
/// `docs/HISTORY_PAGINATION_POLICY.md`.
pub fn get_history_page_with_meta(env: &Env, offset: u32, limit: u32) -> Result<HistoryPage, SLAError> {
crate::SLACalculatorContract::check_version(env)?;
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.
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());
}
}
Ok(HistoryPage {
items,
total,
has_more: end < total,
})
}

/// Returns all history entries for a specific outage ID.
pub fn get_history_by_outage(env: &Env, outage_id: Symbol) -> Result<Vec<SLAResult>, SLAError> {
crate::SLACalculatorContract::check_version(env)?;
Expand Down
63 changes: 63 additions & 0 deletions apexchainx_calculator/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,29 @@ pub struct SLAResult {
pub recorded_at: u64,
}

/// A single page of SLA history with pagination metadata.
///
/// `get_history_page_with_meta` returns this instead of a bare `Vec` so
/// consumers can detect the end of history and the total size in one read,
/// without a separate `get_history` or `get_retention_limit` call.
///
/// The `items` slice is identical to what `get_history_page` returns for the
/// same `(offset, limit)`; `total` is the full history length and `has_more`
/// is `true` when the requested range ends before the end of history (i.e.
/// more entries can be fetched by advancing `offset`).
#[allow(missing_docs)]
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HistoryPage {
/// The entries in this page (oldest-first), up to `limit` items.
pub items: Vec<SLAResult>,
/// Total number of history entries currently stored.
pub total: u32,
/// Whether the requested range ends before the end of history (more
/// entries can be fetched by advancing `offset`).
pub has_more: bool,
}

/// A single severity-to-config mapping entry in a config snapshot.
#[allow(missing_docs)]
#[contracttype]
Expand Down Expand Up @@ -1899,6 +1922,7 @@ impl SLACalculatorContract {
methods.push_back(method("get_history", false, "none", ""));
methods.push_back(method("get_history_by_outage", false, "none", ""));
methods.push_back(method("get_history_page", false, "none", ""));
methods.push_back(method("get_history_page_with_meta", false, "none", ""));
methods.push_back(method("get_latest_by_outage", false, "none", ""));
methods.push_back(method("get_last_config_update", false, "none", ""));
methods.push_back(method("get_migration_state", false, "none", ""));
Expand Down Expand Up @@ -2978,6 +3002,45 @@ impl SLACalculatorContract {
Ok(page)
}

/// Returns a bounded page of history entries together with pagination
/// metadata.
///
/// This is a metadata-carrying companion to `get_history_page`. The
/// `items` slice is identical to what `get_history_page` returns for the
/// same `(offset, limit)`; `total` is the full history length and
/// `has_more` is `true` when the requested range ends before the end of
/// history.
///
/// Pagination semantics (offset-based, oldest-first, saturating
/// `offset + limit`, empty page when `offset >= len` or `limit == 0`) are
/// identical to `get_history_page` — see
/// `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 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.
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());
}
}
Ok(HistoryPage {
items,
total,
has_more: end < total,
})
}

// -------------------------------------------------------------------
// SC-060: History query by outage identifier
// -------------------------------------------------------------------
Expand Down
128 changes: 126 additions & 2 deletions apexchainx_calculator/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3066,6 +3066,130 @@ fn test_get_history_page_order_is_oldest_first() {
assert_eq!(page.get(1).unwrap().outage_id, symbol(&env, "SECOND"));
}

// ============================================================
// #380 – History pagination metadata (get_history_page_with_meta)
// ============================================================

#[test]
fn test_get_history_page_with_meta_returns_total_and_has_more() {
let (_env, client, actors) = setup();

for i in 0..5u32 {
let oid = Symbol::new(&_env, &alloc::format!("PGM_{}", i));
client.calculate_sla(&actors.operator, &oid, &symbol_short!("low"), &10);
}

// First full page of 2 from a 5-entry history: more remain.
let p0 = client.get_history_page_with_meta(&0, &2);
assert_eq!(p0.total, 5);
assert_eq!(p0.items.len(), 2);
assert!(p0.has_more);

// Second full page: more remain.
let p1 = client.get_history_page_with_meta(&2, &2);
assert_eq!(p1.total, 5);
assert_eq!(p1.items.len(), 2);
assert!(p1.has_more);

// Final short page: exactly the remaining entry, nothing after it.
let p2 = client.get_history_page_with_meta(&4, &2);
assert_eq!(p2.total, 5);
assert_eq!(p2.items.len(), 1);
assert!(!p2.has_more);
}

#[test]
fn test_get_history_page_with_meta_empty_history() {
let (_env, client, _actors) = setup();

let page = client.get_history_page_with_meta(&0, &10);
assert_eq!(page.total, 0);
assert_eq!(page.items.len(), 0);
assert!(!page.has_more);
}

#[test]
fn test_get_history_page_with_meta_offset_beyond_end() {
let (_env, client, actors) = setup();

for i in 0..3u32 {
let oid = Symbol::new(&_env, &alloc::format!("PGM_OOB_{}", i));
client.calculate_sla(&actors.operator, &oid, &symbol_short!("low"), &10);
}

let page = client.get_history_page_with_meta(&100, &10);
assert_eq!(page.total, 3);
assert_eq!(page.items.len(), 0);
assert!(!page.has_more);
}

#[test]
fn test_get_history_page_with_meta_zero_limit() {
let (_env, client, actors) = setup();

for i in 0..3u32 {
let oid = Symbol::new(&_env, &alloc::format!("PGM_ZL_{}", i));
client.calculate_sla(&actors.operator, &oid, &symbol_short!("low"), &10);
}

// `limit == 0` returns an empty page with the correct total. The cursor
// has not advanced past `offset`, so history still remains at offset 0.
let page = client.get_history_page_with_meta(&0, &0);
assert_eq!(page.total, 3);
assert_eq!(page.items.len(), 0);
assert!(page.has_more);
}

#[test]
fn test_get_history_page_with_meta_items_match_get_history_page() {
let (_env, client, actors) = setup();

for i in 0..5u32 {
let oid = Symbol::new(&_env, &alloc::format!("PGM_MATCH_{}", i));
client.calculate_sla(&actors.operator, &oid, &symbol_short!("low"), &10);
}

for offset in 0..6u32 {
for limit in [0u32, 1, 2, 5, u32::MAX] {
let plain = client.get_history_page(&offset, &limit);
let meta = client.get_history_page_with_meta(&offset, &limit);
assert_eq!(
meta.items, plain,
"items mismatch at offset={} limit={}",
offset, limit
);
assert_eq!(
meta.total, 5,
"total mismatch at offset={} limit={}",
offset, limit
);
}
}
}

#[test]
fn test_get_history_page_with_meta_saturating_arithmetic() {
let (_env, client, actors) = setup();

for i in 0..4u32 {
let oid = Symbol::new(&_env, &alloc::format!("PGM_SAT_{}", i));
client.calculate_sla(&actors.operator, &oid, &symbol_short!("low"), &10);
}

// `offset + u32::MAX` would wrap in unchecked arithmetic; saturation must
// clamp to the real length so the single remaining entry is returned.
let page = client.get_history_page_with_meta(&3, &u32::MAX);
assert_eq!(page.total, 4);
assert_eq!(page.items.len(), 1);
assert!(!page.has_more);

// An offset at `u32::MAX` is beyond any real history: empty, no more.
let extreme = client.get_history_page_with_meta(&u32::MAX, &1);
assert_eq!(extreme.total, 4);
assert_eq!(extreme.items.len(), 0);
assert!(!extreme.has_more);
}

// ============================================================
// SC-060 – History query by outage identifier
// ============================================================
Expand Down Expand Up @@ -8560,9 +8684,9 @@ fn test_get_public_api_includes_all_major_methods() {
fn test_get_public_api_method_count_is_stable() {
let (_env, client, _actors) = setup();
let api = client.get_public_api();
// 57 methods as of get_contract_info/get_contract_state_fingerprint/etc.
// 58 methods as of get_history_page_with_meta (#380).
// This test catches accidental additions or removals
assert_eq!(api.methods.len(), 57, "Public API method count changed");
assert_eq!(api.methods.len(), 58, "Public API method count changed");
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions docs/API_STABILITY_SCORECARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ safe (additive) or dangerous (breaking).
|-----------|------|------|--------------|-------|
| `get_history` | ⚠️ Stable | Public | **Medium** | Return type `Vec<SLAResult>`. `SLAResult` changes are breaking. |
| `get_history_page` | ⚠️ Stable | Public | **Medium** | Pagination parameters must remain `(offset: u32, limit: u32)`. |
| `get_history_page_with_meta` | ⚠️ Stable | Public | **Medium** | Return type `HistoryPage`. Pagination parameters must remain `(offset: u32, limit: u32)`. |
| `get_history_by_outage` | ⚠️ Stable | Public | **Low** | Filtered read. Return type follows `SLAResult`. |
| `get_latest_by_outage` | ⚠️ Stable | Public | **Low** | Return type `Option<SLAResult>`. |
| `prune_history` | 🛡️ Admin-Gated | Admin | **Medium** | Parameter `keep_latest: u32` must stay. |
Expand Down
5 changes: 3 additions & 2 deletions docs/CONTRACT_MAINTENANCE_POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,9 @@ Every code path that writes to `HISTORY_KEY` MUST be audited for:
- [ ] Duplicate detection (`outage_id` + config hash) is not bypassed
- [ ] `OutageRecalcLimit` is enforced for multi-generation outages
- [ ] `retention_limit` is respected when set
- [ ] History read functions (`get_history_page`, `get_history_by_outage`,
`get_latest_by_outage`) return correct subsets after modification
- [ ] History read functions (`get_history_page`, `get_history_page_with_meta`,
`get_history_by_outage`, `get_latest_by_outage`) return correct subsets after
modification

### Testing Requirements

Expand Down
37 changes: 34 additions & 3 deletions docs/HISTORY_PAGINATION_POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **Status:** Active
> **Reference:** [Issue #263](https://github.com/ApexChainx/ApexChainx-Contracts/issues/263)
> **Last updated:** 2026-08-02
> **Last updated:** 2026-08-20
> **Audience:** Backend consumers, operators, and contract contributors

## Table of Contents
Expand All @@ -12,6 +12,7 @@
- [Policy: offset semantics](#policy-offset-semantics)
- [Policy: limit & page size](#policy-limit--page-size)
- [Policy: end-of-history signalling](#policy-end-of-history-signalling)
- [Policy: pagination metadata](#policy-pagination-metadata)
- [Policy: overflow safety](#policy-overflow-safety)
- [Policy: ordering & stability](#policy-ordering--stability)
- [Canonical source of truth](#canonical-source-of-truth)
Expand All @@ -30,13 +31,14 @@ document exists to make that behaviour explicit and reviewable.

## Contract implementation

The paginated accessor is:
The paginated accessors are:

```rust
pub fn get_history_page(env: Env, offset: u32, limit: u32) -> Result<Vec<SLAResult>, SLAError>
pub fn get_history_page_with_meta(env: Env, offset: u32, limit: u32) -> Result<HistoryPage, SLAError>
```

Implemented in two places that must stay in lockstep:
Each is implemented in two places that must stay in lockstep:

- `apexchainx_calculator/src/lib.rs` — the `#[contractimpl]` entry point
(the on-chain method consumers call).
Expand Down Expand Up @@ -81,6 +83,35 @@ Consumers are encouraged to iterate with a fixed page size and stop on the
first short page, which is exactly one extra call after the last full page
and needs no special-casing for empty histories.

## Policy: pagination metadata

`get_history_page_with_meta` returns the same page as `get_history_page`
wrapped in a `HistoryPage` struct:

```rust
pub struct HistoryPage {
pub items: Vec<SLAResult>, // identical to get_history_page(offset, limit)
pub total: u32, // full history length at read time
pub has_more: bool, // end = min(saturating_add(offset, limit), total) < total
}
```

- `items` is **byte-for-byte identical** to `get_history_page(offset, limit)`
for the same inputs; the legacy accessor remains unchanged for backward
compatibility.
- `total` is the full history length, so consumers no longer need a separate
`get_history` or `get_retention_limit` call to learn the total size.
- `has_more` is `true` exactly when the requested range ends before the end
of history (`end < total`). A consumer can therefore iterate with
`offset += items.len()` and stop when `has_more` is `false`.
- The same empty-page edge cases apply to `items`: `offset >= total` and
`limit == 0` both produce an empty `items`. For `limit == 0` with
`offset < total`, `has_more` is still `true` because the cursor has not
advanced past `offset`.

`get_history_page_with_meta` is read-only, performs no storage writes, emits
no events, and never mutates history.

## Policy: overflow safety

`offset` and `limit` are `u32`, so the naive computation `offset + limit`
Expand Down
Loading
Loading