use std::{fmt::Display, sync::Arc};
use cid::{multihash::Multihash, Cid};
use eyre::{Context, Result};
use tracing::{error, instrument, Span};
use super::{
backend::UserStorageBackend,
local_cache::{FillOutcome, LocalObjectCache},
};
use crate::cid::{cid_v1_raw_cid, parse_persisted_cid, sha2_256_multihash, verify_raw_cid, DAG_PB_CODEC, RAW_CODEC};
const OBJECTS_PREFIX: &str = "objects";
const OBJECT_CHECKSUMS_PREFIX: &str = "object-checksums";
#[derive(Clone)]
pub struct PersistedImmutableDataResolver {
backend: Arc<dyn UserStorageBackend>,
local: Option<Arc<LocalObjectCache>>,
}
impl std::fmt::Debug for PersistedImmutableDataResolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PersistedImmutableDataResolver").finish_non_exhaustive()
}
}
impl PersistedImmutableDataResolver {
pub fn new(backend: Arc<dyn UserStorageBackend>) -> Self {
Self { backend, local: None }
}
#[must_use]
pub fn with_local_cache(mut self, local: Arc<LocalObjectCache>) -> Self {
self.local = Some(local);
self
}
pub fn object_key(cid: impl Display) -> String {
format!("{OBJECTS_PREFIX}/{cid}")
}
pub fn checksum_key(cid: impl Display) -> String {
format!("{OBJECT_CHECKSUMS_PREFIX}/{cid}.sha256")
}
pub async fn get(&self, cid: &str) -> Result<Vec<u8>> {
let parsed = parse_persisted_cid(cid).map_err(|error| eyre::eyre!(error))?;
self.get_cid(&parsed).await
}
pub async fn get_cid(&self, cid: &Cid) -> Result<Vec<u8>> {
self.get_optional(cid)
.await?
.ok_or_else(|| eyre::eyre!("object {cid} not found in persisted immutable data backend"))
}
pub async fn get_optional(&self, cid: &Cid) -> Result<Option<Vec<u8>>> {
self.fetch(cid, true).await
}
pub async fn get_optional_without_caching(&self, cid: &Cid) -> Result<Option<Vec<u8>>> {
self.fetch(cid, false).await
}
#[instrument(
name = "storage.object.fetch",
target = "newton::task_evaluation",
level = "debug",
skip(self),
fields(
storage.backend = self.backend.backend_name(),
storage.object.cid = %cid,
storage.cache = tracing::field::Empty,
)
)]
async fn fetch(&self, cid: &Cid, fill: bool) -> Result<Option<Vec<u8>>> {
let span = Span::current();
let Some(local) = &self.local else {
span.record("storage.cache", "off");
return log_failure(
cid,
self.fetch_verified(cid).await.map(|found| found.map(|(data, _)| data)),
);
};
let name = cid.to_string();
if let Some(bytes) = local.read(&name).await {
span.record("storage.cache", "hit");
return Ok(Some(bytes));
}
if !fill {
span.record("storage.cache", "miss");
return log_failure(
cid,
self.fetch_verified(cid).await.map(|found| found.map(|(data, _)| data)),
);
}
let mut slot = local.fill_lock(&name).await;
if let Some(outcome) = slot.as_ref() {
span.record("storage.cache", "shared");
return match outcome {
Ok(found) => Ok(found.as_deref().map(<[u8]>::to_vec)),
Err(message) => Err(eyre::eyre!("{message}")),
};
}
if let Some(bytes) = local.read(&name).await {
span.record("storage.cache", "hit");
return Ok(Some(bytes));
}
span.record("storage.cache", "miss");
let fetched = log_failure(cid, self.fetch_verified(cid).await);
let outcome: FillOutcome = match &fetched {
Ok(found) => Ok(found.as_ref().map(|(data, _)| Arc::from(data.as_slice()))),
Err(e) => Err(Arc::from(e.to_string())),
};
*slot = Some(outcome);
let fetched = fetched?;
if let Some((data, expected)) = &fetched {
local.write(&name, expected, data).await;
}
Ok(fetched.map(|(data, _)| data))
}
async fn fetch_verified(&self, cid: &Cid) -> Result<Option<(Vec<u8>, Multihash<64>)>> {
let key = Self::object_key(cid);
let Some(data) = self
.backend
.get_optional(&key)
.await
.wrap_err_with(|| format!("resolve object {cid}"))?
else {
return Ok(None);
};
let expected = match cid.codec() {
RAW_CODEC => {
verify_raw_cid(cid, &data).map_err(|e| eyre::eyre!("integrity check failed for {cid}: {e}"))?;
sha2_256_multihash(&data)
}
DAG_PB_CODEC => {
let checksum_key = Self::checksum_key(cid);
let expected = self
.backend
.get_optional(&checksum_key)
.await
.wrap_err_with(|| format!("resolve DAG-PB object checksum {cid}"))?
.ok_or_else(|| eyre::eyre!("DAG-PB object {cid} is missing its checksum sidecar"))?;
let expected = Multihash::<64>::from_bytes(&expected)
.map_err(|error| eyre::eyre!("invalid checksum multihash for DAG-PB object {cid}: {error}"))?;
if expected != sha2_256_multihash(&data) {
eyre::bail!("integrity check failed for DAG-PB object {cid}: SHA2-256 checksum mismatch");
}
expected
}
codec => eyre::bail!("unsupported CID codec for {cid}: 0x{codec:x}"),
};
Ok(Some((data, expected)))
}
pub async fn get_text(&self, cid: &str) -> Result<String> {
let data = self.get(cid).await?;
String::from_utf8(data).wrap_err_with(|| format!("object {cid} is not valid UTF-8"))
}
pub async fn put(&self, data: &[u8]) -> Result<String> {
let cid = cid_v1_raw_cid(data);
self.backend.put(&Self::object_key(cid), data).await?;
Ok(cid.to_string())
}
}
fn log_failure<T>(cid: &Cid, result: Result<T>) -> Result<T> {
if let Err(e) = &result {
error!(cid = %cid, error = format!("{e:#}"), "object fetch failed");
}
result
}
#[cfg(feature = "rpc")]
#[async_trait::async_trait]
impl crate::common::task::rpc::ObjectFetcher for PersistedImmutableDataResolver {
async fn get_object(&self, cid: &str) -> eyre::Result<Vec<u8>> {
self.get(cid).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::persisted_immutable_data::backend::LocalBackend;
#[tokio::test]
async fn put_then_get_roundtrips() {
let tmp = tempfile::tempdir().unwrap();
let resolver = PersistedImmutableDataResolver::new(Arc::new(LocalBackend::new(tmp.path())));
let cid = resolver.put(b"policy rego source").await.unwrap();
let got = resolver.get(&cid).await.unwrap();
assert_eq!(got, b"policy rego source");
}
#[tokio::test]
async fn local_cache_serves_hits_without_the_backend() {
let backend_dir = tempfile::tempdir().unwrap();
let cache_dir = tempfile::tempdir().unwrap();
let backend = Arc::new(LocalBackend::new(backend_dir.path()));
let cid = PersistedImmutableDataResolver::new(backend.clone())
.put(b"policy rego source")
.await
.unwrap();
let local = Arc::new(LocalObjectCache::open(cache_dir.path(), 1 << 20).unwrap());
let resolver = PersistedImmutableDataResolver::new(backend.clone()).with_local_cache(local.clone());
assert_eq!(resolver.get(&cid).await.unwrap(), b"policy rego source");
assert!(local.read(&cid).await.is_some());
let parsed = parse_persisted_cid(&cid).unwrap();
backend
.delete(&PersistedImmutableDataResolver::object_key(parsed))
.await
.unwrap();
assert_eq!(resolver.get(&cid).await.unwrap(), b"policy rego source");
}
#[tokio::test]
async fn local_cache_never_keeps_an_object_that_fails_verification() {
let backend_dir = tempfile::tempdir().unwrap();
let cache_dir = tempfile::tempdir().unwrap();
let backend = Arc::new(LocalBackend::new(backend_dir.path()));
let cid = PersistedImmutableDataResolver::new(backend.clone())
.put(b"original")
.await
.unwrap();
let parsed = parse_persisted_cid(&cid).unwrap();
backend
.put(&PersistedImmutableDataResolver::object_key(parsed), b"tampered")
.await
.unwrap();
let local = Arc::new(LocalObjectCache::open(cache_dir.path(), 1 << 20).unwrap());
let resolver = PersistedImmutableDataResolver::new(backend).with_local_cache(local.clone());
assert!(resolver.get(&cid).await.is_err());
assert!(local.read(&cid).await.is_none());
}
#[tokio::test]
async fn uploads_and_uncached_reads_do_not_fill_the_local_cache() {
let backend_dir = tempfile::tempdir().unwrap();
let cache_dir = tempfile::tempdir().unwrap();
let local = Arc::new(LocalObjectCache::open(cache_dir.path(), 1 << 20).unwrap());
let resolver = PersistedImmutableDataResolver::new(Arc::new(LocalBackend::new(backend_dir.path())))
.with_local_cache(local.clone());
let cid = resolver.put(b"uploaded").await.unwrap();
assert!(local.read(&cid).await.is_none());
let parsed = parse_persisted_cid(&cid).unwrap();
let got = resolver.get_optional_without_caching(&parsed).await.unwrap();
assert_eq!(got.as_deref(), Some(b"uploaded".as_slice()));
assert!(local.read(&cid).await.is_none());
resolver.get(&cid).await.unwrap();
assert!(local.read(&cid).await.is_some());
}
#[tokio::test]
async fn waiters_share_the_fill_outcome_instead_of_refetching() {
let backend_dir = tempfile::tempdir().unwrap();
let cache_dir = tempfile::tempdir().unwrap();
let local = Arc::new(LocalObjectCache::open(cache_dir.path(), 1 << 20).unwrap());
let resolver = PersistedImmutableDataResolver::new(Arc::new(LocalBackend::new(backend_dir.path())))
.with_local_cache(local.clone());
let cid = parse_persisted_cid(&crate::cid::cid_v1_raw(b"absent")).unwrap();
let mut slot = local.fill_lock(&cid.to_string()).await;
*slot = Some(Ok(None));
let waiter = {
let resolver = resolver.clone();
tokio::spawn(async move { resolver.get_optional(&cid).await })
};
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
drop(slot);
assert!(waiter.await.unwrap().unwrap().is_none());
}
#[tokio::test]
async fn missing_object_fails_backend_only() {
let tmp = tempfile::tempdir().unwrap();
let resolver = PersistedImmutableDataResolver::new(Arc::new(LocalBackend::new(tmp.path())));
let cid = crate::cid::cid_v1_raw(b"missing");
let error = resolver.get(&cid).await.unwrap_err();
assert!(error.to_string().contains("not found"), "{error}");
}
#[tokio::test]
async fn get_rejects_corrupted_object() {
let tmp = tempfile::tempdir().unwrap();
let backend = Arc::new(LocalBackend::new(tmp.path()));
let resolver = PersistedImmutableDataResolver::new(backend.clone());
let cid = resolver.put(b"original").await.unwrap();
let parsed = parse_persisted_cid(&cid).unwrap();
backend
.put(&PersistedImmutableDataResolver::object_key(parsed), b"tampered")
.await
.unwrap();
let err = resolver.get(&cid).await.unwrap_err();
assert!(err.to_string().contains("integrity check failed"), "{err}");
}
#[tokio::test]
async fn get_text_reads_utf8() {
let tmp = tempfile::tempdir().unwrap();
let resolver = PersistedImmutableDataResolver::new(Arc::new(LocalBackend::new(tmp.path())));
let cid = resolver.put("схема".as_bytes()).await.unwrap();
assert_eq!(resolver.get_text(&cid).await.unwrap(), "схема");
}
#[tokio::test]
async fn dag_pb_object_requires_matching_checksum_sidecar() {
use sha2::{Digest, Sha256};
let tmp = tempfile::tempdir().unwrap();
let backend = Arc::new(LocalBackend::new(tmp.path()));
let resolver = PersistedImmutableDataResolver::new(backend.clone());
let payload = b"migrated DAG-PB policy";
let root_block_digest = Sha256::digest(b"dag-pb root block");
let multihash = Multihash::<64>::wrap(0x12, root_block_digest.as_slice()).unwrap();
let cid = Cid::new_v1(crate::cid::DAG_PB_CODEC, multihash);
backend
.put(&PersistedImmutableDataResolver::object_key(cid), payload)
.await
.unwrap();
assert!(resolver
.get(&cid.to_string())
.await
.unwrap_err()
.to_string()
.contains("checksum sidecar"));
let checksum = sha2_256_multihash(payload).to_bytes();
backend
.put(&PersistedImmutableDataResolver::checksum_key(cid), &checksum)
.await
.unwrap();
assert_eq!(resolver.get(&cid.to_string()).await.unwrap(), payload);
let wrong_checksum = sha2_256_multihash(b"different payload").to_bytes();
backend
.put(&PersistedImmutableDataResolver::checksum_key(cid), &wrong_checksum)
.await
.unwrap();
assert!(resolver
.get(&cid.to_string())
.await
.unwrap_err()
.to_string()
.contains("checksum mismatch"));
}
}