1use std::fs::File;
14use std::path::Path;
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use anyhow::{bail, ensure, Context, Result};
18use memmap2::Mmap;
19use serde::Serialize;
20
21use super::scan::{self, SegmentEnd};
22use super::verify::{verify_path, VerifyOptions};
23use super::{Report, Verdict, STORAGE_FORMAT_CURRENT, STORE_SCHEMA_VERSION};
24use crate::core::storage::StagedArena;
25use crate::core::store_lock::{StoreLock, LOCK_FILE};
26
27const ARENA_DIRECTORY: &str = "vectors_data.bin";
28
29#[derive(Debug, Clone, Serialize)]
31#[serde(rename_all = "camelCase")]
32pub struct UpgradeStep {
33 pub id: &'static str,
34 pub artefact: &'static str,
35 pub from: u32,
36 pub to: u32,
37 pub description: &'static str,
38}
39
40#[derive(Debug, Clone, Serialize)]
42#[serde(rename_all = "camelCase")]
43pub struct UpgradePlan {
44 pub source: String,
45 pub storage_format: u32,
47 pub target_storage_format: u32,
48 pub store_schema: u32,
49 pub target_store_schema: u32,
50 pub up_to_date: bool,
51 pub steps: Vec<UpgradeStep>,
52}
53
54#[derive(Debug, Clone, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct UpgradeReport {
58 pub plan: UpgradePlan,
59 pub destination: Option<String>,
60 pub frames: usize,
61 pub verification: Option<Report>,
62}
63
64struct StepContext<'a> {
66 source: &'a Path,
67 staging: &'a Path,
68 frames: usize,
69 written: Vec<(u32, u32)>,
72}
73
74struct Migration {
75 step: UpgradeStep,
76 applies: fn(&Detected) -> bool,
78 run: fn(&mut StepContext<'_>) -> Result<()>,
79}
80
81struct Detected {
82 storage_format: u32,
83 store_schema: u32,
84 text_segment: Option<u8>,
85 has_caches: bool,
86}
87
88static MIGRATIONS: &[Migration] = &[
90 Migration {
91 step: UpgradeStep {
92 id: "store-schema-2-to-5",
93 artefact: "store-schema",
94 from: 2,
95 to: 5,
96 description: "enable namespace-scoped durable meaning maintenance; existing journal frames remain readable",
97 },
98 applies: |d| d.store_schema == 2,
99 run: upgrade_schema,
100 },
101 Migration {
102 step: UpgradeStep {
103 id: "store-schema-3-to-5",
104 artefact: "store-schema",
105 from: 3,
106 to: 5,
107 description: "enable namespace-scoped durable meaning maintenance; existing journal frames remain readable",
108 },
109 applies: |d| d.store_schema == 3,
110 run: upgrade_schema,
111 },
112 Migration {
113 step: UpgradeStep {
114 id: "store-schema-4-to-5",
115 artefact: "store-schema",
116 from: 4,
117 to: 5,
118 description: "enable namespace-scoped durable meaning maintenance; existing journal frames remain readable",
119 },
120 applies: |d| d.store_schema == 4,
121 run: upgrade_schema,
122 },
123 Migration {
124 step: UpgradeStep {
125 id: "arena-1-to-4",
126 artefact: "arena",
127 from: 1,
128 to: 4,
129 description: "rewrite the journal frames of a manifest-less store into a format-4 \
130 arena (1 GiB segments allocated in 64 MiB steps, end markers, \
131 checksummed manifest)",
132 },
133 applies: |d| d.storage_format == 1,
134 run: rewrite_arena,
135 },
136 Migration {
137 step: UpgradeStep {
138 id: "arena-2-to-4",
139 artefact: "arena",
140 from: 2,
141 to: 4,
142 description: "rewrite the journal frames of a format-2 arena (fully preallocated 1 \
143 GiB segments, zero terminator) into a format-4 arena",
144 },
145 applies: |d| d.storage_format == 2,
146 run: rewrite_arena,
147 },
148 Migration {
149 step: UpgradeStep {
150 id: "arena-3-to-4",
151 artefact: "arena",
152 from: 3,
153 to: 4,
154 description: "rewrite the journal frames of a format-3 arena (64 MiB fixed \
155 segments) into a format-4 arena with 1 GiB segments",
156 },
157 applies: |d| d.storage_format == 3,
158 run: rewrite_arena,
159 },
160 Migration {
161 step: UpgradeStep {
162 id: "text-segment-1-to-2",
163 artefact: "text-segment",
164 from: 1,
165 to: 2,
166 description: "drop documents.text (a cache; the engine rebuilds it with the current \
167 header from the journal on first open)",
168 },
169 applies: |d| {
170 d.text_segment
171 .is_none_or(|v| v < super::TEXT_SEGMENT_VERSION)
172 },
173 run: |_| Ok(()),
174 },
175 Migration {
176 step: UpgradeStep {
177 id: "index-caches",
178 artefact: "index-caches",
179 from: 0,
180 to: 0,
181 description: "drop indices.json and the trained NSG/IVF files: they are bound to \
182 the old journal length and the engine retrains them",
183 },
184 applies: |d| d.has_caches,
185 run: |_| Ok(()),
186 },
187];
188
189fn detect(source: &Path, report: &Report) -> Detected {
190 Detected {
191 store_schema: report.versions.store_schema.unwrap_or(STORE_SCHEMA_VERSION),
192 storage_format: report
193 .versions
194 .storage_format
195 .unwrap_or(STORAGE_FORMAT_CURRENT),
196 text_segment: source
197 .join("documents.text")
198 .exists()
199 .then_some(report.versions.text_segment.unwrap_or(1)),
200 has_caches: source.join("indices.json").exists(),
201 }
202}
203
204pub fn plan_upgrade(source: &Path) -> Result<UpgradePlan> {
206 let report = verify_path(source, &VerifyOptions::default())?;
207 plan_from(source, &report)
208}
209
210fn plan_from(source: &Path, report: &Report) -> Result<UpgradePlan> {
211 ensure!(
212 report.tenants.is_empty(),
213 "{} is a server data directory; plan each tenant store, or upgrade the directory",
214 source.display()
215 );
216 refuse_unupgradable(report)?;
217 let detected = detect(source, report);
218 let current = detected.storage_format == STORAGE_FORMAT_CURRENT
219 && detected.store_schema == STORE_SCHEMA_VERSION;
220 let steps = if current {
221 Vec::new()
222 } else {
223 MIGRATIONS
224 .iter()
225 .filter(|m| (m.applies)(&detected))
226 .map(|m| m.step.clone())
227 .collect()
228 };
229 Ok(UpgradePlan {
230 source: source.display().to_string(),
231 storage_format: detected.storage_format,
232 target_storage_format: STORAGE_FORMAT_CURRENT,
233 store_schema: detected.store_schema,
234 target_store_schema: STORE_SCHEMA_VERSION,
235 up_to_date: current,
236 steps,
237 })
238}
239
240fn refuse_unupgradable(report: &Report) -> Result<()> {
241 match report.verdict {
242 Verdict::Ok | Verdict::Recoverable => Ok(()),
243 Verdict::Unsupported => {
244 let reasons: Vec<_> = report
245 .findings
246 .iter()
247 .filter(|f| f.severity == super::Severity::Unsupported)
248 .map(|f| format!("{}: {}", f.code, f.message))
249 .collect();
250 bail!(
251 "this build cannot upgrade the store ({}); a store with no store.json or a \
252 pre-HMS-TXN-2 journal is rebuilt with `warm-admin reencode`, a store from a \
253 newer release needs that release's warm-format",
254 reasons.join("; ")
255 )
256 }
257 Verdict::Corrupt => {
258 let reasons: Vec<_> = report
259 .findings
260 .iter()
261 .filter(|f| f.severity == super::Severity::Error)
262 .map(|f| format!("{}: {}", f.code, f.message))
263 .collect();
264 bail!(
265 "the store is damaged and is not upgraded: {}. Run `warm-format verify` for \
266 the full report; restore a snapshot first",
267 reasons.join("; ")
268 )
269 }
270 }
271}
272
273fn prepare_destination(destination: &Path) -> Result<&Path> {
275 ensure!(
276 !destination.exists(),
277 "upgrade destination already exists: {}",
278 destination.display()
279 );
280 let parent = destination
281 .parent()
282 .filter(|p| !p.as_os_str().is_empty())
283 .unwrap_or_else(|| Path::new("."));
284 std::fs::create_dir_all(parent)?;
285 Ok(parent)
286}
287
288pub fn upgrade_store(source: &Path, destination: &Path, dry_run: bool) -> Result<UpgradeReport> {
292 let sniff = verify_path(source, &VerifyOptions::default())?;
293 if !sniff.tenants.is_empty() {
294 return upgrade_data_dir(source, destination, dry_run, &sniff);
295 }
296 let plan = plan_from(source, &sniff)?;
297 if plan.up_to_date || dry_run {
298 return Ok(UpgradeReport {
299 plan,
300 destination: None,
301 frames: sniff.frames,
302 verification: None,
303 });
304 }
305 let source_canonical = source.canonicalize()?;
306 let parent = prepare_destination(destination)?;
307 ensure!(
308 !parent.canonicalize()?.starts_with(&source_canonical),
309 "upgrade destination cannot be inside the source store"
310 );
311 let _lock = StoreLock::acquire(source)?;
312 let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
313 let staging = parent.join(format!(".warm-upgrade-{}-{nonce}", std::process::id()));
314 let outcome = stage_and_publish(source, &staging, destination, plan.clone());
315 if outcome.is_err() && staging.exists() {
316 let _ = std::fs::remove_dir_all(&staging);
318 }
319 outcome
320}
321
322fn upgrade_data_dir(
323 source: &Path,
324 destination: &Path,
325 dry_run: bool,
326 sniff: &Report,
327) -> Result<UpgradeReport> {
328 let mut plans = Vec::new();
329 for tenant in &sniff.tenants {
330 let dir = source
331 .join("tenants")
332 .join(tenant_dir(source, &tenant.name)?);
333 plans.push(plan_from(&dir, &tenant.report)?);
334 }
335 let behind = plans.iter().any(|p| !p.up_to_date);
336 let plan = UpgradePlan {
337 source: source.display().to_string(),
338 storage_format: plans
339 .iter()
340 .map(|p| p.storage_format)
341 .min()
342 .unwrap_or(STORAGE_FORMAT_CURRENT),
343 target_storage_format: STORAGE_FORMAT_CURRENT,
344 store_schema: plans
345 .iter()
346 .map(|p| p.store_schema)
347 .min()
348 .unwrap_or(STORE_SCHEMA_VERSION),
349 target_store_schema: STORE_SCHEMA_VERSION,
350 up_to_date: !behind,
351 steps: plans
352 .iter()
353 .flat_map(|p| p.steps.clone())
354 .fold(Vec::new(), |mut acc, s| {
355 if !acc.iter().any(|a: &UpgradeStep| a.id == s.id) {
356 acc.push(s);
357 }
358 acc
359 }),
360 };
361 if dry_run || !behind {
362 return Ok(UpgradeReport {
363 plan,
364 destination: None,
365 frames: sniff.tenants.iter().map(|t| t.report.frames).sum(),
366 verification: None,
367 });
368 }
369 let parent = prepare_destination(destination)?;
370 let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
371 let staging = parent.join(format!(".warm-upgrade-{}-{nonce}", std::process::id()));
372 let result = (|| -> Result<(usize, Report)> {
373 std::fs::create_dir_all(staging.join("tenants"))?;
374 let mut frames = 0;
375 let mut copied_verdicts = crate::core::hash::FastMap::default();
376 for tenant in &sniff.tenants {
377 let name = tenant_dir(source, &tenant.name)?;
378 let from = source.join("tenants").join(&name);
379 let to = staging.join("tenants").join(&name);
380 let _lock = StoreLock::acquire(&from)?;
381 let tenant_plan = plan_from(&from, &tenant.report)?;
382 if tenant_plan.up_to_date {
383 crate::core::admin::copy_tree(&from, &to)?;
384 copied_verdicts.insert(tenant.name.clone(), tenant.report.verdict);
385 frames += tenant.report.frames;
386 } else {
387 let staged = stage_store(&from, &to)?;
388 if tenant_plan.storage_format == STORAGE_FORMAT_CURRENT {
389 copied_verdicts.insert(tenant.name.clone(), tenant.report.verdict);
390 }
391 frames += staged.frames;
392 }
393 }
394 for entry in std::fs::read_dir(source)? {
395 let entry = entry?;
396 if entry.file_name() != "tenants" && entry.file_type()?.is_file() {
397 std::fs::copy(entry.path(), staging.join(entry.file_name()))?;
398 }
399 }
400 let mut verification = verify_path(&staging, &VerifyOptions::default())?;
401 for tenant in &verification.tenants {
402 let allowed = copied_verdicts
403 .get(&tenant.name)
404 .copied()
405 .unwrap_or(Verdict::Ok);
406 ensure!(
407 tenant.report.verdict == Verdict::Ok
408 || (allowed == Verdict::Recoverable
409 && tenant.report.verdict == Verdict::Recoverable),
410 "upgraded tenant {:?} verifies as {:?} (allowed {:?}): {:?}",
411 tenant.name,
412 tenant.report.verdict,
413 allowed,
414 tenant.report.findings
415 );
416 }
417 ensure!(
418 matches!(verification.verdict, Verdict::Ok | Verdict::Recoverable),
419 "upgraded data directory does not verify: {:?}; tenants: {:?}",
420 verification.findings,
421 verification.tenants
422 );
423 for tenant in &mut verification.tenants {
424 tenant.report.path = destination
425 .join(Path::new(&tenant.report.path).strip_prefix(&staging)?)
426 .display()
427 .to_string();
428 }
429 verification.path = destination.display().to_string();
430 std::fs::rename(&staging, destination)?;
431 crate::core::durable_file::sync_directory(parent)?;
432 Ok((frames, verification))
433 })();
434 match result {
435 Ok((frames, verification)) => Ok(UpgradeReport {
436 plan,
437 destination: Some(destination.display().to_string()),
438 frames,
439 verification: Some(verification),
440 }),
441 Err(error) => {
442 let _ = std::fs::remove_dir_all(&staging);
444 Err(error)
445 }
446 }
447}
448
449fn tenant_dir(source: &Path, name: &str) -> Result<String> {
452 let hex: String = name.bytes().map(|b| format!("{b:02x}")).collect();
453 if source.join("tenants").join(&hex).is_dir() {
454 Ok(hex)
455 } else if source.join("tenants").join(name).is_dir() {
456 Ok(name.to_owned())
457 } else {
458 bail!("tenant directory for {name:?} not found")
459 }
460}
461
462fn stage_and_publish(
463 source: &Path,
464 staging: &Path,
465 destination: &Path,
466 plan: UpgradePlan,
467) -> Result<UpgradeReport> {
468 let original = verify_path(source, &VerifyOptions::default())?;
469 let staged = stage_store(source, staging)?;
470 let verification = verify_path(staging, &VerifyOptions::default())?;
471 ensure!(
472 verification.verdict == Verdict::Ok
473 || (plan.storage_format == STORAGE_FORMAT_CURRENT
474 && original.verdict == Verdict::Recoverable
475 && verification.verdict == Verdict::Recoverable),
476 "the upgraded store does not verify: {:?}",
477 verification.findings
478 );
479 ensure!(
480 verification.frames == staged.frames,
481 "upgraded store holds {} frames, {} were written",
482 verification.frames,
483 staged.frames
484 );
485 std::fs::rename(staging, destination).with_context(|| {
486 format!(
487 "failed to publish {} as {}",
488 staging.display(),
489 destination.display()
490 )
491 })?;
492 if let Some(parent) = destination.parent().filter(|p| !p.as_os_str().is_empty()) {
493 crate::core::durable_file::sync_directory(parent)?;
494 }
495 let mut verification = verification;
496 verification.path = destination.display().to_string();
497 Ok(UpgradeReport {
498 plan,
499 destination: Some(destination.display().to_string()),
500 frames: staged.frames,
501 verification: Some(verification),
502 })
503}
504
505fn copy_store_files(source: &Path, staging: &Path, skip: &[&str]) -> Result<()> {
507 std::fs::create_dir_all(staging)?;
508 for entry in std::fs::read_dir(source)? {
509 let entry = entry?;
510 let name = entry.file_name().to_string_lossy().into_owned();
511 let cache = name.starts_with("nsg_index") || name.starts_with("ivf_index");
512 let skipped = name == LOCK_FILE
513 || name == ARENA_DIRECTORY
514 || name == "indices.json"
515 || name == "indices.json.crc32"
516 || cache
517 || skip.contains(&name.as_str());
518 if skipped {
519 continue;
520 }
521 let kind = entry.file_type()?;
522 ensure!(!kind.is_symlink(), "upgrade refuses symbolic link {name}");
523 if kind.is_file() {
524 let target = staging.join(&name);
525 std::fs::copy(entry.path(), &target)?;
526 std::fs::set_permissions(&target, entry.metadata()?.permissions())?;
527 } else {
528 bail!("upgrade does not know how to carry the directory {name}");
529 }
530 }
531 Ok(())
532}
533
534struct Staged {
535 frames: usize,
536}
537
538fn stage_store(source: &Path, staging: &Path) -> Result<Staged> {
540 let report = verify_path(source, &VerifyOptions::default())?;
541 refuse_unupgradable(&report)?;
542 let detected = detect(source, &report);
543 copy_store_files(source, staging, &["documents.text"])?;
544 let schema = staging.join("store.json");
547 let bytes = std::fs::read(&schema)?;
548 crate::core::durable_file::atomic_write(&schema, &bytes)?;
549 let mut context = StepContext {
550 source,
551 staging,
552 frames: 0,
553 written: Vec::new(),
554 };
555 for migration in MIGRATIONS.iter().filter(|m| (m.applies)(&detected)) {
556 (migration.run)(&mut context)
557 .with_context(|| format!("upgrade step {} failed", migration.step.id))?;
558 }
559 if detected.storage_format == STORAGE_FORMAT_CURRENT {
560 crate::core::admin::copy_tree(
561 &source.join(ARENA_DIRECTORY),
562 &staging.join(ARENA_DIRECTORY),
563 )?;
564 return Ok(Staged {
565 frames: report.frames,
566 });
567 }
568 ensure!(context.frames > 0, "the store has no journal frames");
569 let mut scanned = Vec::new();
571 let arena = staging.join(ARENA_DIRECTORY);
572 for id in 0.. {
573 let path = arena.join(format!("seg_{id}.bin"));
574 if !path.exists() {
575 break;
576 }
577 let map = unsafe { Mmap::map(&File::open(&path)?)? };
580 let scan = scan::scan_segment(&map, true, |f, _| scanned.push((f.raw_len, f.crc)));
581 ensure!(
582 matches!(scan.end, SegmentEnd::Marker | SegmentEnd::Exhausted),
583 "staged segment {id} does not end cleanly: {:?}",
584 scan.end
585 );
586 }
587 ensure!(
588 scanned == context.written,
589 "staged arena differs from the frames read from the source"
590 );
591 Ok(Staged {
592 frames: context.frames,
593 })
594}
595
596fn upgrade_schema(context: &mut StepContext<'_>) -> Result<()> {
597 let path = context.staging.join("store.json");
598 let mut schema: serde_json::Value = serde_json::from_slice(&std::fs::read(&path)?)?;
599 schema["version"] = STORE_SCHEMA_VERSION.into();
600 crate::core::durable_file::atomic_write(&path, &serde_json::to_vec_pretty(&schema)?)
601}
602
603fn rewrite_arena(context: &mut StepContext<'_>) -> Result<()> {
606 let base = context.source.join(ARENA_DIRECTORY);
607 let generation = crate::core::durable_file::arena_generation(&base)?;
608 let ids = crate::core::storage::list_segments(&generation)?;
609 ensure!(!ids.is_empty(), "the source arena has no segments");
610 let directory = context.staging.join(ARENA_DIRECTORY);
612 std::fs::create_dir_all(&directory)?;
613 let mut target = StagedArena::new(&directory, None)?;
614 let mut failure: Option<anyhow::Error> = None;
615 for (n, id) in ids.iter().enumerate() {
616 let path = generation.join(format!("seg_{id}.bin"));
617 let map = unsafe { Mmap::map(&File::open(&path)?)? };
620 let scan = scan::scan_segment(&map, n + 1 == ids.len(), |frame, payload| {
621 if failure.is_none() {
622 match target.write_slice(payload) {
623 Ok(_) => {
624 context.frames += 1;
625 context.written.push((frame.raw_len, frame.crc));
626 }
627 Err(error) => failure = Some(error),
628 }
629 }
630 });
631 if let Some(error) = failure.take() {
632 return Err(error);
633 }
634 if let SegmentEnd::Corrupt { reason, .. } = scan.end {
635 bail!(
636 "{} is corrupt at byte {} ({reason})",
637 path.display(),
638 scan.end_offset
639 )
640 }
641 }
642 target.finish()?;
643 Ok(())
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649
650 #[test]
653 fn the_registry_is_consistent() {
654 let mut ids = std::collections::BTreeSet::new();
655 for migration in MIGRATIONS {
656 assert!(ids.insert(migration.step.id), "duplicate step id");
657 if migration.step.artefact == "arena" {
658 assert!(migration.step.from < migration.step.to);
659 assert_eq!(migration.step.to, STORAGE_FORMAT_CURRENT);
660 }
661 }
662 for from in super::super::STORAGE_FORMAT_OLDEST..STORAGE_FORMAT_CURRENT {
663 assert!(
664 MIGRATIONS
665 .iter()
666 .any(|m| m.step.artefact == "arena" && m.step.from == from),
667 "no arena step leaves format {from}"
668 );
669 }
670 }
671
672 #[test]
673 fn data_directory_paths_are_the_hex_of_the_name() {
674 let root = tempfile::tempdir().unwrap();
675 std::fs::create_dir_all(root.path().join("tenants/616c696365")).unwrap();
676 assert_eq!(tenant_dir(root.path(), "alice").unwrap(), "616c696365");
677 assert!(tenant_dir(root.path(), "bob").is_err());
678 }
679}