1use super::durability::{CommitTier, DurabilityMode};
7use super::lock::StoreLock;
8use super::{AccessMode, OpenOptions, Store, StoreError};
9use crate::ingest::{DeleteBatch, DocId, IngestBatch, IngestDocument};
10use crate::meta::Predicate;
11use crate::vfs::{StdVfs, SyncKind, Vfs};
12use std::collections::BTreeMap;
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15use xxhash_rust::xxh3::xxh3_64;
16
17const RECORD: &str = ".ze-namespaces";
18const REFERENCE: &str = ".ze-namespace-root";
19const PREPARED: &str = ".ze-prepared";
20const MAGIC: &[u8] = b"ZENS0001";
21const MAX_RECORD: usize = 1024 * 1024;
22static NEXT: AtomicU64 = AtomicU64::new(0);
23type Routes = BTreeMap<String, String>;
24
25pub struct NamespaceMutation {
27 pub name: String,
29 pub options: OpenOptions,
31 pub upserts: Vec<IngestDocument>,
33 pub deletes: Vec<DocId>,
35 pub delete_where: Option<Predicate>,
37}
38
39pub struct LiveNamespaceMutation<'a> {
41 pub store: &'a Store,
43 pub mutation: NamespaceMutation,
45}
46
47pub fn namespace_batch_live(
49 root: &Path,
50 participants: Vec<LiveNamespaceMutation<'_>>,
51) -> Result<Vec<u64>, StoreError> {
52 execute_live(root, participants, &StdVfs, &mut |_| Ok(()), None)
53}
54
55#[cfg(any(test, feature = "test-seams"))]
57#[doc(hidden)]
58pub fn namespace_batch_live_with_steps(
59 root: &Path,
60 participants: Vec<LiveNamespaceMutation<'_>>,
61 step: &mut dyn FnMut(&str) -> std::io::Result<()>,
62) -> Result<Vec<u64>, StoreError> {
63 execute_live(root, participants, &StdVfs, step, None)
64}
65
66#[cfg(any(test, feature = "test-seams"))]
68#[doc(hidden)]
69pub fn namespace_batch_live_on_vfs(
70 root: &Path,
71 participants: Vec<LiveNamespaceMutation<'_>>,
72 vfs: &dyn Vfs,
73) -> Result<Vec<u64>, StoreError> {
74 execute_live(root, participants, vfs, &mut |_| Ok(()), None)
75}
76
77fn execute_live(
78 root: &Path,
79 mut participants: Vec<LiveNamespaceMutation<'_>>,
80 vfs: &dyn Vfs,
81 step: &mut dyn FnMut(&str) -> std::io::Result<()>,
82 coordinator: Option<StoreLock>,
83) -> Result<Vec<u64>, StoreError> {
84 use crate::ingest::wal_payload::{self, TransactionBinding};
85 use std::sync::Arc;
86 let root = std::fs::canonicalize(root).map_err(|e| io(root, e))?;
87 if !(2..=128).contains(&participants.len()) {
88 return Err(invalid(
89 &root,
90 "namespace batch requires 2..128 participants",
91 ));
92 }
93 let _coordinator = match coordinator {
94 Some(lock) => lock,
95 None => StoreLock::acquire(&root).map_err(StoreError::Lock)?,
96 };
97 reclamation::run(vfs, &root, step)?;
98 let deleting = participants
99 .iter()
100 .any(|p| !p.mutation.deletes.is_empty() || p.mutation.delete_where.is_some());
101 if deleting {
102 reclamation::require_retired_erased(vfs, &root)?;
103 }
104 normalize_accepted(vfs, &root, step)?;
105 let existing_routes = match root_descriptor_vfs(vfs, &root)? {
106 RootDescriptor::Legacy(routes) => routes,
107 RootDescriptor::Staged(_) => return Err(invalid(&root, "pending adoption")),
108 };
109 let order = participants
110 .iter()
111 .enumerate()
112 .map(|(index, p)| (p.mutation.name.clone(), index))
113 .collect::<BTreeMap<_, _>>();
114 if order.len() != participants.len() {
115 return Err(invalid(&root, "duplicate participant"));
116 }
117 let mut identities = std::collections::BTreeSet::new();
118 for (name, index) in &order {
119 let p = participants
120 .get(*index)
121 .ok_or_else(|| invalid(&root, "participant index"))?;
122 if !name_valid(name) || p.mutation.options.access_mode != AccessMode::ReadWrite {
123 return Err(invalid(&root, "invalid or read-only participant"));
124 }
125 let logical = root.join(name);
126 if std::fs::symlink_metadata(&logical)
127 .map_err(|e| io(&logical, e))?
128 .file_type()
129 .is_symlink()
130 {
131 return Err(invalid(
132 &logical,
133 "namespace links are not transaction participants",
134 ));
135 }
136 let selected = std::fs::canonicalize(resolve(&logical)?).map_err(|e| io(&logical, e))?;
137 let directory = std::fs::canonicalize(&p.store.directory).map_err(|e| io(&logical, e))?;
138 if selected != directory || !identities.insert(directory) {
139 return Err(invalid(&logical, "foreign or aliased namespace handle"));
140 }
141 }
142 let ordered = order
145 .values()
146 .map(|index| {
147 participants
148 .get(*index)
149 .ok_or_else(|| invalid(&root, "participant index"))
150 })
151 .collect::<Result<Vec<_>, _>>()?;
152 let mut maintenance = Vec::new();
153 let mut states = Vec::new();
154 let mut owners = Vec::new();
155 let mut wals = Vec::new();
156 let mut actives = Vec::new();
157 for p in &ordered {
158 maintenance.push(
159 p.store
160 .maintenance
161 .lock()
162 .map_err(|_| invalid(&root, "maintenance lock"))?,
163 );
164 }
165 for p in &ordered {
166 let state = p
167 .store
168 .state
169 .lock()
170 .map_err(|_| invalid(&root, "state lock"))?;
171 match *state {
172 super::StoreState::Open => {}
173 super::StoreState::Closing => return Err(StoreError::Closing),
174 super::StoreState::Closed => return Err(StoreError::Closed),
175 }
176 states.push(state);
177 }
178 for p in &ordered {
179 let owner = p
180 .store
181 .writer_lock
182 .lock()
183 .map_err(|_| invalid(&root, "writer lock"))?;
184 if owner.is_none() {
185 return Err(StoreError::ReadOnly);
186 }
187 owners.push(owner);
188 }
189 for p in &ordered {
190 wals.push(
191 p.store
192 .wal_writer
193 .lock()
194 .map_err(|_| invalid(&root, "WAL lock"))?,
195 );
196 }
197 for p in &ordered {
198 actives.push(
199 p.store
200 .active
201 .lock()
202 .map_err(|_| invalid(&root, "active lock"))?,
203 );
204 }
205 let mut source_snapshots = Vec::new();
206 for ((p, wal), active) in ordered.iter().zip(&wals).zip(&actives) {
208 wal.as_ref().ok_or(StoreError::ReadOnly)?;
209 if read_optional_vfs(
210 p.store.vfs.as_ref(),
211 &p.store.directory.join(crate::ingest::PURGE_INTENT_FILE),
212 )?
213 .is_some()
214 {
215 return Err(StoreError::StoreBusy {
216 path: p.store.directory.clone(),
217 });
218 }
219 active.as_ref().ok_or(StoreError::Closed)?;
220 if !p.mutation.deletes.is_empty() || p.mutation.delete_where.is_some() {
221 p.store.require_no_snapshot_views()?;
222 }
223 let snapshot = p
224 .store
225 .snapshot
226 .read()
227 .map_err(|_| invalid(&root, "snapshot lock"))?
228 .as_ref()
229 .cloned()
230 .ok_or(StoreError::Closed)?;
231 super::resolve_open_schema(
232 true,
233 &snapshot,
234 p.mutation.options.schema.as_ref(),
235 AccessMode::ReadOnly,
236 )?;
237 super::validate_epoch_identity(
238 p.store.epoch_identity(),
239 p.mutation
240 .options
241 .epoch
242 .as_ref()
243 .map(crate::epoch::StoreEpoch::identity),
244 true,
245 AccessMode::ReadOnly,
246 )?;
247 let tokenizer = crate::fts::tokenizer::Analyzer::new(
248 p.mutation
249 .options
250 .tokenizer
251 .clone()
252 .unwrap_or_else(crate::fts::tokenizer::TokenizerConfig::text_default),
253 )
254 .map_err(StoreError::Tokenizer)?;
255 if tokenizer.config() != p.store.tokenizer.config() {
256 return Err(invalid(&root, "participant tokenizer mismatch"));
257 }
258 source_snapshots.push(snapshot);
259 }
260 let mut staged = Vec::new();
261 for (((p, wal), active), snapshot) in ordered
262 .iter()
263 .zip(&wals)
264 .zip(&actives)
265 .zip(&source_snapshots)
266 {
267 let writer = wal.as_ref().ok_or(StoreError::ReadOnly)?;
268 let current = active.as_ref().ok_or(StoreError::Closed)?;
269 match p
270 .store
271 .stage_namespace(&p.mutation, current, snapshot, writer.durable_end())
272 {
273 Ok(stage) => staged.push(stage),
274 Err(error) => {
275 for (prior, stage) in ordered.iter().zip(staged) {
276 cleanup_stage(prior.store, stage, None)?;
277 }
278 return Err(invalid(&p.store.directory, &error.to_string()));
279 }
280 }
281 }
282 let transaction = (u128::from(std::process::id()) << 96)
283 | (u128::from(
284 std::time::SystemTime::now()
285 .duration_since(std::time::UNIX_EPOCH)
286 .map_err(|_| invalid(&root, "transaction clock"))?
287 .as_nanos() as u64,
288 ) << 32)
289 | u128::from(NEXT.fetch_add(1, Ordering::Relaxed));
290 let mut descriptor = StagedDescriptor(BTreeMap::new(), existing_routes);
291 let mut lengths = Vec::new();
292 let mut selections = Vec::new();
293 for (p, stage) in ordered.iter().zip(&staged) {
294 let wal_path = p.store.directory.join("wal.ze");
295 lengths.push(p.store.vfs.open(&wal_path).map_err(|e| io(&wal_path, e))?);
296 if stage.records.is_empty() {
297 selections.push(None);
298 continue;
299 }
300 let bytes =
301 crate::manifest::encode_manifest(&stage.manifest).map_err(StoreError::Manifest)?;
302 let first_seq = wals
303 .get(selections.len())
304 .and_then(|wal| wal.as_ref())
305 .ok_or(StoreError::ReadOnly)?
306 .durable_end()
307 .checked_add(1)
308 .ok_or(StoreError::GenerationOverflow)?;
309 let binding = TransactionBinding {
310 transaction,
311 participant: participant_identity(&root, &p.mutation.name)?,
312 first_seq,
313 last_seq: first_seq
314 .checked_add(stage.records.len() as u64 - 1)
315 .ok_or(StoreError::GenerationOverflow)?,
316 manifest_digest: xxh3_64(&bytes),
317 final_generation: stage.active.generation,
318 };
319 let selection = StagedSelection {
320 manifest: format!(".ze-manifest-{transaction}"),
321 binding,
322 };
323 descriptor
324 .0
325 .insert(p.mutation.name.clone(), selection.clone());
326 selections.push(Some(selection));
327 }
328 if descriptor.0.is_empty() {
329 let generations = ordered
330 .iter()
331 .zip(&staged)
332 .map(|(p, s)| (p.mutation.name.clone(), s.active.generation))
333 .collect::<BTreeMap<_, _>>();
334 return participants
335 .iter()
336 .map(|p| {
337 generations
338 .get(&p.mutation.name)
339 .copied()
340 .ok_or_else(|| invalid(&root, "missing generation"))
341 })
342 .collect();
343 }
344 let decision = encode_staged(&descriptor)?;
345 let prepared = (|| {
346 for (((p, stage), selection), wal) in ordered
347 .iter()
348 .zip(&mut staged)
349 .zip(&selections)
350 .zip(&mut wals)
351 {
352 let Some(selection) = selection else {
353 continue;
354 };
355 let bytes =
356 crate::manifest::encode_manifest(&stage.manifest).map_err(StoreError::Manifest)?;
357 durable_write(
358 p.store.vfs.as_ref(),
359 &p.store.directory.join(&selection.manifest),
360 &bytes,
361 step,
362 )?;
363 sync_dir(p.store.vfs.as_ref(), &p.store.directory, step)?;
364 let count = u32::try_from(stage.records.len())
365 .map_err(|_| invalid(&root, "prepared member count"))?;
366 let frames = stage
367 .records
368 .iter()
369 .zip(0_u32..)
370 .map(|((op, payload), index)| {
371 wal_payload::encode_prepared(selection.binding, index, count, *op, payload)
372 .map_err(|e| invalid(&root, &e.to_string()))
373 })
374 .collect::<Result<Vec<_>, _>>()?;
375 let refs = frames
376 .iter()
377 .map(|frame| (wal_payload::PREPARED_MUTATION_V1, frame.as_slice()))
378 .collect::<Vec<_>>();
379 wal.as_mut()
380 .ok_or(StoreError::ReadOnly)?
381 .commit_many(&refs)?;
382 step("prepared append").map_err(|e| io(&p.store.directory, e))?;
383 p.store
384 .vfs
385 .sync(&p.store.directory.join("wal.ze"), SyncKind::Full)
386 .map_err(|e| io(&p.store.directory, e))?;
387 step("prepared WAL sync").map_err(|e| io(&p.store.directory, e))?;
388 let active = Arc::get_mut(&mut stage.active.segment)
389 .ok_or_else(|| invalid(&root, "private active ownership"))?;
390 for (row, seq) in stage.rows.iter().zip(selection.binding.first_seq..) {
391 for row in row {
392 active.set_sequence(*row, crate::wal::LogSeq::new(seq))?;
393 }
394 }
395 }
396 if read_optional_vfs(vfs, &root.join(RECORD))?.is_none() {
397 publish(vfs, &root, &encode(&Routes::new()), step)?;
398 }
399 for p in &ordered {
400 let logical = root.join(&p.mutation.name);
401 let reference = logical.join(REFERENCE);
402 if read_optional_vfs(vfs, &reference)?.is_none() {
403 let root_text = root.to_str().ok_or_else(|| invalid(&root, "root UTF-8"))?;
404 durable_write(vfs, &reference, &envelope(root_text.as_bytes()), step)?;
405 sync_dir(vfs, &logical, step)?;
406 }
407 }
408 Ok::<_, StoreError>(())
409 })();
410 if let Err(error) = prepared {
411 rollback_stages(&ordered, staged, &selections, &lengths, &mut wals)?;
412 return Err(error);
413 }
414 let mut snapshots = Vec::new();
415 for p in &ordered {
416 match p.store.snapshot.write() {
417 Ok(snapshot) => snapshots.push(snapshot),
418 Err(_) => {
419 rollback_stages(&ordered, staged, &selections, &lengths, &mut wals)?;
420 return Err(invalid(&root, "snapshot lock"));
421 }
422 }
423 }
424 let mut publication_attempted = false;
425 let published = (|| {
426 let temporary = root.join(".ze-namespaces.tmp");
427 durable_write(vfs, &temporary, &decision, step)?;
428 publication_attempted = true;
429 vfs.rename(&temporary, &root.join(RECORD))
430 .map_err(|e| io(&root, e))?;
431 step("commit rename").map_err(|e| io(&root, e))?;
432 sync_dir(vfs, &root, step)
433 })();
434 if let Err(error) = published {
435 if publication_attempted {
436 for wal in &mut wals {
437 **wal = None;
438 }
439 } else {
440 rollback_stages(&ordered, staged, &selections, &lengths, &mut wals)?;
441 }
442 return Err(error);
443 }
444 let adopted = (|| {
447 for ((active, stage), snapshot) in actives.iter_mut().zip(&staged).zip(&mut snapshots) {
448 **active = Some(crate::ingest::ActiveState {
449 generation: stage.active.generation,
450 segment: Arc::clone(&stage.active.segment),
451 });
452 **snapshot = Some(Arc::clone(&stage.snapshot));
453 }
454 step("live states installed").map_err(|e| io(&root, e))?;
455 for ((p, stage), selection) in ordered.iter().zip(&staged).zip(&selections) {
456 if let Some(selection) = selection {
457 accept(
458 p.store.vfs.as_ref(),
459 &p.store.directory,
460 selection,
461 &stage.manifest,
462 step,
463 )?;
464 }
465 }
466 publish(vfs, &root, &encode(&descriptor.1), step)
467 })();
468 if let Err(error) = adopted {
469 for wal in &mut wals {
470 **wal = None;
471 }
472 return Err(error);
473 }
474 drop(snapshots);
475 drop(actives);
476 let mut completed = BTreeMap::new();
477 for ((p, stage), wal) in ordered.iter().zip(&staged).zip(&mut wals) {
478 let generation = if read_optional_vfs(
479 p.store.vfs.as_ref(),
480 &p.store.directory.join(crate::ingest::PURGE_INTENT_FILE),
481 )?
482 .is_some()
483 {
484 match p
485 .store
486 .complete_namespace_purge_locked(wal.as_mut().ok_or(StoreError::ReadOnly)?)
487 {
488 Ok(generation) => generation,
489 Err(error) => {
490 for wal in &mut wals {
491 **wal = None;
492 }
493 return Err(StoreError::PurgeRecovery {
494 detail: error.to_string(),
495 });
496 }
497 }
498 } else {
499 stage.active.generation
500 };
501 completed.insert(p.mutation.name.clone(), generation);
502 }
503 if deleting {
504 reclamation::run(vfs, &root, step)?;
505 reclamation::require_retired_erased(vfs, &root)?;
506 }
507 let by_name = completed;
508 drop(wals);
509 drop(owners);
510 drop(states);
511 drop(maintenance);
512 drop(ordered);
513 participants
514 .iter_mut()
515 .map(|p| {
516 by_name
517 .get(&p.mutation.name)
518 .copied()
519 .ok_or_else(|| invalid(&root, "missing generation"))
520 })
521 .collect()
522}
523
524fn cleanup_stage(
525 store: &Store,
526 stage: crate::ingest::NamespaceStage,
527 selection: Option<&StagedSelection>,
528) -> Result<(), StoreError> {
529 let mut paths = stage
530 .replacements
531 .iter()
532 .map(|id| store.directory.join(id.file_name()))
533 .collect::<Vec<_>>();
534 if let Some(selection) = selection {
535 paths.push(store.directory.join(&selection.manifest));
536 }
537 for path in paths {
538 match store.vfs.delete(&path) {
539 Ok(()) => {}
540 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
541 Err(e) => return Err(io(&path, e)),
542 }
543 }
544 store
545 .vfs
546 .sync(&store.directory, SyncKind::Full)
547 .map_err(|e| io(&store.directory, e))
548}
549
550pub(super) fn io(path: &Path, source: std::io::Error) -> StoreError {
551 StoreError::Io {
552 path: path.to_path_buf(),
553 source,
554 }
555}
556pub(crate) fn invalid(path: &Path, message: &str) -> StoreError {
557 io(
558 path,
559 std::io::Error::new(std::io::ErrorKind::InvalidData, message),
560 )
561}
562pub(super) fn name_valid(name: &str) -> bool {
563 name.as_bytes()
564 .first()
565 .is_some_and(u8::is_ascii_alphanumeric)
566 && name.len() <= 255
567 && name
568 .bytes()
569 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
570}
571pub(super) fn envelope(body: &[u8]) -> Vec<u8> {
572 let mut bytes = MAGIC.to_vec();
573 bytes.extend_from_slice(body);
574 bytes.extend_from_slice(&xxh3_64(&bytes).to_le_bytes());
575 bytes
576}
577pub(super) fn body<'a>(path: &Path, bytes: &'a [u8]) -> Result<&'a [u8], StoreError> {
578 let end = bytes
579 .len()
580 .checked_sub(8)
581 .ok_or_else(|| invalid(path, "short namespace record"))?;
582 let prefix = bytes
583 .get(..end)
584 .ok_or_else(|| invalid(path, "namespace record bounds"))?;
585 if bytes.len() > MAX_RECORD
586 || !prefix.starts_with(MAGIC)
587 || bytes.get(end..) != Some(xxh3_64(prefix).to_le_bytes().as_slice())
588 {
589 return Err(invalid(path, "namespace record checksum/version"));
590 }
591 prefix
592 .get(MAGIC.len()..)
593 .ok_or_else(|| invalid(path, "namespace record header"))
594}
595pub(super) fn read_optional(path: &Path) -> Result<Option<Vec<u8>>, StoreError> {
596 match StdVfs.open(path) {
597 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
598 Err(e) => Err(io(path, e)),
599 Ok(n) if n > MAX_RECORD as u64 => Err(invalid(path, "namespace record too large")),
600 Ok(_) => StdVfs.read(path).map(Some).map_err(|e| io(path, e)),
601 }
602}
603fn encode(routes: &Routes) -> Vec<u8> {
604 let mut text = String::new();
607 for (name, destination) in routes {
608 text.push_str(name);
609 text.push('\t');
610 text.push_str(destination);
611 text.push('\n');
612 }
613 envelope(text.as_bytes())
614}
615enum RootDescriptor {
616 Legacy(Routes),
617 Staged(StagedDescriptor),
618}
619fn routes(root: &Path) -> Result<Routes, StoreError> {
620 match root_descriptor(root)? {
621 RootDescriptor::Legacy(routes) => Ok(routes),
622 RootDescriptor::Staged(_) => Err(invalid(
623 &root.join(RECORD),
624 "staged manifest requires transaction adoption",
625 )),
626 }
627}
628fn root_descriptor(root: &Path) -> Result<RootDescriptor, StoreError> {
629 root_descriptor_vfs(&StdVfs, root)
630}
631fn root_descriptor_vfs(vfs: &dyn Vfs, root: &Path) -> Result<RootDescriptor, StoreError> {
632 let path = root.join(RECORD);
633 let Some(bytes) = read_optional_vfs(vfs, &path)? else {
634 return Ok(RootDescriptor::Legacy(Routes::new()));
635 };
636 if bytes.starts_with(STAGED_MAGIC) {
637 return decode_staged(&path, &bytes).map(RootDescriptor::Staged);
638 }
639 decode_routes(&path, &bytes).map(RootDescriptor::Legacy)
640}
641fn decode_routes(path: &Path, bytes: &[u8]) -> Result<Routes, StoreError> {
642 let text = std::str::from_utf8(body(path, bytes)?)
643 .map_err(|_| invalid(path, "namespace record UTF-8"))?;
644 let mut result = Routes::new();
645 for line in text.split_terminator('\n') {
646 let (name, destination) = line
647 .split_once('\t')
648 .ok_or_else(|| invalid(path, "namespace route"))?;
649 let (transaction, child) = destination
650 .split_once('/')
651 .ok_or_else(|| invalid(path, "namespace destination"))?;
652 if !name_valid(name)
653 || child != name
654 || !transaction.starts_with(".ze-batch-")
655 || !transaction
656 .bytes()
657 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
658 || transaction.contains("..")
659 || result
660 .insert(name.to_owned(), destination.to_owned())
661 .is_some()
662 {
663 return Err(invalid(path, "invalid or duplicate namespace route"));
664 }
665 }
666 if encode(&result) != bytes {
667 return Err(invalid(path, "noncanonical namespace record"));
668 }
669 Ok(result)
670}
671
672pub(super) fn resolve(path: &Path) -> Result<PathBuf, StoreError> {
674 match std::fs::metadata(path) {
675 Ok(metadata) if !metadata.is_dir() => {
676 return Err(StoreError::NotDirectory {
677 path: path.to_path_buf(),
678 });
679 }
680 Ok(_) => {}
681 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
682 Err(error) => return Err(io(path, error)),
683 }
684 let Some(parent) = path.parent() else {
685 return Ok(path.to_path_buf());
686 };
687 let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
688 return Ok(path.to_path_buf());
689 };
690 let reference = read_optional(&path.join(REFERENCE))?;
691 if let Some(reference) = reference {
692 let expected = body(&path.join(REFERENCE), &reference)?;
693 let canonical = std::fs::canonicalize(parent).map_err(|e| io(parent, e))?;
694 if canonical.to_str().map(str::as_bytes) != Some(expected)
695 || read_optional(&parent.join(RECORD))?.is_none()
696 {
697 return Err(invalid(
698 path,
699 "namespace requires its original transaction root",
700 ));
701 }
702 }
703 let selected = match root_descriptor(parent)? {
704 RootDescriptor::Legacy(mut routes) => routes.remove(name),
705 RootDescriptor::Staged(mut descriptor) => descriptor.1.remove(name),
706 };
707 match selected {
708 None => Ok(path.to_path_buf()),
709 Some(destination) => {
710 let selected = parent.join(&destination);
711 let transaction = selected
712 .parent()
713 .ok_or_else(|| invalid(path, "missing transaction directory"))?;
714 let intent_path = transaction.join("intent.ze");
715 let intent = read_optional(&intent_path)?
716 .ok_or_else(|| invalid(&intent_path, "missing transaction intent"))?;
717 let intent_body = std::str::from_utf8(body(&intent_path, &intent)?)
718 .map_err(|_| invalid(&intent_path, "intent UTF-8"))?;
719 if !intent_body
720 .lines()
721 .any(|line| line == format!("{name}\t{destination}"))
722 {
723 return Err(invalid(
724 &intent_path,
725 "decision disagrees with preparation intent",
726 ));
727 }
728 let prepared_path = selected.join(PREPARED);
729 let bytes = read_optional(&prepared_path)?
730 .ok_or_else(|| invalid(&prepared_path, "missing namespace preparation"))?;
731 if body(&prepared_path, &bytes)? != destination.as_bytes() {
732 return Err(invalid(
733 &prepared_path,
734 "namespace preparation identity mismatch",
735 ));
736 }
737 for filename in ["manifest.ze", "wal.ze"] {
741 let required = selected.join(filename);
742 let length = StdVfs.open(&required).map_err(|e| io(&required, e))?;
743 if length == 0 {
744 return Err(invalid(&required, "empty committed preparation file"));
745 }
746 }
747 Ok(selected)
748 }
749 }
750}
751
752pub(super) fn durable_write(
753 vfs: &dyn Vfs,
754 path: &Path,
755 bytes: &[u8],
756 step: &mut dyn FnMut(&str) -> std::io::Result<()>,
757) -> Result<(), StoreError> {
758 vfs.write(path, bytes).map_err(|e| io(path, e))?;
759 step("write").map_err(|e| io(path, e))?;
760 vfs.sync(path, SyncKind::Full).map_err(|e| io(path, e))?;
761 step("file sync").map_err(|e| io(path, e))
762}
763pub(super) fn sync_dir(
764 vfs: &dyn Vfs,
765 path: &Path,
766 step: &mut dyn FnMut(&str) -> std::io::Result<()>,
767) -> Result<(), StoreError> {
768 vfs.sync(path, SyncKind::Full).map_err(|e| io(path, e))?;
769 step("directory sync").map_err(|e| io(path, e))
770}
771fn publish(
772 vfs: &dyn Vfs,
773 root: &Path,
774 bytes: &[u8],
775 step: &mut dyn FnMut(&str) -> std::io::Result<()>,
776) -> Result<(), StoreError> {
777 let temporary = root.join(".ze-namespaces.tmp");
778 durable_write(vfs, &temporary, bytes, step)?;
779 vfs.rename(&temporary, &root.join(RECORD))
780 .map_err(|e| io(root, e))?;
781 step("commit rename").map_err(|e| io(root, e))?;
782 sync_dir(vfs, root, step)
783}
784
785fn prepare_copy(
789 source: &Store,
790 destination: &Path,
791 step: &mut dyn FnMut(&str) -> std::io::Result<()>,
792) -> Result<(), StoreError> {
793 StdVfs
794 .create_directory(destination)
795 .map_err(|e| io(destination, e))?;
796 step("participant directory").map_err(|e| io(destination, e))?;
797 for path in source
798 .vfs
799 .list(&source.directory)
800 .map_err(|e| io(&source.directory, e))?
801 {
802 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
803 return Err(invalid(&path, "store filename is not UTF-8"));
804 };
805 if name != "manifest.ze" && name != "wal.ze" && !name.ends_with(".zseg") {
806 continue;
807 }
808 let length = source.vfs.open(&path).map_err(|e| io(&path, e))?;
809 let target = destination.join(name);
810 let mut file = StdVfs.open_append(&target).map_err(|e| io(&target, e))?;
811 let mut offset = 0;
812 while offset < length {
813 let count = usize::try_from((length - offset).min(1024 * 1024))
814 .map_err(|_| invalid(&path, "copy size"))?;
815 let bytes = source
816 .vfs
817 .read_range(&path, offset, count)
818 .map_err(|e| io(&path, e))?;
819 if bytes.len() != count {
820 return Err(invalid(&path, "short preparation read"));
821 }
822 file.append(&bytes).map_err(|e| io(&target, e))?;
823 offset += bytes.len() as u64;
824 step("copy chunk").map_err(|e| io(&target, e))?;
825 }
826 file.sync(SyncKind::Full).map_err(|e| io(&target, e))?;
827 step("copied file sync").map_err(|e| io(&target, e))?;
828 }
829 sync_dir(&StdVfs, destination, step)
830}
831
832pub fn namespace_batch(
841 root: &Path,
842 mutations: Vec<NamespaceMutation>,
843) -> Result<Vec<u64>, StoreError> {
844 execute(root, mutations, Operation::Batch, &mut |_| Ok(()))
845}
846
847#[cfg(any(test, feature = "test-seams"))]
849#[doc(hidden)]
850pub fn namespace_batch_with_steps(
851 root: &Path,
852 mutations: Vec<NamespaceMutation>,
853 step: &mut dyn FnMut(&str) -> std::io::Result<()>,
854) -> Result<Vec<u64>, StoreError> {
855 execute(root, mutations, Operation::Batch, step)
856}
857
858pub fn namespace_declare_cascade(
861 root: &Path,
862 participants: Vec<NamespaceMutation>,
863 rule: super::CascadeRule,
864) -> Result<(), StoreError> {
865 execute(
866 root,
867 participants,
868 Operation::Declare(rule),
869 &mut |_| Ok(()),
870 )
871 .map(|_| ())
872}
873
874pub fn namespace_delete_cascade(
878 root: &Path,
879 participants: Vec<NamespaceMutation>,
880) -> Result<Vec<u64>, StoreError> {
881 execute(root, participants, Operation::Cascade, &mut |_| Ok(()))
882}
883
884#[cfg(any(test, feature = "test-seams"))]
886#[doc(hidden)]
887pub fn namespace_delete_cascade_with_steps(
888 root: &Path,
889 participants: Vec<NamespaceMutation>,
890 step: &mut dyn FnMut(&str) -> std::io::Result<()>,
891) -> Result<Vec<u64>, StoreError> {
892 execute(root, participants, Operation::Cascade, step)
893}
894
895enum Operation {
896 Batch,
897 Cascade,
898 Declare(super::CascadeRule),
899}
900
901fn execute(
902 root: &Path,
903 mut mutations: Vec<NamespaceMutation>,
904 operation: Operation,
905 step: &mut dyn FnMut(&str) -> std::io::Result<()>,
906) -> Result<Vec<u64>, StoreError> {
907 let root = std::fs::canonicalize(root).map_err(|e| io(root, e))?;
908 let minimum = if matches!(operation, Operation::Batch) {
909 2
910 } else {
911 1
912 };
913 if mutations.len() < minimum || mutations.len() > 128 {
914 return Err(invalid(
915 &root,
916 "namespace batch requires 2..128 participants",
917 ));
918 }
919 if !matches!(operation, Operation::Batch)
920 && mutations.iter().any(|m| {
921 !m.upserts.is_empty()
922 || m.delete_where.is_some()
923 || (matches!(operation, Operation::Declare(_)) && !m.deletes.is_empty())
924 })
925 {
926 return Err(invalid(
927 &root,
928 "cascade operations reject upserts/filters; declarations reject deletes",
929 ));
930 }
931 let _coordinator = StoreLock::acquire(&root).map_err(StoreError::Lock)?;
932 reclamation::run(&StdVfs, &root, step)?;
933 let mut next = routes(&root)?;
934 let mut ordered = BTreeMap::new();
935 for (index, mutation) in mutations.iter().enumerate() {
936 if !name_valid(&mutation.name)
937 || ordered.insert(mutation.name.clone(), index).is_some()
938 || mutation.options.access_mode != AccessMode::ReadWrite
939 {
940 return Err(invalid(
941 &root,
942 "invalid, duplicate, or read-only namespace participant",
943 ));
944 }
945 }
946 let mut sources = BTreeMap::new();
949 for (name, index) in &ordered {
950 let mutation = mutations
951 .get(*index)
952 .ok_or_else(|| invalid(&root, "participant index"))?;
953 let path = root.join(name);
954 if std::fs::symlink_metadata(&path)
955 .map_err(|e| io(&path, e))?
956 .file_type()
957 .is_symlink()
958 {
959 return Err(invalid(
960 &path,
961 "namespace links are not transaction participants",
962 ));
963 }
964 let mut options = mutation.options.clone();
965 options.access_mode = AccessMode::ReadOnly;
966 drop(Store::open(&path, options)?);
968 sources.insert(name.clone(), Store::open(&path, mutation.options.clone())?);
969 }
970 match operation {
971 Operation::Declare(rule) => {
972 super::cascade::declare(&root, &sources, rule, step)?;
973 return Ok(Vec::new());
974 }
975 Operation::Cascade => super::cascade::expand(&root, &sources, &mut mutations)?,
976 Operation::Batch => {
977 let live = mutations
978 .into_iter()
979 .map(|mutation| {
980 sources
981 .get(&mutation.name)
982 .map(|store| LiveNamespaceMutation { store, mutation })
983 .ok_or_else(|| invalid(&root, "missing source"))
984 })
985 .collect::<Result<Vec<_>, _>>()?;
986 return execute_live(&root, live, &StdVfs, step, Some(_coordinator));
987 }
988 }
989 let transaction = loop {
990 let id = NEXT.fetch_add(1, Ordering::Relaxed);
991 let path = root.join(format!(".ze-batch-{}-{id}", std::process::id()));
992 match StdVfs.create_directory(&path) {
993 Ok(()) => break path,
994 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
995 Err(e) => return Err(io(&path, e)),
996 }
997 };
998 step("transaction directory").map_err(|e| io(&transaction, e))?;
999 let transaction_name = transaction
1000 .file_name()
1001 .and_then(|s| s.to_str())
1002 .ok_or_else(|| invalid(&transaction, "transaction name"))?;
1003 for name in ordered.keys() {
1004 next.insert(name.clone(), format!("{transaction_name}/{name}"));
1005 }
1006 let decision = encode(&next);
1007 if decision.len() > MAX_RECORD {
1008 return Err(invalid(&root, "namespace decision too large"));
1009 }
1010 durable_write(&StdVfs, &transaction.join("intent.ze"), &decision, step)?;
1011 sync_dir(&StdVfs, &transaction, step)?;
1012 sync_dir(&StdVfs, &root, step)?;
1013 let mut generations = Vec::new();
1014 for mutation in mutations {
1015 let source = sources
1016 .get(&mutation.name)
1017 .ok_or_else(|| invalid(&root, "missing participant"))?;
1018 let destination = transaction.join(&mutation.name);
1019 prepare_copy(source, &destination, step)?;
1020 step("snapshot prepared").map_err(|e| io(&destination, e))?;
1021 let mut options = mutation.options;
1022 options.schema = Some(source.schema().clone());
1025 options.durability_mode = DurabilityMode::Durable;
1026 options.commit_tier = CommitTier::Durable;
1027 let store = Store::open(&destination, options)?;
1028 if !mutation.upserts.is_empty() {
1029 let mut batch = IngestBatch::new(mutation.upserts);
1030 if let Some(epoch) = store.epoch_identity() {
1031 batch = batch.with_epoch(epoch);
1032 }
1033 store
1034 .ingest(batch)
1035 .map_err(|e| invalid(&destination, &e.to_string()))?;
1036 step("upserts prepared").map_err(|e| io(&destination, e))?;
1037 }
1038 if !mutation.deletes.is_empty() {
1039 store
1040 .delete(DeleteBatch::new(mutation.deletes))
1041 .map_err(|e| invalid(&destination, &e.to_string()))?;
1042 step("deletes prepared").map_err(|e| io(&destination, e))?;
1043 }
1044 if let Some(predicate) = mutation.delete_where {
1045 store
1046 .delete_matching(&predicate)
1047 .map_err(|e| invalid(&destination, &e.to_string()))?;
1048 step("predicate prepared").map_err(|e| io(&destination, e))?;
1049 }
1050 let generation = store.seal()?;
1051 step("prepared checkpoint").map_err(|e| io(&destination, e))?;
1052 store.close()?;
1053 generations.push(generation);
1054 let relative = next
1055 .get(&mutation.name)
1056 .ok_or_else(|| invalid(&root, "missing route"))?;
1057 durable_write(
1058 &StdVfs,
1059 &destination.join(PREPARED),
1060 &envelope(relative.as_bytes()),
1061 step,
1062 )?;
1063 sync_dir(&StdVfs, &destination, step)?;
1064 }
1065 sync_dir(&StdVfs, &transaction, step)?;
1066 if read_optional(&root.join(RECORD))?.is_none() {
1069 publish(&StdVfs, &root, &encode(&Routes::new()), step)?;
1070 }
1071 for name in ordered.keys() {
1072 let directory = root.join(name);
1073 let reference = directory.join(REFERENCE);
1074 if read_optional(&reference)?.is_none() {
1075 let root_text = root
1076 .to_str()
1077 .ok_or_else(|| invalid(&root, "root must be UTF-8"))?;
1078 let temporary = directory.join(".ze-namespace-root.tmp");
1079 durable_write(&StdVfs, &temporary, &envelope(root_text.as_bytes()), step)?;
1080 StdVfs
1081 .rename(&temporary, &reference)
1082 .map_err(|e| io(&reference, e))?;
1083 step("reference rename").map_err(|e| io(&reference, e))?;
1084 sync_dir(&StdVfs, &directory, step)?;
1085 }
1086 }
1087 publish(&StdVfs, &root, &decision, step)?;
1088 drop(sources);
1089 reclamation::run(&StdVfs, &root, step)?;
1090 Ok(generations)
1091}
1092
1093#[cfg(test)]
1094#[allow(clippy::expect_used)]
1095mod tests {
1096 use super::*;
1097 use crate::vfs::crash::{CrashVfs, MemoryVfs};
1098
1099 #[test]
1100 fn root_decision_power_cuts_never_publish_a_torn_or_partial_selection() {
1101 let root = Path::new("/root");
1102 let before = encode(&Routes::new());
1103 let after = encode(&Routes::from([
1104 ("a".into(), ".ze-batch-1/a".into()),
1105 ("b".into(), ".ze-batch-1/b".into()),
1106 ]));
1107 let memory = MemoryVfs::new();
1108 memory
1109 .insert(root.join(RECORD), before.clone())
1110 .expect("seed decision");
1111 let crash = CrashVfs::new(memory).expect("recorder");
1112 publish(&crash, root, &after, &mut |_| Ok(())).expect("publish");
1113 let states = crash.crash_states().expect("power cuts");
1114 assert!(!states.was_capped());
1115 for state in states.iter() {
1116 let bytes = state
1117 .vfs()
1118 .read(&root.join(RECORD))
1119 .expect("previous or new root");
1120 assert!(bytes == before || bytes == after, "{:?}", state.kind());
1121 }
1122 eprintln!("ZE-239 root power-cut states: {}", states.len());
1123 }
1124}
1125
1126#[cfg(test)]
1127#[allow(clippy::expect_used)]
1128mod descriptor_tests {
1129 use super::*;
1130 #[test]
1131 fn nonparticipant_route_survives_v2_publish() {
1132 let temporary = tempfile::tempdir().expect("root");
1133 let root = temporary.path();
1134 let destination = ".ze-batch-1/c";
1135 let selected = root.join(destination);
1136 std::fs::create_dir_all(&selected).expect("selected directory");
1137 std::fs::create_dir(root.join("c")).expect("logical directory");
1138 let routes = Routes::from([("c".into(), destination.into())]);
1139 std::fs::write(root.join(".ze-batch-1/intent.ze"), encode(&routes)).expect("intent");
1140 std::fs::write(selected.join(PREPARED), envelope(destination.as_bytes()))
1141 .expect("prepared marker");
1142 for filename in ["manifest.ze", "wal.ze"] {
1143 std::fs::write(selected.join(filename), b"retained").expect("payload");
1144 }
1145 publish(&StdVfs, root, &encode(&routes), &mut |_| Ok(())).expect("legacy publish");
1146 assert_eq!(resolve(&root.join("c")).expect("legacy resolve"), selected);
1147 let descriptor = StagedDescriptor(
1148 BTreeMap::from([(
1149 "a".into(),
1150 StagedSelection {
1151 manifest: ".ze-manifest-1".into(),
1152 binding: crate::ingest::wal_payload::TransactionBinding {
1153 transaction: 1,
1154 participant: 2,
1155 first_seq: 3,
1156 last_seq: 3,
1157 manifest_digest: 4,
1158 final_generation: 5,
1159 },
1160 },
1161 )]),
1162 routes,
1163 );
1164 publish(
1165 &StdVfs,
1166 root,
1167 &encode_staged(&descriptor).expect("v2"),
1168 &mut |_| Ok(()),
1169 )
1170 .expect("v2 publish");
1171 assert_eq!(resolve(&root.join("c")).expect("v2 resolve"), selected);
1172 }
1173
1174 #[test]
1175 #[allow(clippy::indexing_slicing)]
1176 fn staged_descriptor_round_trips_and_old_reader_rejects() {
1177 let binding = crate::ingest::wal_payload::TransactionBinding {
1178 transaction: 1,
1179 participant: 2,
1180 first_seq: 3,
1181 last_seq: 3,
1182 manifest_digest: 4,
1183 final_generation: 5,
1184 };
1185 let descriptor = StagedDescriptor(
1186 BTreeMap::from([(
1187 "a".into(),
1188 StagedSelection {
1189 manifest: ".ze-manifest-1".into(),
1190 binding,
1191 },
1192 )]),
1193 Routes::new(),
1194 );
1195 let bytes = encode_staged(&descriptor).expect("encode");
1196 assert_eq!(
1197 bytes.iter().map(|b| format!("{b:02x}")).collect::<String>(),
1198 "5a454e5330303032010000000100610e002e7a652d6d616e69666573742d3101000000000000000000000000000000020000000000000000000000000000000300000000000000030000000000000004000000000000000500000000000000100000005a454e5330303031a3d24bff963b33d2986d4df50a4f2e73"
1199 );
1200 for end in 0..bytes.len() {
1201 assert!(decode_staged(Path::new("root"), &bytes[..end]).is_err());
1202 }
1203 let mut bad = bytes.clone();
1204 bad[7] = b'3';
1205 assert!(decode_staged(Path::new("root"), &bad).is_err());
1206 let mut bad = bytes.clone();
1207 bad[40] ^= 1;
1208 assert!(decode_staged(Path::new("root"), &bad).is_err());
1209 assert!(body(Path::new("root"), &bytes).is_err());
1210 assert_eq!(
1211 decode_staged(Path::new("root"), &bytes).expect("decode"),
1212 descriptor
1213 );
1214 }
1215}
1216
1217const STAGED_MAGIC: &[u8] = b"ZENS0002";
1220#[derive(Clone, Debug, Eq, PartialEq)]
1221struct StagedSelection {
1222 manifest: String,
1223 binding: crate::ingest::wal_payload::TransactionBinding,
1224}
1225#[derive(Clone, Debug, Eq, PartialEq)]
1226struct StagedDescriptor(BTreeMap<String, StagedSelection>, Routes);
1227
1228fn encode_staged(descriptor: &StagedDescriptor) -> Result<Vec<u8>, StoreError> {
1229 let path = Path::new(RECORD);
1230 let mut bytes = STAGED_MAGIC.to_vec();
1231 let count =
1232 u32::try_from(descriptor.0.len()).map_err(|_| invalid(path, "participant count"))?;
1233 if count == 0 {
1234 return Err(invalid(path, "empty staged decision"));
1235 }
1236 bytes.extend_from_slice(&count.to_le_bytes());
1237 let mut transaction = None;
1238 let mut identities = std::collections::BTreeSet::new();
1239 for (name, selection) in &descriptor.0 {
1240 if !name_valid(name)
1241 || !selection.manifest.starts_with(".ze-manifest-")
1242 || !name_valid(selection.manifest.trim_start_matches('.'))
1243 || selection.manifest.contains("..")
1244 || transaction.is_some_and(|id| id != selection.binding.transaction)
1245 || !identities.insert(selection.binding.participant)
1246 {
1247 return Err(invalid(path, "staged participant identity or manifest"));
1248 }
1249 transaction = Some(selection.binding.transaction);
1250 for text in [name, &selection.manifest] {
1251 let length =
1252 u16::try_from(text.len()).map_err(|_| invalid(path, "selection length"))?;
1253 bytes.extend_from_slice(&length.to_le_bytes());
1254 bytes.extend_from_slice(text.as_bytes());
1255 }
1256 bytes.extend_from_slice(
1257 &selection
1258 .binding
1259 .encode()
1260 .map_err(|_| invalid(path, "transaction binding"))?,
1261 );
1262 }
1263 let routes = encode(&descriptor.1);
1264 let length = u32::try_from(routes.len()).map_err(|_| invalid(path, "route table length"))?;
1265 bytes.extend_from_slice(&length.to_le_bytes());
1266 bytes.extend_from_slice(&routes);
1267 bytes.extend_from_slice(&xxh3_64(&bytes).to_le_bytes());
1268 if bytes.len() > MAX_RECORD {
1269 return Err(invalid(path, "staged decision too large"));
1270 }
1271 Ok(bytes)
1272}
1273fn decode_staged(path: &Path, bytes: &[u8]) -> Result<StagedDescriptor, StoreError> {
1274 let end = bytes
1275 .len()
1276 .checked_sub(8)
1277 .ok_or_else(|| invalid(path, "short staged decision"))?;
1278 let prefix = bytes
1279 .get(..end)
1280 .ok_or_else(|| invalid(path, "staged bounds"))?;
1281 if bytes.len() > MAX_RECORD
1282 || !prefix.starts_with(STAGED_MAGIC)
1283 || bytes.get(end..) != Some(xxh3_64(prefix).to_le_bytes().as_slice())
1284 {
1285 return Err(invalid(path, "staged checksum/version"));
1286 }
1287 let mut remaining = prefix
1288 .get(8..)
1289 .ok_or_else(|| invalid(path, "staged header"))?;
1290 fn take<'a>(path: &Path, bytes: &mut &'a [u8], n: usize) -> Result<&'a [u8], StoreError> {
1291 let result = bytes
1292 .get(..n)
1293 .ok_or_else(|| invalid(path, "truncated staged decision"))?;
1294 *bytes = bytes
1295 .get(n..)
1296 .ok_or_else(|| invalid(path, "staged bounds"))?;
1297 Ok(result)
1298 }
1299 let count = u32::from_le_bytes(
1300 take(path, &mut remaining, 4)?
1301 .try_into()
1302 .map_err(|_| invalid(path, "count"))?,
1303 );
1304 let mut result = BTreeMap::new();
1305 for _ in 0..count {
1306 let mut texts = Vec::new();
1307 for _ in 0..2 {
1308 let n = u16::from_le_bytes(
1309 take(path, &mut remaining, 2)?
1310 .try_into()
1311 .map_err(|_| invalid(path, "length"))?,
1312 );
1313 texts.push(
1314 std::str::from_utf8(take(path, &mut remaining, usize::from(n))?)
1315 .map_err(|_| invalid(path, "selection UTF-8"))?
1316 .to_owned(),
1317 );
1318 }
1319 let mut texts = texts.into_iter();
1320 let name = texts.next().ok_or_else(|| invalid(path, "name"))?;
1321 let manifest = texts.next().ok_or_else(|| invalid(path, "manifest"))?;
1322 let binding =
1323 crate::ingest::wal_payload::TransactionBinding::decode(take(path, &mut remaining, 64)?)
1324 .map_err(|_| invalid(path, "transaction binding"))?;
1325 if result
1326 .insert(name, StagedSelection { manifest, binding })
1327 .is_some()
1328 {
1329 return Err(invalid(path, "duplicate participant"));
1330 }
1331 }
1332 let route_length = u32::from_le_bytes(
1333 take(path, &mut remaining, 4)?
1334 .try_into()
1335 .map_err(|_| invalid(path, "route table length"))?,
1336 );
1337 let route_length =
1338 usize::try_from(route_length).map_err(|_| invalid(path, "route table length"))?;
1339 let routes = decode_routes(path, take(path, &mut remaining, route_length)?)?;
1340 let descriptor = StagedDescriptor(result, routes);
1341 if !remaining.is_empty() || encode_staged(&descriptor)? != bytes {
1342 return Err(invalid(path, "noncanonical staged decision"));
1343 }
1344 Ok(descriptor)
1345}
1346
1347fn read_optional_vfs(vfs: &dyn Vfs, path: &Path) -> Result<Option<Vec<u8>>, StoreError> {
1348 match vfs.open(path) {
1349 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1350 Err(e) => Err(io(path, e)),
1351 Ok(n) if n > MAX_RECORD as u64 => Err(invalid(path, "namespace record too large")),
1352 Ok(_) => vfs.read(path).map(Some).map_err(|e| io(path, e)),
1353 }
1354}
1355
1356fn location(directory: &Path) -> Result<(&Path, &str), StoreError> {
1357 let name = directory
1358 .file_name()
1359 .and_then(|name| name.to_str())
1360 .ok_or_else(|| invalid(directory, "participant name"))?;
1361 let parent = directory
1362 .parent()
1363 .ok_or_else(|| invalid(directory, "participant root"))?;
1364 let root = if parent
1365 .file_name()
1366 .and_then(|name| name.to_str())
1367 .is_some_and(|name| name.starts_with(".ze-batch-"))
1368 {
1369 parent
1370 .parent()
1371 .ok_or_else(|| invalid(directory, "route root"))?
1372 } else {
1373 parent
1374 };
1375 Ok((root, name))
1376}
1377fn acceptance_path(
1378 directory: &Path,
1379 binding: crate::ingest::wal_payload::TransactionBinding,
1380) -> PathBuf {
1381 directory.join(format!(".ze-accepted-{}", binding.transaction))
1382}
1383fn accepted(
1384 vfs: &dyn Vfs,
1385 directory: &Path,
1386 binding: crate::ingest::wal_payload::TransactionBinding,
1387) -> Result<bool, StoreError> {
1388 let path = acceptance_path(directory, binding);
1389 let Some(bytes) = read_optional_vfs(vfs, &path)? else {
1390 return Ok(false);
1391 };
1392 let recorded = crate::ingest::wal_payload::TransactionBinding::decode(body(&path, &bytes)?)
1393 .map_err(|e| invalid(&path, &e.to_string()))?;
1394 if recorded != binding {
1395 return Err(invalid(&path, "local acceptance binding mismatch"));
1396 }
1397 Ok(true)
1398}
1399pub(crate) fn owns_purge_obligation(
1402 vfs: &dyn Vfs,
1403 directory: &Path,
1404 token: u64,
1405) -> Result<bool, StoreError> {
1406 for path in vfs.list(directory).map_err(|e| io(directory, e))? {
1407 let Some(transaction) = path
1408 .file_name()
1409 .and_then(|name| name.to_str())
1410 .and_then(|name| name.strip_prefix(".ze-accepted-"))
1411 .and_then(|name| name.parse::<u128>().ok())
1412 else {
1413 continue;
1414 };
1415 if xxh3_64(&transaction.to_le_bytes()) != token {
1416 continue;
1417 }
1418 let bytes = vfs.read(&path).map_err(|e| io(&path, e))?;
1419 let binding = crate::ingest::wal_payload::TransactionBinding::decode(body(&path, &bytes)?)
1420 .map_err(|e| invalid(&path, &e.to_string()))?;
1421 if binding.transaction != transaction {
1422 return Err(invalid(&path, "purge acceptance transaction mismatch"));
1423 }
1424 return Ok(true);
1425 }
1426 if let Some(selected) = selection(vfs, directory)?
1427 && xxh3_64(&selected.binding.transaction.to_le_bytes()) == token
1428 {
1429 staged_manifest(vfs, directory, &selected)?;
1430 return Ok(true);
1431 }
1432 Ok(false)
1433}
1434
1435fn selection(vfs: &dyn Vfs, directory: &Path) -> Result<Option<StagedSelection>, StoreError> {
1436 let (root, name) = location(directory)?;
1437 let RootDescriptor::Staged(mut descriptor) = root_descriptor_vfs(vfs, root)? else {
1438 return Ok(None);
1439 };
1440 let Some(selected) = descriptor.0.remove(name) else {
1441 return Ok(None);
1442 };
1443 let identity = participant_identity(root, name)?;
1444 if selected.binding.participant != identity {
1445 return Err(invalid(directory, "participant identity mismatch"));
1446 }
1447 Ok(Some(selected))
1448}
1449fn selected_manifest(
1450 vfs: &dyn Vfs,
1451 directory: &Path,
1452 selected: &StagedSelection,
1453) -> Result<crate::manifest::Manifest, StoreError> {
1454 let path = directory.join(&selected.manifest);
1455 let bytes = vfs.read(&path).map_err(|e| io(&path, e))?;
1456 if xxh3_64(&bytes) != selected.binding.manifest_digest {
1457 return Err(invalid(&path, "staged manifest digest mismatch"));
1458 }
1459 let manifest = crate::manifest::decode_manifest(&path.display().to_string(), &bytes)
1460 .map_err(StoreError::Manifest)?;
1461 if manifest.generation != selected.binding.final_generation {
1462 return Err(invalid(&path, "staged generation mismatch"));
1463 }
1464 Ok(manifest)
1465}
1466
1467fn staged_manifest(
1468 vfs: &dyn Vfs,
1469 directory: &Path,
1470 selected: &StagedSelection,
1471) -> Result<crate::manifest::Manifest, StoreError> {
1472 let manifest = selected_manifest(vfs, directory, selected)?;
1473 let path = directory.join(&selected.manifest);
1474 let wal = crate::wal::WalReader::open(vfs, &directory.join("wal.ze"))
1475 .map_err(StoreError::Wal)?
1476 .into_clean()
1477 .map_err(StoreError::WalRecovery)?;
1478 let selected_records = wal
1480 .records()
1481 .iter()
1482 .filter(|r| (selected.binding.first_seq..=selected.binding.last_seq).contains(&r.seq.get()))
1483 .collect::<Vec<_>>();
1484 let member_count = selected
1485 .binding
1486 .last_seq
1487 .checked_sub(selected.binding.first_seq)
1488 .and_then(|count| count.checked_add(1))
1489 .ok_or_else(|| invalid(&path, "invalid committed prepared range"))?;
1490 if selected_records.len() as u64 != member_count {
1491 return Err(invalid(&path, "committed prepared range incomplete"));
1492 }
1493 for (record, index) in selected_records.iter().zip(0_u32..) {
1494 let payload = record
1495 .payload()
1496 .map_err(|e| invalid(&path, &e.to_string()))?;
1497 let member = crate::ingest::wal_payload::decode_prepared(payload)
1498 .map_err(|e| invalid(&path, &e.to_string()))?;
1499 if record.op != crate::ingest::wal_payload::PREPARED_MUTATION_V1
1500 || member.binding != selected.binding
1501 || member.index != index
1502 {
1503 return Err(invalid(&path, "committed prepared binding mismatch"));
1504 }
1505 }
1506 Ok(manifest)
1507}
1508
1509pub(super) fn manifest_for_open(vfs: &dyn Vfs, directory: &Path) -> Result<PathBuf, StoreError> {
1510 let canonical = directory.join("manifest.ze");
1511 if vfs
1512 .open(&canonical)
1513 .is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound)
1514 && vfs
1515 .list(directory)
1516 .map_err(|e| io(directory, e))?
1517 .iter()
1518 .any(|path| {
1519 path.file_name()
1520 .and_then(|name| name.to_str())
1521 .is_some_and(|name| name.starts_with(".ze-accepted-"))
1522 })
1523 {
1524 return Err(invalid(&canonical, "missing locally accepted manifest"));
1525 }
1526 match selection(vfs, directory)? {
1527 Some(selected) if !accepted(vfs, directory, selected.binding)? => {
1528 staged_manifest(vfs, directory, &selected)?;
1529 Ok(directory.join(selected.manifest))
1530 }
1531 _ => Ok(directory.join("manifest.ze")),
1532 }
1533}
1534fn accept(
1535 vfs: &dyn Vfs,
1536 directory: &Path,
1537 selected: &StagedSelection,
1538 manifest: &crate::manifest::Manifest,
1539 step: &mut dyn FnMut(&str) -> std::io::Result<()>,
1540) -> Result<(), StoreError> {
1541 let wal = crate::wal::WalReader::open(vfs, &directory.join("wal.ze"))
1544 .map_err(StoreError::Wal)?
1545 .into_clean()
1546 .map_err(StoreError::WalRecovery)?;
1547 let mut ids = Vec::new();
1548 for record in wal.records().iter().filter(|record| {
1549 (selected.binding.first_seq..=selected.binding.last_seq).contains(&record.seq.get())
1550 }) {
1551 let payload = record
1552 .payload()
1553 .map_err(|e| invalid(directory, &e.to_string()))?;
1554 let member = crate::ingest::wal_payload::decode_prepared(payload)
1555 .map_err(|e| invalid(directory, &e.to_string()))?;
1556 if member.binding != selected.binding {
1557 return Err(invalid(directory, "purge binding mismatch"));
1558 }
1559 if let crate::ingest::wal_payload::MutationPayload::Delete(deleted) = member.mutation {
1560 ids.extend(deleted);
1561 }
1562 }
1563 ids.sort_unstable();
1564 ids.dedup();
1565 crate::ingest::namespace_purge_intent(vfs, directory, selected.binding.transaction, ids)
1566 .map_err(|e| StoreError::PurgeRecovery {
1567 detail: e.to_string(),
1568 })?;
1569 step("purge obligation adopted").map_err(|e| io(directory, e))?;
1570 let bytes = crate::manifest::encode_manifest(manifest).map_err(StoreError::Manifest)?;
1571 let temporary = directory.join(".manifest.ze.tmp");
1572 durable_write(vfs, &temporary, &bytes, step)?;
1573 vfs.rename(&temporary, &directory.join("manifest.ze"))
1574 .map_err(|e| io(directory, e))?;
1575 step("accept manifest rename").map_err(|e| io(directory, e))?;
1576 sync_dir(vfs, directory, step)?;
1577 let binding = selected
1578 .binding
1579 .encode()
1580 .map_err(|e| invalid(directory, &e.to_string()))?;
1581 let committed = acceptance_path(directory, selected.binding);
1582 let temporary = committed.with_extension("tmp");
1583 durable_write(vfs, &temporary, &envelope(&binding), step)?;
1584 vfs.rename(&temporary, &committed)
1585 .map_err(|e| io(&committed, e))?;
1586 step("accept binding rename").map_err(|e| io(&committed, e))?;
1587 sync_dir(vfs, directory, step)
1588}
1589
1590pub(super) fn adopt_for_open(vfs: &dyn Vfs, directory: &Path) -> Result<(), StoreError> {
1591 if let Some(selected) = selection(vfs, directory)? {
1592 let (root, _) = location(directory)?;
1593 vfs.sync(root, SyncKind::Full).map_err(|e| io(root, e))?;
1596 if accepted(vfs, directory, selected.binding)? {
1597 vfs.sync(directory, SyncKind::Full)
1598 .map_err(|e| io(directory, e))?;
1599 } else {
1600 let manifest = staged_manifest(vfs, directory, &selected)?;
1601 accept(vfs, directory, &selected, &manifest, &mut |_| Ok(()))?;
1602 }
1603 }
1604 Ok(())
1605}
1606fn normalize_accepted(
1607 vfs: &dyn Vfs,
1608 root: &Path,
1609 step: &mut dyn FnMut(&str) -> std::io::Result<()>,
1610) -> Result<(), StoreError> {
1611 let RootDescriptor::Staged(descriptor) = root_descriptor_vfs(vfs, root)? else {
1612 return Ok(());
1613 };
1614 for (name, selected) in &descriptor.0 {
1615 let directory = root.join(descriptor.1.get(name).map_or(name.as_str(), String::as_str));
1616 if !accepted(vfs, &directory, selected.binding)? {
1617 return Err(StoreError::StoreBusy { path: directory });
1618 }
1619 sync_dir(vfs, &directory, step)?;
1620 }
1621 publish(vfs, root, &encode(&descriptor.1), step)
1622}
1623
1624pub(crate) fn transaction_decisions(
1625 vfs: &dyn Vfs,
1626 directory: &Path,
1627 records: &[crate::wal::VisibleRecord],
1628 absorbed: u64,
1629) -> Result<BTreeMap<u128, crate::ingest::wal_payload::TransactionBinding>, StoreError> {
1630 let mut decisions = BTreeMap::new();
1631 let mut checked = std::collections::BTreeSet::new();
1632 for record in records.iter().filter(|r| {
1633 r.seq.get() > absorbed && r.op == crate::ingest::wal_payload::PREPARED_MUTATION_V1
1634 }) {
1635 let payload = record.payload().map_err(|source| StoreError::WalRecord {
1636 seq: record.seq,
1637 source,
1638 })?;
1639 let member = crate::ingest::wal_payload::decode_prepared(payload).map_err(|source| {
1640 StoreError::WalMutation {
1641 seq: record.seq,
1642 op: record.op,
1643 source,
1644 }
1645 })?;
1646 if !checked.insert(member.binding.transaction) {
1647 continue;
1648 }
1649 let (root, name) = location(directory)?;
1650 let identity = participant_identity(root, name)?;
1651 if member.binding.participant != identity {
1652 return Err(StoreError::WalMutation {
1653 seq: record.seq,
1654 op: record.op,
1655 source: crate::ingest::wal_payload::PayloadError::TransactionBinding,
1656 });
1657 }
1658 if accepted(vfs, directory, member.binding)? {
1659 decisions.insert(member.binding.transaction, member.binding);
1660 } else if let Some(selected) = selection(vfs, directory)?
1661 && selected.binding.transaction == member.binding.transaction
1662 {
1663 if selected.binding != member.binding {
1664 return Err(invalid(directory, "root prepared binding mismatch"));
1665 }
1666 staged_manifest(vfs, directory, &selected)?;
1667 decisions.insert(member.binding.transaction, selected.binding);
1668 }
1669 }
1670 Ok(decisions)
1671}
1672
1673fn participant_identity(root: &Path, name: &str) -> Result<u128, StoreError> {
1674 let canonical = std::fs::canonicalize(root).map_err(|e| io(root, e))?;
1675 Ok(xxhash_rust::xxh3::xxh3_128(
1676 format!("{}\t{name}", canonical.display()).as_bytes(),
1677 ))
1678}
1679
1680fn rollback_stages(
1681 ordered: &[&LiveNamespaceMutation<'_>],
1682 staged: Vec<crate::ingest::NamespaceStage>,
1683 selections: &[Option<StagedSelection>],
1684 lengths: &[u64],
1685 wals: &mut [std::sync::MutexGuard<'_, Option<crate::ingest::StoreWal>>],
1686) -> Result<(), StoreError> {
1687 let result = (|| {
1688 for ((((p, stage), selection), length), wal) in ordered
1689 .iter()
1690 .zip(staged)
1691 .zip(selections)
1692 .zip(lengths)
1693 .zip(wals.iter_mut())
1694 {
1695 wal.as_mut()
1696 .ok_or(StoreError::ReadOnly)?
1697 .abort_namespace_suffix(
1698 p.store.vfs.as_ref(),
1699 &p.store.directory.join("wal.ze"),
1700 *length,
1701 p.store.durability_policy,
1702 stage.manifest.log_seq,
1703 &p.store.accounting,
1704 )?;
1705 cleanup_stage(p.store, stage, selection.as_ref())?;
1706 }
1707 Ok(())
1708 })();
1709 if result.is_err() {
1710 for wal in wals {
1711 **wal = None;
1712 }
1713 }
1714 result
1715}
1716
1717#[cfg(any(test, feature = "test-seams"))]
1719#[doc(hidden)]
1720pub fn namespace_reclaim(root: &Path) -> Result<(), StoreError> {
1721 let root = std::fs::canonicalize(root).map_err(|e| io(root, e))?;
1722 let _owner = StoreLock::acquire(&root).map_err(StoreError::Lock)?;
1723 reclamation::run(&StdVfs, &root, &mut |_| Ok(()))
1724}
1725
1726mod reclamation;
1727pub(super) use reclamation::{
1728 admission as reader_admission, for_open as reclaim_for_open, lease as reader_lease,
1729 refuse as refuse_retired,
1730};
1731
1732#[cfg(any(test, feature = "test-seams"))]
1733#[doc(hidden)]
1734pub fn namespace_reclaim_on_vfs(
1735 root: &Path,
1736 vfs: &dyn Vfs,
1737 step: &mut dyn FnMut(&str) -> std::io::Result<()>,
1738) -> Result<(), StoreError> {
1739 let _owner = StoreLock::acquire(root).map_err(StoreError::Lock)?;
1740 reclamation::run(vfs, root, step)
1741}