Skip to main content

prikk_store/
object_store.rs

1//! Container-backed object store (RFC 102 Stage 3). Public API (`FileObjectStore`, `ObjectReader`,
2//! `ObjectWriter`) is unchanged from the loose-file implementation this replaces -- every other call
3//! site in the workspace uses only that trait interface, so none of them needed to change. Only the
4//! internals moved: reads and writes now go through `index.rs`'s lookup/write-protocol functions,
5//! which target `container.rs`'s per-type container files instead of one file per object.
6
7use prikk_error::{PrikkError, Result};
8use prikk_object::{ObjectEnvelope, ObjectId, ObjectType};
9
10use crate::index::{
11    self, IndexEntry, WriteDecision, append_object_to_container, decide_write_outcome,
12    lookup_object_location, read_object_envelope_at,
13};
14use crate::layout::RepositoryLayout;
15
16/// Read-only object access boundary.
17pub trait ObjectReader {
18    /// Read an object by ID.
19    fn read_object(&self, id: ObjectId) -> Result<Option<ObjectEnvelope>>;
20
21    /// Read and require a specific object type. Default-implemented in terms of `read_object` alone
22    /// (RFC 111 §6.1: every implementor -- `FileObjectStore`, `ObjectReadSnapshot`,
23    /// `ObjectWriteSession`, `MemoryObjectStore` -- gets this for free, and any function generic over
24    /// `impl ObjectReader` can call it without depending on a concrete type). One body, not one per
25    /// implementor (Stage 1 review v1 §3): every concrete type's own `read_typed` delegates here too.
26    fn read_typed(&self, id: ObjectId, object_type: ObjectType) -> Result<Option<ObjectEnvelope>> {
27        let Some(envelope) = self.read_object(id)? else {
28            return Ok(None);
29        };
30        if envelope.object_type != object_type {
31            return Err(PrikkError::ObjectTypeMismatch {
32                expected: object_type.to_string(),
33                actual: envelope.object_type.to_string(),
34            });
35        }
36        Ok(Some(envelope))
37    }
38}
39
40/// Write object boundary.
41pub trait ObjectWriter {
42    /// Write an object envelope after validation.
43    fn write_object(&mut self, envelope: &ObjectEnvelope) -> Result<ObjectId>;
44}
45
46/// File-backed object store.
47#[derive(Debug, Clone)]
48pub struct FileObjectStore {
49    layout: RepositoryLayout,
50}
51
52impl FileObjectStore {
53    /// Create a file object store for a repository layout.
54    #[must_use]
55    pub fn new(layout: RepositoryLayout) -> Self {
56        Self { layout }
57    }
58
59    /// Return the repository layout.
60    #[must_use]
61    pub fn layout(&self) -> &RepositoryLayout {
62        &self.layout
63    }
64
65    /// Return true if an object with this id and type is indexed.
66    #[must_use]
67    pub fn contains_object(&self, object_type: ObjectType, id: ObjectId) -> bool {
68        if object_type == ObjectType::RefUpdate {
69            return false;
70        }
71        matches!(
72            lookup_object_location(&self.layout, id),
73            Ok(Some(entry)) if entry.object_type == object_type
74        )
75    }
76}
77
78impl ObjectReader for FileObjectStore {
79    fn read_object(&self, id: ObjectId) -> Result<Option<ObjectEnvelope>> {
80        let Some(entry) = lookup_object_location(&self.layout, id)? else {
81            return Ok(None);
82        };
83        read_object_at_entry(&self.layout, &entry, id)
84    }
85}
86
87impl ObjectWriter for FileObjectStore {
88    fn write_object(&mut self, envelope: &ObjectEnvelope) -> Result<ObjectId> {
89        if envelope.object_type == ObjectType::RefUpdate {
90            return Err(PrikkError::UnsupportedObjectType(
91                "RefUpdate is stored inline in ref logs for v1".to_string(),
92            ));
93        }
94        self.layout.validate_format()?;
95        crate::format::validate_object_envelope(self.layout.format(), envelope)?;
96        // The write protocol (design §5, handoff §3) lives in `index.rs`, not here: append the
97        // object record to its container and make it durable, then and only then append the index
98        // entry. Stated at that call site too, not only here. The idempotency decision itself (RFC
99        // 111 §6.1 addendum, C2) is `index::decide_write_outcome`, shared verbatim with
100        // `ObjectWriteSession` below -- only where its `existing` lookup comes from differs: this
101        // type always re-decodes the whole index (unchanged cost, a safe default for any call site
102        // not migrated to a snapshot-backed type).
103        let existing = lookup_object_location(&self.layout, envelope.object_id())?;
104        match decide_write_outcome(
105            &self.layout,
106            envelope.object_type,
107            envelope,
108            existing.as_ref(),
109        )? {
110            WriteDecision::AlreadyPresent(id) => Ok(id),
111            WriteDecision::New => {
112                append_object_to_container(&self.layout, envelope.object_type, envelope)
113                    .map(|entry| entry.object_id)
114            }
115        }
116    }
117}
118
119/// Read validation shared by every reader below (`FileObjectStore`, `ObjectReadSnapshot`,
120/// `ObjectWriteSession`): the index is trusted for *location*, but the bytes found there are always
121/// checked against the id actually asked for by recomputing it from the decoded content -- free,
122/// since decoding already happened. A mismatch is reported, never silently accepted and never a
123/// fallback to scanning ("one seek", design §12/§10.3).
124fn read_object_at_entry(
125    layout: &RepositoryLayout,
126    entry: &IndexEntry,
127    id: ObjectId,
128) -> Result<Option<ObjectEnvelope>> {
129    let envelope = read_object_envelope_at(layout, entry)?;
130    let computed = envelope.object_id();
131    if computed != id {
132        return Err(PrikkError::Integrity(format!(
133            "index entry for {id} resolves to an envelope with computed id {computed}"
134        )));
135    }
136    if envelope.object_type != entry.object_type {
137        return Err(PrikkError::Integrity(format!(
138            "index entry for {id} names type {}, envelope decoded as {}",
139            entry.object_type, envelope.object_type
140        )));
141    }
142    crate::format::validate_read_schema(layout.format(), &envelope)?;
143    Ok(Some(envelope))
144}
145
146/// A decoded object-index snapshot, taken once. Backs both `ObjectReadSnapshot` and
147/// `ObjectWriteSession` (RFC 111 §6.1) -- the read logic (lookup, then decode at a known offset) is
148/// identical between them, so it exists here once rather than twice. Has no public API of its own;
149/// both public types below wrap it.
150struct IndexSnapshot {
151    entries: Vec<IndexEntry>,
152    /// The object index's byte length as of the last time `entries` was known-current --
153    /// `bytes.len() - trailing_partial_bytes` from whichever decode produced `entries`, never the
154    /// raw stat size (RFC 111 §6.1 addendum §3.2: a torn trailing write must not be counted as
155    /// decoded).
156    known_length: u64,
157}
158
159impl IndexSnapshot {
160    fn open(layout: &RepositoryLayout) -> Result<Self> {
161        let (replay, known_length) = index::replay_index_with_extent(layout)?;
162        if replay.has_item_failure() {
163            return Err(PrikkError::Integrity(
164                "object index has a damaged entry; run doctor before reading".to_string(),
165            ));
166        }
167        Ok(Self {
168            entries: replay.entries,
169            known_length,
170        })
171    }
172
173    /// Same last-entry-wins semantics `lookup_object_location` already has, preserved verbatim.
174    fn lookup(&self, id: ObjectId) -> Option<&IndexEntry> {
175        self.entries
176            .iter()
177            .rev()
178            .find(|entry| entry.object_id == id)
179    }
180
181    /// Re-stat only; decode only if the stat disagrees with what this snapshot already knows. Every
182    /// write decision calls this first (RFC 111 §6.1 addendum, C1) -- it is what makes a stale
183    /// idempotency decision structurally impossible regardless of *what* wrote the new bytes: a
184    /// nested unmediated writer in the same process (`refs/publication.rs`'s current shape), a call
185    /// site not yet migrated to a snapshot-backed type, or a genuinely separate process. All three
186    /// grow the index file, and this catches every one the same way, because it checks the one fact
187    /// that is true regardless of cause. The common case -- nothing else wrote -- costs one stat, no
188    /// decode. `ObjectReadSnapshot` never calls this: a reader's staleness is already accepted and
189    /// bounded (RFC 111 Q3/Q4), so charging every read a stat here would buy nothing.
190    fn ensure_current(&mut self, layout: &RepositoryLayout) -> Result<()> {
191        let relative = layout.repository_relative(&layout.container_index_path())?;
192        let current_length =
193            crate::fsutil::stat_file_state_if_exists(layout.repository_mutation_root(), &relative)?
194                .map_or(0, |stat| stat.size);
195        if current_length == self.known_length {
196            return Ok(());
197        }
198        if current_length < self.known_length {
199            // The object index is append-only and must never shrink (it is not one of the four
200            // compactable containers). A shorter file than this snapshot last knew means either the
201            // file was rebuilt out from under an open session or something is badly wrong -- fail
202            // closed rather than decode from an offset past the new end (RFC 111 §6.1 addendum §3.1).
203            return Err(PrikkError::Integrity(format!(
204                "object index shrank from {} to {current_length} bytes since it was last read; \
205                 the object index is append-only and must never shrink -- run doctor",
206                self.known_length
207            )));
208        }
209        let (tail, new_extent) = index::replay_index_tail_with_extent(layout, self.known_length)?;
210        if tail.has_item_failure() {
211            return Err(PrikkError::Integrity(
212                "object index has a damaged entry; run doctor before reading".to_string(),
213            ));
214        }
215        self.entries.extend(tail.entries);
216        self.known_length = new_extent;
217        Ok(())
218    }
219}
220
221/// Read-only object access for one operation's lifetime (RFC 111 §6.1). Takes one decoded index
222/// snapshot at construction and never re-decodes -- correct because a reader never writes, so it can
223/// never observe its *own* write as missing the way a writer holding a stale snapshot could (RFC 111
224/// Q3). A snapshot taken here may miss an object a concurrent writer appends after construction; that
225/// is `verify`'s own already-documented point-in-time semantics, unchanged by this type (RFC 111 Q4).
226pub struct ObjectReadSnapshot {
227    layout: RepositoryLayout,
228    snapshot: IndexSnapshot,
229}
230
231impl ObjectReadSnapshot {
232    /// Open a read-only snapshot of `layout`'s object index, decoding it exactly once.
233    pub fn open(layout: &RepositoryLayout) -> Result<Self> {
234        Ok(Self {
235            layout: layout.clone(),
236            snapshot: IndexSnapshot::open(layout)?,
237        })
238    }
239
240    /// Return true if an object with this id and type is indexed, as of when this snapshot was
241    /// taken.
242    #[must_use]
243    pub fn contains_object(&self, object_type: ObjectType, id: ObjectId) -> bool {
244        if object_type == ObjectType::RefUpdate {
245            return false;
246        }
247        matches!(self.snapshot.lookup(id), Some(entry) if entry.object_type == object_type)
248    }
249}
250
251impl ObjectReader for ObjectReadSnapshot {
252    fn read_object(&self, id: ObjectId) -> Result<Option<ObjectEnvelope>> {
253        let Some(entry) = self.snapshot.lookup(id) else {
254            return Ok(None);
255        };
256        read_object_at_entry(&self.layout, entry, id)
257    }
258}
259
260/// Read-write object access for one writing operation's lifetime (RFC 111 §6.1). Holds the same kind
261/// of in-memory index snapshot `ObjectReadSnapshot` does, but every write decision first calls
262/// `IndexSnapshot::ensure_current` (see its own doc), and every successful write calls it again
263/// afterward instead of computing what changed itself (Stage 1 review v1, B1) -- `ensure_current`'s
264/// own tail-decode is the only thing ever allowed to grow `entries`/`known_length`, whether what it
265/// finds is this session's own write, a concurrent one, or both.
266pub struct ObjectWriteSession {
267    layout: RepositoryLayout,
268    snapshot: IndexSnapshot,
269}
270
271impl ObjectWriteSession {
272    /// Open a read-write session over `layout`'s object index, decoding it exactly once.
273    pub fn open(layout: &RepositoryLayout) -> Result<Self> {
274        Ok(Self {
275            layout: layout.clone(),
276            snapshot: IndexSnapshot::open(layout)?,
277        })
278    }
279
280    /// Return true if an object with this id and type is indexed, refreshing the snapshot first if
281    /// something else has grown the index since it was last known-current.
282    pub fn contains_object(&mut self, object_type: ObjectType, id: ObjectId) -> Result<bool> {
283        if object_type == ObjectType::RefUpdate {
284            return Ok(false);
285        }
286        self.snapshot.ensure_current(&self.layout)?;
287        Ok(matches!(self.snapshot.lookup(id), Some(entry) if entry.object_type == object_type))
288    }
289}
290
291impl ObjectReader for ObjectWriteSession {
292    fn read_object(&self, id: ObjectId) -> Result<Option<ObjectEnvelope>> {
293        let Some(entry) = self.snapshot.lookup(id) else {
294            return Ok(None);
295        };
296        read_object_at_entry(&self.layout, entry, id)
297    }
298}
299
300impl ObjectWriter for ObjectWriteSession {
301    fn write_object(&mut self, envelope: &ObjectEnvelope) -> Result<ObjectId> {
302        if envelope.object_type == ObjectType::RefUpdate {
303            return Err(PrikkError::UnsupportedObjectType(
304                "RefUpdate is stored inline in ref logs for v1".to_string(),
305            ));
306        }
307        self.layout.validate_format()?;
308        crate::format::validate_object_envelope(self.layout.format(), envelope)?;
309        self.snapshot.ensure_current(&self.layout)?;
310        let existing = self.snapshot.lookup(envelope.object_id());
311        match decide_write_outcome(&self.layout, envelope.object_type, envelope, existing)? {
312            WriteDecision::AlreadyPresent(id) => Ok(id),
313            WriteDecision::New => {
314                let object_id = envelope.object_id();
315                append_object_to_container(&self.layout, envelope.object_type, envelope)?;
316                // Do not trust the append's own return value for what changed (RFC 111 Stage 1
317                // review v1, B1): re-derive by re-checking freshness through the same primitive
318                // every other read decision uses. `ensure_current`'s tail-decode is frame-aligned
319                // by construction and correctly picks up this write, a concurrent writer's, or
320                // both -- a stat taken immediately after only this call's own append cannot tell
321                // those apart.
322                self.snapshot.ensure_current(&self.layout)?;
323                Ok(object_id)
324            }
325        }
326    }
327}
328
329// DC-97 correction of the comment this replaced: the Linux/macOS-only reasoning was true when
330// written (DC-71/DC-81, before DC-87 made Windows a mutating platform) and nobody revisited it once
331// Windows mutation shipped -- found only by DC-97's own G5 investigation, back when this module's
332// now-deleted `tests::immutable` still made the claimed Windows evidence for G5. `publish_immutable`
333// and its tests are gone entirely as of DC-98 (G5 retired, zero production callers). What remains
334// here is gated the same way regardless: `RepositoryLayout::init` and real repository mutation are
335// not Linux/macOS-only, so what is still unix-only inside this module (failpoints, symlinks, FIFOs)
336// is gated per-test/per-file instead of by one blanket gate.
337#[cfg(test)]
338impl ObjectWriteSession {
339    /// The session's own current view of the object index's byte extent -- exposed only so tests can
340    /// assert it lands on the true file length rather than a value this type accumulated itself (RFC
341    /// 111 §6.1 addendum §3.3, the load-bearing assertion the design review named explicitly).
342    pub(crate) fn known_index_length_for_test(&self) -> u64 {
343        self.snapshot.known_length
344    }
345
346    /// The session's own current view of how many index entries it holds -- exposed only for tests.
347    pub(crate) fn entry_count_for_test(&self) -> usize {
348        self.snapshot.entries.len()
349    }
350}
351
352#[cfg(all(
353    test,
354    any(target_os = "linux", target_os = "macos", target_os = "windows")
355))]
356mod tests;