Skip to main content

vtcode_memory/event_log/
append.rs

1//! Append path: event serialization, turn-boundary persistence, and cap eviction.
2//!
3//! Appends batch in memory until a turn boundary (or an explicit read/flush);
4//! cap enforcement is the one append-time path that rewrites the on-disk file.
5
6use super::*;
7
8impl SessionEventLog {
9    /// Append an event to the log and update the in-memory index/manifest.
10    pub fn append(&self, event: &ThreadEvent) -> Result<(), SessionStoreError> {
11        let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
12        let mut st = self.shared.state.lock().map_err(poison)?;
13
14        if st.write_failed {
15            return Err(self.event_write_failed());
16        }
17
18        if st.needs_separator {
19            st.write_buf.push(b'\n');
20            st.next_offset += 1;
21            st.needs_separator = false;
22        }
23
24        // Serialize into the write buffer with rollback on failure — the
25        // invariant that `write_buf` never contains partial JSON is
26        // encapsulated in `serialize_event`.
27        let (start, end) = st.serialize_event(event)?;
28
29        st.manifest.event_count += 1;
30        st.manifest.updated_at = now_rfc3339();
31
32        // Route through the single turn-lifecycle state machine.  When the
33        // event closes a turn, persist metadata immediately so a reopen
34        // after a mid-turn crash sees a consistent index.
35        let is_turn_boundary = st.apply_lifecycle_event(LifecycleKind::from_event(event), start, end);
36        if is_turn_boundary {
37            self.persist_meta_locked(&mut st)?;
38        }
39
40        if st.write_buf.len() >= MAX_WRITE_BUFFER_BYTES {
41            // Persist metadata with the bounded byte flush so a reopen after
42            // a mid-turn crash does not trust an index that predates these
43            // already-written events.
44            self.persist_meta_locked(&mut st)?;
45        }
46        drop(st);
47        self.enforce_event_cap()
48    }
49
50    /// Enforce the per-session event cap by evicting the oldest completed
51    /// turns when the log exceeds [`Self::max_events`]. Returns `Ok(())` even
52    /// when no truncation is needed or the cap is disabled (`max_events == 0`).
53    fn enforce_event_cap(&self) -> Result<(), SessionStoreError> {
54        let mut st = self.shared.state.lock().map_err(poison)?;
55
56        // `plan_cap_eviction` encapsulates the index arithmetic and returns
57        // `None` when the cap is disabled or not yet exceeded.
58        let Some(plan) = st.plan_cap_eviction(self.max_events) else {
59            return Ok(());
60        };
61
62        // Keep ordinary appends in memory until a turn boundary or an
63        // explicit read. Cap enforcement is the one append-time path that
64        // needs the complete on-disk file before rewriting it.
65        self.flush_write_buf_locked(&mut st)?;
66
67        let (evicted, mut remaining, previous_file_len) = {
68            let mut file_slot = self.shared.file.lock().map_err(poison)?;
69            let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
70            let file_len = file
71                .metadata()
72                .map_err(|error| SessionStoreError::io(&self.events_path, error))?
73                .len();
74            if plan.truncate_offset > file_len {
75                return Err(SessionStoreError::io(
76                    &self.events_path,
77                    std::io::Error::new(std::io::ErrorKind::InvalidData, "cap offset exceeds event log length"),
78                ));
79            }
80            file.seek(SeekFrom::Start(0))
81                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
82            let mut evicted = vec![
83                0u8;
84                usize::try_from(plan.truncate_offset).map_err(|error| {
85                    SessionStoreError::io(
86                        &self.events_path,
87                        std::io::Error::new(std::io::ErrorKind::InvalidData, error),
88                    )
89                })?
90            ];
91            file.read_exact(&mut evicted)
92                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
93            file.seek(SeekFrom::Start(plan.truncate_offset))
94                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
95            let mut remaining = Vec::new();
96            file.read_to_end(&mut remaining)
97                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
98            (evicted, remaining, file_len)
99        };
100        let retained_turn_base = st.retained_turn_base_after_eviction(plan.evicted_turn_count);
101        drop(st);
102
103        // The turn index counts only records inside indexed turns. The bytes
104        // removed by a cap rewrite may also contain valid session-level
105        // records (for example `thread.started`) before the first turn, so
106        // reconcile the manifest against the actual persisted prefix rather
107        // than the turn-only estimate from `plan_cap_eviction`.
108        let mut plan = plan;
109        plan.evicted_event_count = count_persisted_event_records(&evicted);
110        let evicted_events = decode_events(&evicted);
111        (self.eviction_summary_hook)(&evicted_events)?;
112
113        // Matrix snapshots are complete session-level checkpoints. Preserve the
114        // latest evicted checkpoint only when no newer snapshot survives.
115        let mut matrix_checkpoints = BTreeMap::new();
116        let mut planning_checkpoint = None;
117        for event in &evicted_events {
118            if let ThreadEvent::PlanDelta(delta) = event
119                && delta.recovery.is_some()
120            {
121                planning_checkpoint = Some(event);
122            }
123            if let ThreadEvent::MatrixUpdated(snapshot) = event {
124                matrix_checkpoints.insert(snapshot.spec.id.clone(), event);
125            }
126        }
127        for event in decode_events(&remaining) {
128            if let ThreadEvent::PlanDelta(delta) = &event
129                && delta.recovery.is_some()
130            {
131                planning_checkpoint = None;
132            }
133            if let ThreadEvent::MatrixUpdated(snapshot) = event {
134                matrix_checkpoints.remove(&snapshot.spec.id);
135            }
136        }
137        let mut checkpoint_prefix = Vec::new();
138        for event in matrix_checkpoints.values().copied().chain(planning_checkpoint) {
139            serde_json::to_writer(&mut checkpoint_prefix, &VersionedThreadEvent::new((*event).clone()))
140                .map_err(|error| SessionStoreError::io(&self.events_path, std::io::Error::other(error)))?;
141            checkpoint_prefix.push(b'\n');
142        }
143        let checkpoint_bytes = checkpoint_prefix.len() as u64;
144        let checkpoint_count = matrix_checkpoints.len() as u64 + u64::from(planning_checkpoint.is_some());
145        if !checkpoint_prefix.is_empty() {
146            checkpoint_prefix.extend_from_slice(&remaining);
147            remaining = checkpoint_prefix;
148        }
149
150        let new_file_len = u64::try_from(remaining.len()).map_err(|error| {
151            SessionStoreError::io(&self.events_path, std::io::Error::new(std::io::ErrorKind::InvalidData, error))
152        })?;
153        self.manifest_store.write_pending_cap_rewrite(&PendingCapRewrite {
154            previous_file_len,
155            new_file_len,
156            retained_turn_base,
157        })?;
158        let next_offset = self.replace_event_file_contents(&remaining)?;
159        let mut st = self.shared.state.lock().map_err(poison)?;
160        st.apply_cap_eviction(plan, next_offset);
161        for entry in &mut st.index.entries {
162            entry.start_offset += checkpoint_bytes;
163            entry.end_offset += checkpoint_bytes;
164        }
165        st.manifest.event_count += checkpoint_count;
166        // The rewrite changed byte offsets and retained counts; persist the
167        // derived metadata before exposing the append as successful.
168        self.persist_meta_locked(&mut st)?;
169        self.manifest_store.clear_pending_cap_rewrite()?;
170        Ok(())
171    }
172}