1use 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
22pub const MAX_PROMPT_CACHE_SHARD_HEADER_BYTES: u64 = 1024 * 1024;
24pub const PROMPT_CACHE_GENERATIONS_DIRECTORY: &str = ".generations";
26pub const PROMPT_CACHE_CURRENT_FILE: &str = "CURRENT";
28
29#[derive(Debug, thiserror::Error)]
31pub enum PromptCachePersistenceError {
32 #[error(transparent)]
34 PromptCache(#[from] PromptCacheError),
35 #[error("failed to {action} at {path}: {source}")]
37 Io {
38 action: &'static str,
40 path: PathBuf,
42 #[source]
44 source: std::io::Error,
45 },
46 #[error("invalid prompt cache manifest JSON: {0}")]
48 ManifestJson(#[source] serde_json::Error),
49 #[error("malformed prompt cache storage: {0}")]
51 MalformedStorage(String),
52 #[error("unsafe prompt cache shard path {0:?}")]
54 UnsafeShardPath(String),
55 #[error("missing prompt cache shard {0}")]
57 MissingShard(PathBuf),
58 #[error("malformed prompt cache shard {path}: {reason}")]
60 MalformedShard {
61 path: PathBuf,
63 reason: String,
65 },
66 #[error("invalid prompt cache path {0}")]
68 InvalidPromptCachePath(PathBuf),
69 #[error("prompt cache destination already exists: {0}")]
71 PromptCacheExists(PathBuf),
72}
73
74#[derive(Debug, thiserror::Error)]
76pub enum LiveCachePublicationError {
77 #[error("failed to {action} at {path}: {source}")]
79 Io {
80 action: &'static str,
82 path: PathBuf,
84 #[source]
86 source: std::io::Error,
87 },
88}
89
90#[derive(Debug)]
92pub struct LiveCacheBlockPublication {
93 destination: PathBuf,
94 staging: PathBuf,
95 committed: bool,
96}
97
98impl LiveCacheBlockPublication {
99 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 pub fn staging_path(&self) -> &Path {
139 &self.staging
140 }
141
142 pub fn destination_path(&self) -> &Path {
144 &self.destination
145 }
146
147 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#[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 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 pub fn staging_directory(&self) -> &Path {
287 &self.staging
288 }
289
290 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
366pub 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
399pub 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(¤t_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
450pub 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
473pub 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
511pub fn finalize_prompt_cache_shard(path: &Path) -> Result<String, PromptCachePersistenceError> {
513 sync_file(path)?;
514 hash_prompt_cache_shard_payload(path)
515}
516
517pub 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, ¤t, 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}