Skip to main content

appcore_sync/sync/
outbox_journal.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: outbox_journal.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/26 00:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/26 00:00:00 by dnettoRaw
8//      ###########      S: 2.0.0
9// =============================================================================
10
11//! Incremental binary journal for the durable synchronization outbox.
12
13use crate::sync::error::{SyncError, SyncResult};
14use crate::sync::outbox::SyncOutbox;
15use crate::sync::outbox_format::{
16    append_record, corrupt_record, create_empty_journal, encoded_frame_bytes,
17    ensure_append_capacity, new_generation, outbox_full, read_header, record_hash, scan_records,
18    validate_batch_id, validate_record_data, write_frame, write_header, JournalOperation,
19    ScanResult, ACK_KIND, ACK_SPACE_RESERVE_BYTES, COMPACTION_ACK_RECORDS,
20    COMPACTION_RECLAIM_BYTES, ENQUEUE_KIND, GENERATION_BYTES, HASH_BYTES, HEADER_BYTES,
21    MAX_OUTBOX_FILE_BYTES,
22};
23use crate::sync::persistence::{acquire_persistence_lock, atomic_write_with, truncate_synced};
24use crate::sync::types::SyncMessage;
25use parking_lot::Mutex;
26use std::collections::VecDeque;
27use std::path::{Path, PathBuf};
28
29/// Stable on-disk format marker for incremental durable sync outboxes.
30pub use crate::sync::outbox_format::SYNC_OUTBOX_FORMAT_V2;
31
32/// Crash-consistent incremental file-backed synchronization outbox.
33pub struct FileSyncOutbox {
34    file_path: PathBuf,
35    state: Mutex<JournalState>,
36}
37
38struct JournalState {
39    generation: [u8; GENERATION_BYTES],
40    messages: VecDeque<PendingMessage>,
41    scanned_bytes: u64,
42    record_count: u64,
43    acknowledged_records: u64,
44    live_frame_bytes: u64,
45    chain_head: [u8; HASH_BYTES],
46}
47
48struct PendingMessage {
49    message: SyncMessage,
50    frame_bytes: u64,
51}
52
53impl std::fmt::Debug for FileSyncOutbox {
54    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        formatter
56            .debug_struct("FileSyncOutbox")
57            .field("file_path", &self.file_path)
58            .field("pending_messages", &self.state.lock().messages.len())
59            .finish()
60    }
61}
62
63impl FileSyncOutbox {
64    /// Opens or creates a V2 outbox and validates every complete journal frame.
65    pub fn new(file_path: impl Into<PathBuf>) -> SyncResult<Self> {
66        let file_path = file_path.into();
67        if let Some(parent) = file_path.parent() {
68            std::fs::create_dir_all(parent)
69                .map_err(|error| SyncError::ReplicationFailed(error.to_string()))?;
70        }
71        let _process_lock = acquire_persistence_lock(&file_path)?;
72        if !file_path.exists() {
73            create_empty_journal(&file_path)?;
74        }
75        let state = load_state(&file_path)?;
76        Ok(Self {
77            file_path,
78            state: Mutex::new(state),
79        })
80    }
81
82    /// Returns the durable outbox journal path.
83    pub fn file_path(&self) -> &Path {
84        &self.file_path
85    }
86
87    fn refresh(&self, state: &mut JournalState) -> SyncResult<()> {
88        let header = read_header(&self.file_path)?;
89        if header.generation != state.generation || header.file_bytes < state.scanned_bytes {
90            *state = load_state(&self.file_path)?;
91            return Ok(());
92        }
93        if header.file_bytes == state.scanned_bytes {
94            return Ok(());
95        }
96        let batch_ids = state
97            .messages
98            .iter()
99            .map(|pending| pending.message.batch_id.clone())
100            .collect();
101        let scan = scan_records(
102            &self.file_path,
103            state.scanned_bytes,
104            state.record_count,
105            state.chain_head,
106            batch_ids,
107        )?;
108        apply_scan(state, scan, &self.file_path)
109    }
110
111    fn compact_if_needed(&self, state: &mut JournalState) -> SyncResult<()> {
112        let reclaimable = state
113            .scanned_bytes
114            .saturating_sub(HEADER_BYTES as u64)
115            .saturating_sub(state.live_frame_bytes);
116        if reclaimable >= COMPACTION_RECLAIM_BYTES
117            || state.acknowledged_records >= COMPACTION_ACK_RECORDS
118        {
119            compact(&self.file_path, state)?;
120        }
121        Ok(())
122    }
123}
124
125impl SyncOutbox for FileSyncOutbox {
126    fn try_enqueue(&self, message: SyncMessage, max_len: usize) -> SyncResult<bool> {
127        validate_batch_id(&message.batch_id)?;
128        let mut state = self.state.lock();
129        let _process_lock = acquire_persistence_lock(&self.file_path)?;
130        self.refresh(&mut state)?;
131        self.compact_if_needed(&mut state)?;
132        if state.messages.len() >= max_len {
133            return Ok(false);
134        }
135        let data = serde_json::to_vec(&message)
136            .map_err(|error| SyncError::ReplicationFailed(error.to_string()))?;
137        validate_record_data(&data)?;
138        let frame_bytes = encoded_frame_bytes(data.len())?;
139        if state
140            .scanned_bytes
141            .checked_add(frame_bytes)
142            .is_none_or(|size| size > MAX_OUTBOX_FILE_BYTES.saturating_sub(ACK_SPACE_RESERVE_BYTES))
143        {
144            compact(&self.file_path, &mut state)?;
145        }
146        ensure_append_capacity(state.scanned_bytes, frame_bytes, ACK_SPACE_RESERVE_BYTES)?;
147        let hash = append_record(
148            &self.file_path,
149            state.record_count,
150            state.chain_head,
151            ENQUEUE_KIND,
152            &data,
153        )?;
154        state.scanned_bytes += frame_bytes;
155        state.record_count += 1;
156        state.live_frame_bytes += frame_bytes;
157        state.chain_head = hash;
158        state.messages.push_back(PendingMessage {
159            message,
160            frame_bytes,
161        });
162        Ok(true)
163    }
164
165    fn front(&self) -> SyncResult<Option<SyncMessage>> {
166        let mut state = self.state.lock();
167        let _process_lock = acquire_persistence_lock(&self.file_path)?;
168        self.refresh(&mut state)?;
169        Ok(state
170            .messages
171            .front()
172            .map(|pending| pending.message.clone()))
173    }
174
175    fn acknowledge_front(&self, batch_id: &str) -> SyncResult<()> {
176        validate_batch_id(batch_id)?;
177        let mut state = self.state.lock();
178        let _process_lock = acquire_persistence_lock(&self.file_path)?;
179        self.refresh(&mut state)?;
180        self.compact_if_needed(&mut state)?;
181        if state
182            .messages
183            .front()
184            .map(|pending| pending.message.batch_id.as_str())
185            != Some(batch_id)
186        {
187            return Err(SyncError::InvalidSyncMessage(
188                "outbox acknowledgement mismatch",
189            ));
190        }
191        let frame_bytes = encoded_frame_bytes(batch_id.len())?;
192        ensure_append_capacity(state.scanned_bytes, frame_bytes, 0)?;
193        let hash = append_record(
194            &self.file_path,
195            state.record_count,
196            state.chain_head,
197            ACK_KIND,
198            batch_id.as_bytes(),
199        )?;
200        let acknowledged = state
201            .messages
202            .pop_front()
203            .ok_or(SyncError::InvalidSyncMessage(
204                "outbox acknowledgement mismatch",
205            ))?;
206        state.scanned_bytes += frame_bytes;
207        state.record_count += 1;
208        state.acknowledged_records += 1;
209        state.live_frame_bytes = state
210            .live_frame_bytes
211            .saturating_sub(acknowledged.frame_bytes);
212        state.chain_head = hash;
213        Ok(())
214    }
215
216    fn messages(&self) -> SyncResult<Vec<SyncMessage>> {
217        let mut state = self.state.lock();
218        let _process_lock = acquire_persistence_lock(&self.file_path)?;
219        self.refresh(&mut state)?;
220        Ok(state
221            .messages
222            .iter()
223            .map(|pending| pending.message.clone())
224            .collect())
225    }
226
227    fn len(&self) -> SyncResult<usize> {
228        let mut state = self.state.lock();
229        let _process_lock = acquire_persistence_lock(&self.file_path)?;
230        self.refresh(&mut state)?;
231        Ok(state.messages.len())
232    }
233}
234
235fn load_state(path: &Path) -> SyncResult<JournalState> {
236    let header = read_header(path)?;
237    let scan = scan_records(
238        path,
239        HEADER_BYTES as u64,
240        0,
241        [0; HASH_BYTES],
242        VecDeque::new(),
243    )?;
244    let mut state = JournalState {
245        generation: header.generation,
246        messages: VecDeque::new(),
247        scanned_bytes: HEADER_BYTES as u64,
248        record_count: 0,
249        acknowledged_records: 0,
250        live_frame_bytes: 0,
251        chain_head: [0; HASH_BYTES],
252    };
253    apply_scan(&mut state, scan, path)?;
254    Ok(state)
255}
256
257fn apply_scan(state: &mut JournalState, scan: ScanResult, path: &Path) -> SyncResult<()> {
258    if scan.recovered_tail {
259        truncate_synced(path, scan.scanned_bytes)?;
260    }
261    for operation in scan.operations {
262        match operation {
263            JournalOperation::Enqueue {
264                message,
265                frame_bytes,
266            } => {
267                state.live_frame_bytes += frame_bytes;
268                state.messages.push_back(PendingMessage {
269                    message,
270                    frame_bytes,
271                });
272            }
273            JournalOperation::Acknowledge => {
274                let acknowledged = state
275                    .messages
276                    .pop_front()
277                    .ok_or_else(|| corrupt_record(state.record_count))?;
278                state.live_frame_bytes = state
279                    .live_frame_bytes
280                    .saturating_sub(acknowledged.frame_bytes);
281                state.acknowledged_records += 1;
282            }
283        }
284    }
285    state.scanned_bytes = scan.scanned_bytes;
286    state.record_count = scan.record_count;
287    state.chain_head = scan.chain_head;
288    Ok(())
289}
290
291fn compact(path: &Path, state: &mut JournalState) -> SyncResult<()> {
292    let generation = new_generation();
293    let mut frame_sizes = Vec::with_capacity(state.messages.len());
294    let mut chain_head = [0; HASH_BYTES];
295    let mut record_count = 0u64;
296    let mut total_bytes = HEADER_BYTES as u64;
297    atomic_write_with(path, |file| {
298        write_header(file, generation)?;
299        for pending in &state.messages {
300            let data = serde_json::to_vec(&pending.message)
301                .map_err(|error| SyncError::ReplicationFailed(error.to_string()))?;
302            validate_record_data(&data)?;
303            record_count += 1;
304            let hash = record_hash(record_count, ENQUEUE_KIND, &data, chain_head);
305            write_frame(file, record_count, ENQUEUE_KIND, &data, chain_head, hash)?;
306            chain_head = hash;
307            let size = encoded_frame_bytes(data.len())?;
308            total_bytes = total_bytes.checked_add(size).ok_or_else(outbox_full)?;
309            if total_bytes > MAX_OUTBOX_FILE_BYTES {
310                return Err(outbox_full());
311            }
312            frame_sizes.push(size);
313        }
314        Ok(())
315    })?;
316    for (pending, frame_bytes) in state.messages.iter_mut().zip(frame_sizes) {
317        pending.frame_bytes = frame_bytes;
318    }
319    state.generation = generation;
320    state.scanned_bytes = total_bytes;
321    state.record_count = record_count;
322    state.acknowledged_records = 0;
323    state.live_frame_bytes = total_bytes.saturating_sub(HEADER_BYTES as u64);
324    state.chain_head = chain_head;
325    Ok(())
326}