prikk_store/lifecycle_cache/
incremental.rs1use prikk_error::Result;
16use prikk_object::{CanonicalWriter, ObjectId, WireType};
17
18use crate::byte_cursor::ByteCursor;
19use crate::fsutil::{read_file_if_exists, write_file_atomically};
20use crate::layout::RepositoryLayout;
21use crate::node_lifecycle::{LiveNode, NodeContent, NodeLifecycleState, Tombstone};
22use crate::object_store::ObjectReader;
23use crate::path::RepoPath;
24
25use super::{ReplayDerivedLifecycleState, replay, replay_derived_state};
26
27const CACHE_FILE_NAME: &str = "lifecycle-state.v1";
28const CACHE_MAGIC: &[u8] = b"PRIKK-LIFECYCLE-INCREMENTAL-CACHE-v1\0";
29const CACHE_SCHEMA_VERSION: u32 = 1;
30
31const REANCHOR_BOUND: u32 = 64;
37
38struct IncrementalCache {
39 baseline_block_id: ObjectId,
40 horizon_id: ObjectId,
41 steps_since_reanchor: u32,
42 state: NodeLifecycleState,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct LifecycleCacheDivergence {
50 pub baseline_block_id: ObjectId,
52 pub detail: String,
55}
56
57pub(crate) fn verify_divergence(
63 reader: &impl ObjectReader,
64 layout: &RepositoryLayout,
65) -> Vec<LifecycleCacheDivergence> {
66 let Some(cached) = load(layout) else {
67 return Vec::new();
68 };
69 match replay_derived_state(reader, cached.baseline_block_id, cached.horizon_id) {
70 Ok(replayed) if replayed.state() == &cached.state => Vec::new(),
71 Ok(_) => vec![LifecycleCacheDivergence {
72 baseline_block_id: cached.baseline_block_id,
73 detail: "cached lifecycle state disagrees with an independent full replay".to_string(),
74 }],
75 Err(err) => vec![LifecycleCacheDivergence {
76 baseline_block_id: cached.baseline_block_id,
77 detail: format!("cached lifecycle state could not be independently verified: {err}"),
78 }],
79 }
80}
81
82pub(crate) fn resolve_baseline_state(
87 layout: &RepositoryLayout,
88 reader: &impl ObjectReader,
89 baseline_block_id: ObjectId,
90 horizon_id: ObjectId,
91) -> Result<ReplayDerivedLifecycleState> {
92 if let Some(cached) = load(layout) {
93 if cached.horizon_id == horizon_id && cached.steps_since_reanchor < REANCHOR_BOUND {
94 if let Some(state) = try_incremental_step(reader, &cached, baseline_block_id)? {
95 let result = ReplayDerivedLifecycleState::from_replay(baseline_block_id, state)?;
96 persist(
97 layout,
98 baseline_block_id,
99 horizon_id,
100 cached.steps_since_reanchor + 1,
101 result.state(),
102 );
103 return Ok(result);
104 }
105 }
106 }
107 let result = replay_derived_state(reader, baseline_block_id, horizon_id)?;
108 persist(layout, baseline_block_id, horizon_id, 0, result.state());
109 Ok(result)
110}
111
112fn try_incremental_step(
126 reader: &impl ObjectReader,
127 cached: &IncrementalCache,
128 baseline_block_id: ObjectId,
129) -> Result<Option<NodeLifecycleState>> {
130 let Ok(block) = replay::read_block(reader, baseline_block_id) else {
131 return Ok(None);
132 };
133 if block.parent_block_ids.as_slice() != [cached.baseline_block_id] {
134 return Ok(None);
135 }
136 let mut state = cached.state.clone();
137 match replay::apply_one_block(reader, &block, &mut state, false) {
138 Ok(()) => Ok(Some(state)),
139 Err(replay::LifecycleReplayError::MissingBlobForLifecycleEffect { .. }) => Ok(None),
140 Err(other) => Err(other.into()),
141 }
142}
143
144fn persist(
148 layout: &RepositoryLayout,
149 baseline_block_id: ObjectId,
150 horizon_id: ObjectId,
151 steps_since_reanchor: u32,
152 state: &NodeLifecycleState,
153) {
154 let cache = IncrementalCache {
155 baseline_block_id,
156 horizon_id,
157 steps_since_reanchor,
158 state: state.clone(),
159 };
160 let _ = save(layout, &cache);
161}
162
163fn cache_path(layout: &RepositoryLayout) -> std::path::PathBuf {
164 layout.cache_dir().join(CACHE_FILE_NAME)
165}
166
167fn load(layout: &RepositoryLayout) -> Option<IncrementalCache> {
168 let relative = layout.repository_relative(&cache_path(layout)).ok()?;
169 let bytes = read_file_if_exists(layout.repository_mutation_root(), &relative).ok()??;
170 decode(&bytes)
171}
172
173fn save(layout: &RepositoryLayout, cache: &IncrementalCache) -> Result<()> {
174 let relative = layout.repository_relative(&cache_path(layout))?;
175 write_file_atomically(layout.repository_mutation_root(), &relative, &encode(cache))
176}
177
178fn encode(cache: &IncrementalCache) -> Vec<u8> {
179 let mut writer = CanonicalWriter::new();
180 let _ = writer.field_u32(1, CACHE_SCHEMA_VERSION);
181 let _ = writer.field_object_id(2, &cache.baseline_block_id);
182 let _ = writer.field_object_id(3, &cache.horizon_id);
183 let _ = writer.field_u32(4, cache.steps_since_reanchor);
184 for (node_id, node) in cache.state.live_nodes() {
185 if let Ok(record) = encode_node_record(node_id, &node.path, node.kind, &node.content) {
186 let _ = writer.field_raw(10, WireType::RecordListItem, &record);
187 }
188 }
189 for (node_id, tombstone) in cache.state.tombstones() {
190 if let Ok(record) =
191 encode_node_record(node_id, &tombstone.path, tombstone.kind, &tombstone.content)
192 {
193 let _ = writer.field_raw(11, WireType::RecordListItem, &record);
194 }
195 }
196 let body = writer.finish();
197 let checksum = prikk_hash::sha256(&body);
198
199 let mut out = Vec::with_capacity(CACHE_MAGIC.len() + 32 + body.len());
200 out.extend_from_slice(CACHE_MAGIC);
201 out.extend_from_slice(&checksum);
202 out.extend_from_slice(&body);
203 out
204}
205
206fn decode(bytes: &[u8]) -> Option<IncrementalCache> {
207 let after_magic = bytes.strip_prefix(CACHE_MAGIC)?;
208 if after_magic.len() < 32 {
209 return None;
210 }
211 let (checksum, body) = after_magic.split_at(32);
212 if prikk_hash::sha256(body) != checksum {
213 return None;
214 }
215
216 let mut cursor = ByteCursor::new(body);
217 let mut schema_version: Option<u32> = None;
218 let mut baseline_block_id: Option<ObjectId> = None;
219 let mut horizon_id: Option<ObjectId> = None;
220 let mut steps_since_reanchor: Option<u32> = None;
221 let mut state = NodeLifecycleState::new();
222
223 let mut last_tag: Option<u16> = None;
224 while let Some(field) = next_field(&mut cursor)? {
225 if let Some(previous) = last_tag {
226 if field.tag < previous {
227 return None;
228 }
229 }
230 last_tag = Some(field.tag);
231 match field.tag {
232 1 => {
233 if field.wire != WireType::U32 as u8 || schema_version.is_some() {
234 return None;
235 }
236 schema_version = Some(u32::from_be_bytes(field.value.try_into().ok()?));
237 }
238 2 => {
239 if field.wire != WireType::ObjectId as u8 || baseline_block_id.is_some() {
240 return None;
241 }
242 baseline_block_id = Some(ObjectId::from_bytes(field.value.try_into().ok()?));
243 }
244 3 => {
245 if field.wire != WireType::ObjectId as u8 || horizon_id.is_some() {
246 return None;
247 }
248 horizon_id = Some(ObjectId::from_bytes(field.value.try_into().ok()?));
249 }
250 4 => {
251 if field.wire != WireType::U32 as u8 || steps_since_reanchor.is_some() {
252 return None;
253 }
254 steps_since_reanchor = Some(u32::from_be_bytes(field.value.try_into().ok()?));
255 }
256 10 => {
257 if field.wire != WireType::RecordListItem as u8 {
258 return None;
259 }
260 let (node_id, path, kind, content) = decode_node_record(field.value)?;
261 state
262 .seed_live_node(
263 node_id,
264 LiveNode {
265 path,
266 kind,
267 content,
268 },
269 )
270 .ok()?;
271 }
272 11 => {
273 if field.wire != WireType::RecordListItem as u8 {
274 return None;
275 }
276 let (node_id, path, kind, content) = decode_node_record(field.value)?;
277 state
278 .seed_tombstone(
279 node_id,
280 Tombstone {
281 kind,
282 content,
283 path,
284 },
285 )
286 .ok()?;
287 }
288 _ => return None,
289 }
290 }
291
292 if schema_version? != CACHE_SCHEMA_VERSION {
293 return None;
294 }
295 Some(IncrementalCache {
296 baseline_block_id: baseline_block_id?,
297 horizon_id: horizon_id?,
298 steps_since_reanchor: steps_since_reanchor?,
299 state,
300 })
301}
302
303struct Field<'a> {
304 tag: u16,
305 wire: u8,
306 value: &'a [u8],
307}
308
309fn next_field<'a>(cursor: &mut ByteCursor<'a>) -> Option<Option<Field<'a>>> {
310 if cursor.is_finished() {
311 return Some(None);
312 }
313 let tag = cursor.read_u16().ok()?;
314 let wire = cursor.read_array::<1>().ok()?[0];
315 let len = usize::try_from(cursor.read_u64().ok()?).ok()?;
316 let value = cursor.read_exact(len).ok()?;
317 Some(Some(Field { tag, wire, value }))
318}
319
320fn encode_node_record(
321 node_id: &prikk_object::NodeId,
322 path: &RepoPath,
323 kind: prikk_object::NodeKind,
324 content: &NodeContent,
325) -> Result<Vec<u8>> {
326 let mut writer = CanonicalWriter::new();
327 writer.field_repo_path(1, path.as_str())?;
328 writer.field_bytes(2, node_id.as_bytes())?;
329 writer.field_enum_u16(3, kind.code())?;
330 match content {
331 NodeContent::File { blob_id, mode } => {
332 writer.field_object_id(4, blob_id)?;
333 writer.field_u32(5, *mode)?;
334 }
335 NodeContent::Symlink { target } => {
336 writer.field_string(6, target)?;
337 }
338 }
339 Ok(writer.finish())
340}
341
342fn decode_node_record(
343 bytes: &[u8],
344) -> Option<(
345 prikk_object::NodeId,
346 RepoPath,
347 prikk_object::NodeKind,
348 NodeContent,
349)> {
350 let mut cursor = ByteCursor::new(bytes);
351 let mut path: Option<RepoPath> = None;
352 let mut node_id: Option<prikk_object::NodeId> = None;
353 let mut kind: Option<prikk_object::NodeKind> = None;
354 let mut blob_id: Option<ObjectId> = None;
355 let mut mode: Option<u32> = None;
356 let mut target: Option<String> = None;
357
358 let mut last_tag: Option<u16> = None;
359 while let Some(field) = next_field(&mut cursor)? {
360 if let Some(previous) = last_tag {
361 if field.tag < previous {
362 return None;
363 }
364 }
365 last_tag = Some(field.tag);
366 match field.tag {
367 1 => {
368 if field.wire != WireType::RepoPath as u8 || path.is_some() {
369 return None;
370 }
371 path = Some(RepoPath::parse(core::str::from_utf8(field.value).ok()?).ok()?);
372 }
373 2 => {
374 if field.wire != WireType::Bytes as u8 || node_id.is_some() {
375 return None;
376 }
377 node_id =
378 Some(prikk_object::NodeId::try_from_bytes(field.value.try_into().ok()?).ok()?);
379 }
380 3 => {
381 if field.wire != WireType::EnumU16 as u8 || kind.is_some() {
382 return None;
383 }
384 let code = u16::from_be_bytes(field.value.try_into().ok()?);
385 kind = Some(prikk_object::NodeKind::from_code(code).ok()?);
386 }
387 4 => {
388 if field.wire != WireType::ObjectId as u8 || blob_id.is_some() {
389 return None;
390 }
391 blob_id = Some(ObjectId::from_bytes(field.value.try_into().ok()?));
392 }
393 5 => {
394 if field.wire != WireType::U32 as u8 || mode.is_some() {
395 return None;
396 }
397 mode = Some(u32::from_be_bytes(field.value.try_into().ok()?));
398 }
399 6 => {
400 if field.wire != WireType::String as u8 || target.is_some() {
401 return None;
402 }
403 target = Some(core::str::from_utf8(field.value).ok()?.to_string());
404 }
405 _ => return None,
406 }
407 }
408
409 let path = path?;
410 let node_id = node_id?;
411 let kind = kind?;
412 let content = match kind {
413 prikk_object::NodeKind::TextFile | prikk_object::NodeKind::BinaryFile => {
414 if target.is_some() {
415 return None;
416 }
417 NodeContent::File {
418 blob_id: blob_id?,
419 mode: mode?,
420 }
421 }
422 prikk_object::NodeKind::Symlink => {
423 if blob_id.is_some() || mode.is_some() {
424 return None;
425 }
426 NodeContent::Symlink { target: target? }
427 }
428 };
429 Some((node_id, path, kind, content))
430}
431
432#[cfg(test)]
433mod tests;