From fc5b942285e8dce211116e87e8833de03cd94dad Mon Sep 17 00:00:00 2001 From: ebenezershadrack123-star Date: Thu, 20 Aug 2026 16:58:19 +0000 Subject: [PATCH] feat(history): add get_history_page_with_meta with pagination metadata (#380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a metadata-carrying companion to get_history_page so consumers can detect end-of-history and total size in a single read without a separate get_history call. Returns a HistoryPage { items, total, has_more } while keeping the existing get_history_page behavior unchanged for backward compatibility. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- CHANGELOG.md | 1 + README.md | 6 +- apexchainx_calculator/src/history.rs | 41 ++++++++- apexchainx_calculator/src/lib.rs | 63 +++++++++++++ apexchainx_calculator/src/tests.rs | 128 ++++++++++++++++++++++++++- docs/API_STABILITY_SCORECARD.md | 1 + docs/CONTRACT_MAINTENANCE_POLICY.md | 5 +- docs/HISTORY_PAGINATION_POLICY.md | 37 +++++++- docs/MODULE_OWNERSHIP.md | 2 +- docs/PROJECT_CONTEXT.md | 3 +- 10 files changed, 274 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5dcb2a..1da85b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/README.md b/README.md index acd3185..666655f 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/apexchainx_calculator/src/history.rs b/apexchainx_calculator/src/history.rs index eaaae9a..9a7584b 100644 --- a/apexchainx_calculator/src/history.rs +++ b/apexchainx_calculator/src/history.rs @@ -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. @@ -135,6 +135,43 @@ pub fn get_history_page(env: &Env, offset: u32, limit: u32) -> Result= 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 { + crate::SLACalculatorContract::check_version(env)?; + let history: Vec = 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, SLAError> { crate::SLACalculatorContract::check_version(env)?; diff --git a/apexchainx_calculator/src/lib.rs b/apexchainx_calculator/src/lib.rs index ff0d917..8c28915 100644 --- a/apexchainx_calculator/src/lib.rs +++ b/apexchainx_calculator/src/lib.rs @@ -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, + /// 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] @@ -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", "")); @@ -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 { + Self::check_version(&env)?; + let history: Vec = 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 // ------------------------------------------------------------------- diff --git a/apexchainx_calculator/src/tests.rs b/apexchainx_calculator/src/tests.rs index 8728330..400526f 100644 --- a/apexchainx_calculator/src/tests.rs +++ b/apexchainx_calculator/src/tests.rs @@ -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 // ============================================================ @@ -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] diff --git a/docs/API_STABILITY_SCORECARD.md b/docs/API_STABILITY_SCORECARD.md index 8ee458d..9a9d438 100644 --- a/docs/API_STABILITY_SCORECARD.md +++ b/docs/API_STABILITY_SCORECARD.md @@ -110,6 +110,7 @@ safe (additive) or dangerous (breaking). |-----------|------|------|--------------|-------| | `get_history` | ⚠️ Stable | Public | **Medium** | Return type `Vec`. `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`. | | `prune_history` | 🛡️ Admin-Gated | Admin | **Medium** | Parameter `keep_latest: u32` must stay. | diff --git a/docs/CONTRACT_MAINTENANCE_POLICY.md b/docs/CONTRACT_MAINTENANCE_POLICY.md index bfd7ab2..eef720a 100644 --- a/docs/CONTRACT_MAINTENANCE_POLICY.md +++ b/docs/CONTRACT_MAINTENANCE_POLICY.md @@ -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 diff --git a/docs/HISTORY_PAGINATION_POLICY.md b/docs/HISTORY_PAGINATION_POLICY.md index 7cab35a..cfd59b8 100644 --- a/docs/HISTORY_PAGINATION_POLICY.md +++ b/docs/HISTORY_PAGINATION_POLICY.md @@ -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 @@ -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) @@ -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, SLAError> +pub fn get_history_page_with_meta(env: Env, offset: u32, limit: u32) -> Result ``` -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). @@ -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, // 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` diff --git a/docs/MODULE_OWNERSHIP.md b/docs/MODULE_OWNERSHIP.md index e15e955..1dabcbc 100644 --- a/docs/MODULE_OWNERSHIP.md +++ b/docs/MODULE_OWNERSHIP.md @@ -61,7 +61,7 @@ All paths are relative to `apexchainx_calculator/src/`. | `governance.rs` | Contract Governance | `set_operator`, `propose_admin`, `accept_admin`, `cancel_admin_proposal`, `get_pending_admin`, `propose_operator`, `accept_operator`, `cancel_operator_proposal`, `get_pending_operator`, `renounce_admin` | **High** | | `config_freeze.rs` | Contract Governance | `freeze_config`, `unfreeze_config`, `is_config_frozen` | **Medium** | | `metadata.rs` | Contract Governance | `pause`, `unpause`, `is_paused`, `get_pause_info`, `require_not_paused` | **High** | -| `history.rs` | Contract Data Layer | `get_history`, `prune_history`, `prune_history_by_age`, `get_history_page`, `get_history_by_outage`, `get_latest_by_outage`, `get_config_count`, `set_retention_limit`, `get_retention_limit` | **High** | +| `history.rs` | Contract Data Layer | `get_history`, `prune_history`, `prune_history_by_age`, `get_history_page`, `get_history_page_with_meta`, `get_history_by_outage`, `get_latest_by_outage`, `get_config_count`, `set_retention_limit`, `get_retention_limit` | **High** | | `history_snapshot.rs` | Contract Data Layer | `normalize_history` | **Medium** | | `config_metadata.rs` | Contract Data Layer | `record_config_update`, `get_last_config_update` | **Medium** | | `config_bundle.rs` | Contract Data Layer | (composed types for `get_config_bundle`) | **Low** | diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 9e188d2..40eca2f 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -63,7 +63,7 @@ No on-chain state is written and no events are emitted. | 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` | | 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` | @@ -269,6 +269,7 @@ None require caller auth, mutate storage, or emit events. | `get_economic_exposure` | `CONFIG_KEY` | Reads all canonical configs, computes max-reward + penalty-rate totals. | | `get_history` | `HISTORY_KEY` | Returns full `Vec`. | | `get_history_page` | `HISTORY_KEY` | Bounded slice of history. See the [History Pagination Policy](HISTORY_PAGINATION_POLICY.md) (issue #263): offset-based, oldest-first, empty-page end-of-history signalling, saturating `offset + limit` arithmetic. | +| `get_history_page_with_meta` | `HISTORY_KEY` | Same page as `get_history_page` plus `HistoryPage` metadata (`items`, `total`, `has_more`) so consumers can page without a separate `get_history` call (#380). | | `get_history_by_outage` | `HISTORY_KEY` | Filters history by `outage_id`. | | `get_latest_by_outage` | `HISTORY_KEY` | Scans history for newest match. | | `get_retention_limit` | `RETENTION_LIMIT_KEY` | Returns `u32`, defaults to `MAX_HISTORY_SIZE`. |