Skip to main content

eredu_runtime/cache/
persistence.rs

1//! Backend-neutral prompt-cache catalog validation and durable publication.
2
3use eredu_core::cache::{
4    CacheBlockId, CacheRepresentation, PromptCacheBlock, PromptCacheError, PromptCacheManifest,
5    PromptCacheStateTensor, PROMPT_CACHE_SCHEMA_VERSION,
6};
7use sha2::{Digest, Sha256};
8use std::{
9    fs::{self, File},
10    io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write},
11    path::{Component, Path, PathBuf},
12    sync::{
13        atomic::{AtomicU64, Ordering},
14        OnceLock,
15    },
16    time::{SystemTime, UNIX_EPOCH},
17};
18
19static NEXT_LIVE_CACHE_PUBLICATION_ID: AtomicU64 = AtomicU64::new(1);
20static LIVE_CACHE_PROCESS_NAMESPACE: OnceLock<String> = OnceLock::new();
21
22/// Maximum accepted safetensors metadata header size for a prompt-cache shard.
23pub const MAX_PROMPT_CACHE_SHARD_HEADER_BYTES: u64 = 1024 * 1024;
24/// Directory containing immutable replacement generations.
25pub const PROMPT_CACHE_GENERATIONS_DIRECTORY: &str = ".generations";
26/// Atomic pointer to the active immutable generation.
27pub const PROMPT_CACHE_CURRENT_FILE: &str = "CURRENT";
28
29/// Filesystem, catalog, and publication failures for a reusable prompt cache.
30#[derive(Debug, thiserror::Error)]
31pub enum PromptCachePersistenceError {
32    /// Backend-neutral manifest geometry or identity is invalid.
33    #[error(transparent)]
34    PromptCache(#[from] PromptCacheError),
35    /// A filesystem operation failed.
36    #[error("failed to {action} at {path}: {source}")]
37    Io {
38        /// Filesystem action that failed.
39        action: &'static str,
40        /// Path involved in the failed action.
41        path: PathBuf,
42        /// Underlying filesystem failure.
43        #[source]
44        source: std::io::Error,
45    },
46    /// A manifest could not be encoded or decoded.
47    #[error("invalid prompt cache manifest JSON: {0}")]
48    ManifestJson(#[source] serde_json::Error),
49    /// Filesystem publication metadata is malformed.
50    #[error("malformed prompt cache storage: {0}")]
51    MalformedStorage(String),
52    /// A shard path could escape the prompt-cache directory.
53    #[error("unsafe prompt cache shard path {0:?}")]
54    UnsafeShardPath(String),
55    /// A manifest referenced a missing shard.
56    #[error("missing prompt cache shard {0}")]
57    MissingShard(PathBuf),
58    /// A safetensors shard had missing, extra, or corrupt arrays.
59    #[error("malformed prompt cache shard {path}: {reason}")]
60    MalformedShard {
61        /// Invalid shard path.
62        path: PathBuf,
63        /// Structural or data validation failure.
64        reason: String,
65    },
66    /// The target path cannot be published atomically.
67    #[error("invalid prompt cache path {0}")]
68    InvalidPromptCachePath(PathBuf),
69    /// The destination exists and explicit replacement was not requested.
70    #[error("prompt cache destination already exists: {0}")]
71    PromptCacheExists(PathBuf),
72}
73
74/// Filesystem failure while publishing one ephemeral live-cache block.
75#[derive(Debug, thiserror::Error)]
76pub enum LiveCachePublicationError {
77    /// A filesystem operation failed.
78    #[error("failed to {action} at {path}: {source}")]
79    Io {
80        /// Filesystem action that failed.
81        action: &'static str,
82        /// Path involved in the failed action.
83        path: PathBuf,
84        /// Underlying filesystem failure.
85        #[source]
86        source: std::io::Error,
87    },
88}
89
90/// Runtime-owned unique staging and atomic publication for one live-cache block.
91#[derive(Debug)]
92pub struct LiveCacheBlockPublication {
93    destination: PathBuf,
94    staging: PathBuf,
95    committed: bool,
96}
97
98impl LiveCacheBlockPublication {
99    /// Reserves unique paths derived from the complete block and rank identity.
100    pub fn begin(directory: &Path, id: &CacheBlockId) -> Self {
101        let process_namespace = LIVE_CACHE_PROCESS_NAMESPACE.get_or_init(|| {
102            let started = SystemTime::now()
103                .duration_since(UNIX_EPOCH)
104                .unwrap_or_default()
105                .as_nanos();
106            format!("p{:08x}-t{started:032x}", std::process::id())
107        });
108        let publication_id = NEXT_LIVE_CACHE_PUBLICATION_ID.fetch_add(1, Ordering::Relaxed);
109        let representation = match id.representation {
110            CacheRepresentation::KeyValue => "kv",
111            CacheRepresentation::CompressedLatentRotary => "mla",
112        };
113        let rank_component =
114            |rank: Option<usize>| rank.map_or_else(|| "x".to_string(), |rank| rank.to_string());
115        let rank = id.rank.map_or_else(
116            || "rank-px-tx-ex".to_string(),
117            |rank| {
118                format!(
119                    "rank-p{}-t{}-e{}",
120                    rank_component(rank.stage_rank()),
121                    rank_component(rank.shard_rank()),
122                    rank_component(rank.addressable_rank())
123                )
124            },
125        );
126        let base = format!(
127            "live-{process_namespace}-w{publication_id:016x}-s{:016x}-layer-{:05}-{representation}-{rank}-{}-{}",
128            id.session_id, id.global_layer, id.start, id.end
129        );
130        Self {
131            destination: directory.join(format!("{base}.safetensors")),
132            staging: directory.join(format!(".{base}.tmp.safetensors")),
133            committed: false,
134        }
135    }
136
137    /// Unique temporary path into which the backend serializes native storage.
138    pub fn staging_path(&self) -> &Path {
139        &self.staging
140    }
141
142    /// Final unique path used by the live-cache catalog.
143    pub fn destination_path(&self) -> &Path {
144        &self.destination
145    }
146
147    /// Atomically publishes the staged file without replacing an existing path.
148    pub fn commit(mut self) -> Result<PathBuf, LiveCachePublicationError> {
149        fs::hard_link(&self.staging, &self.destination).map_err(|source| {
150            LiveCachePublicationError::Io {
151                action: "publish uniquely named live cache block",
152                path: self.destination.clone(),
153                source,
154            }
155        })?;
156        if let Err(source) = fs::remove_file(&self.staging) {
157            let _ = fs::remove_file(&self.destination);
158            return Err(LiveCachePublicationError::Io {
159                action: "remove published live cache temporary file",
160                path: self.staging.clone(),
161                source,
162            });
163        }
164        self.committed = true;
165        Ok(self.destination.clone())
166    }
167}
168
169impl Drop for LiveCacheBlockPublication {
170    fn drop(&mut self) {
171        if !self.committed {
172            let _ = fs::remove_file(&self.staging);
173        }
174    }
175}
176
177/// A runtime-owned staging directory that publishes one immutable cache atomically.
178#[derive(Debug)]
179pub struct PromptCachePublication {
180    destination: PathBuf,
181    parent: PathBuf,
182    generations: PathBuf,
183    generation_name: String,
184    publication_root: Option<PathBuf>,
185    staging: PathBuf,
186    replacing: bool,
187    nonce: u128,
188    committed: bool,
189}
190
191impl PromptCachePublication {
192    /// Creates an isolated staging directory for a new cache or replacement generation.
193    pub fn begin(
194        destination: impl AsRef<Path>,
195        replace_existing: bool,
196    ) -> Result<Self, PromptCachePersistenceError> {
197        let destination = destination.as_ref().to_path_buf();
198        let parent = destination
199            .parent()
200            .ok_or_else(|| {
201                PromptCachePersistenceError::InvalidPromptCachePath(destination.clone())
202            })?
203            .to_path_buf();
204        fs::create_dir_all(&parent).map_err(|source| PromptCachePersistenceError::Io {
205            action: "create prompt cache parent",
206            path: parent.clone(),
207            source,
208        })?;
209        let replacing = destination.exists();
210        if replacing && !replace_existing {
211            return Err(PromptCachePersistenceError::PromptCacheExists(destination));
212        }
213        if replacing && !destination.is_dir() {
214            return Err(PromptCachePersistenceError::InvalidPromptCachePath(
215                destination,
216            ));
217        }
218        let file_name = destination
219            .file_name()
220            .and_then(|name| name.to_str())
221            .ok_or_else(|| {
222                PromptCachePersistenceError::InvalidPromptCachePath(destination.clone())
223            })?;
224        let nonce = SystemTime::now()
225            .duration_since(UNIX_EPOCH)
226            .unwrap_or_default()
227            .as_nanos();
228        let generation_name = format!("generation-{nonce}");
229        let (generations, staging, publication_root) = if replacing {
230            let generations = destination.join(PROMPT_CACHE_GENERATIONS_DIRECTORY);
231            fs::create_dir_all(&generations).map_err(|source| PromptCachePersistenceError::Io {
232                action: "create prompt cache generation directory",
233                path: generations.clone(),
234                source,
235            })?;
236            let staging = generations.join(format!(".tmp-{nonce}"));
237            fs::create_dir(&staging).map_err(|source| PromptCachePersistenceError::Io {
238                action: "create temporary prompt cache",
239                path: staging.clone(),
240                source,
241            })?;
242            (generations, staging, None)
243        } else {
244            let publication_root = parent.join(format!(".{file_name}.tmp-{nonce}"));
245            fs::create_dir(&publication_root).map_err(|source| {
246                PromptCachePersistenceError::Io {
247                    action: "create temporary prompt cache root",
248                    path: publication_root.clone(),
249                    source,
250                }
251            })?;
252            let generations = publication_root.join(PROMPT_CACHE_GENERATIONS_DIRECTORY);
253            if let Err(source) = fs::create_dir(&generations) {
254                let _ = fs::remove_dir_all(&publication_root);
255                return Err(PromptCachePersistenceError::Io {
256                    action: "create prompt cache generation directory",
257                    path: generations,
258                    source,
259                });
260            }
261            let staging = generations.join(&generation_name);
262            if let Err(source) = fs::create_dir(&staging) {
263                let _ = fs::remove_dir_all(&publication_root);
264                return Err(PromptCachePersistenceError::Io {
265                    action: "create temporary prompt cache",
266                    path: staging,
267                    source,
268                });
269            }
270            (generations, staging, Some(publication_root))
271        };
272        Ok(Self {
273            destination,
274            parent,
275            generations,
276            generation_name,
277            publication_root,
278            staging,
279            replacing,
280            nonce,
281            committed: false,
282        })
283    }
284
285    /// Directory into which the backend writes its native tensor shards.
286    pub fn staging_directory(&self) -> &Path {
287        &self.staging
288    }
289
290    /// Writes the manifest, validates every shard, and atomically publishes the cache.
291    pub fn commit(
292        mut self,
293        manifest: &PromptCacheManifest,
294    ) -> Result<(), PromptCachePersistenceError> {
295        let manifest_path = self.staging.join("manifest.json");
296        let file =
297            File::create(&manifest_path).map_err(|source| PromptCachePersistenceError::Io {
298                action: "create prompt cache manifest",
299                path: manifest_path.clone(),
300                source,
301            })?;
302        let mut writer = BufWriter::new(file);
303        serde_json::to_writer_pretty(&mut writer, manifest)
304            .map_err(PromptCachePersistenceError::ManifestJson)?;
305        writer
306            .write_all(b"\n")
307            .map_err(|source| PromptCachePersistenceError::Io {
308                action: "write prompt cache manifest",
309                path: manifest_path.clone(),
310                source,
311            })?;
312        writer
313            .flush()
314            .map_err(|source| PromptCachePersistenceError::Io {
315                action: "flush prompt cache manifest",
316                path: manifest_path.clone(),
317                source,
318            })?;
319        sync_file(&manifest_path)?;
320        validate_prompt_cache_manifest(&self.staging, manifest)?;
321        sync_directory(&self.staging)?;
322
323        if self.replacing {
324            let generation = self.generations.join(&self.generation_name);
325            durable_rename(&self.staging, &generation, false).map_err(|source| {
326                PromptCachePersistenceError::Io {
327                    action: "publish prompt cache generation",
328                    path: generation,
329                    source,
330                }
331            })?;
332            sync_directory(&self.generations)?;
333            publish_generation_pointer(&self.destination, &self.generation_name, self.nonce)?;
334        } else {
335            sync_directory(&self.generations)?;
336            let publication_root = self
337                .publication_root
338                .as_ref()
339                .expect("new prompt-cache publication owns a staging root");
340            publish_generation_pointer(publication_root, &self.generation_name, self.nonce)?;
341            durable_rename(publication_root, &self.destination, false).map_err(|source| {
342                PromptCachePersistenceError::Io {
343                    action: "publish prompt cache",
344                    path: self.destination.clone(),
345                    source,
346                }
347            })?;
348        }
349        sync_directory(&self.parent)?;
350        self.committed = true;
351        Ok(())
352    }
353}
354
355impl Drop for PromptCachePublication {
356    fn drop(&mut self) {
357        if !self.committed {
358            let staging = self.publication_root.as_ref().unwrap_or(&self.staging);
359            if staging.exists() {
360                let _ = fs::remove_dir_all(staging);
361            }
362        }
363    }
364}
365
366/// Reads and validates a prompt-cache manifest without loading tensor arrays.
367pub fn inspect_prompt_cache(
368    directory: impl AsRef<Path>,
369) -> Result<PromptCacheManifest, PromptCachePersistenceError> {
370    let directory = resolve_prompt_cache_root(directory.as_ref())?;
371    let manifest_path = directory.join("manifest.json");
372    let reader = BufReader::new(File::open(&manifest_path).map_err(|source| {
373        PromptCachePersistenceError::Io {
374            action: "open prompt cache manifest",
375            path: manifest_path.clone(),
376            source,
377        }
378    })?);
379    let value: serde_json::Value =
380        serde_json::from_reader(reader).map_err(PromptCachePersistenceError::ManifestJson)?;
381    let schema_version = value
382        .get("schema_version")
383        .and_then(serde_json::Value::as_u64)
384        .and_then(|version| u32::try_from(version).ok())
385        .ok_or_else(|| {
386            PromptCachePersistenceError::PromptCache(PromptCacheError::Malformed(
387                "prompt-cache schema_version is missing or is not a u32".into(),
388            ))
389        })?;
390    if schema_version != PROMPT_CACHE_SCHEMA_VERSION {
391        return Err(PromptCacheError::UnsupportedSchema(schema_version).into());
392    }
393    let manifest =
394        serde_json::from_value(value).map_err(PromptCachePersistenceError::ManifestJson)?;
395    validate_prompt_cache_manifest(&directory, &manifest)?;
396    Ok(manifest)
397}
398
399/// Resolves the active immutable generation selected by the durable pointer.
400pub fn resolve_prompt_cache_root(directory: &Path) -> Result<PathBuf, PromptCachePersistenceError> {
401    let current_path = directory.join(PROMPT_CACHE_CURRENT_FILE);
402    let metadata = current_path.metadata().map_err(|source| {
403        if source.kind() == std::io::ErrorKind::NotFound {
404            PromptCachePersistenceError::MalformedStorage(
405                "prompt-cache generation pointer CURRENT is missing".into(),
406            )
407        } else {
408            PromptCachePersistenceError::Io {
409                action: "stat prompt cache generation pointer",
410                path: current_path.clone(),
411                source,
412            }
413        }
414    })?;
415    let length = metadata.len();
416    if length == 0 || length > 256 {
417        return Err(PromptCachePersistenceError::MalformedStorage(
418            "prompt-cache generation pointer has an invalid length".into(),
419        ));
420    }
421    let generation =
422        fs::read_to_string(&current_path).map_err(|source| PromptCachePersistenceError::Io {
423            action: "read prompt cache generation pointer",
424            path: current_path.clone(),
425            source,
426        })?;
427    let generation = generation.trim();
428    let generation_path = Path::new(generation);
429    if generation.is_empty()
430        || generation_path
431            .components()
432            .any(|component| !matches!(component, Component::Normal(_)))
433        || generation_path.components().count() != 1
434    {
435        return Err(PromptCachePersistenceError::MalformedStorage(
436            "prompt-cache generation pointer is unsafe".into(),
437        ));
438    }
439    let root = directory
440        .join(PROMPT_CACHE_GENERATIONS_DIRECTORY)
441        .join(generation_path);
442    if !root.is_dir() {
443        return Err(PromptCachePersistenceError::MalformedStorage(format!(
444            "prompt-cache generation {generation:?} is missing"
445        )));
446    }
447    Ok(root)
448}
449
450/// Validates manifest structure and the bounded metadata of every referenced shard.
451pub fn validate_prompt_cache_manifest(
452    directory: &Path,
453    manifest: &PromptCacheManifest,
454) -> Result<(), PromptCachePersistenceError> {
455    manifest.validate()?;
456    for block in &manifest.blocks {
457        let shard = safe_prompt_cache_shard_path(directory, &block.shard)?;
458        if !shard.is_file() {
459            return Err(PromptCachePersistenceError::MissingShard(shard));
460        }
461        validate_block_shard(&shard, block)?;
462    }
463    for state in &manifest.state_tensors {
464        let shard = safe_prompt_cache_shard_path(directory, &state.shard)?;
465        if !shard.is_file() {
466            return Err(PromptCachePersistenceError::MissingShard(shard));
467        }
468        validate_state_shard(&shard, state)?;
469    }
470    Ok(())
471}
472
473/// Resolves a manifest shard path while rejecting traversal and symlink escapes.
474pub fn safe_prompt_cache_shard_path(
475    directory: &Path,
476    relative: &str,
477) -> Result<PathBuf, PromptCachePersistenceError> {
478    let path = Path::new(relative);
479    if path.is_absolute()
480        || path
481            .components()
482            .any(|component| !matches!(component, Component::Normal(_)))
483    {
484        return Err(PromptCachePersistenceError::UnsafeShardPath(
485            relative.into(),
486        ));
487    }
488    let joined = directory.join(path);
489    if joined.exists() {
490        let root =
491            fs::canonicalize(directory).map_err(|source| PromptCachePersistenceError::Io {
492                action: "canonicalize prompt cache directory",
493                path: directory.to_path_buf(),
494                source,
495            })?;
496        let canonical =
497            fs::canonicalize(&joined).map_err(|source| PromptCachePersistenceError::Io {
498                action: "canonicalize prompt cache shard",
499                path: joined.clone(),
500                source,
501            })?;
502        if !canonical.starts_with(&root) {
503            return Err(PromptCachePersistenceError::UnsafeShardPath(
504                relative.into(),
505            ));
506        }
507    }
508    Ok(joined)
509}
510
511/// Synchronizes a newly written shard and returns its exact payload SHA-256.
512pub fn finalize_prompt_cache_shard(path: &Path) -> Result<String, PromptCachePersistenceError> {
513    sync_file(path)?;
514    hash_prompt_cache_shard_payload(path)
515}
516
517/// Hashes the safetensors payload bytes, excluding its bounded metadata header.
518pub fn hash_prompt_cache_shard_payload(path: &Path) -> Result<String, PromptCachePersistenceError> {
519    let (_, _, data_start) = read_shard_metadata(path)?;
520    let mut file = File::open(path).map_err(|source| PromptCachePersistenceError::Io {
521        action: "open prompt cache shard payload",
522        path: path.to_path_buf(),
523        source,
524    })?;
525    file.seek(SeekFrom::Start(data_start))
526        .map_err(|source| PromptCachePersistenceError::Io {
527            action: "seek prompt cache shard payload",
528            path: path.to_path_buf(),
529            source,
530        })?;
531    let mut hasher = Sha256::new();
532    let mut buffer = [0u8; 64 * 1024];
533    loop {
534        let read = file
535            .read(&mut buffer)
536            .map_err(|source| PromptCachePersistenceError::Io {
537                action: "hash prompt cache shard payload",
538                path: path.to_path_buf(),
539                source,
540            })?;
541        if read == 0 {
542            break;
543        }
544        hasher.update(&buffer[..read]);
545    }
546    Ok(hex(hasher.finalize()))
547}
548
549fn validate_block_shard(
550    path: &Path,
551    block: &PromptCacheBlock,
552) -> Result<(), PromptCachePersistenceError> {
553    let (metadata, file_len, data_start) = read_shard_metadata(path)?;
554    let entries = metadata.tensors();
555    if entries.len() != 2 {
556        return Err(malformed(
557            path,
558            format!("expected two arrays, found {}", entries.len()),
559        ));
560    }
561    let mut logical_bytes = 0u64;
562    for (name, expected_shape, expected_dtype) in [
563        (&block.first_array, &block.first_shape, &block.first_dtype),
564        (
565            &block.second_array,
566            &block.second_shape,
567            &block.second_dtype,
568        ),
569    ] {
570        let tensor = metadata
571            .info(name)
572            .ok_or_else(|| malformed(path, format!("missing array {name}")))?;
573        let shape = tensor
574            .shape
575            .iter()
576            .map(|dimension| i32::try_from(*dimension))
577            .collect::<Result<Vec<_>, _>>()
578            .map_err(|_| malformed(path, "array dimension exceeds runtime range"))?;
579        if &shape != expected_shape || stored_dtype_name(tensor.dtype) != *expected_dtype {
580            return Err(malformed(
581                path,
582                format!("array {name} shape or dtype does not match the manifest"),
583            ));
584        }
585        logical_bytes = logical_bytes.saturating_add(
586            u64::try_from(tensor.data_offsets.1.saturating_sub(tensor.data_offsets.0))
587                .unwrap_or(u64::MAX),
588        );
589    }
590    if logical_bytes != block.logical_bytes {
591        return Err(malformed(
592            path,
593            format!(
594                "logical byte count {logical_bytes} does not match manifest value {}",
595                block.logical_bytes
596            ),
597        ));
598    }
599    validate_file_boundary(path, &metadata, file_len, data_start)
600}
601
602fn validate_state_shard(
603    path: &Path,
604    state: &PromptCacheStateTensor,
605) -> Result<(), PromptCachePersistenceError> {
606    let (metadata, file_len, data_start) = read_shard_metadata(path)?;
607    let entries = metadata.tensors();
608    if entries.len() != 1 {
609        return Err(malformed(
610            path,
611            format!("expected one state array, found {}", entries.len()),
612        ));
613    }
614    let tensor = metadata
615        .info(&state.array)
616        .ok_or_else(|| malformed(path, format!("missing state array {}", state.array)))?;
617    let shape = tensor
618        .shape
619        .iter()
620        .map(|dimension| i32::try_from(*dimension))
621        .collect::<Result<Vec<_>, _>>()
622        .map_err(|_| malformed(path, "state array dimension exceeds runtime range"))?;
623    let logical_bytes = u64::try_from(tensor.data_offsets.1.saturating_sub(tensor.data_offsets.0))
624        .unwrap_or(u64::MAX);
625    if shape != state.shape
626        || stored_dtype_name(tensor.dtype) != state.dtype
627        || logical_bytes != state.logical_bytes
628    {
629        return Err(malformed(
630            path,
631            "state array shape, dtype, or byte count does not match the manifest",
632        ));
633    }
634    validate_file_boundary(path, &metadata, file_len, data_start)
635}
636
637fn validate_file_boundary(
638    path: &Path,
639    metadata: &safetensors::tensor::Metadata,
640    file_len: u64,
641    data_start: u64,
642) -> Result<(), PromptCachePersistenceError> {
643    let expected_file_len = data_start
644        .checked_add(metadata.data_len() as u64)
645        .ok_or_else(|| malformed(path, "safetensors file length overflow"))?;
646    if expected_file_len != file_len {
647        return Err(malformed(
648            path,
649            format!(
650                "safetensors payload boundary {expected_file_len} does not match file length {file_len}"
651            ),
652        ));
653    }
654    Ok(())
655}
656
657fn read_shard_metadata(
658    path: &Path,
659) -> Result<(safetensors::tensor::Metadata, u64, u64), PromptCachePersistenceError> {
660    let mut file = File::open(path).map_err(|source| PromptCachePersistenceError::Io {
661        action: "open prompt cache shard metadata",
662        path: path.to_path_buf(),
663        source,
664    })?;
665    let file_len = file
666        .metadata()
667        .map_err(|source| PromptCachePersistenceError::Io {
668            action: "stat prompt cache shard",
669            path: path.to_path_buf(),
670            source,
671        })?
672        .len();
673    let mut length_bytes = [0u8; 8];
674    file.read_exact(&mut length_bytes)
675        .map_err(|source| PromptCachePersistenceError::Io {
676            action: "read prompt cache shard header length",
677            path: path.to_path_buf(),
678            source,
679        })?;
680    let header_len = u64::from_le_bytes(length_bytes);
681    if header_len == 0 || header_len > MAX_PROMPT_CACHE_SHARD_HEADER_BYTES {
682        return Err(malformed(
683            path,
684            format!("safetensors header length {header_len} exceeds the prompt-cache bound"),
685        ));
686    }
687    let data_start = 8u64
688        .checked_add(header_len)
689        .ok_or_else(|| malformed(path, "safetensors header length overflow"))?;
690    if data_start > file_len {
691        return Err(malformed(
692            path,
693            "safetensors header extends beyond the file",
694        ));
695    }
696    let mut header = vec![0u8; header_len as usize];
697    file.read_exact(&mut header)
698        .map_err(|source| PromptCachePersistenceError::Io {
699            action: "read prompt cache shard header",
700            path: path.to_path_buf(),
701            source,
702        })?;
703    let metadata =
704        serde_json::from_slice(&header).map_err(|error| malformed(path, error.to_string()))?;
705    Ok((metadata, file_len, data_start))
706}
707
708fn malformed(path: &Path, reason: impl Into<String>) -> PromptCachePersistenceError {
709    PromptCachePersistenceError::MalformedShard {
710        path: path.to_path_buf(),
711        reason: reason.into(),
712    }
713}
714
715fn stored_dtype_name(dtype: safetensors::Dtype) -> String {
716    use safetensors::Dtype as Stored;
717    match dtype {
718        Stored::BOOL => "Bool",
719        Stored::U8 => "Uint8",
720        Stored::U16 => "Uint16",
721        Stored::U32 => "Uint32",
722        Stored::U64 => "Uint64",
723        Stored::I8 => "Int8",
724        Stored::I16 => "Int16",
725        Stored::I32 => "Int32",
726        Stored::I64 => "Int64",
727        Stored::F16 => "Float16",
728        Stored::BF16 => "Bfloat16",
729        Stored::F32 => "Float32",
730        Stored::F64 => "Float64",
731        dtype => return format!("{dtype:?}"),
732    }
733    .into()
734}
735
736fn publish_generation_pointer(
737    destination: &Path,
738    generation_name: &str,
739    nonce: u128,
740) -> Result<(), PromptCachePersistenceError> {
741    let temporary = destination.join(format!(".{PROMPT_CACHE_CURRENT_FILE}.tmp-{nonce}"));
742    let current = destination.join(PROMPT_CACHE_CURRENT_FILE);
743    let mut file = File::create(&temporary).map_err(|source| PromptCachePersistenceError::Io {
744        action: "create prompt cache generation pointer",
745        path: temporary.clone(),
746        source,
747    })?;
748    writeln!(file, "{generation_name}").map_err(|source| PromptCachePersistenceError::Io {
749        action: "write prompt cache generation pointer",
750        path: temporary.clone(),
751        source,
752    })?;
753    file.sync_all()
754        .map_err(|source| PromptCachePersistenceError::Io {
755            action: "sync prompt cache generation pointer",
756            path: temporary.clone(),
757            source,
758        })?;
759    durable_rename(&temporary, &current, true).map_err(|source| {
760        PromptCachePersistenceError::Io {
761            action: "switch prompt cache generation",
762            path: current,
763            source,
764        }
765    })?;
766    sync_directory(destination)
767}
768
769fn sync_file(path: &Path) -> Result<(), PromptCachePersistenceError> {
770    File::open(path)
771        .and_then(|file| file.sync_all())
772        .map_err(|source| PromptCachePersistenceError::Io {
773            action: "synchronize cache file",
774            path: path.to_path_buf(),
775            source,
776        })
777}
778
779#[cfg(unix)]
780fn sync_directory(path: &Path) -> Result<(), PromptCachePersistenceError> {
781    File::open(path)
782        .and_then(|file| file.sync_all())
783        .map_err(|source| PromptCachePersistenceError::Io {
784            action: "synchronize cache directory",
785            path: path.to_path_buf(),
786            source,
787        })
788}
789
790#[cfg(windows)]
791fn sync_directory(path: &Path) -> Result<(), PromptCachePersistenceError> {
792    if path.is_dir() {
793        Ok(())
794    } else {
795        Err(PromptCachePersistenceError::Io {
796            action: "validate cache directory before durable publication",
797            path: path.to_path_buf(),
798            source: std::io::Error::new(
799                std::io::ErrorKind::NotADirectory,
800                "cache publication path is not a directory",
801            ),
802        })
803    }
804}
805
806#[cfg(not(any(unix, windows)))]
807fn sync_directory(path: &Path) -> Result<(), PromptCachePersistenceError> {
808    if path.is_dir() {
809        Ok(())
810    } else {
811        Err(PromptCachePersistenceError::Io {
812            action: "validate cache directory before publication",
813            path: path.to_path_buf(),
814            source: std::io::Error::new(
815                std::io::ErrorKind::NotADirectory,
816                "cache publication path is not a directory",
817            ),
818        })
819    }
820}
821
822#[cfg(not(windows))]
823fn durable_rename(source: &Path, destination: &Path, _replace: bool) -> std::io::Result<()> {
824    fs::rename(source, destination)
825}
826
827#[cfg(windows)]
828fn durable_rename(source: &Path, destination: &Path, _replace: bool) -> std::io::Result<()> {
829    fs::rename(source, destination)
830}
831
832fn hex(digest: impl AsRef<[u8]>) -> String {
833    const HEX: &[u8; 16] = b"0123456789abcdef";
834    let digest = digest.as_ref();
835    let mut encoded = String::with_capacity(digest.len() * 2);
836    for &byte in digest {
837        encoded.push(HEX[usize::from(byte >> 4)] as char);
838        encoded.push(HEX[usize::from(byte & 0x0f)] as char);
839    }
840    encoded
841}
842
843#[cfg(test)]
844mod tests {
845    use super::*;
846    use eredu_core::cache::{
847        CacheRepresentation, LayerCachePolicy, PromptCacheDescriptor, PromptCacheStateSegment,
848        PromptCacheTopology,
849    };
850    use eredu_core::{AttentionPolicy, LayerSchedule};
851    use safetensors::tensor::{serialize_to_file, Dtype, TensorView};
852    use std::collections::HashMap;
853
854    fn manifest(shard: &Path) -> PromptCacheManifest {
855        let bytes = [0u8; 16];
856        let tensor = TensorView::new(Dtype::F32, vec![1, 1, 2, 2], &bytes).unwrap();
857        serialize_to_file(
858            HashMap::from([("keys", tensor.clone()), ("values", tensor)]),
859            None,
860            shard,
861        )
862        .unwrap();
863        let hash = finalize_prompt_cache_shard(shard).unwrap();
864        let descriptor = PromptCacheDescriptor::new(
865            "test",
866            "test",
867            "checkpoint",
868            "content",
869            "architecture",
870            1,
871            0,
872            1,
873            1,
874            LayerSchedule::new(
875                1,
876                vec![LayerCachePolicy::key_value(AttentionPolicy::Full, 1, 2).unwrap()],
877            )
878            .unwrap(),
879            vec![0],
880            vec![PromptCacheStateSegment::new("state", 0..1).unwrap()],
881            0,
882            PromptCacheTopology::default(),
883        )
884        .unwrap();
885        PromptCacheManifest {
886            schema_version: PROMPT_CACHE_SCHEMA_VERSION,
887            model_family: descriptor.model_family().into(),
888            effective_model_type: descriptor.effective_model_type().into(),
889            checkpoint_fingerprint: descriptor.checkpoint_fingerprint().into(),
890            prefix_content_fingerprint: descriptor.prefix_content_fingerprint().into(),
891            architecture_fingerprint: descriptor.architecture_fingerprint().into(),
892            layer_count: 1,
893            global_layer_start: 0,
894            global_layer_end: 1,
895            block_size_tokens: 2,
896            batch_size: 1,
897            total_prefix_tokens: 2,
898            prefix_sha256: "00".repeat(32),
899            layer_layout: descriptor.layer_layout().clone(),
900            layer_prefix_offsets: vec![0],
901            state_segments: descriptor.state_segments().to_vec(),
902            sink_tokens: 0,
903            topology: descriptor.topology().clone(),
904            application_namespace: None,
905            blocks: vec![PromptCacheBlock {
906                global_layer: 0,
907                representation: CacheRepresentation::KeyValue,
908                start: 0,
909                end: 2,
910                rank: None,
911                shard: shard.file_name().unwrap().to_str().unwrap().into(),
912                first_array: "keys".into(),
913                second_array: "values".into(),
914                first_shape: vec![1, 1, 2, 2],
915                second_shape: vec![1, 1, 2, 2],
916                first_dtype: "Float32".into(),
917                second_dtype: "Float32".into(),
918                logical_bytes: 32,
919                payload_sha256: hash,
920            }],
921            state_tensors: vec![],
922        }
923    }
924
925    #[test]
926    fn publication_validates_and_atomically_replaces_generations() {
927        let root = tempfile::tempdir().unwrap();
928        let destination = root.path().join("cache");
929        let publication = PromptCachePublication::begin(&destination, false).unwrap();
930        let first = manifest(&publication.staging_directory().join("block.safetensors"));
931        publication.commit(&first).unwrap();
932        assert_eq!(inspect_prompt_cache(&destination).unwrap(), first);
933        assert!(destination.join(PROMPT_CACHE_CURRENT_FILE).is_file());
934        let first_root = resolve_prompt_cache_root(&destination).unwrap();
935        assert_eq!(
936            first_root.parent().unwrap(),
937            destination.join(PROMPT_CACHE_GENERATIONS_DIRECTORY)
938        );
939        assert!(first_root.join("manifest.json").is_file());
940
941        let publication = PromptCachePublication::begin(&destination, true).unwrap();
942        let second = manifest(&publication.staging_directory().join("block.safetensors"));
943        publication.commit(&second).unwrap();
944        assert_eq!(inspect_prompt_cache(&destination).unwrap(), second);
945        assert!(destination.join(PROMPT_CACHE_CURRENT_FILE).is_file());
946    }
947
948    #[test]
949    fn pointerless_legacy_prompt_cache_layout_is_rejected() {
950        let root = tempfile::tempdir().unwrap();
951        let destination = root.path().join("cache");
952        fs::create_dir(&destination).unwrap();
953        let legacy = manifest(&destination.join("block.safetensors"));
954        serde_json::to_writer(
955            File::create(destination.join("manifest.json")).unwrap(),
956            &legacy,
957        )
958        .unwrap();
959
960        for result in [
961            resolve_prompt_cache_root(&destination).map(|_| ()),
962            inspect_prompt_cache(&destination).map(|_| ()),
963        ] {
964            assert!(matches!(
965                result,
966                Err(PromptCachePersistenceError::MalformedStorage(reason))
967                    if reason == "prompt-cache generation pointer CURRENT is missing"
968            ));
969        }
970    }
971
972    #[test]
973    fn failed_publication_removes_staging_directory() {
974        let root = tempfile::tempdir().unwrap();
975        let destination = root.path().join("cache");
976        let staging = {
977            let publication = PromptCachePublication::begin(&destination, false).unwrap();
978            publication.staging_directory().to_path_buf()
979        };
980        assert!(!staging.exists());
981    }
982
983    #[test]
984    fn shard_paths_reject_traversal() {
985        let root = Path::new("/tmp/cache");
986        assert_eq!(
987            safe_prompt_cache_shard_path(root, "block.safetensors").unwrap(),
988            root.join("block.safetensors")
989        );
990        assert!(matches!(
991            safe_prompt_cache_shard_path(root, "../outside.safetensors"),
992            Err(PromptCachePersistenceError::UnsafeShardPath(_))
993        ));
994        assert!(safe_prompt_cache_shard_path(root, "/outside.safetensors").is_err());
995    }
996
997    #[test]
998    fn malformed_manifest_is_rejected_before_tensor_loading() {
999        let directory = tempfile::tempdir().unwrap();
1000        let generation = directory
1001            .path()
1002            .join(PROMPT_CACHE_GENERATIONS_DIRECTORY)
1003            .join("generation-test");
1004        fs::create_dir_all(&generation).unwrap();
1005        fs::write(generation.join("manifest.json"), b"{not-json").unwrap();
1006        fs::write(
1007            directory.path().join(PROMPT_CACHE_CURRENT_FILE),
1008            b"generation-test\n",
1009        )
1010        .unwrap();
1011        assert!(matches!(
1012            inspect_prompt_cache(directory.path()),
1013            Err(PromptCachePersistenceError::ManifestJson(_))
1014        ));
1015    }
1016
1017    #[test]
1018    fn shard_metadata_reads_are_bounded() {
1019        let directory = tempfile::tempdir().unwrap();
1020        let path = directory.path().join("oversized.safetensors");
1021        fs::write(
1022            &path,
1023            (MAX_PROMPT_CACHE_SHARD_HEADER_BYTES + 1).to_le_bytes(),
1024        )
1025        .unwrap();
1026        assert!(matches!(
1027            hash_prompt_cache_shard_payload(&path),
1028            Err(PromptCachePersistenceError::MalformedShard { .. })
1029        ));
1030    }
1031
1032    #[test]
1033    fn live_cache_publication_is_unique_rank_aware_and_atomic() {
1034        let directory = tempfile::tempdir().unwrap();
1035        let id = CacheBlockId {
1036            session_id: 7,
1037            global_layer: 3,
1038            representation: CacheRepresentation::KeyValue,
1039            start: 4,
1040            end: 8,
1041            rank: Some(eredu_core::cache::CacheRankIdentity::new(
1042                Some(1),
1043                Some(2),
1044                None,
1045            )),
1046        };
1047        let first = LiveCacheBlockPublication::begin(directory.path(), &id);
1048        let second = LiveCacheBlockPublication::begin(directory.path(), &id);
1049        assert_ne!(first.destination_path(), second.destination_path());
1050        assert!(first
1051            .destination_path()
1052            .to_string_lossy()
1053            .contains("layer-00003-kv-rank-p1-t2-ex-4-8"));
1054
1055        fs::write(first.staging_path(), b"block").unwrap();
1056        let destination = first.commit().unwrap();
1057        assert_eq!(fs::read(destination).unwrap(), b"block");
1058    }
1059
1060    #[test]
1061    fn live_cache_publication_cleans_staging_and_never_replaces() {
1062        let directory = tempfile::tempdir().unwrap();
1063        let id = CacheBlockId {
1064            session_id: 1,
1065            global_layer: 0,
1066            representation: CacheRepresentation::CompressedLatentRotary,
1067            start: 0,
1068            end: 1,
1069            rank: None,
1070        };
1071        let abandoned = LiveCacheBlockPublication::begin(directory.path(), &id);
1072        let abandoned_path = abandoned.staging_path().to_path_buf();
1073        fs::write(&abandoned_path, b"temporary").unwrap();
1074        drop(abandoned);
1075        assert!(!abandoned_path.exists());
1076
1077        let colliding = LiveCacheBlockPublication::begin(directory.path(), &id);
1078        fs::write(colliding.staging_path(), b"new").unwrap();
1079        fs::write(colliding.destination_path(), b"existing").unwrap();
1080        let destination = colliding.destination_path().to_path_buf();
1081        assert!(colliding.commit().is_err());
1082        assert_eq!(fs::read(destination).unwrap(), b"existing");
1083    }
1084}