fraiseql-storage 2.3.2

Object storage backends and HTTP handlers for FraiseQL
Documentation
//! Transform result caching.
//!
//! Caches transformed images to avoid re-computing the same transforms.
//! Cache entries are invalidated when the source object is re-uploaded.

use std::sync::Arc;

use fraiseql_error::Result;

use super::TransformParams;
#[cfg(test)]
use super::{ImageTransformer, TransformOutput};
use crate::backend::LocalBackend;

/// Transform cache for storing and retrieving cached transformed images.
///
/// Caches transformed images using the storage backend itself, storing them
/// under a special `_transforms/` prefix. When the source object is re-uploaded,
/// all cached transforms for that key are invalidated.
///
/// # Implementation Details
///
/// - Cache keys: `_transforms/{key}/{width}x{height}_{format}_{quality}`
/// - Invalidation: All transforms with matching source key are deleted on re-upload
/// - Source data: The cache does NOT store the source image, only transformed results
pub struct TransformCache {
    /// Storage backend used for caching transformed images
    backend: Arc<LocalBackend>,
}

impl TransformCache {
    /// Creates a new transform cache backed by the given storage backend.
    ///
    /// # Arguments
    ///
    /// * `backend` - Storage backend for persisting cached transforms
    #[must_use]
    pub const fn new(backend: Arc<LocalBackend>) -> Self {
        Self { backend }
    }

    /// Builds a cache key for a specific transform.
    ///
    /// Cache key format: `_transforms/{key}/{width}x{height}_{format}_{quality}`
    ///
    /// # Arguments
    ///
    /// * `key` - Original object key
    /// * `params` - Transform parameters
    ///
    /// # Returns
    ///
    /// A predictable cache key string.
    #[must_use]
    pub fn build_cache_key(key: &str, params: &TransformParams) -> String {
        let width = params.width.map_or_else(|| "auto".to_string(), |w| w.to_string());
        let height = params.height.map_or_else(|| "auto".to_string(), |h| h.to_string());
        let format = params
            .format
            .as_ref()
            .map_or_else(|| "original".to_string(), |f| format!("{f:?}").to_lowercase());
        let quality = params.quality.map_or_else(|| "default".to_string(), |q| q.to_string());

        format!("_transforms/{key}/{width}_{height}_{format}_{quality}")
    }

    /// Gets or transforms an image, using cache when possible.
    ///
    /// If the transformed image exists in cache, returns it immediately.
    /// Otherwise, transforms the image and stores the result in cache.
    ///
    /// Cache is invalidated if the source data changes (detected via SHA256 hash).
    ///
    /// # Arguments
    ///
    /// * `key` - Original object key
    /// * `source_data` - Original image data
    /// * `params` - Transform parameters
    ///
    /// # Errors
    ///
    /// Returns error if transformation fails or backend operation fails.
    ///
    /// # Returns
    ///
    /// `Ok(Some(output))` if cache hit or successful transform, `Ok(None)` if source doesn't exist.
    #[cfg(test)]
    pub async fn get_or_transform(
        &self,
        key: &str,
        source_data: &[u8],
        params: &TransformParams,
    ) -> Result<Option<TransformOutput>> {
        use sha2::{Digest, Sha256};

        let cache_key = Self::build_cache_key(key, params);

        // Compute source data hash for cache validation
        let mut hasher = Sha256::new();
        hasher.update(source_data);
        let source_hash = format!("{:x}", hasher.finalize());

        // Try to get from cache
        if let Ok(cached_data) = self.backend.download(&cache_key).await {
            // Check if we stored metadata with the cache
            let metadata_key = format!("{}_meta", cache_key);
            if let Ok(metadata) = self.backend.download(&metadata_key).await {
                if let Ok(metadata_str) = String::from_utf8(metadata) {
                    // If source hash matches, use cached result
                    if metadata_str == source_hash {
                        if let Ok(cached) = serde_json::from_slice::<TransformOutput>(&cached_data)
                        {
                            return Ok(Some(cached));
                        }
                    }
                }
            }
        }

        // Cache miss or invalidated - transform and store
        let output = ImageTransformer::transform(source_data, params)?;

        // Store in cache with metadata
        let serialized = serde_json::to_vec(&output)?;
        self.backend.upload(&cache_key, &serialized, "application/octet-stream").await?;

        // Store metadata (source hash)
        let metadata_key = format!("{}_meta", cache_key);
        self.backend.upload(&metadata_key, source_hash.as_bytes(), "text/plain").await?;

        Ok(Some(output))
    }

    /// Fetches the source image from the backend.
    ///
    /// # Arguments
    ///
    /// * `key` - Original object key
    ///
    /// # Errors
    ///
    /// Returns error if the object doesn't exist or backend operation fails.
    #[cfg(test)]
    pub async fn get_source(&self, key: &str) -> Result<Vec<u8>> {
        self.backend.download(key).await
    }

    /// Invalidates all cached transforms for a given source key.
    ///
    /// Called when the source object is re-uploaded to prevent stale cached results.
    ///
    /// # Arguments
    ///
    /// * `key` - Original object key
    ///
    /// # Errors
    ///
    /// Returns error if backend operation fails.
    pub async fn invalidate(&self, key: &str) -> Result<()> {
        // Delete all cache entries that match this key
        // Since we can't list with a pattern, we'll use a marker to track invalidation
        // In production, this would iterate through all cache entries with this key prefix

        // For now, mark cache as invalid by storing a timestamp
        let invalidation_key = format!("_transforms/{}/_invalidated", key);
        let timestamp = chrono::Utc::now().to_rfc3339();
        self.backend
            .upload(&invalidation_key, timestamp.as_bytes(), "text/plain")
            .await?;

        Ok(())
    }
}