1use std::collections::BTreeSet;
4use std::fs::{File, OpenOptions};
5use std::io::Write;
6use std::path::{Path, PathBuf};
7use std::time::Instant;
8
9use serde::Deserialize;
10
11use crate::{
12 EmbeddingCompatibilityId, EmbeddingGenerationId, EmbeddingGenerationManifest,
13 EmbeddingMutationObservation, EmbeddingSourceFingerprint, EmbeddingSourceState,
14 SearchArtifactError, SearchCoordinationLimits,
15};
16
17pub const EMBEDDING_MUTATION_JOURNAL_VERSION: u32 = 1;
19const JOURNAL_FILE: &str = "mutations.json";
20const MAX_DEFAULT_BYTES: usize = 64 * 1024 * 1024;
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub struct EmbeddingMutationJournalLimits {
25 pub changed_uuids: usize,
27 pub metadata_bytes: usize,
29}
30
31impl Default for EmbeddingMutationJournalLimits {
32 fn default() -> Self {
33 Self {
34 changed_uuids: 1_000_000,
35 metadata_bytes: MAX_DEFAULT_BYTES,
36 }
37 }
38}
39
40#[derive(Clone, Copy, Debug)]
42pub struct EmbeddingMutationBatch<'a> {
43 pub current_source: EmbeddingSourceState,
45 pub changed_uuids: &'a [[u8; 16]],
47 pub structural_mutation: bool,
49 pub scope_proven: bool,
51}
52
53#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct EmbeddingMutationJournal {
56 compatibility_id: EmbeddingCompatibilityId,
57 generation_id: EmbeddingGenerationId,
58 recorded_source: EmbeddingSourceFingerprint,
59 current_source: EmbeddingSourceState,
60 changed_uuids: BTreeSet<[u8; 16]>,
61 relevant_committed_batches: u64,
62 structural_mutation: bool,
63 scope_proven: bool,
64}
65
66impl EmbeddingMutationJournal {
67 #[must_use]
69 pub const fn compatibility_id(&self) -> EmbeddingCompatibilityId {
70 self.compatibility_id
71 }
72
73 #[must_use]
75 pub const fn generation_id(&self) -> EmbeddingGenerationId {
76 self.generation_id
77 }
78
79 #[must_use]
81 pub fn changed_uuids(&self) -> &BTreeSet<[u8; 16]> {
82 &self.changed_uuids
83 }
84
85 pub fn observation(&self) -> Result<EmbeddingMutationObservation, SearchArtifactError> {
90 let changed_distinct_uuids = u64::try_from(self.changed_uuids.len())
91 .map_err(|_| exhausted("embedding_mutation_changed_uuids", self.changed_uuids.len()))?;
92 EmbeddingMutationObservation::new(
93 self.current_source,
94 changed_distinct_uuids,
95 self.relevant_committed_batches,
96 self.structural_mutation,
97 self.scope_proven,
98 )
99 }
100
101 fn reset(manifest: &EmbeddingGenerationManifest) -> Self {
102 Self {
103 compatibility_id: manifest.compatibility_id(),
104 generation_id: manifest.generation_id(),
105 recorded_source: manifest.source().fingerprint(),
106 current_source: manifest.source(),
107 changed_uuids: BTreeSet::new(),
108 relevant_committed_batches: 0,
109 structural_mutation: false,
110 scope_proven: true,
111 }
112 }
113
114 fn to_json(
115 &self,
116 limits: EmbeddingMutationJournalLimits,
117 ) -> Result<Vec<u8>, SearchArtifactError> {
118 let changed = self.changed_uuids.iter().map(encode).collect::<Vec<_>>();
119 let source = self.current_source;
120 let bytes = serde_json::to_vec(&serde_json::json!({
121 "changed_uuids": changed,
122 "compatibility_id": self.compatibility_id.to_hex(),
123 "current_dependency_input_digest": encode(&source.dependency_input_digest()),
124 "current_eligible_uuid_count": source.eligible_uuid_count(),
125 "current_graph_generation": source.graph_generation(),
126 "current_label_membership_digest": encode(&source.label_membership_digest()),
127 "current_source_fingerprint": source.fingerprint().to_hex(),
128 "generation_id": self.generation_id.to_hex(),
129 "journal_version": EMBEDDING_MUTATION_JOURNAL_VERSION,
130 "recorded_source_fingerprint": self.recorded_source.to_hex(),
131 "relevant_committed_batches": self.relevant_committed_batches,
132 "scope_proven": self.scope_proven,
133 "structural_mutation": self.structural_mutation,
134 }))
135 .map_err(|error| SearchArtifactError::Build(error.to_string()))?;
136 if bytes.len() > limits.metadata_bytes {
137 return Err(exhausted(
138 "embedding_mutation_journal_bytes",
139 limits.metadata_bytes,
140 ));
141 }
142 Ok(bytes)
143 }
144}
145
146pub fn reset_embedding_mutation_journal<C>(
148 project_dir: &Path,
149 manifest: &EmbeddingGenerationManifest,
150 limits: EmbeddingMutationJournalLimits,
151 coordination: SearchCoordinationLimits,
152 mut checkpoint: C,
153) -> Result<EmbeddingMutationJournal, SearchArtifactError>
154where
155 C: FnMut() -> Result<(), SearchArtifactError>,
156{
157 checkpoint()?;
158 let root = checked_space_root(project_dir, manifest.compatibility_id())?;
159 let _lock = WriterLock::acquire(&root, coordination, &mut checkpoint)?;
160 let path = root.join(JOURNAL_FILE);
161 ensure_optional_regular_file(&path)?;
162 let journal = EmbeddingMutationJournal::reset(manifest);
163 let bytes = journal.to_json(limits)?;
164 checkpoint()?;
165 persist(&path, &bytes)?;
166 Ok(journal)
167}
168
169pub fn merge_embedding_mutation_batch<C>(
171 project_dir: &Path,
172 manifest: &EmbeddingGenerationManifest,
173 batch: EmbeddingMutationBatch<'_>,
174 limits: EmbeddingMutationJournalLimits,
175 coordination: SearchCoordinationLimits,
176 mut checkpoint: C,
177) -> Result<EmbeddingMutationJournal, SearchArtifactError>
178where
179 C: FnMut() -> Result<(), SearchArtifactError>,
180{
181 checkpoint()?;
182 let root = checked_space_root(project_dir, manifest.compatibility_id())?;
183 let _lock = WriterLock::acquire(&root, coordination, &mut checkpoint)?;
184 let path = root.join(JOURNAL_FILE);
185 let mut journal = read_linked(&path, manifest, limits)?
186 .ok_or_else(|| SearchArtifactError::Missing { path: path.clone() })?;
187 if batch.current_source.graph_generation() <= journal.current_source.graph_generation() {
188 return Err(invalid(
189 "embedding mutation batch",
190 "current graph generation must increase",
191 ));
192 }
193 for uuid in batch.changed_uuids {
194 checkpoint()?;
195 journal.changed_uuids.insert(*uuid);
196 if journal.changed_uuids.len() > limits.changed_uuids {
197 return Err(exhausted(
198 "embedding_mutation_changed_uuids",
199 limits.changed_uuids,
200 ));
201 }
202 }
203 journal.relevant_committed_batches = journal
204 .relevant_committed_batches
205 .checked_add(1)
206 .ok_or_else(|| exhausted("embedding_mutation_batches", usize::MAX))?;
207 journal.current_source = batch.current_source;
208 journal.structural_mutation |= batch.structural_mutation;
209 journal.scope_proven &= batch.scope_proven;
210 let bytes = journal.to_json(limits)?;
211 checkpoint()?;
212 persist(&path, &bytes)?;
213 Ok(journal)
214}
215
216pub fn read_embedding_mutation_journal(
218 project_dir: &Path,
219 manifest: &EmbeddingGenerationManifest,
220 limits: EmbeddingMutationJournalLimits,
221) -> Result<Option<EmbeddingMutationJournal>, SearchArtifactError> {
222 let path = checked_space_root(project_dir, manifest.compatibility_id())?.join(JOURNAL_FILE);
223 read_linked(&path, manifest, limits)
224}
225
226fn read_linked(
227 path: &Path,
228 manifest: &EmbeddingGenerationManifest,
229 limits: EmbeddingMutationJournalLimits,
230) -> Result<Option<EmbeddingMutationJournal>, SearchArtifactError> {
231 let Some(bytes) = bounded_read(path, limits.metadata_bytes)? else {
232 return Ok(None);
233 };
234 let raw: RawJournal =
235 serde_json::from_slice(&bytes).map_err(|error| corrupt(path, error.to_string()))?;
236 if raw.journal_version != u64::from(EMBEDDING_MUTATION_JOURNAL_VERSION) {
237 return Err(SearchArtifactError::IncompatibleManifest {
238 path: path.to_path_buf(),
239 found: raw.journal_version,
240 supported: EMBEDDING_MUTATION_JOURNAL_VERSION,
241 });
242 }
243 let compatibility_id = EmbeddingCompatibilityId::from_hex(&raw.compatibility_id)
244 .map_err(|error| corrupt(path, error.to_string()))?;
245 let generation_id = EmbeddingGenerationId::from_hex(&raw.generation_id)
246 .map_err(|error| corrupt(path, error.to_string()))?;
247 let recorded_source = EmbeddingSourceFingerprint::from_hex(&raw.recorded_source_fingerprint)
248 .map_err(|error| corrupt(path, error.to_string()))?;
249 if compatibility_id != manifest.compatibility_id()
250 || generation_id != manifest.generation_id()
251 || recorded_source != manifest.source().fingerprint()
252 {
253 return Err(corrupt(
254 path,
255 "journal linkage does not match active generation",
256 ));
257 }
258 let current_source = EmbeddingSourceState::new(
259 raw.current_graph_generation,
260 decode32(path, &raw.current_label_membership_digest)?,
261 decode32(path, &raw.current_dependency_input_digest)?,
262 raw.current_eligible_uuid_count,
263 );
264 if current_source.fingerprint().to_hex() != raw.current_source_fingerprint {
265 return Err(corrupt(path, "current source fingerprint mismatch"));
266 }
267 if current_source.graph_generation() < manifest.source().graph_generation() {
268 return Err(corrupt(
269 path,
270 "current source predates the recorded generation source",
271 ));
272 }
273 let mut changed_uuids = BTreeSet::new();
274 for value in raw.changed_uuids {
275 if !changed_uuids.insert(decode16(path, &value)?) {
276 return Err(corrupt(path, "duplicate changed UUID"));
277 }
278 }
279 if changed_uuids.len() > limits.changed_uuids {
280 return Err(exhausted(
281 "embedding_mutation_changed_uuids",
282 limits.changed_uuids,
283 ));
284 }
285 let journal = EmbeddingMutationJournal {
286 compatibility_id,
287 generation_id,
288 recorded_source,
289 current_source,
290 changed_uuids,
291 relevant_committed_batches: raw.relevant_committed_batches,
292 structural_mutation: raw.structural_mutation,
293 scope_proven: raw.scope_proven,
294 };
295 journal
296 .observation()
297 .map_err(|error| corrupt(path, error.to_string()))?;
298 if journal.to_json(limits)? != bytes {
299 return Err(corrupt(path, "journal bytes are not exact canonical JSON"));
300 }
301 Ok(Some(journal))
302}
303
304#[derive(Deserialize)]
305#[serde(deny_unknown_fields)]
306struct RawJournal {
307 journal_version: u64,
308 compatibility_id: String,
309 generation_id: String,
310 recorded_source_fingerprint: String,
311 current_source_fingerprint: String,
312 current_graph_generation: u64,
313 current_label_membership_digest: String,
314 current_dependency_input_digest: String,
315 current_eligible_uuid_count: u64,
316 changed_uuids: Vec<String>,
317 relevant_committed_batches: u64,
318 structural_mutation: bool,
319 scope_proven: bool,
320}
321
322fn checked_space_root(
323 project: &Path,
324 id: EmbeddingCompatibilityId,
325) -> Result<PathBuf, SearchArtifactError> {
326 let embeddings = project.join("embeddings");
327 ensure_real_directory(&embeddings)?;
328 let spaces = embeddings.join("spaces");
329 ensure_real_directory(&spaces)?;
330 let root = spaces.join(id.to_hex());
331 ensure_real_directory(&root)?;
332 Ok(root)
333}
334
335fn ensure_real_directory(path: &Path) -> Result<(), SearchArtifactError> {
336 let metadata = std::fs::symlink_metadata(path)
337 .map_err(|source| io("inspect embedding directory", path, source))?;
338 if metadata.file_type().is_symlink() || !metadata.is_dir() {
339 return Err(corrupt(path, "embedding path must be a real directory"));
340 }
341 Ok(())
342}
343
344fn ensure_optional_regular_file(path: &Path) -> Result<(), SearchArtifactError> {
345 match std::fs::symlink_metadata(path) {
346 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
347 Err(corrupt(path, "journal path must be a regular file"))
348 }
349 Ok(_) => Ok(()),
350 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
351 Err(source) => Err(io("inspect mutation journal", path, source)),
352 }
353}
354
355fn bounded_read(path: &Path, max: usize) -> Result<Option<Vec<u8>>, SearchArtifactError> {
356 let metadata = match std::fs::symlink_metadata(path) {
357 Ok(metadata) => metadata,
358 Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
359 Err(source) => return Err(io("inspect mutation journal", path, source)),
360 };
361 if metadata.file_type().is_symlink() || !metadata.is_file() {
362 return Err(corrupt(path, "journal must be a regular file"));
363 }
364 if metadata.len() > usize_limit(max) {
365 return Err(exhausted("embedding_mutation_journal_bytes", max));
366 }
367 std::fs::read(path)
368 .map(Some)
369 .map_err(|source| io("read mutation journal", path, source))
370}
371
372fn persist(path: &Path, bytes: &[u8]) -> Result<(), SearchArtifactError> {
373 ensure_optional_regular_file(path)?;
374 let parent = path
375 .parent()
376 .ok_or_else(|| SearchArtifactError::Build("journal has no parent".to_owned()))?;
377 let mut temp = tempfile::Builder::new()
378 .prefix(".mutations.json.")
379 .suffix(".tmp")
380 .tempfile_in(parent)
381 .map_err(|source| io("create mutation journal temp", path, source))?;
382 temp.write_all(bytes)
383 .map_err(|source| io("write mutation journal", path, source))?;
384 temp.as_file()
385 .sync_all()
386 .map_err(|source| io("sync mutation journal", path, source))?;
387 temp.persist(path)
388 .map_err(|error| io("publish mutation journal", path, error.error))?;
389 sync_dir(parent)
390}
391
392struct WriterLock {
393 file: File,
394}
395
396impl WriterLock {
397 fn acquire<C>(
398 root: &Path,
399 limits: SearchCoordinationLimits,
400 checkpoint: &mut C,
401 ) -> Result<Self, SearchArtifactError>
402 where
403 C: FnMut() -> Result<(), SearchArtifactError>,
404 {
405 let path = root.join(".writer.lock");
406 ensure_optional_regular_file(&path)?;
407 let file = OpenOptions::new()
408 .read(true)
409 .write(true)
410 .create(true)
411 .truncate(false)
412 .open(&path)
413 .map_err(|source| SearchArtifactError::Lock {
414 path: path.clone(),
415 reason: source.to_string(),
416 })?;
417 let start = Instant::now();
418 loop {
419 match file.try_lock() {
420 Ok(()) => return Ok(Self { file }),
421 Err(std::fs::TryLockError::WouldBlock) => {
422 checkpoint()?;
423 if start.elapsed() >= limits.lock_timeout {
424 return Err(SearchArtifactError::Lock {
425 path,
426 reason: "timed out waiting for embedding writer".to_owned(),
427 });
428 }
429 std::thread::sleep(limits.lock_poll_interval);
430 }
431 Err(std::fs::TryLockError::Error(source)) => {
432 return Err(SearchArtifactError::Lock {
433 path,
434 reason: source.to_string(),
435 });
436 }
437 }
438 }
439 }
440}
441
442impl Drop for WriterLock {
443 fn drop(&mut self) {
444 let _ = self.file.unlock();
445 }
446}
447
448fn encode<const N: usize>(value: &[u8; N]) -> String {
449 const HEX: &[u8; 16] = b"0123456789abcdef";
450 let mut output = String::with_capacity(N * 2);
451 for byte in value {
452 output.push(char::from(HEX[usize::from(byte >> 4)]));
453 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
454 }
455 output
456}
457
458fn decode16(path: &Path, value: &str) -> Result<[u8; 16], SearchArtifactError> {
459 decode(path, value)
460}
461
462fn decode32(path: &Path, value: &str) -> Result<[u8; 32], SearchArtifactError> {
463 decode(path, value)
464}
465
466fn decode<const N: usize>(path: &Path, value: &str) -> Result<[u8; N], SearchArtifactError> {
467 if value.len() != N * 2
468 || !value
469 .bytes()
470 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
471 {
472 return Err(corrupt(path, "invalid lowercase hexadecimal field"));
473 }
474 let mut output = [0; N];
475 for (index, byte) in output.iter_mut().enumerate() {
476 let offset = index * 2;
477 *byte = u8::from_str_radix(&value[offset..offset + 2], 16)
478 .map_err(|error| corrupt(path, error.to_string()))?;
479 }
480 Ok(output)
481}
482
483#[cfg(unix)]
484fn sync_dir(path: &Path) -> Result<(), SearchArtifactError> {
485 File::open(path)
486 .and_then(|file| file.sync_all())
487 .map_err(|source| io("sync mutation journal directory", path, source))
488}
489
490#[cfg(not(unix))]
491fn sync_dir(_: &Path) -> Result<(), SearchArtifactError> {
492 Ok(())
493}
494
495fn usize_limit(limit: usize) -> u64 {
496 u64::try_from(limit).unwrap_or(u64::MAX)
497}
498
499fn exhausted(resource: &'static str, limit: usize) -> SearchArtifactError {
500 SearchArtifactError::ResourceExhausted {
501 resource,
502 limit: usize_limit(limit),
503 }
504}
505
506fn invalid(field: &'static str, reason: impl Into<String>) -> SearchArtifactError {
507 SearchArtifactError::InvalidSelector {
508 field,
509 reason: reason.into(),
510 }
511}
512
513fn corrupt(path: &Path, reason: impl Into<String>) -> SearchArtifactError {
514 SearchArtifactError::CorruptManifest {
515 path: path.to_path_buf(),
516 reason: reason.into(),
517 }
518}
519
520fn io(operation: &'static str, path: &Path, source: std::io::Error) -> SearchArtifactError {
521 SearchArtifactError::Io {
522 operation,
523 path: path.to_path_buf(),
524 source,
525 }
526}
527
528#[cfg(test)]
529mod tests {
530 use serde_json::{Value, json};
531
532 use super::*;
533 use crate::{
534 EmbeddingContentDigest, EmbeddingGenerationManifestInput, EmbeddingPublicationFingerprint,
535 };
536
537 const UUID_A: [u8; 16] = [1; 16];
538 const UUID_B: [u8; 16] = [2; 16];
539 const UUID_C: [u8; 16] = [3; 16];
540
541 fn compatibility(marker: u8) -> EmbeddingCompatibilityId {
542 EmbeddingCompatibilityId::from_hex(&format!("{marker:02x}").repeat(32)).unwrap()
543 }
544
545 fn source(generation: u64, marker: u8) -> EmbeddingSourceState {
546 EmbeddingSourceState::new(generation, [marker; 32], [marker + 1; 32], 3)
547 }
548
549 fn manifest(marker: u8) -> EmbeddingGenerationManifest {
550 EmbeddingGenerationManifest::new(EmbeddingGenerationManifestInput {
551 compatibility_id: compatibility(marker),
552 source: source(10, marker),
553 content_digest: EmbeddingContentDigest::digest(&[marker]),
554 vector_count: 3,
555 dimension: 2,
556 generated_at_micros: 20,
557 committed_at_micros: 21,
558 publication_fingerprint: EmbeddingPublicationFingerprint::digest(&[marker]),
559 })
560 .unwrap()
561 }
562
563 fn create_space(project: &Path, manifest: &EmbeddingGenerationManifest) -> PathBuf {
564 let root = project
565 .join("embeddings")
566 .join("spaces")
567 .join(manifest.compatibility_id().to_hex());
568 std::fs::create_dir_all(&root).unwrap();
569 root
570 }
571
572 fn reset(project: &Path, manifest: &EmbeddingGenerationManifest) -> EmbeddingMutationJournal {
573 reset_embedding_mutation_journal(
574 project,
575 manifest,
576 EmbeddingMutationJournalLimits::default(),
577 SearchCoordinationLimits::default(),
578 || Ok(()),
579 )
580 .unwrap()
581 }
582
583 fn merge(
584 project: &Path,
585 manifest: &EmbeddingGenerationManifest,
586 batch: EmbeddingMutationBatch<'_>,
587 ) -> EmbeddingMutationJournal {
588 merge_embedding_mutation_batch(
589 project,
590 manifest,
591 batch,
592 EmbeddingMutationJournalLimits::default(),
593 SearchCoordinationLimits::default(),
594 || Ok(()),
595 )
596 .unwrap()
597 }
598
599 #[test]
600 fn reset_merge_and_reopen_reconstruct_exact_observation() {
601 let project = tempfile::tempdir().unwrap();
602 let manifest = manifest(1);
603 let root = create_space(project.path(), &manifest);
604
605 let initial = reset(project.path(), &manifest);
606 assert_eq!(initial.current_source, manifest.source());
607 assert!(initial.changed_uuids().is_empty());
608
609 merge(
610 project.path(),
611 &manifest,
612 EmbeddingMutationBatch {
613 current_source: source(11, 2),
614 changed_uuids: &[UUID_B, UUID_A, UUID_A],
615 structural_mutation: false,
616 scope_proven: true,
617 },
618 );
619 let merged = merge(
620 project.path(),
621 &manifest,
622 EmbeddingMutationBatch {
623 current_source: source(12, 3),
624 changed_uuids: &[UUID_C, UUID_A],
625 structural_mutation: true,
626 scope_proven: false,
627 },
628 );
629 let reopened = read_embedding_mutation_journal(
630 project.path(),
631 &manifest,
632 EmbeddingMutationJournalLimits::default(),
633 )
634 .unwrap()
635 .unwrap();
636 assert_eq!(reopened, merged);
637 assert_eq!(
638 reopened.changed_uuids().iter().copied().collect::<Vec<_>>(),
639 [UUID_A, UUID_B, UUID_C]
640 );
641 let observation = reopened.observation().unwrap();
642 assert_eq!(observation.current_source(), source(12, 3));
643 assert_eq!(observation.changed_distinct_uuids(), 3);
644 assert_eq!(observation.relevant_committed_batches(), 2);
645 assert!(observation.structural_mutation());
646 assert!(!observation.scope_proven());
647
648 let raw: Value =
649 serde_json::from_slice(&std::fs::read(root.join(JOURNAL_FILE)).unwrap()).unwrap();
650 assert_eq!(
651 raw["changed_uuids"],
652 json!([encode(&UUID_A), encode(&UUID_B), encode(&UUID_C)])
653 );
654 assert_eq!(reset(project.path(), &manifest), initial);
655 }
656
657 #[test]
658 fn missing_journal_is_distinct_and_lineages_are_byte_isolated() {
659 let project = tempfile::tempdir().unwrap();
660 let left = manifest(4);
661 let right = manifest(5);
662 let left_root = create_space(project.path(), &left);
663 let right_root = create_space(project.path(), &right);
664 assert!(
665 read_embedding_mutation_journal(
666 project.path(),
667 &left,
668 EmbeddingMutationJournalLimits::default(),
669 )
670 .unwrap()
671 .is_none()
672 );
673
674 reset(project.path(), &left);
675 reset(project.path(), &right);
676 let right_before = std::fs::read(right_root.join(JOURNAL_FILE)).unwrap();
677 merge(
678 project.path(),
679 &left,
680 EmbeddingMutationBatch {
681 current_source: source(11, 6),
682 changed_uuids: &[UUID_A],
683 structural_mutation: false,
684 scope_proven: true,
685 },
686 );
687 assert_ne!(
688 std::fs::read(left_root.join(JOURNAL_FILE)).unwrap(),
689 right_before
690 );
691 assert_eq!(
692 std::fs::read(right_root.join(JOURNAL_FILE)).unwrap(),
693 right_before
694 );
695 }
696
697 #[test]
698 fn cancellation_limits_monotonicity_and_overflow_preserve_prior_bytes() {
699 let project = tempfile::tempdir().unwrap();
700 let manifest = manifest(7);
701 let root = create_space(project.path(), &manifest);
702 reset(project.path(), &manifest);
703 let path = root.join(JOURNAL_FILE);
704
705 let before = std::fs::read(&path).unwrap();
706 let mut checkpoints = 0;
707 let cancelled = merge_embedding_mutation_batch(
708 project.path(),
709 &manifest,
710 EmbeddingMutationBatch {
711 current_source: source(11, 8),
712 changed_uuids: &[UUID_A, UUID_B],
713 structural_mutation: false,
714 scope_proven: true,
715 },
716 EmbeddingMutationJournalLimits::default(),
717 SearchCoordinationLimits::default(),
718 || {
719 checkpoints += 1;
720 if checkpoints == 3 {
721 Err(SearchArtifactError::Cancelled)
722 } else {
723 Ok(())
724 }
725 },
726 )
727 .unwrap_err();
728 assert!(matches!(cancelled, SearchArtifactError::Cancelled));
729 assert_eq!(std::fs::read(&path).unwrap(), before);
730
731 let limited = merge_embedding_mutation_batch(
732 project.path(),
733 &manifest,
734 EmbeddingMutationBatch {
735 current_source: source(11, 8),
736 changed_uuids: &[UUID_A],
737 structural_mutation: false,
738 scope_proven: true,
739 },
740 EmbeddingMutationJournalLimits {
741 changed_uuids: 0,
742 metadata_bytes: MAX_DEFAULT_BYTES,
743 },
744 SearchCoordinationLimits::default(),
745 || Ok(()),
746 )
747 .unwrap_err();
748 assert!(matches!(
749 limited,
750 SearchArtifactError::ResourceExhausted { .. }
751 ));
752 assert_eq!(std::fs::read(&path).unwrap(), before);
753
754 let non_monotonic = merge_embedding_mutation_batch(
755 project.path(),
756 &manifest,
757 EmbeddingMutationBatch {
758 current_source: source(10, 8),
759 changed_uuids: &[],
760 structural_mutation: false,
761 scope_proven: true,
762 },
763 EmbeddingMutationJournalLimits::default(),
764 SearchCoordinationLimits::default(),
765 || Ok(()),
766 )
767 .unwrap_err();
768 assert!(matches!(
769 non_monotonic,
770 SearchArtifactError::InvalidSelector { .. }
771 ));
772 assert_eq!(std::fs::read(&path).unwrap(), before);
773
774 let mut raw: Value = serde_json::from_slice(&before).unwrap();
775 raw["relevant_committed_batches"] = json!(u64::MAX);
776 let maximum = serde_json::to_vec(&raw).unwrap();
777 std::fs::write(&path, &maximum).unwrap();
778 let overflow = merge_embedding_mutation_batch(
779 project.path(),
780 &manifest,
781 EmbeddingMutationBatch {
782 current_source: source(11, 8),
783 changed_uuids: &[],
784 structural_mutation: false,
785 scope_proven: true,
786 },
787 EmbeddingMutationJournalLimits::default(),
788 SearchCoordinationLimits::default(),
789 || Ok(()),
790 )
791 .unwrap_err();
792 assert!(matches!(
793 overflow,
794 SearchArtifactError::ResourceExhausted { .. }
795 ));
796 assert_eq!(std::fs::read(&path).unwrap(), maximum);
797 }
798
799 #[test]
800 fn reset_cancellation_and_metadata_limit_preserve_prior_bytes() {
801 let project = tempfile::tempdir().unwrap();
802 let manifest = manifest(8);
803 let root = create_space(project.path(), &manifest);
804 reset(project.path(), &manifest);
805 let path = root.join(JOURNAL_FILE);
806 let before = std::fs::read(&path).unwrap();
807
808 let mut checkpoints = 0;
809 let cancelled = reset_embedding_mutation_journal(
810 project.path(),
811 &manifest,
812 EmbeddingMutationJournalLimits::default(),
813 SearchCoordinationLimits::default(),
814 || {
815 checkpoints += 1;
816 if checkpoints == 2 {
817 Err(SearchArtifactError::Cancelled)
818 } else {
819 Ok(())
820 }
821 },
822 )
823 .unwrap_err();
824 assert!(matches!(cancelled, SearchArtifactError::Cancelled));
825 assert_eq!(std::fs::read(&path).unwrap(), before);
826
827 let limited = reset_embedding_mutation_journal(
828 project.path(),
829 &manifest,
830 EmbeddingMutationJournalLimits {
831 changed_uuids: 1,
832 metadata_bytes: 1,
833 },
834 SearchCoordinationLimits::default(),
835 || Ok(()),
836 )
837 .unwrap_err();
838 assert!(matches!(
839 limited,
840 SearchArtifactError::ResourceExhausted { .. }
841 ));
842 assert_eq!(std::fs::read(&path).unwrap(), before);
843 }
844
845 #[test]
846 fn corruption_incompatible_versions_and_linkage_mismatch_fail_closed() {
847 let cases = ["duplicate", "unknown", "noncanonical", "version"];
848 for case in cases {
849 let project = tempfile::tempdir().unwrap();
850 let manifest = manifest(9);
851 let root = create_space(project.path(), &manifest);
852 reset(project.path(), &manifest);
853 let path = root.join(JOURNAL_FILE);
854 let canonical = std::fs::read(&path).unwrap();
855 let replacement = match case {
856 "duplicate" => {
857 let mut bytes = br#"{"journal_version":1,"#.to_vec();
858 bytes.extend_from_slice(&canonical[1..]);
859 bytes
860 }
861 "unknown" => {
862 let mut raw: Value = serde_json::from_slice(&canonical).unwrap();
863 raw["unexpected"] = json!(true);
864 serde_json::to_vec(&raw).unwrap()
865 }
866 "noncanonical" => {
867 let mut bytes = canonical.clone();
868 bytes.push(b'\n');
869 bytes
870 }
871 "version" => {
872 let mut raw: Value = serde_json::from_slice(&canonical).unwrap();
873 raw["journal_version"] = json!(99);
874 serde_json::to_vec(&raw).unwrap()
875 }
876 _ => unreachable!(),
877 };
878 std::fs::write(&path, &replacement).unwrap();
879 let error = read_embedding_mutation_journal(
880 project.path(),
881 &manifest,
882 EmbeddingMutationJournalLimits::default(),
883 )
884 .unwrap_err();
885 if case == "version" {
886 assert!(matches!(
887 error,
888 SearchArtifactError::IncompatibleManifest { found: 99, .. }
889 ));
890 } else {
891 assert!(matches!(error, SearchArtifactError::CorruptManifest { .. }));
892 }
893 assert_eq!(std::fs::read(&path).unwrap(), replacement);
894 }
895
896 let project = tempfile::tempdir().unwrap();
897 let active = manifest(10);
898 let other = manifest(11);
899 let root = create_space(project.path(), &active);
900 reset(project.path(), &active);
901 let path = root.join(JOURNAL_FILE);
902 let before = std::fs::read(&path).unwrap();
903 let mut raw: Value = serde_json::from_slice(&before).unwrap();
904 raw["generation_id"] = json!(other.generation_id().to_hex());
905 let mismatch = serde_json::to_vec(&raw).unwrap();
906 std::fs::write(&path, &mismatch).unwrap();
907 assert!(matches!(
908 read_embedding_mutation_journal(
909 project.path(),
910 &active,
911 EmbeddingMutationJournalLimits::default(),
912 ),
913 Err(SearchArtifactError::CorruptManifest { .. })
914 ));
915 assert_eq!(std::fs::read(&path).unwrap(), mismatch);
916 }
917
918 #[cfg(unix)]
919 #[test]
920 fn symlinked_ancestors_journal_and_lock_fail_closed() {
921 use std::os::unix::fs::symlink;
922
923 let external = tempfile::tempdir().unwrap();
924 let project = tempfile::tempdir().unwrap();
925 let manifest = manifest(12);
926 symlink(external.path(), project.path().join("embeddings")).unwrap();
927 assert!(matches!(
928 reset_embedding_mutation_journal(
929 project.path(),
930 &manifest,
931 EmbeddingMutationJournalLimits::default(),
932 SearchCoordinationLimits::default(),
933 || Ok(()),
934 ),
935 Err(SearchArtifactError::CorruptManifest { .. })
936 ));
937
938 let project = tempfile::tempdir().unwrap();
939 let root = create_space(project.path(), &manifest);
940 let external_file = external.path().join("external");
941 std::fs::write(&external_file, b"unchanged").unwrap();
942 symlink(&external_file, root.join(JOURNAL_FILE)).unwrap();
943 assert!(matches!(
944 reset_embedding_mutation_journal(
945 project.path(),
946 &manifest,
947 EmbeddingMutationJournalLimits::default(),
948 SearchCoordinationLimits::default(),
949 || Ok(()),
950 ),
951 Err(SearchArtifactError::CorruptManifest { .. })
952 ));
953 assert_eq!(std::fs::read(&external_file).unwrap(), b"unchanged");
954
955 std::fs::remove_file(root.join(JOURNAL_FILE)).unwrap();
956 std::fs::remove_file(root.join(".writer.lock")).unwrap();
957 symlink(&external_file, root.join(".writer.lock")).unwrap();
958 assert!(matches!(
959 reset_embedding_mutation_journal(
960 project.path(),
961 &manifest,
962 EmbeddingMutationJournalLimits::default(),
963 SearchCoordinationLimits::default(),
964 || Ok(()),
965 ),
966 Err(SearchArtifactError::CorruptManifest { .. })
967 ));
968 assert_eq!(std::fs::read(&external_file).unwrap(), b"unchanged");
969 }
970}