zebflow 0.6.1

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
use std::collections::{HashMap, VecDeque};
use std::fmt::Write as _;
use std::io::{Read, Write};
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};

use flate2::Compression;
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;

use crate::mapserver::publish::manifest::PublishedLayerManifest;

use super::ResolveRequest;

const RESPONSE_CACHE_TTL: Duration = Duration::from_secs(10);
const RESPONSE_CACHE_MAX_ENTRIES: usize = 8;
const RESPONSE_CACHE_MAX_BODY_BYTES: usize = 768 * 1024;

#[derive(Clone)]
pub struct ResponseCacheEntry {
    pub body_gzip: Vec<u8>,
    inserted_at: Instant,
}

struct ResponseCacheState {
    entries: HashMap<String, ResponseCacheEntry>,
    order: VecDeque<String>,
}

fn response_cache() -> &'static Mutex<ResponseCacheState> {
    static CACHE: OnceLock<Mutex<ResponseCacheState>> = OnceLock::new();
    CACHE.get_or_init(|| {
        Mutex::new(ResponseCacheState {
            entries: HashMap::new(),
            order: VecDeque::new(),
        })
    })
}

pub fn cache_key(request: &ResolveRequest) -> String {
    let mut key = format!("{}:", request.layer_id);
    if let Some(bbox) = request.bbox {
        let _ = write!(
            key,
            "{:.4},{:.4},{:.4},{:.4}",
            bbox[0], bbox[1], bbox[2], bbox[3]
        );
    } else {
        key.push('*');
    }
    let _ = write!(
        key,
        ":z={}:l={}",
        request
            .zoom
            .map(|v| v.to_string())
            .unwrap_or_else(|| "-".to_string()),
        request
            .limit
            .map(|v| v.to_string())
            .unwrap_or_else(|| "-".to_string())
    );
    key
}

pub fn response_cache_key(manifest: &PublishedLayerManifest, request: &ResolveRequest) -> String {
    let mut key = cache_key(request);
    let source_version = source_version(&manifest.source_ref);
    let _ = write!(
        key,
        ":src={}:bbox={}:max={}:props={}",
        source_version,
        if manifest.bbox_required { "1" } else { "0" },
        manifest.max_features,
        manifest.allowed_properties.join(",")
    );
    key
}

pub fn get_response_bytes(key: &str) -> Option<Vec<u8>> {
    let mut cache = response_cache().lock().ok()?;
    let entry = cache.entries.get(key)?.clone();
    if entry.inserted_at.elapsed() > RESPONSE_CACHE_TTL {
        cache.entries.remove(key);
        return None;
    }
    decode_gzip(&entry.body_gzip).ok()
}

pub fn put_response_bytes(key: String, body: Vec<u8>) {
    if body.len() > RESPONSE_CACHE_MAX_BODY_BYTES {
        return;
    }
    let Ok(body_gzip) = encode_gzip(&body) else {
        return;
    };
    let Ok(mut cache) = response_cache().lock() else {
        return;
    };
    cache.entries.insert(
        key.clone(),
        ResponseCacheEntry {
            body_gzip,
            inserted_at: Instant::now(),
        },
    );
    cache.order.push_back(key);
    evict_response_cache(&mut cache);
}

fn evict_response_cache(cache: &mut ResponseCacheState) {
    while cache.entries.len() > RESPONSE_CACHE_MAX_ENTRIES {
        let Some(oldest) = cache.order.pop_front() else {
            break;
        };
        cache.entries.remove(&oldest);
    }
}

fn source_version(source_ref: &str) -> String {
    let path = Path::new(source_ref);
    let Ok(meta) = std::fs::metadata(path) else {
        return "missing".to_string();
    };
    let modified = meta
        .modified()
        .ok()
        .and_then(|ts| ts.duration_since(std::time::UNIX_EPOCH).ok())
        .map(|v| v.as_secs())
        .unwrap_or(0);
    format!("{}:{}", meta.len(), modified)
}

fn encode_gzip(input: &[u8]) -> Result<Vec<u8>, String> {
    let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
    encoder
        .write_all(input)
        .map_err(|err| format!("failed writing gzip body: {err}"))?;
    encoder
        .finish()
        .map_err(|err| format!("failed finishing gzip body: {err}"))
}

fn decode_gzip(input: &[u8]) -> Result<Vec<u8>, String> {
    let mut decoder = GzDecoder::new(input);
    let mut out = Vec::new();
    decoder
        .read_to_end(&mut out)
        .map_err(|err| format!("failed decoding gzip body: {err}"))?;
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn gzip_roundtrip_cache_body() {
        let body = vec![b'x'; 32 * 1024];
        let zipped = encode_gzip(&body).expect("encode");
        let unzipped = decode_gzip(&zipped).expect("decode");
        assert_eq!(body, unzipped);
        assert!(zipped.len() < body.len());
    }
}