ic-query 0.101.0

Internet Computer query library for CloudEngine, NNS, SNS, ICRC, system canisters, and public network metadata
Documentation
//! Module: icrc::account_transaction_cache::attempt
//!
//! Responsibility: persist and validate complete account-history refresh lifecycle status.
//! Does not own: live collection, cache publication, or text rendering.
//! Boundary: keeps failed collection progress observable without publishing partial rows.

use super::{ICRC_ACCOUNT_TRANSACTION_CACHE_COMPONENT, storage::cache_identity_matches};
use crate::{
    cache::CacheRefreshAttemptStatus,
    icrc::{
        live::account_transactions::validate_transaction_cursor_text,
        model::{
            IcrcAccountTransactionCacheRequest, IcrcAccountTransactionError,
            IcrcAccountTransactionRefreshAttemptStatus, IcrcAccountTransactionRefreshRequest,
        },
    },
    snapshot_cache::{
        SnapshotRefreshAttempt, SnapshotRefreshProgress, read_snapshot_refresh_attempt_strict,
        validate_snapshot_refresh_attempt,
    },
    subnet_catalog::MAINNET_NETWORK,
};
use ic_host_text::timestamp::format_utc_timestamp_secs;
use ic_platform::Principal;
use serde::{Deserialize as SerdeDeserialize, Serialize};
use std::path::Path;

type AccountTransactionRefreshAttempt =
    SnapshotRefreshAttempt<AccountTransactionRefreshAttemptMetadata>;

///
/// AccountTransactionRefreshAttemptMetadata
///
/// Stable target identity and optional requested index recorded in an attempt sidecar.
///

#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct AccountTransactionRefreshAttemptMetadata {
    ledger_canister_id: String,
    account_owner: String,
    subaccount_hex: Option<String>,
    index_canister_id: Option<String>,
}

pub(super) fn read_refresh_attempt_status(
    path: &Path,
    request: &IcrcAccountTransactionCacheRequest,
) -> Result<Option<IcrcAccountTransactionRefreshAttemptStatus>, IcrcAccountTransactionError> {
    read_snapshot_refresh_attempt_strict::<AccountTransactionRefreshAttempt>(
        &request.cache_root,
        path,
        None,
    )
    .map_err(|error| {
        error.into_error(
            ICRC_ACCOUNT_TRANSACTION_CACHE_COMPONENT,
            IcrcAccountTransactionError::from,
            |path, reason| IcrcAccountTransactionError::InvalidRefreshAttempt { path, reason },
        )
    })?
    .map(|attempt| {
        validate_attempt(path, request, &attempt)?;
        Ok(IcrcAccountTransactionRefreshAttemptStatus {
            index_canister_id: attempt.metadata.index_canister_id.clone(),
            lifecycle: attempt.into(),
        })
    })
    .transpose()
}

pub(super) fn write_starting_attempt(
    path: &Path,
    request: &IcrcAccountTransactionRefreshRequest,
) -> Result<(), IcrcAccountTransactionError> {
    write_attempt(
        path,
        request,
        CacheRefreshAttemptStatus::Running,
        request.index_canister_id.clone(),
        SnapshotRefreshProgress::default(),
        None,
    )
}

pub(super) fn write_complete_attempt(
    path: &Path,
    request: &IcrcAccountTransactionRefreshRequest,
    index_canister_id: &str,
    last_cursor: Option<String>,
    pages_fetched: u32,
    rows_fetched: usize,
) -> Result<(), IcrcAccountTransactionError> {
    write_attempt(
        path,
        request,
        CacheRefreshAttemptStatus::Complete,
        Some(index_canister_id.to_string()),
        SnapshotRefreshProgress::new(pages_fetched, rows_fetched, last_cursor),
        None,
    )
}

pub(super) fn write_failed_attempt(
    path: &Path,
    request: &IcrcAccountTransactionRefreshRequest,
    error: &IcrcAccountTransactionError,
) {
    let (index_canister_id, progress) = collection_error_evidence(error);
    let _ = write_attempt(
        path,
        request,
        CacheRefreshAttemptStatus::Failed,
        index_canister_id.or_else(|| request.index_canister_id.clone()),
        progress,
        Some(error.to_string()),
    );
}

fn collection_error_evidence(
    error: &IcrcAccountTransactionError,
) -> (Option<String>, SnapshotRefreshProgress) {
    match error {
        IcrcAccountTransactionError::IncompleteCollection {
            index_canister_id,
            pages_fetched,
            rows_fetched,
            last_cursor,
            ..
        }
        | IcrcAccountTransactionError::CollectionPage {
            index_canister_id,
            pages_fetched,
            rows_fetched,
            last_cursor,
            ..
        } => (
            index_canister_id.clone(),
            SnapshotRefreshProgress::new(*pages_fetched, *rows_fetched, last_cursor.clone()),
        ),
        _ => (None, SnapshotRefreshProgress::default()),
    }
}

fn write_attempt(
    path: &Path,
    request: &IcrcAccountTransactionRefreshRequest,
    status: CacheRefreshAttemptStatus,
    index_canister_id: Option<String>,
    progress: SnapshotRefreshProgress,
    last_error: Option<String>,
) -> Result<(), IcrcAccountTransactionError> {
    AccountTransactionRefreshAttempt::new(
        MAINNET_NETWORK,
        &request.cache.source_endpoint,
        format_utc_timestamp_secs(request.now_unix_secs),
        AccountTransactionRefreshAttemptMetadata {
            ledger_canister_id: request.cache.ledger_canister_id.clone(),
            account_owner: request.cache.account_owner.clone(),
            subaccount_hex: request.cache.subaccount_hex.clone(),
            index_canister_id,
        },
        request.page_size,
    )
    .write(
        &request.cache.cache_root,
        path,
        ICRC_ACCOUNT_TRANSACTION_CACHE_COMPONENT,
        status,
        progress,
        last_error,
    )
    .map_err(IcrcAccountTransactionError::from)
}

fn validate_attempt(
    path: &Path,
    request: &IcrcAccountTransactionCacheRequest,
    attempt: &AccountTransactionRefreshAttempt,
) -> Result<(), IcrcAccountTransactionError> {
    let invalid = |reason| IcrcAccountTransactionError::InvalidRefreshAttempt {
        path: path.to_path_buf(),
        reason,
    };
    validate_snapshot_refresh_attempt(attempt, MAINNET_NETWORK).map_err(invalid)?;
    if !cache_identity_matches(
        request,
        &attempt.source_endpoint,
        &attempt.metadata.ledger_canister_id,
        &attempt.metadata.account_owner,
        attempt.metadata.subaccount_hex.as_deref(),
    ) {
        return Err(invalid(
            "attempt identity does not match requested endpoint, ledger, or account".to_string(),
        ));
    }
    if let Some(index_canister_id) = attempt.metadata.index_canister_id.as_deref() {
        Principal::from_text(index_canister_id)
            .map_err(|error| invalid(format!("invalid index canister id: {error}")))?;
    }
    if let Some(cursor) = attempt.last_cursor.as_deref() {
        validate_transaction_cursor_text(cursor).map_err(|error| invalid(error.to_string()))?;
    }
    Ok(())
}