use super::{
CatalogSourceSelection, LiveSubnetCatalogSource, SubnetCatalogFailureCacheDisposition,
SubnetCatalogHostError, SubnetCatalogLoadFailure, SubnetCatalogLoadStage,
SubnetCatalogRefreshRequest, SubnetCatalogRefreshTrigger, SubnetCatalogSource,
SubnetCatalogSourceFailure, SubnetCatalogSubject, error::enforce_mainnet_network,
failure::subject_from_catalog_error,
refresh::refresh_subnet_catalog_detailed_with_source_async, subnet_catalog_path,
};
use crate::{
cache_file::{BoundedManagedFileReadError, HostCacheError, read_bounded_managed_file},
runtime::block_on_current_thread,
subnet_catalog::{
CatalogAssurance, CatalogSnapshotAuthorityEvidence, CatalogValidationContext,
DEFAULT_CATALOG_MAX_FUTURE_SKEW_SECONDS, DEFAULT_REFRESH_LOCK_STALE_SECONDS,
MAINNET_REGISTRY_CANISTER_ID, ValidatedSubnetCatalog, catalog_stale_status,
},
};
use serde::{Deserialize, Serialize};
use std::{io, path::PathBuf};
const MAX_CACHED_CATALOG_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SubnetCatalogCacheRequest {
pub cache_root: PathBuf,
pub network: String,
}
impl SubnetCatalogCacheRequest {
#[must_use]
pub fn new(cache_root: impl Into<PathBuf>, network: impl Into<String>) -> Self {
Self {
cache_root: cache_root.into(),
network: network.into(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CatalogReadPolicy {
CacheOnly,
RefreshMissing {
source: CatalogSourceSelection,
},
RefreshMissingOrInvalid {
source: CatalogSourceSelection,
},
RefreshMissingInvalidOrOlderThan {
source: CatalogSourceSelection,
max_age_seconds: u64,
},
ForceRefresh {
source: CatalogSourceSelection,
},
}
impl CatalogReadPolicy {
pub(super) const fn source(&self) -> Option<&CatalogSourceSelection> {
match self {
Self::CacheOnly => None,
Self::RefreshMissing { source }
| Self::RefreshMissingOrInvalid { source }
| Self::RefreshMissingInvalidOrOlderThan { source, .. }
| Self::ForceRefresh { source } => Some(source),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SubnetCatalogLoadRequest {
pub cache: SubnetCatalogCacheRequest,
pub now_unix_secs: u64,
pub max_future_skew_seconds: u64,
pub minimum_assurance: CatalogAssurance,
pub policy: CatalogReadPolicy,
}
impl SubnetCatalogLoadRequest {
#[must_use]
pub const fn cache_only(cache: SubnetCatalogCacheRequest, now_unix_secs: u64) -> Self {
Self {
cache,
now_unix_secs,
max_future_skew_seconds: DEFAULT_CATALOG_MAX_FUTURE_SKEW_SECONDS,
minimum_assurance: CatalogAssurance::UncertifiedQuery,
policy: CatalogReadPolicy::CacheOnly,
}
}
#[must_use]
pub const fn refresh_missing_or_invalid(
cache: SubnetCatalogCacheRequest,
source: CatalogSourceSelection,
now_unix_secs: u64,
) -> Self {
Self {
cache,
now_unix_secs,
max_future_skew_seconds: DEFAULT_CATALOG_MAX_FUTURE_SKEW_SECONDS,
minimum_assurance: CatalogAssurance::UncertifiedQuery,
policy: CatalogReadPolicy::RefreshMissingOrInvalid { source },
}
}
#[must_use]
pub const fn refresh_missing_invalid_or_older_than(
cache: SubnetCatalogCacheRequest,
source: CatalogSourceSelection,
now_unix_secs: u64,
max_age_seconds: u64,
) -> Self {
Self {
cache,
now_unix_secs,
max_future_skew_seconds: DEFAULT_CATALOG_MAX_FUTURE_SKEW_SECONDS,
minimum_assurance: CatalogAssurance::UncertifiedQuery,
policy: CatalogReadPolicy::RefreshMissingInvalidOrOlderThan {
source,
max_age_seconds,
},
}
}
#[must_use]
pub const fn with_max_future_skew_seconds(mut self, seconds: u64) -> Self {
self.max_future_skew_seconds = seconds;
self
}
#[must_use]
pub const fn with_minimum_assurance(mut self, minimum: CatalogAssurance) -> Self {
self.minimum_assurance = minimum;
self
}
#[must_use]
pub fn with_policy(mut self, policy: CatalogReadPolicy) -> Self {
self.policy = policy;
self
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CacheDisposition {
CacheHit,
RefreshedMissing,
RefreshedInvalid,
RefreshedStale,
ForcedRefresh,
}
impl CacheDisposition {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::CacheHit => "cache_hit",
Self::RefreshedMissing => "refreshed_missing",
Self::RefreshedInvalid => "refreshed_invalid",
Self::RefreshedStale => "refreshed_stale",
Self::ForcedRefresh => "forced_refresh",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CatalogLoadOutcome {
pub path: PathBuf,
pub catalog: ValidatedSubnetCatalog,
pub disposition: CacheDisposition,
}
impl CatalogLoadOutcome {
#[must_use]
pub fn snapshot_authority(&self) -> CatalogSnapshotAuthorityEvidence {
self.catalog.snapshot_authority()
}
}
pub fn load_cached_subnet_catalog(
request: &SubnetCatalogLoadRequest,
) -> Result<CatalogLoadOutcome, SubnetCatalogHostError> {
load_cached_subnet_catalog_detailed(request).map_err(SubnetCatalogLoadFailure::into_source)
}
pub fn load_cached_subnet_catalog_detailed(
request: &SubnetCatalogLoadRequest,
) -> Result<CatalogLoadOutcome, Box<SubnetCatalogLoadFailure>> {
if request.policy != CatalogReadPolicy::CacheOnly {
return Err(load_failure(
request,
SubnetCatalogLoadStage::RequestValidation,
SubnetCatalogFailureCacheDisposition::NotExamined,
SubnetCatalogSourceFailure::from_source(SubnetCatalogHostError::InvalidReadPolicy {
reason: "load_cached_subnet_catalog requires CatalogReadPolicy::CacheOnly"
.to_string(),
}),
));
}
load_cached_with_disposition_detailed(request, CacheDisposition::CacheHit)
.map_err(|failure| cache_load_failure(request, true, failure))
}
pub fn load_subnet_catalog(
request: &SubnetCatalogLoadRequest,
) -> Result<CatalogLoadOutcome, SubnetCatalogHostError> {
load_subnet_catalog_detailed(request).map_err(SubnetCatalogLoadFailure::into_source)
}
pub fn load_subnet_catalog_detailed(
request: &SubnetCatalogLoadRequest,
) -> Result<CatalogLoadOutcome, Box<SubnetCatalogLoadFailure>> {
match block_on_current_thread(load_subnet_catalog_detailed_async(request)) {
Ok(result) => result,
Err(source) => Err(runtime_load_failure(request, source)),
}
}
pub fn load_subnet_catalog_with_source(
request: &SubnetCatalogLoadRequest,
source: &dyn SubnetCatalogSource,
) -> Result<CatalogLoadOutcome, SubnetCatalogHostError> {
load_subnet_catalog_detailed_with_source(request, source)
.map_err(SubnetCatalogLoadFailure::into_source)
}
pub fn load_subnet_catalog_detailed_with_source(
request: &SubnetCatalogLoadRequest,
source: &dyn SubnetCatalogSource,
) -> Result<CatalogLoadOutcome, Box<SubnetCatalogLoadFailure>> {
match block_on_current_thread(load_subnet_catalog_detailed_with_source_async(
request, source,
)) {
Ok(result) => result,
Err(error) => Err(runtime_load_failure(request, error)),
}
}
pub async fn load_subnet_catalog_async(
request: &SubnetCatalogLoadRequest,
) -> Result<CatalogLoadOutcome, SubnetCatalogHostError> {
load_subnet_catalog_detailed_async(request)
.await
.map_err(SubnetCatalogLoadFailure::into_source)
}
pub async fn load_subnet_catalog_detailed_async(
request: &SubnetCatalogLoadRequest,
) -> Result<CatalogLoadOutcome, Box<SubnetCatalogLoadFailure>> {
let source = LiveSubnetCatalogSource::default().with_history_cache(&request.cache.cache_root);
load_subnet_catalog_detailed_with_source_async(request, &source).await
}
pub async fn load_subnet_catalog_with_source_async(
request: &SubnetCatalogLoadRequest,
source: &dyn SubnetCatalogSource,
) -> Result<CatalogLoadOutcome, SubnetCatalogHostError> {
load_subnet_catalog_detailed_with_source_async(request, source)
.await
.map_err(SubnetCatalogLoadFailure::into_source)
}
pub async fn load_subnet_catalog_detailed_with_source_async(
request: &SubnetCatalogLoadRequest,
source: &dyn SubnetCatalogSource,
) -> Result<CatalogLoadOutcome, Box<SubnetCatalogLoadFailure>> {
enforce_mainnet_network(&request.cache.network).map_err(|source| {
load_failure(
request,
SubnetCatalogLoadStage::RequestValidation,
SubnetCatalogFailureCacheDisposition::NotExamined,
SubnetCatalogSourceFailure::new(
None,
Some(SubnetCatalogSubject::Network(request.cache.network.clone())),
source,
),
)
})?;
match &request.policy {
CatalogReadPolicy::CacheOnly => {
load_cached_with_disposition_detailed(request, CacheDisposition::CacheHit)
.map_err(|failure| cache_load_failure(request, true, failure))
}
CatalogReadPolicy::ForceRefresh { .. } => {
refresh_then_load_detailed(
request,
source,
CacheDisposition::ForcedRefresh,
SubnetCatalogRefreshTrigger::Forced,
)
.await
}
policy => {
match load_cached_with_disposition_detailed(request, CacheDisposition::CacheHit) {
Ok(cached) => {
let max_age_seconds = match policy {
CatalogReadPolicy::RefreshMissingInvalidOrOlderThan {
max_age_seconds,
..
} => Some(*max_age_seconds),
_ => None,
};
if max_age_seconds.is_some_and(|max_age_seconds| {
catalog_stale_status(
cached.catalog.raw(),
request.now_unix_secs,
max_age_seconds,
)
.catalog_stale
}) {
refresh_then_load_detailed(
request,
source,
CacheDisposition::RefreshedStale,
SubnetCatalogRefreshTrigger::Stale,
)
.await
} else {
Ok(cached)
}
}
Err(failure)
if matches!(
failure.source,
SubnetCatalogHostError::MissingCatalog { .. }
) =>
{
refresh_then_load_detailed(
request,
source,
CacheDisposition::RefreshedMissing,
SubnetCatalogRefreshTrigger::Missing,
)
.await
}
Err(failure)
if matches!(
policy,
CatalogReadPolicy::RefreshMissingOrInvalid { .. }
| CatalogReadPolicy::RefreshMissingInvalidOrOlderThan { .. }
) && matches!(
failure.source,
SubnetCatalogHostError::Catalog(_)
| SubnetCatalogHostError::CachedCatalogTooLarge { .. }
) =>
{
refresh_then_load_detailed(
request,
source,
CacheDisposition::RefreshedInvalid,
SubnetCatalogRefreshTrigger::Rejected,
)
.await
}
Err(failure) => Err(cache_load_failure(request, false, failure)),
}
}
}
}
fn load_cached_with_disposition_detailed(
request: &SubnetCatalogLoadRequest,
disposition: CacheDisposition,
) -> Result<CatalogLoadOutcome, Box<SubnetCatalogSourceFailure>> {
enforce_mainnet_network(&request.cache.network).map_err(|source| {
SubnetCatalogSourceFailure::new(
None,
Some(SubnetCatalogSubject::Network(request.cache.network.clone())),
source,
)
})?;
let path = subnet_catalog_path(&request.cache.cache_root, &request.cache.network);
let Some(data) = read_bounded_managed_file(
&request.cache.cache_root,
&path,
MAX_CACHED_CATALOG_BYTES,
None,
)
.map_err(|error| {
SubnetCatalogSourceFailure::new(
None,
Some(SubnetCatalogSubject::CachePath(path.clone())),
bounded_catalog_read_error(error),
)
})?
else {
return Err(SubnetCatalogSourceFailure::new(
None,
Some(SubnetCatalogSubject::CachePath(path.clone())),
SubnetCatalogHostError::MissingCatalog { path },
));
};
let raw = serde_json::from_slice::<crate::subnet_catalog::RawSubnetCatalog>(&data)
.map_err(crate::subnet_catalog::CatalogError::from)
.and_then(|catalog| {
catalog.validate()?;
Ok(catalog)
})
.map_err(|source| {
let subject = subject_from_catalog_error(&source);
SubnetCatalogSourceFailure::new(None, subject, SubnetCatalogHostError::Catalog(source))
})?;
let registry_version = raw.provenance.registry_version;
let validation = CatalogValidationContext::new(
&request.cache.network,
MAINNET_REGISTRY_CANISTER_ID,
request.now_unix_secs,
request.max_future_skew_seconds,
);
let catalog = ValidatedSubnetCatalog::try_from_raw(raw, &validation).map_err(|source| {
let subject = subject_from_catalog_error(&source);
SubnetCatalogSourceFailure::new(
Some(registry_version),
subject,
SubnetCatalogHostError::Catalog(source),
)
})?;
enforce_minimum_assurance(catalog.provenance().assurance, request.minimum_assurance)
.map_err(|source| SubnetCatalogSourceFailure::new(Some(registry_version), None, source))?;
Ok(CatalogLoadOutcome {
path,
catalog,
disposition,
})
}
fn bounded_catalog_read_error(error: BoundedManagedFileReadError) -> SubnetCatalogHostError {
match error {
BoundedManagedFileReadError::Operation(source) => super::error::subnet_cache_error(source),
BoundedManagedFileReadError::Read { path, source } => HostCacheError::ReadCache {
component: "subnet catalog",
path,
source,
}
.into(),
BoundedManagedFileReadError::LimitExceeded {
path,
actual,
maximum,
} => SubnetCatalogHostError::CachedCatalogTooLarge {
path,
actual,
maximum,
},
BoundedManagedFileReadError::Accounting { path } => HostCacheError::ReadCache {
component: "subnet catalog",
path,
source: io::Error::new(
io::ErrorKind::InvalidData,
"catalog byte count cannot be represented",
),
}
.into(),
}
}
async fn refresh_then_load_detailed(
request: &SubnetCatalogLoadRequest,
source: &dyn SubnetCatalogSource,
disposition: CacheDisposition,
trigger: SubnetCatalogRefreshTrigger,
) -> Result<CatalogLoadOutcome, Box<SubnetCatalogLoadFailure>> {
let source_selection = request.policy.source().ok_or_else(|| {
load_failure(
request,
SubnetCatalogLoadStage::RefreshAttempted,
SubnetCatalogFailureCacheDisposition::RefreshAttempted(trigger),
SubnetCatalogSourceFailure::from_source(SubnetCatalogHostError::InvalidReadPolicy {
reason: "refresh policy is missing its source selection".to_string(),
}),
)
})?;
enforce_minimum_assurance(source_selection.assurance(), request.minimum_assurance).map_err(
|source| {
let (stage, cache_disposition) = if trigger == SubnetCatalogRefreshTrigger::Forced {
(
SubnetCatalogLoadStage::CacheBypass,
SubnetCatalogFailureCacheDisposition::CacheBypassed,
)
} else {
(
SubnetCatalogLoadStage::RefreshAttempted,
SubnetCatalogFailureCacheDisposition::RefreshAttempted(trigger),
)
};
load_failure(
request,
stage,
cache_disposition,
SubnetCatalogSourceFailure::from_source(source),
)
},
)?;
let refresh_request = SubnetCatalogRefreshRequest::new(
request.cache.clone(),
source_selection.clone(),
request.now_unix_secs,
DEFAULT_REFRESH_LOCK_STALE_SECONDS,
)
.with_max_future_skew_seconds(request.max_future_skew_seconds);
let refresh = refresh_subnet_catalog_detailed_with_source_async(&refresh_request, source)
.await
.map_err(|failure| {
load_failure(
request,
SubnetCatalogLoadStage::RefreshFailed,
SubnetCatalogFailureCacheDisposition::RefreshFailed(trigger),
failure,
)
})?;
load_cached_with_disposition_detailed(request, disposition).map_err(|mut failure| {
if failure.registry_version.is_none() {
failure.registry_version = Some(refresh.registry_version);
}
post_refresh_load_failure(request, trigger, failure)
})
}
fn post_refresh_load_failure(
request: &SubnetCatalogLoadRequest,
trigger: SubnetCatalogRefreshTrigger,
failure: Box<SubnetCatalogSourceFailure>,
) -> Box<SubnetCatalogLoadFailure> {
load_failure(
request,
SubnetCatalogLoadStage::PostRefreshCacheLoadFailed,
SubnetCatalogFailureCacheDisposition::PostRefreshLoadFailed(trigger),
failure,
)
}
fn cache_load_failure(
request: &SubnetCatalogLoadRequest,
cache_only: bool,
failure: Box<SubnetCatalogSourceFailure>,
) -> Box<SubnetCatalogLoadFailure> {
let (stage, cache_disposition) = match &failure.source {
SubnetCatalogHostError::UnsupportedNetwork { .. } => (
SubnetCatalogLoadStage::RequestValidation,
SubnetCatalogFailureCacheDisposition::NotExamined,
),
SubnetCatalogHostError::MissingCatalog { .. } => (
SubnetCatalogLoadStage::CacheAbsence,
SubnetCatalogFailureCacheDisposition::CacheMissing,
),
SubnetCatalogHostError::Catalog(_)
| SubnetCatalogHostError::CachedCatalogTooLarge { .. }
| SubnetCatalogHostError::InsufficientAssurance { .. } => (
SubnetCatalogLoadStage::CacheRejection,
SubnetCatalogFailureCacheDisposition::CacheRejected,
),
_ if cache_only => (
SubnetCatalogLoadStage::CacheOnlyLoad,
SubnetCatalogFailureCacheDisposition::CacheOnly,
),
_ => (
SubnetCatalogLoadStage::CacheLookup,
SubnetCatalogFailureCacheDisposition::CacheReadFailed,
),
};
load_failure(request, stage, cache_disposition, failure)
}
fn runtime_load_failure(
request: &SubnetCatalogLoadRequest,
source: crate::runtime::RuntimeError,
) -> Box<SubnetCatalogLoadFailure> {
load_failure(
request,
SubnetCatalogLoadStage::RuntimeAdapter,
SubnetCatalogFailureCacheDisposition::NotExamined,
SubnetCatalogSourceFailure::from_source(SubnetCatalogHostError::RuntimeAdapter(source)),
)
}
fn load_failure(
request: &SubnetCatalogLoadRequest,
stage: SubnetCatalogLoadStage,
cache_disposition: SubnetCatalogFailureCacheDisposition,
failure: Box<SubnetCatalogSourceFailure>,
) -> Box<SubnetCatalogLoadFailure> {
SubnetCatalogLoadFailure::from_source_failure(request, stage, cache_disposition, failure)
}
const fn enforce_minimum_assurance(
actual: CatalogAssurance,
required: CatalogAssurance,
) -> Result<(), SubnetCatalogHostError> {
if actual.satisfies(required) {
Ok(())
} else {
Err(SubnetCatalogHostError::InsufficientAssurance { required, actual })
}
}
#[cfg(test)]
mod detailed_failure_tests {
use super::*;
#[test]
fn post_refresh_cache_load_failure_has_a_distinct_typed_stage_and_disposition() {
let request = SubnetCatalogLoadRequest::cache_only(
SubnetCatalogCacheRequest::new("/tmp/ic-query-post-refresh-test", "ic"),
123,
);
let source = SubnetCatalogSourceFailure::new(
Some(9001),
Some(SubnetCatalogSubject::CachePath(PathBuf::from(
"/tmp/ic-query-post-refresh-test/catalog.json",
))),
SubnetCatalogHostError::MissingCatalog {
path: PathBuf::from("/tmp/ic-query-post-refresh-test/catalog.json"),
},
);
let failure =
post_refresh_load_failure(&request, SubnetCatalogRefreshTrigger::Missing, source);
assert_eq!(
failure.stage,
SubnetCatalogLoadStage::PostRefreshCacheLoadFailed
);
assert_eq!(
failure.cache_disposition,
SubnetCatalogFailureCacheDisposition::PostRefreshLoadFailed(
SubnetCatalogRefreshTrigger::Missing
)
);
assert_eq!(failure.registry_version, Some(9001));
}
}