1use eredu_core::cache::{
4 CacheBlockId, CacheRepresentation, PromptCacheBlock, PromptCacheError, PromptCacheManifest,
5 PromptCacheStateTensor, PromptCacheTopology, 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 NEXT_REVERSIBLE_CACHE_PUBLICATION_ID: AtomicU64 = AtomicU64::new(1);
21static LIVE_CACHE_PROCESS_NAMESPACE: OnceLock<String> = OnceLock::new();
22
23pub const MAX_PROMPT_CACHE_SHARD_HEADER_BYTES: u64 = 1024 * 1024;
25pub const PROMPT_CACHE_GENERATIONS_DIRECTORY: &str = ".generations";
27pub const PROMPT_CACHE_CURRENT_FILE: &str = "CURRENT";
29
30pub fn prompt_cache_rank_path(root: &Path, topology: &PromptCacheTopology) -> PathBuf {
35 let coordinate = |axis: Option<(usize, usize)>| {
36 axis.map_or_else(|| "x".to_owned(), |(_, rank)| rank.to_string())
37 };
38 if topology.cache_rank_identity().is_none() {
39 root.to_path_buf()
40 } else {
41 root.join(format!(
42 "rank-p{}-t{}-e{}",
43 coordinate(topology.stage()),
44 coordinate(topology.shard()),
45 coordinate(topology.addressable())
46 ))
47 }
48}
49
50#[derive(Debug, thiserror::Error)]
52pub enum PromptCachePersistenceError {
53 #[error(transparent)]
55 PromptCache(#[from] PromptCacheError),
56 #[error("failed to {action} at {path}: {source}")]
58 Io {
59 action: &'static str,
61 path: PathBuf,
63 #[source]
65 source: std::io::Error,
66 },
67 #[error("invalid prompt cache manifest JSON: {0}")]
69 ManifestJson(#[source] serde_json::Error),
70 #[error("malformed prompt cache storage: {0}")]
72 MalformedStorage(String),
73 #[error("unsafe prompt cache shard path {0:?}")]
75 UnsafeShardPath(String),
76 #[error("missing prompt cache shard {0}")]
78 MissingShard(PathBuf),
79 #[error("malformed prompt cache shard {path}: {reason}")]
81 MalformedShard {
82 path: PathBuf,
84 reason: String,
86 },
87 #[error("invalid prompt cache path {0}")]
89 InvalidPromptCachePath(PathBuf),
90 #[error("prompt cache destination already exists: {0}")]
92 PromptCacheExists(PathBuf),
93 #[error("invalid reversible prompt cache publication state: {0}")]
95 InvalidReversiblePublication(&'static str),
96}
97
98#[derive(Debug, thiserror::Error)]
100pub enum LiveCachePublicationError {
101 #[error("failed to {action} at {path}: {source}")]
103 Io {
104 action: &'static str,
106 path: PathBuf,
108 #[source]
110 source: std::io::Error,
111 },
112}
113
114#[derive(Debug)]
116pub struct LiveCacheBlockPublication {
117 destination: PathBuf,
118 staging: PathBuf,
119 committed: bool,
120}
121
122impl LiveCacheBlockPublication {
123 pub fn begin(directory: &Path, id: &CacheBlockId) -> Self {
125 let process_namespace = LIVE_CACHE_PROCESS_NAMESPACE.get_or_init(|| {
126 let started = SystemTime::now()
127 .duration_since(UNIX_EPOCH)
128 .unwrap_or_default()
129 .as_nanos();
130 format!("p{:08x}-t{started:032x}", std::process::id())
131 });
132 let publication_id = NEXT_LIVE_CACHE_PUBLICATION_ID.fetch_add(1, Ordering::Relaxed);
133 let representation = match id.representation {
134 CacheRepresentation::KeyValue => "kv",
135 CacheRepresentation::CompressedLatentRotary => "mla",
136 };
137 let rank_component =
138 |rank: Option<usize>| rank.map_or_else(|| "x".to_string(), |rank| rank.to_string());
139 let rank = id.rank.map_or_else(
140 || "rank-px-tx-ex".to_string(),
141 |rank| {
142 format!(
143 "rank-p{}-t{}-e{}",
144 rank_component(rank.stage_rank()),
145 rank_component(rank.shard_rank()),
146 rank_component(rank.addressable_rank())
147 )
148 },
149 );
150 let base = format!(
151 "live-{process_namespace}-w{publication_id:016x}-s{:016x}-layer-{:05}-{representation}-{rank}-{}-{}",
152 id.session_id, id.global_layer, id.start, id.end
153 );
154 Self {
155 destination: directory.join(format!("{base}.safetensors")),
156 staging: directory.join(format!(".{base}.tmp.safetensors")),
157 committed: false,
158 }
159 }
160
161 pub fn staging_path(&self) -> &Path {
163 &self.staging
164 }
165
166 pub fn destination_path(&self) -> &Path {
168 &self.destination
169 }
170
171 pub fn commit(mut self) -> Result<PathBuf, LiveCachePublicationError> {
173 fs::hard_link(&self.staging, &self.destination).map_err(|source| {
174 LiveCachePublicationError::Io {
175 action: "publish uniquely named live cache block",
176 path: self.destination.clone(),
177 source,
178 }
179 })?;
180 if let Err(source) = fs::remove_file(&self.staging) {
181 let _ = fs::remove_file(&self.destination);
182 return Err(LiveCachePublicationError::Io {
183 action: "remove published live cache temporary file",
184 path: self.staging.clone(),
185 source,
186 });
187 }
188 self.committed = true;
189 Ok(self.destination.clone())
190 }
191}
192
193impl Drop for LiveCacheBlockPublication {
194 fn drop(&mut self) {
195 if !self.committed {
196 let _ = fs::remove_file(&self.staging);
197 }
198 }
199}
200
201#[derive(Debug)]
203pub struct PromptCachePublication {
204 destination: PathBuf,
205 parent: PathBuf,
206 generations: PathBuf,
207 generation_name: String,
208 publication_root: Option<PathBuf>,
209 staging: PathBuf,
210 replacing: bool,
211 nonce: u128,
212 committed: bool,
213}
214
215impl PromptCachePublication {
216 pub fn begin(
218 destination: impl AsRef<Path>,
219 replace_existing: bool,
220 ) -> Result<Self, PromptCachePersistenceError> {
221 let destination = destination.as_ref().to_path_buf();
222 let parent = destination
223 .parent()
224 .ok_or_else(|| {
225 PromptCachePersistenceError::InvalidPromptCachePath(destination.clone())
226 })?
227 .to_path_buf();
228 fs::create_dir_all(&parent).map_err(|source| PromptCachePersistenceError::Io {
229 action: "create prompt cache parent",
230 path: parent.clone(),
231 source,
232 })?;
233 let replacing = destination.exists();
234 if replacing && !replace_existing {
235 return Err(PromptCachePersistenceError::PromptCacheExists(destination));
236 }
237 if replacing && !destination.is_dir() {
238 return Err(PromptCachePersistenceError::InvalidPromptCachePath(
239 destination,
240 ));
241 }
242 let file_name = destination
243 .file_name()
244 .and_then(|name| name.to_str())
245 .ok_or_else(|| {
246 PromptCachePersistenceError::InvalidPromptCachePath(destination.clone())
247 })?;
248 let nonce = SystemTime::now()
249 .duration_since(UNIX_EPOCH)
250 .unwrap_or_default()
251 .as_nanos();
252 let generation_name = format!("generation-{nonce}");
253 let (generations, staging, publication_root) = if replacing {
254 let generations = destination.join(PROMPT_CACHE_GENERATIONS_DIRECTORY);
255 fs::create_dir_all(&generations).map_err(|source| PromptCachePersistenceError::Io {
256 action: "create prompt cache generation directory",
257 path: generations.clone(),
258 source,
259 })?;
260 let staging = generations.join(format!(".tmp-{nonce}"));
261 fs::create_dir(&staging).map_err(|source| PromptCachePersistenceError::Io {
262 action: "create temporary prompt cache",
263 path: staging.clone(),
264 source,
265 })?;
266 (generations, staging, None)
267 } else {
268 let publication_root = parent.join(format!(".{file_name}.tmp-{nonce}"));
269 fs::create_dir(&publication_root).map_err(|source| {
270 PromptCachePersistenceError::Io {
271 action: "create temporary prompt cache root",
272 path: publication_root.clone(),
273 source,
274 }
275 })?;
276 let generations = publication_root.join(PROMPT_CACHE_GENERATIONS_DIRECTORY);
277 if let Err(source) = fs::create_dir(&generations) {
278 let _ = fs::remove_dir_all(&publication_root);
279 return Err(PromptCachePersistenceError::Io {
280 action: "create prompt cache generation directory",
281 path: generations,
282 source,
283 });
284 }
285 let staging = generations.join(&generation_name);
286 if let Err(source) = fs::create_dir(&staging) {
287 let _ = fs::remove_dir_all(&publication_root);
288 return Err(PromptCachePersistenceError::Io {
289 action: "create temporary prompt cache",
290 path: staging,
291 source,
292 });
293 }
294 (generations, staging, Some(publication_root))
295 };
296 Ok(Self {
297 destination,
298 parent,
299 generations,
300 generation_name,
301 publication_root,
302 staging,
303 replacing,
304 nonce,
305 committed: false,
306 })
307 }
308
309 pub fn staging_directory(&self) -> &Path {
311 &self.staging
312 }
313
314 pub fn commit(
316 mut self,
317 manifest: &PromptCacheManifest,
318 ) -> Result<(), PromptCachePersistenceError> {
319 let manifest_path = self.staging.join("manifest.json");
320 let file =
321 File::create(&manifest_path).map_err(|source| PromptCachePersistenceError::Io {
322 action: "create prompt cache manifest",
323 path: manifest_path.clone(),
324 source,
325 })?;
326 let mut writer = BufWriter::new(file);
327 serde_json::to_writer_pretty(&mut writer, manifest)
328 .map_err(PromptCachePersistenceError::ManifestJson)?;
329 writer
330 .write_all(b"\n")
331 .map_err(|source| PromptCachePersistenceError::Io {
332 action: "write prompt cache manifest",
333 path: manifest_path.clone(),
334 source,
335 })?;
336 writer
337 .flush()
338 .map_err(|source| PromptCachePersistenceError::Io {
339 action: "flush prompt cache manifest",
340 path: manifest_path.clone(),
341 source,
342 })?;
343 sync_file(&manifest_path)?;
344 validate_prompt_cache_manifest(&self.staging, manifest)?;
345 sync_directory(&self.staging)?;
346
347 if self.replacing {
348 let generation = self.generations.join(&self.generation_name);
349 durable_rename(&self.staging, &generation, false).map_err(|source| {
350 PromptCachePersistenceError::Io {
351 action: "publish prompt cache generation",
352 path: generation,
353 source,
354 }
355 })?;
356 sync_directory(&self.generations)?;
357 publish_generation_pointer(&self.destination, &self.generation_name, self.nonce)?;
358 } else {
359 sync_directory(&self.generations)?;
360 let publication_root = self
361 .publication_root
362 .as_ref()
363 .expect("new prompt-cache publication owns a staging root");
364 publish_generation_pointer(publication_root, &self.generation_name, self.nonce)?;
365 durable_rename(publication_root, &self.destination, false).map_err(|source| {
366 PromptCachePersistenceError::Io {
367 action: "publish prompt cache",
368 path: self.destination.clone(),
369 source,
370 }
371 })?;
372 }
373 sync_directory(&self.parent)?;
374 self.committed = true;
375 Ok(())
376 }
377}
378
379impl Drop for PromptCachePublication {
380 fn drop(&mut self) {
381 if !self.committed {
382 let staging = self.publication_root.as_ref().unwrap_or(&self.staging);
383 if staging.exists() {
384 let _ = fs::remove_dir_all(staging);
385 }
386 }
387 }
388}
389
390#[derive(Debug)]
397pub struct ReversiblePromptCachePublication {
398 destination: PathBuf,
399 parent: PathBuf,
400 staging: PathBuf,
401 replace_existing: bool,
402 previous_generation: Option<String>,
403 moved_generation: Option<PathBuf>,
404 published: bool,
405 committed: bool,
406 nonce: u128,
407}
408
409impl ReversiblePromptCachePublication {
410 pub fn begin(
412 destination: impl AsRef<Path>,
413 replace_existing: bool,
414 ) -> Result<Self, PromptCachePersistenceError> {
415 let destination = destination.as_ref().to_path_buf();
416 let parent = destination
417 .parent()
418 .ok_or_else(|| {
419 PromptCachePersistenceError::InvalidPromptCachePath(destination.clone())
420 })?
421 .to_path_buf();
422 fs::create_dir_all(&parent).map_err(|source| PromptCachePersistenceError::Io {
423 action: "create reversible prompt cache parent",
424 path: parent.clone(),
425 source,
426 })?;
427 if destination.exists() && !replace_existing {
428 return Err(PromptCachePersistenceError::PromptCacheExists(destination));
429 }
430 if destination.exists() && !destination.is_dir() {
431 return Err(PromptCachePersistenceError::InvalidPromptCachePath(
432 destination,
433 ));
434 }
435 let file_name = destination
436 .file_name()
437 .and_then(|name| name.to_str())
438 .ok_or_else(|| {
439 PromptCachePersistenceError::InvalidPromptCachePath(destination.clone())
440 })?;
441 let publication_id = NEXT_REVERSIBLE_CACHE_PUBLICATION_ID.fetch_add(1, Ordering::Relaxed);
442 let nonce = SystemTime::now()
443 .duration_since(UNIX_EPOCH)
444 .unwrap_or_default()
445 .as_nanos()
446 ^ u128::from(publication_id);
447 let staging = parent.join(format!(
448 ".{file_name}.transaction-p{:08x}-{nonce:032x}",
449 std::process::id()
450 ));
451 if staging.exists() {
452 return Err(PromptCachePersistenceError::InvalidPromptCachePath(staging));
453 }
454 Ok(Self {
455 destination,
456 parent,
457 staging,
458 replace_existing,
459 previous_generation: None,
460 moved_generation: None,
461 published: false,
462 committed: false,
463 nonce,
464 })
465 }
466
467 pub fn staging_destination(&self) -> &Path {
469 &self.staging
470 }
471
472 pub fn publish(&mut self) -> Result<(), PromptCachePersistenceError> {
474 if self.published || self.moved_generation.is_some() {
475 return Err(PromptCachePersistenceError::InvalidReversiblePublication(
476 "publication was already attempted",
477 ));
478 }
479 inspect_prompt_cache(&self.staging)?;
480 if !self.destination.exists() {
481 durable_rename(&self.staging, &self.destination, false).map_err(|source| {
482 PromptCachePersistenceError::Io {
483 action: "publish prepared prompt cache",
484 path: self.destination.clone(),
485 source,
486 }
487 })?;
488 sync_directory(&self.parent)?;
489 self.published = true;
490 return Ok(());
491 }
492 if !self.replace_existing {
493 return Err(PromptCachePersistenceError::PromptCacheExists(
494 self.destination.clone(),
495 ));
496 }
497
498 let previous = resolve_prompt_cache_root(&self.destination)?;
499 let previous_generation = previous
500 .file_name()
501 .and_then(|name| name.to_str())
502 .ok_or_else(|| {
503 PromptCachePersistenceError::MalformedStorage(
504 "active prompt-cache generation has no safe name".into(),
505 )
506 })?
507 .to_owned();
508 let staged = resolve_prompt_cache_root(&self.staging)?;
509 let generation_name = staged
510 .file_name()
511 .and_then(|name| name.to_str())
512 .ok_or_else(|| {
513 PromptCachePersistenceError::MalformedStorage(
514 "prepared prompt-cache generation has no safe name".into(),
515 )
516 })?
517 .to_owned();
518 let generations = self.destination.join(PROMPT_CACHE_GENERATIONS_DIRECTORY);
519 let target = generations.join(&generation_name);
520 if target.exists() {
521 return Err(PromptCachePersistenceError::InvalidPromptCachePath(target));
522 }
523 durable_rename(&staged, &target, false).map_err(|source| {
524 PromptCachePersistenceError::Io {
525 action: "install prepared prompt cache generation",
526 path: target.clone(),
527 source,
528 }
529 })?;
530 self.previous_generation = Some(previous_generation);
531 self.moved_generation = Some(target);
532 sync_directory(&generations)?;
533 publish_generation_pointer(&self.destination, &generation_name, self.nonce)?;
534 self.published = true;
535 Ok(())
536 }
537
538 pub fn commit(mut self) -> Result<(), PromptCachePersistenceError> {
540 if !self.published {
541 return Err(PromptCachePersistenceError::InvalidReversiblePublication(
542 "an unpublished cache cannot commit",
543 ));
544 }
545 if self.staging.exists() {
546 fs::remove_dir_all(&self.staging).map_err(|source| {
547 PromptCachePersistenceError::Io {
548 action: "remove committed prompt cache staging directory",
549 path: self.staging.clone(),
550 source,
551 }
552 })?;
553 }
554 self.committed = true;
555 Ok(())
556 }
557
558 pub fn rollback(mut self) -> Result<(), PromptCachePersistenceError> {
560 self.rollback_inner()?;
561 self.committed = true;
562 Ok(())
563 }
564
565 fn rollback_inner(&mut self) -> Result<(), PromptCachePersistenceError> {
566 if self.published {
567 match self.previous_generation.as_deref() {
568 Some(previous) => {
569 publish_generation_pointer(&self.destination, previous, self.nonce ^ 1)?;
570 }
571 None if self.destination.exists() => {
572 fs::remove_dir_all(&self.destination).map_err(|source| {
573 PromptCachePersistenceError::Io {
574 action: "remove rolled-back prompt cache",
575 path: self.destination.clone(),
576 source,
577 }
578 })?;
579 sync_directory(&self.parent)?;
580 }
581 None => {}
582 }
583 }
584 if let Some(generation) = self.moved_generation.take() {
585 if generation.exists() {
586 fs::remove_dir_all(&generation).map_err(|source| {
587 PromptCachePersistenceError::Io {
588 action: "remove rolled-back prompt cache generation",
589 path: generation.clone(),
590 source,
591 }
592 })?;
593 if let Some(parent) = generation.parent() {
594 sync_directory(parent)?;
595 }
596 }
597 }
598 if self.staging.exists() {
599 fs::remove_dir_all(&self.staging).map_err(|source| {
600 PromptCachePersistenceError::Io {
601 action: "remove rolled-back prompt cache staging directory",
602 path: self.staging.clone(),
603 source,
604 }
605 })?;
606 }
607 Ok(())
608 }
609}
610
611impl Drop for ReversiblePromptCachePublication {
612 fn drop(&mut self) {
613 if !self.committed {
614 let _ = self.rollback_inner();
615 }
616 }
617}
618
619pub fn inspect_prompt_cache(
621 directory: impl AsRef<Path>,
622) -> Result<PromptCacheManifest, PromptCachePersistenceError> {
623 let directory = resolve_prompt_cache_root(directory.as_ref())?;
624 let manifest_path = directory.join("manifest.json");
625 let reader = BufReader::new(File::open(&manifest_path).map_err(|source| {
626 PromptCachePersistenceError::Io {
627 action: "open prompt cache manifest",
628 path: manifest_path.clone(),
629 source,
630 }
631 })?);
632 let value: serde_json::Value =
633 serde_json::from_reader(reader).map_err(PromptCachePersistenceError::ManifestJson)?;
634 let schema_version = value
635 .get("schema_version")
636 .and_then(serde_json::Value::as_u64)
637 .and_then(|version| u32::try_from(version).ok())
638 .ok_or_else(|| {
639 PromptCachePersistenceError::PromptCache(PromptCacheError::Malformed(
640 "prompt-cache schema_version is missing or is not a u32".into(),
641 ))
642 })?;
643 if schema_version != PROMPT_CACHE_SCHEMA_VERSION {
644 return Err(PromptCacheError::UnsupportedSchema(schema_version).into());
645 }
646 let manifest =
647 serde_json::from_value(value).map_err(PromptCachePersistenceError::ManifestJson)?;
648 validate_prompt_cache_manifest(&directory, &manifest)?;
649 Ok(manifest)
650}
651
652pub fn resolve_prompt_cache_root(directory: &Path) -> Result<PathBuf, PromptCachePersistenceError> {
654 let current_path = directory.join(PROMPT_CACHE_CURRENT_FILE);
655 let metadata = current_path.metadata().map_err(|source| {
656 if source.kind() == std::io::ErrorKind::NotFound {
657 PromptCachePersistenceError::MalformedStorage(
658 "prompt-cache generation pointer CURRENT is missing".into(),
659 )
660 } else {
661 PromptCachePersistenceError::Io {
662 action: "stat prompt cache generation pointer",
663 path: current_path.clone(),
664 source,
665 }
666 }
667 })?;
668 let length = metadata.len();
669 if length == 0 || length > 256 {
670 return Err(PromptCachePersistenceError::MalformedStorage(
671 "prompt-cache generation pointer has an invalid length".into(),
672 ));
673 }
674 let generation =
675 fs::read_to_string(¤t_path).map_err(|source| PromptCachePersistenceError::Io {
676 action: "read prompt cache generation pointer",
677 path: current_path.clone(),
678 source,
679 })?;
680 let generation = generation.trim();
681 let generation_path = Path::new(generation);
682 if generation.is_empty()
683 || generation_path
684 .components()
685 .any(|component| !matches!(component, Component::Normal(_)))
686 || generation_path.components().count() != 1
687 {
688 return Err(PromptCachePersistenceError::MalformedStorage(
689 "prompt-cache generation pointer is unsafe".into(),
690 ));
691 }
692 let root = directory
693 .join(PROMPT_CACHE_GENERATIONS_DIRECTORY)
694 .join(generation_path);
695 if !root.is_dir() {
696 return Err(PromptCachePersistenceError::MalformedStorage(format!(
697 "prompt-cache generation {generation:?} is missing"
698 )));
699 }
700 Ok(root)
701}
702
703pub fn validate_prompt_cache_manifest(
705 directory: &Path,
706 manifest: &PromptCacheManifest,
707) -> Result<(), PromptCachePersistenceError> {
708 manifest.validate()?;
709 for block in &manifest.blocks {
710 let shard = safe_prompt_cache_shard_path(directory, &block.shard)?;
711 if !shard.is_file() {
712 return Err(PromptCachePersistenceError::MissingShard(shard));
713 }
714 validate_block_shard(&shard, block)?;
715 }
716 for state in &manifest.state_tensors {
717 let shard = safe_prompt_cache_shard_path(directory, &state.shard)?;
718 if !shard.is_file() {
719 return Err(PromptCachePersistenceError::MissingShard(shard));
720 }
721 validate_state_shard(&shard, state)?;
722 }
723 Ok(())
724}
725
726pub fn safe_prompt_cache_shard_path(
728 directory: &Path,
729 relative: &str,
730) -> Result<PathBuf, PromptCachePersistenceError> {
731 let path = Path::new(relative);
732 if path.is_absolute()
733 || path
734 .components()
735 .any(|component| !matches!(component, Component::Normal(_)))
736 {
737 return Err(PromptCachePersistenceError::UnsafeShardPath(
738 relative.into(),
739 ));
740 }
741 let joined = directory.join(path);
742 if joined.exists() {
743 let root =
744 fs::canonicalize(directory).map_err(|source| PromptCachePersistenceError::Io {
745 action: "canonicalize prompt cache directory",
746 path: directory.to_path_buf(),
747 source,
748 })?;
749 let canonical =
750 fs::canonicalize(&joined).map_err(|source| PromptCachePersistenceError::Io {
751 action: "canonicalize prompt cache shard",
752 path: joined.clone(),
753 source,
754 })?;
755 if !canonical.starts_with(&root) {
756 return Err(PromptCachePersistenceError::UnsafeShardPath(
757 relative.into(),
758 ));
759 }
760 }
761 Ok(joined)
762}
763
764pub fn finalize_prompt_cache_shard(path: &Path) -> Result<String, PromptCachePersistenceError> {
766 sync_file(path)?;
767 hash_prompt_cache_shard_payload(path)
768}
769
770pub fn hash_prompt_cache_shard_payload(path: &Path) -> Result<String, PromptCachePersistenceError> {
772 let (_, _, data_start) = read_shard_metadata(path)?;
773 let mut file = File::open(path).map_err(|source| PromptCachePersistenceError::Io {
774 action: "open prompt cache shard payload",
775 path: path.to_path_buf(),
776 source,
777 })?;
778 file.seek(SeekFrom::Start(data_start))
779 .map_err(|source| PromptCachePersistenceError::Io {
780 action: "seek prompt cache shard payload",
781 path: path.to_path_buf(),
782 source,
783 })?;
784 let mut hasher = Sha256::new();
785 let mut buffer = [0u8; 64 * 1024];
786 loop {
787 let read = file
788 .read(&mut buffer)
789 .map_err(|source| PromptCachePersistenceError::Io {
790 action: "hash prompt cache shard payload",
791 path: path.to_path_buf(),
792 source,
793 })?;
794 if read == 0 {
795 break;
796 }
797 hasher.update(&buffer[..read]);
798 }
799 Ok(hex(hasher.finalize()))
800}
801
802fn validate_block_shard(
803 path: &Path,
804 block: &PromptCacheBlock,
805) -> Result<(), PromptCachePersistenceError> {
806 let (metadata, file_len, data_start) = read_shard_metadata(path)?;
807 let entries = metadata.tensors();
808 if entries.len() != 2 {
809 return Err(malformed(
810 path,
811 format!("expected two arrays, found {}", entries.len()),
812 ));
813 }
814 let mut logical_bytes = 0u64;
815 for (name, expected_shape, expected_dtype) in [
816 (&block.first_array, &block.first_shape, &block.first_dtype),
817 (
818 &block.second_array,
819 &block.second_shape,
820 &block.second_dtype,
821 ),
822 ] {
823 let tensor = metadata
824 .info(name)
825 .ok_or_else(|| malformed(path, format!("missing array {name}")))?;
826 let shape = tensor
827 .shape
828 .iter()
829 .map(|dimension| i32::try_from(*dimension))
830 .collect::<Result<Vec<_>, _>>()
831 .map_err(|_| malformed(path, "array dimension exceeds runtime range"))?;
832 if &shape != expected_shape || stored_dtype_name(tensor.dtype) != *expected_dtype {
833 return Err(malformed(
834 path,
835 format!("array {name} shape or dtype does not match the manifest"),
836 ));
837 }
838 logical_bytes = logical_bytes.saturating_add(
839 u64::try_from(tensor.data_offsets.1.saturating_sub(tensor.data_offsets.0))
840 .unwrap_or(u64::MAX),
841 );
842 }
843 if logical_bytes != block.logical_bytes {
844 return Err(malformed(
845 path,
846 format!(
847 "logical byte count {logical_bytes} does not match manifest value {}",
848 block.logical_bytes
849 ),
850 ));
851 }
852 validate_file_boundary(path, &metadata, file_len, data_start)
853}
854
855fn validate_state_shard(
856 path: &Path,
857 state: &PromptCacheStateTensor,
858) -> Result<(), PromptCachePersistenceError> {
859 let (metadata, file_len, data_start) = read_shard_metadata(path)?;
860 let entries = metadata.tensors();
861 if entries.len() != 1 {
862 return Err(malformed(
863 path,
864 format!("expected one state array, found {}", entries.len()),
865 ));
866 }
867 let tensor = metadata
868 .info(&state.array)
869 .ok_or_else(|| malformed(path, format!("missing state array {}", state.array)))?;
870 let shape = tensor
871 .shape
872 .iter()
873 .map(|dimension| i32::try_from(*dimension))
874 .collect::<Result<Vec<_>, _>>()
875 .map_err(|_| malformed(path, "state array dimension exceeds runtime range"))?;
876 let logical_bytes = u64::try_from(tensor.data_offsets.1.saturating_sub(tensor.data_offsets.0))
877 .unwrap_or(u64::MAX);
878 if shape != state.shape
879 || stored_dtype_name(tensor.dtype) != state.dtype
880 || logical_bytes != state.logical_bytes
881 {
882 return Err(malformed(
883 path,
884 "state array shape, dtype, or byte count does not match the manifest",
885 ));
886 }
887 validate_file_boundary(path, &metadata, file_len, data_start)
888}
889
890fn validate_file_boundary(
891 path: &Path,
892 metadata: &safetensors::tensor::Metadata,
893 file_len: u64,
894 data_start: u64,
895) -> Result<(), PromptCachePersistenceError> {
896 let expected_file_len = data_start
897 .checked_add(metadata.data_len() as u64)
898 .ok_or_else(|| malformed(path, "safetensors file length overflow"))?;
899 if expected_file_len != file_len {
900 return Err(malformed(
901 path,
902 format!(
903 "safetensors payload boundary {expected_file_len} does not match file length {file_len}"
904 ),
905 ));
906 }
907 Ok(())
908}
909
910fn read_shard_metadata(
911 path: &Path,
912) -> Result<(safetensors::tensor::Metadata, u64, u64), PromptCachePersistenceError> {
913 let mut file = File::open(path).map_err(|source| PromptCachePersistenceError::Io {
914 action: "open prompt cache shard metadata",
915 path: path.to_path_buf(),
916 source,
917 })?;
918 let file_len = file
919 .metadata()
920 .map_err(|source| PromptCachePersistenceError::Io {
921 action: "stat prompt cache shard",
922 path: path.to_path_buf(),
923 source,
924 })?
925 .len();
926 let mut length_bytes = [0u8; 8];
927 file.read_exact(&mut length_bytes)
928 .map_err(|source| PromptCachePersistenceError::Io {
929 action: "read prompt cache shard header length",
930 path: path.to_path_buf(),
931 source,
932 })?;
933 let header_len = u64::from_le_bytes(length_bytes);
934 if header_len == 0 || header_len > MAX_PROMPT_CACHE_SHARD_HEADER_BYTES {
935 return Err(malformed(
936 path,
937 format!("safetensors header length {header_len} exceeds the prompt-cache bound"),
938 ));
939 }
940 let data_start = 8u64
941 .checked_add(header_len)
942 .ok_or_else(|| malformed(path, "safetensors header length overflow"))?;
943 if data_start > file_len {
944 return Err(malformed(
945 path,
946 "safetensors header extends beyond the file",
947 ));
948 }
949 let mut header = vec![0u8; header_len as usize];
950 file.read_exact(&mut header)
951 .map_err(|source| PromptCachePersistenceError::Io {
952 action: "read prompt cache shard header",
953 path: path.to_path_buf(),
954 source,
955 })?;
956 let metadata =
957 serde_json::from_slice(&header).map_err(|error| malformed(path, error.to_string()))?;
958 Ok((metadata, file_len, data_start))
959}
960
961fn malformed(path: &Path, reason: impl Into<String>) -> PromptCachePersistenceError {
962 PromptCachePersistenceError::MalformedShard {
963 path: path.to_path_buf(),
964 reason: reason.into(),
965 }
966}
967
968fn stored_dtype_name(dtype: safetensors::Dtype) -> String {
969 use safetensors::Dtype as Stored;
970 match dtype {
971 Stored::BOOL => "Bool",
972 Stored::U8 => "Uint8",
973 Stored::U16 => "Uint16",
974 Stored::U32 => "Uint32",
975 Stored::U64 => "Uint64",
976 Stored::I8 => "Int8",
977 Stored::I16 => "Int16",
978 Stored::I32 => "Int32",
979 Stored::I64 => "Int64",
980 Stored::F16 => "Float16",
981 Stored::BF16 => "Bfloat16",
982 Stored::F32 => "Float32",
983 Stored::F64 => "Float64",
984 dtype => return format!("{dtype:?}"),
985 }
986 .into()
987}
988
989fn publish_generation_pointer(
990 destination: &Path,
991 generation_name: &str,
992 nonce: u128,
993) -> Result<(), PromptCachePersistenceError> {
994 let temporary = destination.join(format!(".{PROMPT_CACHE_CURRENT_FILE}.tmp-{nonce}"));
995 let current = destination.join(PROMPT_CACHE_CURRENT_FILE);
996 let mut file = File::create(&temporary).map_err(|source| PromptCachePersistenceError::Io {
997 action: "create prompt cache generation pointer",
998 path: temporary.clone(),
999 source,
1000 })?;
1001 writeln!(file, "{generation_name}").map_err(|source| PromptCachePersistenceError::Io {
1002 action: "write prompt cache generation pointer",
1003 path: temporary.clone(),
1004 source,
1005 })?;
1006 file.sync_all()
1007 .map_err(|source| PromptCachePersistenceError::Io {
1008 action: "sync prompt cache generation pointer",
1009 path: temporary.clone(),
1010 source,
1011 })?;
1012 durable_rename(&temporary, ¤t, true).map_err(|source| {
1013 PromptCachePersistenceError::Io {
1014 action: "switch prompt cache generation",
1015 path: current,
1016 source,
1017 }
1018 })?;
1019 sync_directory(destination)
1020}
1021
1022fn sync_file(path: &Path) -> Result<(), PromptCachePersistenceError> {
1023 File::open(path)
1024 .and_then(|file| file.sync_all())
1025 .map_err(|source| PromptCachePersistenceError::Io {
1026 action: "synchronize cache file",
1027 path: path.to_path_buf(),
1028 source,
1029 })
1030}
1031
1032#[cfg(unix)]
1033fn sync_directory(path: &Path) -> Result<(), PromptCachePersistenceError> {
1034 File::open(path)
1035 .and_then(|file| file.sync_all())
1036 .map_err(|source| PromptCachePersistenceError::Io {
1037 action: "synchronize cache directory",
1038 path: path.to_path_buf(),
1039 source,
1040 })
1041}
1042
1043#[cfg(windows)]
1044fn sync_directory(path: &Path) -> Result<(), PromptCachePersistenceError> {
1045 if path.is_dir() {
1046 Ok(())
1047 } else {
1048 Err(PromptCachePersistenceError::Io {
1049 action: "validate cache directory before durable publication",
1050 path: path.to_path_buf(),
1051 source: std::io::Error::new(
1052 std::io::ErrorKind::NotADirectory,
1053 "cache publication path is not a directory",
1054 ),
1055 })
1056 }
1057}
1058
1059#[cfg(not(any(unix, windows)))]
1060fn sync_directory(path: &Path) -> Result<(), PromptCachePersistenceError> {
1061 if path.is_dir() {
1062 Ok(())
1063 } else {
1064 Err(PromptCachePersistenceError::Io {
1065 action: "validate cache directory before publication",
1066 path: path.to_path_buf(),
1067 source: std::io::Error::new(
1068 std::io::ErrorKind::NotADirectory,
1069 "cache publication path is not a directory",
1070 ),
1071 })
1072 }
1073}
1074
1075#[cfg(not(windows))]
1076fn durable_rename(source: &Path, destination: &Path, _replace: bool) -> std::io::Result<()> {
1077 fs::rename(source, destination)
1078}
1079
1080#[cfg(windows)]
1081fn durable_rename(source: &Path, destination: &Path, _replace: bool) -> std::io::Result<()> {
1082 fs::rename(source, destination)
1083}
1084
1085fn hex(digest: impl AsRef<[u8]>) -> String {
1086 const HEX: &[u8; 16] = b"0123456789abcdef";
1087 let digest = digest.as_ref();
1088 let mut encoded = String::with_capacity(digest.len() * 2);
1089 for &byte in digest {
1090 encoded.push(HEX[usize::from(byte >> 4)] as char);
1091 encoded.push(HEX[usize::from(byte & 0x0f)] as char);
1092 }
1093 encoded
1094}
1095
1096#[cfg(test)]
1097mod tests {
1098 use super::*;
1099 use eredu_core::cache::{
1100 CacheRepresentation, LayerCachePolicy, PromptCacheDescriptor, PromptCacheStateSegment,
1101 PromptCacheTopology,
1102 };
1103 use eredu_core::{AttentionPolicy, LayerSchedule};
1104 use safetensors::tensor::{serialize_to_file, Dtype, TensorView};
1105 use std::collections::HashMap;
1106
1107 fn manifest(shard: &Path) -> PromptCacheManifest {
1108 let bytes = [0u8; 16];
1109 let tensor = TensorView::new(Dtype::F32, vec![1, 1, 2, 2], &bytes).unwrap();
1110 serialize_to_file(
1111 HashMap::from([("keys", tensor.clone()), ("values", tensor)]),
1112 None,
1113 shard,
1114 )
1115 .unwrap();
1116 let hash = finalize_prompt_cache_shard(shard).unwrap();
1117 let descriptor = PromptCacheDescriptor::new(
1118 "test",
1119 "test",
1120 "checkpoint",
1121 "content",
1122 "architecture",
1123 1,
1124 0,
1125 1,
1126 1,
1127 LayerSchedule::new(
1128 1,
1129 vec![LayerCachePolicy::key_value(AttentionPolicy::Full, 1, 2).unwrap()],
1130 )
1131 .unwrap(),
1132 vec![0],
1133 vec![PromptCacheStateSegment::new("state", 0..1).unwrap()],
1134 0,
1135 PromptCacheTopology::default(),
1136 )
1137 .unwrap();
1138 PromptCacheManifest {
1139 schema_version: PROMPT_CACHE_SCHEMA_VERSION,
1140 model_family: descriptor.model_family().into(),
1141 effective_model_type: descriptor.effective_model_type().into(),
1142 checkpoint_fingerprint: descriptor.checkpoint_fingerprint().into(),
1143 prefix_content_fingerprint: descriptor.prefix_content_fingerprint().into(),
1144 architecture_fingerprint: descriptor.architecture_fingerprint().into(),
1145 layer_count: 1,
1146 global_layer_start: 0,
1147 global_layer_end: 1,
1148 block_size_tokens: 2,
1149 batch_size: 1,
1150 total_prefix_tokens: 2,
1151 prefix_sha256: "00".repeat(32),
1152 layer_layout: descriptor.layer_layout().clone(),
1153 layer_prefix_offsets: vec![0],
1154 state_segments: descriptor.state_segments().to_vec(),
1155 sink_tokens: 0,
1156 topology: descriptor.topology().clone(),
1157 distributed_commit: None,
1158 application_namespace: None,
1159 blocks: vec![PromptCacheBlock {
1160 global_layer: 0,
1161 representation: CacheRepresentation::KeyValue,
1162 start: 0,
1163 end: 2,
1164 rank: None,
1165 shard: shard.file_name().unwrap().to_str().unwrap().into(),
1166 first_array: "keys".into(),
1167 second_array: "values".into(),
1168 first_shape: vec![1, 1, 2, 2],
1169 second_shape: vec![1, 1, 2, 2],
1170 first_dtype: "Float32".into(),
1171 second_dtype: "Float32".into(),
1172 logical_bytes: 32,
1173 payload_sha256: hash,
1174 }],
1175 state_tensors: vec![],
1176 }
1177 }
1178
1179 #[test]
1180 fn publication_validates_and_atomically_replaces_generations() {
1181 let root = tempfile::tempdir().unwrap();
1182 let destination = root.path().join("cache");
1183 let publication = PromptCachePublication::begin(&destination, false).unwrap();
1184 let first = manifest(&publication.staging_directory().join("block.safetensors"));
1185 publication.commit(&first).unwrap();
1186 assert_eq!(inspect_prompt_cache(&destination).unwrap(), first);
1187 assert!(destination.join(PROMPT_CACHE_CURRENT_FILE).is_file());
1188 let first_root = resolve_prompt_cache_root(&destination).unwrap();
1189 assert_eq!(
1190 first_root.parent().unwrap(),
1191 destination.join(PROMPT_CACHE_GENERATIONS_DIRECTORY)
1192 );
1193 assert!(first_root.join("manifest.json").is_file());
1194
1195 let publication = PromptCachePublication::begin(&destination, true).unwrap();
1196 let second = manifest(&publication.staging_directory().join("block.safetensors"));
1197 publication.commit(&second).unwrap();
1198 assert_eq!(inspect_prompt_cache(&destination).unwrap(), second);
1199 assert!(destination.join(PROMPT_CACHE_CURRENT_FILE).is_file());
1200 }
1201
1202 #[test]
1203 fn reversible_publication_restores_replacement_and_removes_new_destination() {
1204 fn prepare_cache(destination: &Path, label: &str) -> PromptCacheManifest {
1205 let publication = PromptCachePublication::begin(destination, false).unwrap();
1206 let mut manifest = manifest(&publication.staging_directory().join("block.safetensors"));
1207 manifest.application_namespace = Some(label.into());
1208 publication.commit(&manifest).unwrap();
1209 manifest
1210 }
1211
1212 let root = tempfile::tempdir().unwrap();
1213 let destination = root.path().join("cache");
1214 let mut fresh = ReversiblePromptCachePublication::begin(&destination, false).unwrap();
1215 let fresh_manifest = prepare_cache(fresh.staging_destination(), "fresh");
1216 fresh.publish().unwrap();
1217 assert_eq!(inspect_prompt_cache(&destination).unwrap(), fresh_manifest);
1218 fresh.rollback().unwrap();
1219 assert!(!destination.exists());
1220
1221 let first = prepare_cache(&destination, "first");
1222 let first_root = resolve_prompt_cache_root(&destination).unwrap();
1223 let mut replacement = ReversiblePromptCachePublication::begin(&destination, true).unwrap();
1224 let second = prepare_cache(replacement.staging_destination(), "second");
1225 replacement.publish().unwrap();
1226 assert_eq!(inspect_prompt_cache(&destination).unwrap(), second);
1227 replacement.rollback().unwrap();
1228 assert_eq!(inspect_prompt_cache(&destination).unwrap(), first);
1229 assert_eq!(resolve_prompt_cache_root(&destination).unwrap(), first_root);
1230
1231 let mut committed = ReversiblePromptCachePublication::begin(&destination, true).unwrap();
1232 let third = prepare_cache(committed.staging_destination(), "third");
1233 committed.publish().unwrap();
1234 committed.commit().unwrap();
1235 assert_eq!(inspect_prompt_cache(&destination).unwrap(), third);
1236 }
1237
1238 #[test]
1239 fn pointerless_legacy_prompt_cache_layout_is_rejected() {
1240 let root = tempfile::tempdir().unwrap();
1241 let destination = root.path().join("cache");
1242 fs::create_dir(&destination).unwrap();
1243 let legacy = manifest(&destination.join("block.safetensors"));
1244 serde_json::to_writer(
1245 File::create(destination.join("manifest.json")).unwrap(),
1246 &legacy,
1247 )
1248 .unwrap();
1249
1250 for result in [
1251 resolve_prompt_cache_root(&destination).map(|_| ()),
1252 inspect_prompt_cache(&destination).map(|_| ()),
1253 ] {
1254 assert!(matches!(
1255 result,
1256 Err(PromptCachePersistenceError::MalformedStorage(reason))
1257 if reason == "prompt-cache generation pointer CURRENT is missing"
1258 ));
1259 }
1260 }
1261
1262 #[test]
1263 fn failed_publication_removes_staging_directory() {
1264 let root = tempfile::tempdir().unwrap();
1265 let destination = root.path().join("cache");
1266 let staging = {
1267 let publication = PromptCachePublication::begin(&destination, false).unwrap();
1268 publication.staging_directory().to_path_buf()
1269 };
1270 assert!(!staging.exists());
1271 }
1272
1273 #[test]
1274 fn shard_paths_reject_traversal() {
1275 let root = Path::new("/tmp/cache");
1276 assert_eq!(
1277 safe_prompt_cache_shard_path(root, "block.safetensors").unwrap(),
1278 root.join("block.safetensors")
1279 );
1280 assert!(matches!(
1281 safe_prompt_cache_shard_path(root, "../outside.safetensors"),
1282 Err(PromptCachePersistenceError::UnsafeShardPath(_))
1283 ));
1284 assert!(safe_prompt_cache_shard_path(root, "/outside.safetensors").is_err());
1285 }
1286
1287 #[test]
1288 fn malformed_manifest_is_rejected_before_tensor_loading() {
1289 let directory = tempfile::tempdir().unwrap();
1290 let generation = directory
1291 .path()
1292 .join(PROMPT_CACHE_GENERATIONS_DIRECTORY)
1293 .join("generation-test");
1294 fs::create_dir_all(&generation).unwrap();
1295 fs::write(generation.join("manifest.json"), b"{not-json").unwrap();
1296 fs::write(
1297 directory.path().join(PROMPT_CACHE_CURRENT_FILE),
1298 b"generation-test\n",
1299 )
1300 .unwrap();
1301 assert!(matches!(
1302 inspect_prompt_cache(directory.path()),
1303 Err(PromptCachePersistenceError::ManifestJson(_))
1304 ));
1305 }
1306
1307 #[test]
1308 fn shard_metadata_reads_are_bounded() {
1309 let directory = tempfile::tempdir().unwrap();
1310 let path = directory.path().join("oversized.safetensors");
1311 fs::write(
1312 &path,
1313 (MAX_PROMPT_CACHE_SHARD_HEADER_BYTES + 1).to_le_bytes(),
1314 )
1315 .unwrap();
1316 assert!(matches!(
1317 hash_prompt_cache_shard_payload(&path),
1318 Err(PromptCachePersistenceError::MalformedShard { .. })
1319 ));
1320 }
1321
1322 #[test]
1323 fn live_cache_publication_is_unique_rank_aware_and_atomic() {
1324 let directory = tempfile::tempdir().unwrap();
1325 let id = CacheBlockId {
1326 session_id: 7,
1327 global_layer: 3,
1328 representation: CacheRepresentation::KeyValue,
1329 start: 4,
1330 end: 8,
1331 rank: Some(eredu_core::cache::CacheRankIdentity::new(
1332 Some(1),
1333 Some(2),
1334 None,
1335 )),
1336 };
1337 let first = LiveCacheBlockPublication::begin(directory.path(), &id);
1338 let second = LiveCacheBlockPublication::begin(directory.path(), &id);
1339 assert_ne!(first.destination_path(), second.destination_path());
1340 assert!(first
1341 .destination_path()
1342 .to_string_lossy()
1343 .contains("layer-00003-kv-rank-p1-t2-ex-4-8"));
1344
1345 fs::write(first.staging_path(), b"block").unwrap();
1346 let destination = first.commit().unwrap();
1347 assert_eq!(fs::read(destination).unwrap(), b"block");
1348 }
1349
1350 #[test]
1351 fn live_cache_publication_cleans_staging_and_never_replaces() {
1352 let directory = tempfile::tempdir().unwrap();
1353 let id = CacheBlockId {
1354 session_id: 1,
1355 global_layer: 0,
1356 representation: CacheRepresentation::CompressedLatentRotary,
1357 start: 0,
1358 end: 1,
1359 rank: None,
1360 };
1361 let abandoned = LiveCacheBlockPublication::begin(directory.path(), &id);
1362 let abandoned_path = abandoned.staging_path().to_path_buf();
1363 fs::write(&abandoned_path, b"temporary").unwrap();
1364 drop(abandoned);
1365 assert!(!abandoned_path.exists());
1366
1367 let colliding = LiveCacheBlockPublication::begin(directory.path(), &id);
1368 fs::write(colliding.staging_path(), b"new").unwrap();
1369 fs::write(colliding.destination_path(), b"existing").unwrap();
1370 let destination = colliding.destination_path().to_path_buf();
1371 assert!(colliding.commit().is_err());
1372 assert_eq!(fs::read(destination).unwrap(), b"existing");
1373 }
1374}