1use crate::checkpoint::MetadataTableCache;
6use crate::commit::CommitFingerprint;
7use crate::context::MutationContext;
8use crate::error::{CoreError, MetadataViewError, Result, WriterFence};
9use crate::metadata::MetadataState;
10use crate::namespace::basis::MetadataBasis;
11use crate::namespace::writer_epoch::acquire_writer_epoch;
12use crate::options::DeleteNamespaceOptions;
13use crate::path::write::{commit_fingerprint, CommitRequest, FilesystemOperation};
14use crate::protocol::{
15 load_publish_metadata_view, PublishTailOptions, PublishTailProjection, PublishTailWeight,
16};
17use crate::storage::content_admission::{ContentAdmission, ContentTokenError, PreparedContent};
18use crate::timing::{MonotonicTimer, StdMonotonicTimer};
19use loonfs_api::v0::CommitResponse as ApiCommitResponse;
20use loonfs_api::wire::control::{AcquiredWriter, HeadState};
21use loonfs_api::{
22 ChangeSeq, CommitId, ContentId, DeleteNamespaceResponse, ManifestId, NamespaceId,
23};
24use loonfs_objectstore::ObjectStore;
25use std::collections::HashSet;
26use std::sync::{Arc, Mutex};
27use thiserror::Error;
28
29#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct CommitCandidate {
33 request: CommitRequest,
34 content: ContentPreparation,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum ContentPreparation {
40 Ready(Vec<ContentAdmission>),
41 Rejected(ContentPreparationError),
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Error)]
46#[non_exhaustive]
47pub enum ContentPreparationError {
48 #[error("content token was rejected: {0}")]
50 ContentToken(#[from] ContentTokenError),
51 #[error("content object `{content_id}` is not prepared for publication")]
53 ContentNotPrepared { content_id: ContentId },
54}
55
56impl CommitCandidate {
57 pub fn new(request: CommitRequest) -> Self {
59 Self {
60 request,
61 content: ContentPreparation::Ready(Vec::new()),
62 }
63 }
64
65 pub fn prepared(request: CommitRequest, content: Vec<PreparedContent>) -> Self {
67 Self {
68 request,
69 content: ContentPreparation::Ready(
70 content
71 .into_iter()
72 .map(PreparedContent::into_admission)
73 .collect(),
74 ),
75 }
76 }
77
78 pub fn rejected(request: CommitRequest, error: ContentPreparationError) -> Self {
80 Self {
81 request,
82 content: ContentPreparation::Rejected(error),
83 }
84 }
85
86 pub(crate) fn request(&self) -> &CommitRequest {
87 &self.request
88 }
89
90 pub(crate) fn content_preparation(&self) -> &ContentPreparation {
91 &self.content
92 }
93
94 pub fn commit_id(&self) -> &CommitId {
96 &self.request.commit_id
97 }
98
99 pub fn semantic_identity(&self, namespace_id: &NamespaceId) -> Result<CommitFingerprint> {
102 commit_fingerprint(namespace_id, &self.request)
103 }
104
105 pub(crate) fn validate_request_limits(&self) -> Result<()> {
106 if self.request.operations.len() > crate::limits::MAX_COMMIT_OPERATIONS {
109 return Err(CoreError::InvalidCommitRequest(format!(
110 "mutation has {} operations; maximum is {}",
111 self.request.operations.len(),
112 crate::limits::MAX_COMMIT_OPERATIONS
113 )));
114 }
115 if let Some(message) = &self.request.message {
116 if message.len() > crate::limits::MAX_COMMIT_MESSAGE_BYTES {
117 return Err(CoreError::InvalidCommitRequest(format!(
118 "mutation message is {} bytes; maximum is {}",
119 message.len(),
120 crate::limits::MAX_COMMIT_MESSAGE_BYTES
121 )));
122 }
123 }
124 let prepared_count = match &self.content {
125 ContentPreparation::Ready(content) => content.len(),
126 ContentPreparation::Rejected(_) => 0,
127 };
128 if prepared_count > crate::limits::MAX_COMMIT_CONTENT_TOKENS {
129 return Err(CoreError::InvalidCommitRequest(format!(
130 "mutation has {prepared_count} prepared content proofs; maximum is {}",
131 crate::limits::MAX_COMMIT_CONTENT_TOKENS
132 )));
133 }
134 let distinct_content_refs = self
135 .request
136 .operations
137 .iter()
138 .filter_map(|operation| match operation {
139 FilesystemOperation::PutFile { content_ref, .. } => Some(content_ref),
140 _ => None,
141 })
142 .collect::<HashSet<_>>()
143 .len();
144 if distinct_content_refs > crate::limits::MAX_COMMIT_EXTERNAL_CONTENT_REFS {
145 return Err(CoreError::InvalidCommitRequest(format!(
146 "mutation references {distinct_content_refs} distinct external content refs; maximum is {}",
147 crate::limits::MAX_COMMIT_EXTERNAL_CONTENT_REFS
148 )));
149 }
150 Ok(())
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub struct WalTailPolicy {
163 pub checkpoint_at_segments: u64,
166 pub reject_writes_at_segments: u64,
169}
170
171impl WalTailPolicy {
172 pub const DEFAULT: Self = Self {
174 checkpoint_at_segments: 32,
175 reject_writes_at_segments: 128,
176 };
177}
178
179impl Default for WalTailPolicy {
180 fn default() -> Self {
181 Self::DEFAULT
182 }
183}
184
185const _: () = assert!(
188 0 < WalTailPolicy::DEFAULT.checkpoint_at_segments
189 && WalTailPolicy::DEFAULT.checkpoint_at_segments
190 < WalTailPolicy::DEFAULT.reject_writes_at_segments,
191);
192
193#[derive(Debug, Clone)]
194pub struct NamespaceCommitEnginePublishResult {
195 pub results: Vec<Result<ApiCommitResponse>>,
196 pub wal_tail_segments: u64,
199 pub resulting_read_state: Option<ResultingReadState>,
203}
204
205#[derive(Debug, Clone)]
207pub struct ResultingReadState {
208 pub head: HeadState,
209 pub head_etag: String,
210 pub basis: MetadataBasis,
213 pub manifest_id: ManifestId,
214 pub manifest_head_seq: ChangeSeq,
215 pub tail_rows: Arc<MetadataState>,
216}
217
218#[derive(Debug, Default)]
230pub struct WriterSessionState {
231 acquired_writer: Option<AcquiredWriter>,
234 fenced: Option<WriterFence>,
239}
240
241pub type SharedWriterSessionState = Arc<Mutex<WriterSessionState>>;
243
244#[derive(Debug, Clone)]
245pub struct NamespaceCommitEngine {
246 namespace_id: NamespaceId,
247 publish_tail_projection: Option<PublishTailProjection>,
248 session: SharedWriterSessionState,
251 timer: Arc<dyn MonotonicTimer>,
253 table_cache: Option<Arc<MetadataTableCache>>,
257}
258
259impl NamespaceCommitEngine {
260 pub fn new(namespace_id: NamespaceId) -> Self {
261 Self {
262 namespace_id,
263 publish_tail_projection: None,
264 session: SharedWriterSessionState::default(),
265 timer: Arc::new(StdMonotonicTimer::default()),
266 table_cache: None,
267 }
268 }
269
270 pub fn writer_session(mut self, session: SharedWriterSessionState) -> Self {
273 self.session = session;
274 self
275 }
276
277 fn lock_session(&self) -> std::sync::MutexGuard<'_, WriterSessionState> {
278 self.session
282 .lock()
283 .expect("writer session state lock should not be poisoned")
284 }
285
286 #[cfg(test)]
287 pub(crate) fn monotonic_timer(mut self, timer: Arc<dyn MonotonicTimer>) -> Self {
288 self.timer = timer;
289 self
290 }
291
292 pub fn table_cache(mut self, table_cache: Arc<MetadataTableCache>) -> Self {
293 self.table_cache = Some(table_cache);
294 self
295 }
296
297 pub fn invalidate(&mut self) {
298 self.publish_tail_projection = None;
303 }
304
305 pub fn retained_tail_weight(&self) -> Option<PublishTailWeight> {
312 self.publish_tail_projection
313 .as_ref()
314 .map(PublishTailProjection::weight)
315 }
316
317 async fn session_writer_epoch<S: ObjectStore + ?Sized>(
323 &self,
324 store: &S,
325 context: &MutationContext,
326 ) -> Result<AcquiredWriter> {
327 let already_acquired = {
328 let session = self.lock_session();
329 if let Some(fence) = &session.fenced {
330 return Err(CoreError::WriterFenced(fence.clone()));
331 }
332 session.acquired_writer.clone()
333 };
334 if let Some(acquired_writer) = already_acquired {
335 return Ok(acquired_writer);
336 }
337 let acquired_writer = acquire_writer_epoch(store, &self.namespace_id, context)
338 .await
339 .map_err(CoreError::WriterEpoch)?;
340 let mut session = self.lock_session();
341 if let Some(fence) = session.fenced.clone() {
342 return Err(CoreError::WriterFenced(fence));
345 }
346 session.acquired_writer = Some(acquired_writer.clone());
347 Ok(acquired_writer)
348 }
349
350 pub async fn delete_namespace<S: ObjectStore + ?Sized>(
359 &mut self,
360 store: &S,
361 options: DeleteNamespaceOptions,
362 context: &MutationContext,
363 ) -> Result<DeleteNamespaceResponse> {
364 let acquired_writer = self.session_writer_epoch(store, context).await?;
365 let deleted = crate::namespace::delete::delete_namespace(
366 store,
367 &self.namespace_id,
368 options,
369 acquired_writer,
370 )
371 .await;
372 if let Err(CoreError::WriterFenced(fence)) = &deleted {
373 let mut session = self.lock_session();
374 session.fenced = Some(fence.clone());
375 session.acquired_writer = None;
376 }
377 deleted
378 }
379
380 pub async fn publish_batch<S: ObjectStore + ?Sized>(
381 &mut self,
382 store: &S,
383 candidates: Vec<CommitCandidate>,
384 context: &MutationContext,
385 tail_options: &PublishTailOptions,
386 ) -> NamespaceCommitEnginePublishResult {
387 if candidates.is_empty() {
388 return NamespaceCommitEnginePublishResult {
389 results: Vec::new(),
390 wal_tail_segments: 0,
391 resulting_read_state: None,
392 };
393 }
394
395 let candidate_count = candidates.len();
396 let acquired_writer = match self.session_writer_epoch(store, context).await {
397 Ok(value) => value,
398 Err(error) => {
399 return NamespaceCommitEnginePublishResult {
400 results: repeated_error(candidate_count, error),
401 wal_tail_segments: 0,
402 resulting_read_state: None,
403 };
404 }
405 };
406
407 let (publish_view, projection) = match load_publish_metadata_view(
408 store,
409 self.table_cache.as_deref(),
410 &self.namespace_id,
411 Some(acquired_writer),
412 self.publish_tail_projection.as_ref(),
413 tail_options,
414 )
415 .await
416 {
417 Ok(value) => value,
418 Err(error) => {
419 self.invalidate();
420 if let CoreError::WriterFenced(fence) = &error {
421 let mut session = self.lock_session();
422 session.fenced = Some(fence.clone());
423 session.acquired_writer = None;
424 }
425 return NamespaceCommitEnginePublishResult {
426 results: repeated_error(candidate_count, error),
427 wal_tail_segments: 0,
428 resulting_read_state: None,
429 };
430 }
431 };
432
433 let reject_writes_at_segments = WalTailPolicy::DEFAULT.reject_writes_at_segments;
434 if projection.wal_tail_segments > reject_writes_at_segments {
435 let wal_tail_segments = projection.wal_tail_segments;
436 self.publish_tail_projection = Some(projection);
437 let error = MetadataViewError::MaintenanceRequired {
438 namespace_id: self.namespace_id.clone(),
439 reason: format!(
440 "wal tail has {wal_tail_segments} segments; publishes resume once maintenance brings it back under {reject_writes_at_segments}"
441 ),
442 };
443 return NamespaceCommitEnginePublishResult {
444 results: repeated_error(candidate_count, CoreError::from(error)),
445 wal_tail_segments,
446 resulting_read_state: None,
447 };
448 }
449
450 let published = crate::protocol::publish_namespace_commits_batch_against_publish_view(
451 store,
452 &self.namespace_id,
453 &candidates,
454 context,
455 &publish_view,
456 self.timer.as_ref(),
457 )
458 .await;
459 let resulting_head = published.resulting_head.clone();
460 let wal_tail_segments =
461 self.update_publish_tail_projection(projection, &published, tail_options);
462 let resulting_read_state = match (resulting_head, self.publish_tail_projection.as_ref()) {
465 (Some(head), Some(projection)) if projection.head_seq == head.seq => {
466 Some(ResultingReadState {
467 head,
468 head_etag: projection.head_etag.clone(),
469 basis: projection.basis.clone(),
470 manifest_id: projection.manifest_id,
471 manifest_head_seq: projection.manifest_head_seq,
472 tail_rows: Arc::new(projection.tail_state.clone()),
473 })
474 }
475 _ => None,
476 };
477 NamespaceCommitEnginePublishResult {
478 results: published.results,
479 wal_tail_segments,
480 resulting_read_state,
481 }
482 }
483
484 fn update_publish_tail_projection(
485 &mut self,
486 mut projection: PublishTailProjection,
487 published: &crate::protocol::PublishBatchAgainstViewResult,
488 tail_options: &PublishTailOptions,
489 ) -> u64 {
490 if !published.published_records.is_empty() {
491 projection.wal_tail_segments = projection.wal_tail_segments.saturating_add(1);
492 }
493 let wal_tail_segments = projection.wal_tail_segments;
494 let Some(resulting_head) = published.resulting_head.clone() else {
495 if published.can_reuse_loaded_projection {
496 self.publish_tail_projection = Some(projection);
497 } else {
498 self.invalidate();
499 }
500 return wal_tail_segments;
501 };
502 let Some(resulting_head_etag) = published.resulting_head_etag.clone() else {
503 self.invalidate();
504 return wal_tail_segments;
505 };
506 for record in &published.published_records {
507 projection.tail_state.apply_committed_wal_record_mut(record);
508 }
509 projection.head_seq = resulting_head.seq;
510 projection.head_etag = resulting_head_etag;
511 if projection.within_limits(tail_options) {
512 self.publish_tail_projection = Some(projection);
513 } else {
514 self.invalidate();
515 }
516 wal_tail_segments
517 }
518}
519
520fn repeated_error(count: usize, error: CoreError) -> Vec<Result<ApiCommitResponse>> {
521 (0..count).map(|_| Err(error.clone())).collect()
522}
523
524pub(crate) async fn publish_namespace_commits_batch<S: ObjectStore + ?Sized>(
526 store: &S,
527 namespace_id: &NamespaceId,
528 candidates: Vec<CommitCandidate>,
529 context: &MutationContext,
530) -> Vec<Result<ApiCommitResponse>> {
531 let mut engine = NamespaceCommitEngine::new(namespace_id.clone());
532 engine
533 .publish_batch(store, candidates, context, &PublishTailOptions::default())
534 .await
535 .results
536}
537
538pub(crate) async fn delete_namespace<S: ObjectStore + ?Sized>(
541 store: &S,
542 namespace_id: &NamespaceId,
543 options: DeleteNamespaceOptions,
544 context: &MutationContext,
545) -> Result<DeleteNamespaceResponse> {
546 NamespaceCommitEngine::new(namespace_id.clone())
547 .delete_namespace(store, options, context)
548 .await
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554 use crate::error::ErrorCode;
555 use crate::limits::WAL_PUBLISH_BUDGET_MS;
556 use crate::namespace::bootstrap::bootstrap_namespace;
557 use crate::namespace::control::read_head_object;
558 use futures::StreamExt;
559 use loonfs_api::{ChangeSeq, ContentRef, ContentStoreId, WriterEpoch};
560 use loonfs_objectstore::keys::wal_segment_prefix;
561 use loonfs_objectstore::local_fs_store::LocalFsStore;
562 use loonfs_objectstore::ObjectStore;
563 use loonfs_test_support::stores::{CountingStore, OperationClass};
564 use std::sync::atomic::{AtomicU64, Ordering};
565 use tempfile::tempdir;
566
567 fn context(writer_id: &str) -> MutationContext {
568 MutationContext {
569 writer_id: writer_id.to_owned(),
570 now_ms: 1_000,
571 }
572 }
573
574 fn create_dir_request(commit_id: &str, name: &str) -> CommitRequest {
575 CommitRequest::single(
576 CommitId::parse(commit_id).expect("valid commit id"),
577 None,
578 FilesystemOperation::CreateDirectory {
579 path: loonfs_api::AbsolutePath::parse(format!("/{name}")).expect("valid path"),
580 parents: false,
581 },
582 )
583 }
584
585 #[test]
586 fn semantic_identity_excludes_content_preparation() {
587 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
588 let request = create_dir_request("same-mutation", "docs");
589 let ready = CommitCandidate::new(request.clone());
590 let rejected = CommitCandidate::rejected(
591 request,
592 ContentPreparationError::ContentToken(ContentTokenError::Expired),
593 );
594
595 assert_eq!(
596 ready.semantic_identity(&namespace_id).expect("identity"),
597 rejected.semantic_identity(&namespace_id).expect("identity")
598 );
599 }
600
601 #[test]
602 fn semantic_identity_ignores_current_request_limits() {
603 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
604 let oversized_ops = CommitCandidate::new(CommitRequest {
605 commit_id: CommitId::parse("too-many-ops").expect("valid commit id"),
606 message: None,
607 operations: (0..=crate::limits::MAX_COMMIT_OPERATIONS)
608 .map(|index| FilesystemOperation::CreateDirectory {
609 path: loonfs_api::AbsolutePath::parse(format!("/dir-{index}"))
610 .expect("valid path"),
611 parents: false,
612 })
613 .collect(),
614 });
615 oversized_ops
616 .semantic_identity(&namespace_id)
617 .expect("operation limits must not affect identity");
618
619 let content_ref = ContentRef::blob_v1(ContentId::generate(), b"proof");
620 let admission = ContentAdmission::for_durable_content_write(
621 ContentStoreId::parse("cs_00000000000000000000000000000001").expect("content store id"),
622 content_ref,
623 );
624 let prepared = PreparedContent::from_admission(admission);
625 let oversized_proofs = CommitCandidate::prepared(
626 create_dir_request("too-many-proofs", "docs"),
627 vec![prepared; crate::limits::MAX_COMMIT_CONTENT_TOKENS + 1],
628 );
629 oversized_proofs
630 .semantic_identity(&namespace_id)
631 .expect("prepared proof limits must not affect identity");
632
633 let oversized_message = CommitCandidate::new(CommitRequest {
634 commit_id: CommitId::parse("too-long-message").expect("valid commit id"),
635 message: Some("m".repeat(crate::limits::MAX_COMMIT_MESSAGE_BYTES + 1)),
636 operations: vec![FilesystemOperation::CreateDirectory {
637 path: loonfs_api::AbsolutePath::parse("/docs").expect("valid path"),
638 parents: false,
639 }],
640 });
641 oversized_message
642 .semantic_identity(&namespace_id)
643 .expect("message limits must not affect identity");
644 }
645
646 #[test]
649 fn a_batch_past_the_operation_ceiling_is_rejected() {
650 let oversized = CommitCandidate::new(CommitRequest {
651 commit_id: CommitId::parse("oversized-batch").expect("valid commit id"),
652 message: None,
653 operations: (0..=crate::limits::MAX_COMMIT_OPERATIONS)
654 .map(|index| FilesystemOperation::CreateDirectory {
655 path: loonfs_api::AbsolutePath::parse(format!("/dir-{index}"))
656 .expect("valid path"),
657 parents: false,
658 })
659 .collect(),
660 });
661
662 let error = oversized
663 .validate_request_limits()
664 .expect_err("the batch is over the operation ceiling");
665 assert_eq!(error.code(), ErrorCode::InvalidRequest);
666
667 let at_ceiling = CommitCandidate::new(CommitRequest {
668 commit_id: CommitId::parse("largest-batch").expect("valid commit id"),
669 message: None,
670 operations: (0..crate::limits::MAX_COMMIT_OPERATIONS)
671 .map(|index| FilesystemOperation::CreateDirectory {
672 path: loonfs_api::AbsolutePath::parse(format!("/dir-{index}"))
673 .expect("valid path"),
674 parents: false,
675 })
676 .collect(),
677 });
678 at_ceiling
679 .validate_request_limits()
680 .expect("a batch at the ceiling is admitted");
681 }
682
683 #[test]
686 fn a_message_past_the_byte_ceiling_is_rejected() {
687 let operations = vec![FilesystemOperation::CreateDirectory {
688 path: loonfs_api::AbsolutePath::parse("/docs").expect("valid path"),
689 parents: false,
690 }];
691
692 let oversized = CommitCandidate::new(CommitRequest {
693 commit_id: CommitId::parse("oversized-message").expect("valid commit id"),
694 message: Some("m".repeat(crate::limits::MAX_COMMIT_MESSAGE_BYTES + 1)),
695 operations: operations.clone(),
696 });
697 let error = oversized
698 .validate_request_limits()
699 .expect_err("the message is over the byte ceiling");
700 assert_eq!(error.code(), ErrorCode::InvalidRequest);
701
702 let at_ceiling = CommitCandidate::new(CommitRequest {
703 commit_id: CommitId::parse("largest-message").expect("valid commit id"),
704 message: Some("m".repeat(crate::limits::MAX_COMMIT_MESSAGE_BYTES)),
705 operations,
706 });
707 at_ceiling
708 .validate_request_limits()
709 .expect("a message at the ceiling is admitted");
710 }
711
712 fn create_dir(commit_id: &str, display_name: &str) -> CommitCandidate {
713 CommitCandidate::new(create_dir_request(commit_id, display_name))
714 }
715
716 async fn wal_segment_count(store: &LocalFsStore, namespace_id: &NamespaceId) -> usize {
717 store
718 .list_prefix_stream(&wal_segment_prefix(namespace_id.as_str()))
719 .collect::<Vec<_>>()
720 .await
721 .len()
722 }
723
724 #[tokio::test]
725 async fn commit_engine_is_terminally_fenced_after_takeover() {
726 let temp_dir = tempdir().expect("tempdir");
727 let store = LocalFsStore::new(temp_dir.path()).expect("store");
728 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
729 let writer_a = context("writer-a");
730 bootstrap_namespace(&store, &namespace_id, &writer_a, false)
731 .await
732 .expect("bootstrap");
733
734 let mut engine_a = NamespaceCommitEngine::new(namespace_id.clone());
735 let first = engine_a
736 .publish_batch(
737 &store,
738 vec![create_dir("from-a-first", "alpha")],
739 &writer_a,
740 &PublishTailOptions::default(),
741 )
742 .await;
743 first.results[0].as_ref().expect("writer a first commit");
744
745 let writer_b = context("writer-b");
748 let mut engine_b = NamespaceCommitEngine::new(namespace_id.clone());
749 let takeover = engine_b
750 .publish_batch(
751 &store,
752 vec![create_dir("from-b-first", "beta")],
753 &writer_b,
754 &PublishTailOptions::default(),
755 )
756 .await;
757 takeover.results[0]
758 .as_ref()
759 .expect("writer b takeover commit");
760 let epoch_after_takeover = read_head_object(&store, &namespace_id)
761 .await
762 .expect("read head")
763 .envelope
764 .state
765 .writer_epoch;
766
767 for attempt in 0..2 {
771 let fenced = engine_a
772 .publish_batch(
773 &store,
774 vec![create_dir("from-a-second", "gamma")],
775 &writer_a,
776 &PublishTailOptions::default(),
777 )
778 .await;
779 let error = fenced.results[0].as_ref().expect_err("fenced publish");
780 assert_eq!(error.code(), ErrorCode::WriterFenced, "attempt {attempt}");
781 }
782 let head = read_head_object(&store, &namespace_id)
783 .await
784 .expect("read head")
785 .envelope
786 .state;
787 assert_eq!(head.writer_epoch, epoch_after_takeover);
788 assert_eq!(head.writer.expect("writer block").writer_id, "writer-b");
789 }
790
791 #[tokio::test]
795 async fn fencing_during_publish_view_load_still_reports_writer_fenced() {
796 use loonfs_test_support::stores::{BlockingStore, KeyPredicate};
797 use std::sync::Arc as StdArc;
798
799 let temp_dir = tempdir().expect("tempdir");
800 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
801 let writer_a = context("writer-a");
802
803 let store = StdArc::new(BlockingStore::new(
806 LocalFsStore::new(temp_dir.path()).expect("store"),
807 KeyPredicate::prefix(wal_segment_prefix(namespace_id.as_str())),
808 OperationClass::Read,
809 ));
810
811 bootstrap_namespace(store.inner(), &namespace_id, &writer_a, false)
812 .await
813 .expect("bootstrap");
814
815 let mut engine_a = NamespaceCommitEngine::new(namespace_id.clone());
816 engine_a
817 .publish_batch(
818 store.inner(),
819 vec![create_dir("from-a-first", "alpha")],
820 &writer_a,
821 &PublishTailOptions::default(),
822 )
823 .await
824 .results[0]
825 .as_ref()
826 .expect("writer a first commit");
827 engine_a.invalidate();
829
830 store.block_next();
831 let blocked_store = StdArc::clone(&store);
832 let publish_a = tokio::spawn(async move {
833 let mut engine = engine_a;
834 let result = engine
835 .publish_batch(
836 blocked_store.as_ref(),
837 vec![create_dir("from-a-second", "gamma")],
838 &writer_a,
839 &PublishTailOptions::default(),
840 )
841 .await;
842 result.results[0].as_ref().err().map(|error| error.code())
843 });
844
845 store.wait_until_blocked().await;
849 let writer_b = context("writer-b");
850 let mut engine_b = NamespaceCommitEngine::new(namespace_id.clone());
851 engine_b
852 .publish_batch(
853 store.inner(),
854 vec![create_dir("from-b-first", "beta")],
855 &writer_b,
856 &PublishTailOptions::default(),
857 )
858 .await
859 .results[0]
860 .as_ref()
861 .expect("writer b takeover commit");
862 store.release();
863
864 let code = publish_a.await.expect("join publish a");
865 assert_eq!(code, Some(ErrorCode::WriterFenced));
866 }
867
868 #[tokio::test]
869 async fn shared_session_keeps_fencing_across_engine_rebuilds() {
870 let temp_dir = tempdir().expect("tempdir");
871 let store = LocalFsStore::new(temp_dir.path()).expect("store");
872 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
873 let writer_a = context("writer-a");
874 bootstrap_namespace(&store, &namespace_id, &writer_a, false)
875 .await
876 .expect("bootstrap");
877
878 let session = SharedWriterSessionState::default();
879 let mut engine_a1 =
880 NamespaceCommitEngine::new(namespace_id.clone()).writer_session(Arc::clone(&session));
881 engine_a1
882 .publish_batch(
883 &store,
884 vec![create_dir("from-a-first", "alpha")],
885 &writer_a,
886 &PublishTailOptions::default(),
887 )
888 .await
889 .results
890 .remove(0)
891 .expect("writer a first commit");
892
893 let writer_b = context("writer-b");
894 let mut engine_b = NamespaceCommitEngine::new(namespace_id.clone());
895 engine_b
896 .publish_batch(
897 &store,
898 vec![create_dir("from-b-first", "beta")],
899 &writer_b,
900 &PublishTailOptions::default(),
901 )
902 .await
903 .results
904 .remove(0)
905 .expect("writer b takeover commit");
906
907 let fenced = engine_a1
908 .publish_batch(
909 &store,
910 vec![create_dir("from-a-second", "gamma")],
911 &writer_a,
912 &PublishTailOptions::default(),
913 )
914 .await;
915 let error = fenced.results[0].as_ref().expect_err("fenced publish");
916 assert_eq!(error.code(), ErrorCode::WriterFenced);
917 let epoch_after_fencing = read_head_object(&store, &namespace_id)
918 .await
919 .expect("read head")
920 .envelope
921 .state
922 .writer_epoch;
923
924 drop(engine_a1);
928 let mut engine_a2 =
929 NamespaceCommitEngine::new(namespace_id.clone()).writer_session(session);
930 let still_fenced = engine_a2
931 .publish_batch(
932 &store,
933 vec![create_dir("from-a-third", "delta")],
934 &writer_a,
935 &PublishTailOptions::default(),
936 )
937 .await;
938 let error = still_fenced.results[0]
939 .as_ref()
940 .expect_err("rebuilt engine stays fenced");
941 assert_eq!(error.code(), ErrorCode::WriterFenced);
942 let head = read_head_object(&store, &namespace_id)
943 .await
944 .expect("read head")
945 .envelope
946 .state;
947 assert_eq!(head.writer_epoch, epoch_after_fencing);
948 assert_eq!(head.writer.expect("writer block").writer_id, "writer-b");
949 }
950
951 #[derive(Debug)]
954 struct ExpiredBudgetTimer(AtomicU64);
955
956 impl MonotonicTimer for ExpiredBudgetTimer {
957 fn monotonic_now_ms(&self) -> u64 {
958 self.0
959 .fetch_add(WAL_PUBLISH_BUDGET_MS + 1_000, Ordering::SeqCst)
960 }
961 }
962
963 #[tokio::test]
964 async fn publish_over_budget_abandons_the_segment_and_a_retry_rebuilds() {
965 let temp_dir = tempdir().expect("tempdir");
966 let store = LocalFsStore::new(temp_dir.path()).expect("store");
967 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
968 let writer = context("writer-a");
969 bootstrap_namespace(&store, &namespace_id, &writer, false)
970 .await
971 .expect("bootstrap");
972 let head_before = read_head_object(&store, &namespace_id)
973 .await
974 .expect("read head")
975 .envelope
976 .state;
977
978 let mut over_budget = NamespaceCommitEngine::new(namespace_id.clone())
979 .monotonic_timer(Arc::new(ExpiredBudgetTimer(AtomicU64::new(0))));
980 let abandoned = over_budget
981 .publish_batch(
982 &store,
983 vec![create_dir("budgeted", "alpha")],
984 &writer,
985 &PublishTailOptions::default(),
986 )
987 .await;
988 let error = abandoned.results[0]
989 .as_ref()
990 .expect_err("over-budget publish must abandon");
991 assert!(
992 matches!(
993 error,
994 CoreError::HeadPublish(
995 crate::commit::CommitHeadPublishError::PublishBudgetExceeded { .. }
996 )
997 ),
998 "unexpected error: {error:?}"
999 );
1000 assert_eq!(error.code(), ErrorCode::StaleHead);
1003
1004 let head_after = read_head_object(&store, &namespace_id)
1006 .await
1007 .expect("read head")
1008 .envelope
1009 .state;
1010 assert_eq!(head_after.seq, head_before.seq);
1011 assert_eq!(head_after.visible_wal_tip, head_before.visible_wal_tip);
1012 assert_eq!(wal_segment_count(&store, &namespace_id).await, 1);
1013
1014 let mut healthy = NamespaceCommitEngine::new(namespace_id.clone());
1017 let retried = healthy
1018 .publish_batch(
1019 &store,
1020 vec![create_dir("budgeted", "alpha")],
1021 &writer,
1022 &PublishTailOptions::default(),
1023 )
1024 .await;
1025 let response = retried.results[0].as_ref().expect("rebuilt publish");
1026 assert_eq!(response.committed_seq, ChangeSeq(1));
1027 assert_eq!(wal_segment_count(&store, &namespace_id).await, 2);
1028 let head_final = read_head_object(&store, &namespace_id)
1029 .await
1030 .expect("read head")
1031 .envelope
1032 .state;
1033 assert_eq!(head_final.seq, ChangeSeq(1));
1034 assert_eq!(head_final.writer_epoch, WriterEpoch(2));
1037 }
1038
1039 #[tokio::test]
1040 async fn publish_views_reuse_cached_table_blocks_across_publishes() {
1041 use crate::cache::MetadataTableCacheConfig;
1042 let temp_dir = tempdir().expect("tempdir");
1043 let store =
1044 CountingStore::metadata_tables(LocalFsStore::new(temp_dir.path()).expect("store"));
1045 let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
1046 let writer = context("writer-a");
1047 bootstrap_namespace(&store, &namespace_id, &writer, false)
1048 .await
1049 .expect("bootstrap");
1050 let mut seed = NamespaceCommitEngine::new(namespace_id.clone());
1051 seed.publish_batch(
1052 &store,
1053 vec![create_dir("seed-commit", "docs")],
1054 &writer,
1055 &PublishTailOptions::default(),
1056 )
1057 .await
1058 .results
1059 .remove(0)
1060 .expect("seed publish");
1061 crate::checkpoint::create_checkpoint(
1062 &store,
1063 &namespace_id,
1064 loonfs_api::wire::control::CheckpointOwner::User {
1065 name: "test-pin".to_owned(),
1066 },
1067 None,
1068 &writer,
1069 )
1070 .await
1071 .expect("checkpoint");
1072
1073 let mut uncached = NamespaceCommitEngine::new(namespace_id.clone());
1076 store.reset();
1077 uncached
1078 .publish_batch(
1079 &store,
1080 vec![create_dir("uncached-a", "alpha")],
1081 &writer,
1082 &PublishTailOptions::default(),
1083 )
1084 .await
1085 .results
1086 .remove(0)
1087 .expect("uncached publish a");
1088 assert!(
1089 store.count(OperationClass::Read) > 0,
1090 "publish validation should read table blocks"
1091 );
1092 store.reset();
1093 uncached
1094 .publish_batch(
1095 &store,
1096 vec![create_dir("uncached-b", "beta")],
1097 &writer,
1098 &PublishTailOptions::default(),
1099 )
1100 .await
1101 .results
1102 .remove(0)
1103 .expect("uncached publish b");
1104 assert!(
1105 store.count(OperationClass::Read) > 0,
1106 "without a cache the next publish re-fetches the same blocks"
1107 );
1108
1109 let cache = Arc::new(MetadataTableCache::new(MetadataTableCacheConfig::default()));
1110 let mut cached = NamespaceCommitEngine::new(namespace_id.clone()).table_cache(cache);
1111 store.reset();
1112 cached
1113 .publish_batch(
1114 &store,
1115 vec![create_dir("cached-a", "gamma")],
1116 &writer,
1117 &PublishTailOptions::default(),
1118 )
1119 .await
1120 .results
1121 .remove(0)
1122 .expect("cached publish a");
1123 assert!(
1124 store.count(OperationClass::Read) > 0,
1125 "the first cached publish fills the cache"
1126 );
1127 store.reset();
1128 cached
1129 .publish_batch(
1130 &store,
1131 vec![create_dir("cached-b", "delta")],
1132 &writer,
1133 &PublishTailOptions::default(),
1134 )
1135 .await
1136 .results
1137 .remove(0)
1138 .expect("cached publish b");
1139 assert_eq!(
1140 store.count(OperationClass::Read),
1141 0,
1142 "a warm cache serves every publish-view table read"
1143 );
1144 }
1145}