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;