Skip to main content

loonfs_core/commit/
publish.rs

1//! Publishes a prepared WAL segment: the segment PUT and the head
2//! compare-and-swap that makes its commits visible.
3
4use super::{CommitHeadPublishError, CommitPlan};
5use crate::wal::PreparedWalSegment;
6use bytes::Bytes;
7use loonfs_api::wire::control::{
8    encode_control_object, ControlObjectKind, HeadState, HeadStateEnvelope, WalSegmentPointer,
9};
10use loonfs_api::ChangeSeq;
11use loonfs_objectstore::keys::wal_head;
12use loonfs_objectstore::ObjectStoreError;
13use loonfs_objectstore::{ObjectMetadata, ObjectStore};
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct PreparedCommitHeadPublish {
18    pub object_key: String,
19    pub resulting_head: HeadState,
20    pub envelope: HeadStateEnvelope,
21    pub encoded_bytes: Vec<u8>,
22}
23
24pub fn prepare_commit_head_publish(
25    current_head: &HeadState,
26    plan: &CommitPlan,
27    wal: &PreparedWalSegment,
28) -> Result<PreparedCommitHeadPublish, CommitHeadPublishError> {
29    if current_head.namespace_id != plan.namespace_id {
30        return Err(CommitHeadPublishError::NamespaceMismatch {
31            head: current_head.namespace_id.clone(),
32            plan: plan.namespace_id.clone(),
33        });
34    }
35
36    let wal_payload = &wal.envelope.payload;
37    if wal_payload.namespace_id != current_head.namespace_id {
38        return Err(CommitHeadPublishError::WalSegmentNamespaceMismatch {
39            head: current_head.namespace_id.clone(),
40            wal: wal_payload.namespace_id.clone(),
41        });
42    }
43    if wal_payload.writer_epoch != current_head.writer_epoch {
44        return Err(CommitHeadPublishError::WalSegmentWriterEpochMismatch {
45            expected: current_head.writer_epoch,
46            actual: wal_payload.writer_epoch,
47        });
48    }
49
50    if wal_payload.records.is_empty() {
51        return Err(CommitHeadPublishError::EmptyWalSegment);
52    }
53
54    if wal_payload.base_head_seq != current_head.seq {
55        return Err(CommitHeadPublishError::WalSegmentBaseHeadSeqMismatch {
56            expected: current_head.seq,
57            actual: wal_payload.base_head_seq,
58        });
59    }
60
61    let expected_start_seq = ChangeSeq(
62        current_head
63            .seq
64            .0
65            .checked_add(1)
66            .ok_or(CommitHeadPublishError::SeqOverflow)?,
67    );
68    if wal_payload.start_seq != expected_start_seq {
69        return Err(CommitHeadPublishError::WalSegmentStartSeqMismatch {
70            expected: expected_start_seq,
71            actual: wal_payload.start_seq,
72        });
73    }
74
75    if wal_payload.end_seq != plan.assigned_seq {
76        return Err(CommitHeadPublishError::WalSegmentEndSeqMismatch {
77            expected: plan.assigned_seq,
78            actual: wal_payload.end_seq,
79        });
80    }
81
82    let object_key = wal_head(current_head.namespace_id.as_str());
83    let new_tip = wal.envelope.pointer(wal.object_key.clone());
84    let resulting_head = HeadState {
85        namespace_id: current_head.namespace_id.clone(),
86        // The head is the only durable home of the namespace's content
87        // store, name policy, and fork provenance: every successor carries
88        // them forward verbatim, and the assertion below proves it did.
89        content_store_id: current_head.content_store_id.clone(),
90        fork_basis: current_head.fork_basis.clone(),
91        seq: plan.assigned_seq,
92        head_commit_id: plan.commit_id.clone(),
93        writer_epoch: current_head.writer_epoch,
94        writer: current_head.writer.clone(),
95        next_inode_id: plan.resulting_next_inode_id,
96        recent_segments: next_recent_segments(current_head, new_tip.clone()),
97        visible_wal_tip: Some(new_tip),
98        state: current_head.state,
99    };
100    current_head
101        .ensure_successor_identity(&resulting_head)
102        .map_err(CommitHeadPublishError::HeadIdentityDrift)?;
103    let envelope =
104        HeadStateEnvelope::from_state(ControlObjectKind::WalHead, resulting_head.clone()).map_err(
105            |err| CommitHeadPublishError::Codec {
106                object_key: object_key.clone(),
107                message: err.to_string(),
108            },
109        )?;
110    let encoded_bytes =
111        encode_control_object(&envelope).map_err(|err| CommitHeadPublishError::Codec {
112            object_key: object_key.clone(),
113            message: err.to_string(),
114        })?;
115
116    Ok(PreparedCommitHeadPublish {
117        object_key,
118        resulting_head,
119        envelope,
120        encoded_bytes,
121    })
122}
123
124/// How many segment pointers the head carries as a replay accelerator.
125///
126/// Newest first, tip included. The bound keeps the head one small object at
127/// any commit rate; readers needing older history walk the chain links,
128/// which remain the only authority.
129const RECENT_SEGMENTS_LIMIT: usize = 32;
130
131fn next_recent_segments(
132    current_head: &HeadState,
133    new_tip: WalSegmentPointer,
134) -> Vec<WalSegmentPointer> {
135    let mut recent = Vec::with_capacity(RECENT_SEGMENTS_LIMIT);
136    recent.push(new_tip);
137    if current_head.recent_segments.is_empty() {
138        // Heads published before the accelerator existed carry only the tip
139        // pointer; seed from it so the hint list stays gap-free.
140        recent.extend(current_head.visible_wal_tip.iter().cloned());
141    } else {
142        recent.extend(current_head.recent_segments.iter().cloned());
143    }
144    recent.truncate(RECENT_SEGMENTS_LIMIT);
145    recent
146}
147
148pub async fn publish_commit_head<S: ObjectStore + ?Sized>(
149    store: &S,
150    expected_head_etag: &str,
151    prepared: &PreparedCommitHeadPublish,
152) -> Result<ObjectMetadata, CommitHeadPublishError> {
153    if expected_head_etag.trim().is_empty() {
154        return Err(CommitHeadPublishError::EmptyExpectedHeadEtag);
155    }
156
157    store
158        .compare_and_swap(
159            &prepared.object_key,
160            expected_head_etag,
161            Bytes::copy_from_slice(&prepared.encoded_bytes),
162        )
163        .await
164        .map_err(|error| map_object_store_error(&prepared.object_key, error))
165}
166
167fn map_object_store_error(object_key: &str, err: ObjectStoreError) -> CommitHeadPublishError {
168    match err {
169        ObjectStoreError::PreconditionFailed { .. } => CommitHeadPublishError::StaleHead,
170        // A transport failure after the CAS was sent leaves the outcome
171        // unobserved: the head may already reference the new segment.
172        ObjectStoreError::Transport { message, .. } => {
173            CommitHeadPublishError::OutcomeUnknown(message)
174        }
175        other => CommitHeadPublishError::Store {
176            object_key: object_key.to_owned(),
177            message: other.to_string(),
178        },
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use loonfs_objectstore::keys::wal_segment as wal_segment_key;
186
187    #[test]
188    fn head_cas_transport_failure_maps_to_unknown_outcome_not_failure() {
189        assert_eq!(
190            map_object_store_error(
191                "namespaces/demo/control/head.json",
192                ObjectStoreError::transport("namespaces/demo/control/head.json", "timeout"),
193            ),
194            CommitHeadPublishError::OutcomeUnknown("timeout".to_owned())
195        );
196        assert_eq!(
197            map_object_store_error(
198                "namespaces/demo/control/head.json",
199                ObjectStoreError::PreconditionFailed {
200                    object_key: "namespaces/demo/control/head.json".to_owned(),
201                },
202            ),
203            CommitHeadPublishError::StaleHead
204        );
205        assert!(matches!(
206            map_object_store_error(
207                "namespaces/demo/control/head.json",
208                ObjectStoreError::NotFound {
209                    object_key: "namespaces/demo/control/head.json".to_owned(),
210                },
211            ),
212            CommitHeadPublishError::Store { .. }
213        ));
214    }
215    use loonfs_api::wire::control::WriterBlock;
216    use loonfs_api::wire::wal::{WalCommitPayload, WalSegmentEnvelope, WalSegmentPayload};
217    use loonfs_api::{CommitId, InodeId, NamespaceId, WalSegmentId, WriterEpoch};
218
219    fn head(namespace_id: NamespaceId, seq: ChangeSeq) -> HeadState {
220        HeadState {
221            namespace_id,
222            content_store_id: loonfs_api::ContentStoreId::parse(
223                "cs_0123456789abcdef0123456789abcdef",
224            )
225            .expect("content store id"),
226            fork_basis: None,
227            seq,
228            head_commit_id: CommitId::parse("c_00000000000000000000000000000000")
229                .expect("commit id"),
230            writer_epoch: WriterEpoch(1),
231            writer: Some(WriterBlock {
232                writer_id: "writer-a".to_owned(),
233                acquired_at_ms: 1_000,
234            }),
235            next_inode_id: InodeId(10),
236            visible_wal_tip: None,
237            recent_segments: Vec::new(),
238            state: Default::default(),
239        }
240    }
241
242    fn plan(namespace_id: NamespaceId, assigned_seq: ChangeSeq) -> CommitPlan {
243        CommitPlan {
244            namespace_id,
245            commit_id: CommitId::parse("publish-plan").expect("valid commit id"),
246            apply_after_seq: ChangeSeq(assigned_seq.0.saturating_sub(1)),
247            assigned_seq,
248            validated_ops: Vec::new(),
249            resulting_next_inode_id: InodeId(10),
250        }
251    }
252
253    fn wal_segment(
254        namespace_id: NamespaceId,
255        base_head_seq: ChangeSeq,
256        start_seq: ChangeSeq,
257        end_seq: ChangeSeq,
258        record_count: usize,
259    ) -> PreparedWalSegment {
260        let records = (0..record_count)
261            .map(|index| {
262                let offset = u64::try_from(index).expect("test index");
263                let seq = ChangeSeq(start_seq.0 + offset);
264                WalCommitPayload {
265                    seq,
266                    commit_id: CommitId::parse(format!("publish-record-{index}"))
267                        .expect("valid commit id"),
268                    semantic_commit_fingerprint: format!("fingerprint-{index}"),
269                    committed_at_ms: 4_200,
270                    message: None,
271                    deltas: Vec::new(),
272                }
273            })
274            .collect();
275        let segment_id =
276            WalSegmentId::parse("00000000000000000001-aaaaaaaaaaaaaaaa").expect("valid segment id");
277        let payload = WalSegmentPayload {
278            namespace_id: namespace_id.clone(),
279            segment_id: segment_id.clone(),
280            writer_epoch: WriterEpoch(1),
281            prev_visible_segment: None,
282            base_head_seq,
283            start_seq,
284            end_seq,
285            records,
286        };
287        let envelope = WalSegmentEnvelope::from_payload(payload).expect("wal envelope");
288        PreparedWalSegment {
289            object_key: wal_segment_key(namespace_id.as_str(), segment_id.as_str()),
290            segment_id,
291            envelope,
292            encoded_bytes: Vec::new(),
293        }
294    }
295
296    #[test]
297    fn head_publish_accepts_segment_connecting_current_head_to_plan() {
298        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
299        let current_head = head(namespace_id.clone(), ChangeSeq(7));
300        let plan = plan(namespace_id.clone(), ChangeSeq(9));
301        let wal = wal_segment(namespace_id, ChangeSeq(7), ChangeSeq(8), ChangeSeq(9), 2);
302
303        let prepared =
304            prepare_commit_head_publish(&current_head, &plan, &wal).expect("prepare head publish");
305
306        assert_eq!(prepared.resulting_head.seq, ChangeSeq(9));
307        assert_eq!(
308            prepared.resulting_head.visible_wal_tip,
309            Some(wal.envelope.pointer(wal.object_key.clone()))
310        );
311    }
312
313    #[test]
314    fn head_publish_seeds_recent_segments_from_the_prior_tip() {
315        // Upgrade path: a head that has only a tip pointer still produces a
316        // gap-free hint list.
317        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
318        let mut current_head = head(namespace_id.clone(), ChangeSeq(7));
319        let prior = wal_segment(
320            namespace_id.clone(),
321            ChangeSeq(5),
322            ChangeSeq(6),
323            ChangeSeq(7),
324            2,
325        );
326        let prior_tip = prior.envelope.pointer(prior.object_key.clone());
327        current_head.visible_wal_tip = Some(prior_tip.clone());
328        let plan = plan(namespace_id.clone(), ChangeSeq(9));
329        let wal = wal_segment(namespace_id, ChangeSeq(7), ChangeSeq(8), ChangeSeq(9), 2);
330
331        let prepared =
332            prepare_commit_head_publish(&current_head, &plan, &wal).expect("prepare head publish");
333
334        let new_tip = wal.envelope.pointer(wal.object_key.clone());
335        assert_eq!(
336            prepared.resulting_head.recent_segments,
337            vec![new_tip.clone(), prior_tip]
338        );
339        assert_eq!(prepared.resulting_head.visible_wal_tip, Some(new_tip));
340    }
341
342    #[test]
343    fn head_publish_prepends_the_tip_and_truncates_recent_segments() {
344        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
345        let mut current_head = head(namespace_id.clone(), ChangeSeq(100));
346        let filler = |index: u64| {
347            let segment = wal_segment(
348                namespace_id.clone(),
349                ChangeSeq(index),
350                ChangeSeq(index + 1),
351                ChangeSeq(index + 1),
352                1,
353            );
354            segment.envelope.pointer(segment.object_key.clone())
355        };
356        current_head.recent_segments = (0..32).rev().map(filler).collect();
357        let oldest = current_head
358            .recent_segments
359            .last()
360            .cloned()
361            .expect("oldest");
362        let plan = plan(namespace_id.clone(), ChangeSeq(101));
363        let wal = wal_segment(
364            namespace_id,
365            ChangeSeq(100),
366            ChangeSeq(101),
367            ChangeSeq(101),
368            1,
369        );
370
371        let prepared =
372            prepare_commit_head_publish(&current_head, &plan, &wal).expect("prepare head publish");
373
374        let recent = &prepared.resulting_head.recent_segments;
375        assert_eq!(recent.len(), 32);
376        assert_eq!(recent[0], wal.envelope.pointer(wal.object_key.clone()));
377        assert!(!recent.contains(&oldest), "oldest hint must fall off");
378    }
379
380    #[test]
381    fn head_publish_rejects_segment_base_after_current_head() {
382        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
383        let current_head = head(namespace_id.clone(), ChangeSeq(7));
384        let plan = plan(namespace_id.clone(), ChangeSeq(9));
385        let wal = wal_segment(namespace_id, ChangeSeq(8), ChangeSeq(9), ChangeSeq(9), 1);
386
387        assert!(matches!(
388            prepare_commit_head_publish(&current_head, &plan, &wal),
389            Err(CommitHeadPublishError::WalSegmentBaseHeadSeqMismatch {
390                expected: ChangeSeq(7),
391                actual: ChangeSeq(8),
392            })
393        ));
394    }
395
396    #[test]
397    fn head_publish_rejects_segment_base_before_current_head() {
398        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
399        let current_head = head(namespace_id.clone(), ChangeSeq(7));
400        let plan = plan(namespace_id.clone(), ChangeSeq(9));
401        let wal = wal_segment(namespace_id, ChangeSeq(6), ChangeSeq(7), ChangeSeq(9), 3);
402
403        assert!(matches!(
404            prepare_commit_head_publish(&current_head, &plan, &wal),
405            Err(CommitHeadPublishError::WalSegmentBaseHeadSeqMismatch {
406                expected: ChangeSeq(7),
407                actual: ChangeSeq(6),
408            })
409        ));
410    }
411
412    #[test]
413    fn head_publish_rejects_empty_segment() {
414        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
415        let current_head = head(namespace_id.clone(), ChangeSeq(7));
416        let plan = plan(namespace_id.clone(), ChangeSeq(9));
417        let wal = wal_segment(namespace_id, ChangeSeq(7), ChangeSeq(8), ChangeSeq(9), 0);
418
419        assert!(matches!(
420            prepare_commit_head_publish(&current_head, &plan, &wal),
421            Err(CommitHeadPublishError::EmptyWalSegment)
422        ));
423    }
424
425    #[test]
426    fn head_publish_rejects_segment_start_that_skips_current_head() {
427        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
428        let current_head = head(namespace_id.clone(), ChangeSeq(7));
429        let plan = plan(namespace_id.clone(), ChangeSeq(9));
430        let wal = wal_segment(namespace_id, ChangeSeq(7), ChangeSeq(9), ChangeSeq(9), 1);
431
432        assert!(matches!(
433            prepare_commit_head_publish(&current_head, &plan, &wal),
434            Err(CommitHeadPublishError::WalSegmentStartSeqMismatch {
435                expected: ChangeSeq(8),
436                actual: ChangeSeq(9),
437            })
438        ));
439    }
440
441    #[test]
442    fn head_publish_rejects_segment_end_that_differs_from_plan() {
443        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
444        let current_head = head(namespace_id.clone(), ChangeSeq(7));
445        let plan = plan(namespace_id.clone(), ChangeSeq(9));
446        let wal = wal_segment(namespace_id, ChangeSeq(7), ChangeSeq(8), ChangeSeq(10), 3);
447
448        assert!(matches!(
449            prepare_commit_head_publish(&current_head, &plan, &wal),
450            Err(CommitHeadPublishError::WalSegmentEndSeqMismatch {
451                expected: ChangeSeq(9),
452                actual: ChangeSeq(10),
453            })
454        ));
455    }
456
457    #[test]
458    fn head_publish_rejects_segment_namespace_mismatch() {
459        let current_head = head(
460            NamespaceId::parse("demo").expect("valid namespace id"),
461            ChangeSeq(7),
462        );
463        let plan = plan(
464            NamespaceId::parse("demo").expect("valid namespace id"),
465            ChangeSeq(9),
466        );
467        let wal = wal_segment(
468            NamespaceId::parse("other").expect("valid namespace id"),
469            ChangeSeq(7),
470            ChangeSeq(8),
471            ChangeSeq(9),
472            2,
473        );
474
475        assert!(matches!(
476            prepare_commit_head_publish(&current_head, &plan, &wal),
477            Err(CommitHeadPublishError::WalSegmentNamespaceMismatch { head, wal })
478                if head == NamespaceId::parse("demo").expect("valid namespace id") && wal == NamespaceId::parse("other").expect("valid namespace id")
479        ));
480    }
481}