Skip to main content

choir_oplog/
lib.rs

1//! Append-only operation log: the L1 wire format and the log-backend seam.
2//!
3//! One-way-door rules (DECISIONS.md) enforced here:
4//! - every persisted entry carries `format_version`
5//! - hashes are self-describing (codec byte + digest), so the hash function
6//!   can change under the same envelope (D6)
7//! - `witnesses` exists from day 1 and stays empty (D67), so the append-only →
8//!   witnessed swap (D16) is additive, not a migration
9//!
10//! # Examples
11//!
12//! ```
13//! use choir_oplog::{MemLog, OpEntry, OpLog, FORMAT_VERSION};
14//!
15//! let mut log = MemLog::new();
16//! let genesis = OpEntry {
17//!     format_version: FORMAT_VERSION,
18//!     parent: None,
19//!     seq: 0,
20//!     channel: "agent-1".into(),
21//!     payload: b"first op".to_vec(),
22//!     witnesses: Vec::new(),
23//!     author_sig: None,
24//! };
25//! let head = log.append(genesis).unwrap();
26//! assert_eq!(log.head(), Some(head));
27//! assert_eq!(log.len(), 1);
28//! ```
29//!
30//! # Where this sits
31//!
32//! `docs/architecture.md` is the map of the whole workspace.
33//! This crate is L1, the op log's wire format and the seam every log backend implements.
34//!
35//! It builds on [`choir_hash`].
36
37use serde::{Deserialize, Serialize};
38
39pub use choir_hash::ContentHash;
40
41pub mod repair;
42
43/// Current wire-format version. Bump on any incompatible change; additive
44/// changes keep the version (DECISIONS.md).
45pub const FORMAT_VERSION: u16 = 1;
46
47/// Signature scheme identifiers for [`Witness::scheme`] (D39).
48///
49/// These are choir-local numbers rather than multicodec entries, unlike
50/// [`ContentHash`]'s codec byte, and the difference is deliberate:
51/// multicodec names a *key type*, while a verifier needs the
52/// *construction* — which bytes were actually signed. A WebAuthn
53/// signature covers `authenticator_data ‖ SHA-256(client_data_json)`
54/// rather than the message, and no curve identifier says that.
55pub mod scheme {
56    /// Ed25519 over the signed bytes directly. The only scheme that
57    /// existed before D39, which is why an absent [`super::Witness::scheme`]
58    /// means this one.
59    pub const ED25519: u16 = 1;
60    /// WebAuthn ES256 (D39): ECDSA P-256 with SHA-256, over
61    /// `authenticator_data ‖ SHA-256(client_data_json)`, where the
62    /// challenge inside `client_data_json` is the entry's
63    /// [`super::OpEntry::signing_hash`].
64    pub const WEBAUTHN_ES256: u16 = 2;
65}
66
67/// A cosignature: a witness cosignature (never populated here, D67) or,
68/// in [`OpEntry::author_sig`], the author's own. Present in the format
69/// from the first persisted byte so adding witnessing never rewrites
70/// history.
71///
72/// The scheme and WebAuthn fields are additive (`serde(default)` plus
73/// `skip_serializing_if`), so a signature written before D39 serializes
74/// to exactly the bytes it always did and every entry hash containing
75/// one is unchanged.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct Witness {
78    /// Identifier of the witness key that produced [`Witness::signature`].
79    pub key_id: String,
80    /// Signature over the entry's content hash.
81    pub signature: Vec<u8>,
82    /// Which scheme produced [`Witness::signature`], from [`scheme`].
83    ///
84    /// Absent means [`scheme::ED25519`]: entries written before D39
85    /// carry no tag, and giving them one would change their bytes and
86    /// therefore their hash. An unrecognised value decodes fine on
87    /// purpose — an old reader must be able to replay a log containing
88    /// a scheme it cannot verify, so the refusal belongs at
89    /// verification rather than at decode.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub scheme: Option<u16>,
92    /// WebAuthn authenticator data, the first half of what a
93    /// [`scheme::WEBAUTHN_ES256`] signature covers. Absent for every
94    /// other scheme.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub authenticator_data: Option<Vec<u8>>,
97    /// WebAuthn client data JSON, whose `challenge` member carries the
98    /// [`OpEntry::signing_hash`] the human approved. Absent for every
99    /// other scheme.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub client_data_json: Option<Vec<u8>>,
102    /// The credential's public key as SubjectPublicKeyInfo DER, so a
103    /// [`scheme::WEBAUTHN_ES256`] signature can be checked by someone
104    /// holding nothing but the log (D45). Absent for every other scheme,
105    /// and absent from entries written before D45.
106    ///
107    /// The other two WebAuthn fields are *inside* what the authenticator
108    /// signed, so altering them breaks the signature. This one is the key
109    /// the signature is checked **against**, so altering it forges
110    /// nothing — it stops a good entry from verifying. It is covered by
111    /// [`OpEntry::content_hash`] and therefore by the chain, which is
112    /// what makes that substitution detectable; it is not covered by
113    /// [`OpEntry::signing_hash`], and calling it signed would be wrong.
114    ///
115    /// Written by the node from the credential it just verified against,
116    /// never by the client: `getPublicKey()` exists on a WebAuthn
117    /// *registration* response only, so a browser holding an assertion
118    /// does not have this value to send.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub credential_key: Option<Vec<u8>>,
121}
122
123impl Witness {
124    /// An ed25519 cosignature, the shape every caller before D39 wrote
125    /// as a struct literal.
126    pub fn ed25519(key_id: impl Into<String>, signature: Vec<u8>) -> Self {
127        Self {
128            key_id: key_id.into(),
129            signature,
130            scheme: None,
131            authenticator_data: None,
132            client_data_json: None,
133            credential_key: None,
134        }
135    }
136
137    /// A WebAuthn ES256 cosignature (D39), carrying the two byte strings
138    /// a verifier needs and cannot reconstruct: the authenticator data,
139    /// and the client data JSON whose `challenge` binds the signature to
140    /// one [`OpEntry::signing_hash`].
141    pub fn webauthn_es256(
142        key_id: impl Into<String>,
143        signature: Vec<u8>,
144        authenticator_data: Vec<u8>,
145        client_data_json: Vec<u8>,
146    ) -> Self {
147        Self {
148            key_id: key_id.into(),
149            signature,
150            scheme: Some(scheme::WEBAUTHN_ES256),
151            authenticator_data: Some(authenticator_data),
152            client_data_json: Some(client_data_json),
153            credential_key: None,
154        }
155    }
156
157    /// Attaches the credential public key a verifier needs (D45).
158    ///
159    /// A builder rather than a fifth argument to
160    /// [`Witness::webauthn_es256`], because the two values arrive at
161    /// different moments: the browser sends the assertion, and the node
162    /// adds the key after looking it up to verify against.
163    #[must_use]
164    pub fn with_credential_key(mut self, spki_der: Vec<u8>) -> Self {
165        self.credential_key = Some(spki_der);
166        self
167    }
168
169    /// The scheme this signature claims, resolving the pre-D39 absence
170    /// to [`scheme::ED25519`]. Verifiers should match on this rather
171    /// than on [`Witness::scheme`] directly, so the two spellings of
172    /// ed25519 never diverge.
173    pub fn scheme_id(&self) -> u16 {
174        self.scheme.unwrap_or(scheme::ED25519)
175    }
176}
177
178/// One operation in the log. Payload semantics live above this layer.
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180pub struct OpEntry {
181    /// Wire-format version this entry was written with; see [`FORMAT_VERSION`].
182    pub format_version: u16,
183    /// Hash of the previous entry; `None` only for the genesis entry.
184    pub parent: Option<ContentHash>,
185    /// Sequence number assigned by the single-writer sequencer.
186    pub seq: u64,
187    /// Signature-covered attribution channel that submitted the op.
188    ///
189    /// Serialized as `workspace` because that field name is frozen into
190    /// format v1 and therefore into every entry hash. The Rust name is
191    /// deliberately accurate: workspace ids live in `ViewOp`, while this
192    /// value identifies the collaboration channel an actor spoke on.
193    #[serde(rename = "workspace")]
194    pub channel: String,
195    /// Opaque operation body; interpreted by the layers above L1.
196    pub payload: Vec<u8>,
197    /// Witness cosignatures. **Always empty, and now permanently so
198    /// (D67).**
199    ///
200    /// This field is inside the bytes [`OpEntry::content_hash`] covers,
201    /// so a cosignature added after the entry was hashed would rewrite
202    /// the entry and orphan every descendant. Filling it is therefore
203    /// possible only *before* the append — signatures gathered on the
204    /// sequencer's critical path, which is what D16's latency tripwire
205    /// exists to avoid. D67 takes the alternative that row names: a
206    /// witness cosigns the D25 ref-state attestation as its own op. The
207    /// field stays for format stability, not as a placeholder for
208    /// something still coming.
209    pub witnesses: Vec<Witness>,
210    /// Author signature over [`OpEntry::signing_hash`] (L8). Additive
211    /// field (`serde(default)`): entries written before L8 decode with
212    /// `None`, keeping [`FORMAT_VERSION`] at 1.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub author_sig: Option<Witness>,
215}
216
217impl OpEntry {
218    /// Content address of this entry (its canonical serialization, hashed).
219    pub fn content_hash(&self) -> ContentHash {
220        let bytes = serde_json::to_vec(self).expect("OpEntry is always serializable");
221        ContentHash::blake3(&bytes)
222    }
223
224    /// What the author signs: a hash over `(channel, payload)` only.
225    ///
226    /// The author asserts *what* they submitted, not *where* it landed —
227    /// `seq`/`parent` are assigned by the sequencer after signing.
228    /// Position is covered instead by the D25 attestation, whose
229    /// `at_seq` a D67 witness cosigns. Replaying a signed op at
230    /// a different position is rejected by the CAS `prev` carried inside
231    /// the payload, not by the signature.
232    pub fn signing_hash(&self) -> ContentHash {
233        signing_hash(&self.channel, &self.payload)
234    }
235}
236
237/// Hash over an author's submission content — see
238/// [`OpEntry::signing_hash`]. Standalone so clients can sign before the
239/// sequencer has built the entry.
240pub fn signing_hash(channel: &str, payload: &[u8]) -> ContentHash {
241    // This tuple has no field names, so renaming the concept from the
242    // overloaded `workspace` to `channel` changes no signed bytes.
243    let canonical = serde_json::to_vec(&(channel, payload)).expect("tuple always serializes");
244    ContentHash::blake3(&canonical)
245}
246
247/// Failure modes of an [`OpLog`] backend.
248#[derive(Debug)]
249pub enum LogError {
250    /// Parent hash of the appended entry does not match the current head.
251    HeadMismatch,
252    /// Underlying storage I/O failure.
253    Io(std::io::Error),
254    /// Stored data could not be decoded as valid entries.
255    Corrupt(String),
256}
257
258/// The log-backend seam (D16). Conformance suite: `tests/it/conformance.rs`,
259/// run against every implementation.
260///
261/// Implementations must reject appends whose `parent` is not the current
262/// head, and a rejected append must not mutate the log.
263pub trait OpLog: Send {
264    /// Appends `entry` and returns its content hash (the new head).
265    ///
266    /// # Errors
267    ///
268    /// Returns [`LogError::HeadMismatch`] when `entry.parent` is not the
269    /// current head, or a backend-specific [`LogError`] on storage failure.
270    fn append(&mut self, entry: OpEntry) -> Result<ContentHash, LogError>;
271
272    /// Content hash of the newest entry, or `None` for an empty log.
273    fn head(&self) -> Option<ContentHash>;
274
275    /// Number of entries in the log.
276    fn len(&self) -> u64;
277
278    /// Whether the log has no entries.
279    fn is_empty(&self) -> bool {
280        self.len() == 0
281    }
282
283    /// Entry at sequence number `seq`, or `None` if out of range.
284    fn get(&self, seq: u64) -> Option<OpEntry>;
285
286    /// Borrows the newest entry without cloning it.
287    ///
288    /// The sequencer uses this immediately after a successful append so
289    /// its cached policy state is updated only after storage accepted the
290    /// entry. Implementations must therefore return the entry whose hash
291    /// is [`OpLog::head`].
292    fn last(&self) -> Option<&OpEntry>;
293
294    /// Makes every prior [`OpLog::append`] durable — survives power loss,
295    /// not merely process death.
296    ///
297    /// Until this returns `Ok`, an appended entry may exist only in the
298    /// OS page cache. That matters here beyond losing a tail: the
299    /// `pre-receive` hook submits a ref op *before* git applies the ref,
300    /// so an unsynced log can leave git holding a ref whose authorising
301    /// op does not exist. Two sources of truth then disagree, and the
302    /// next push fails its CAS against a view that never saw the update.
303    ///
304    /// The sequencer calls this once per batch rather than once per
305    /// append, and only acknowledges submitters afterwards.
306    ///
307    /// # Errors
308    ///
309    /// Backend-specific [`LogError`] on storage failure. A failure here
310    /// must be treated as "the batch is not durable", never as success.
311    ///
312    /// Default: a no-op, correct for backends that never outlive the
313    /// process (see [`MemLog`]).
314    fn sync(&mut self) -> Result<(), LogError> {
315        Ok(())
316    }
317}
318
319/// Primary in-memory implementation (also the dev/test runtime).
320#[derive(Default)]
321pub struct MemLog {
322    entries: Vec<OpEntry>,
323    head: Option<ContentHash>,
324}
325
326impl MemLog {
327    /// Creates an empty in-memory log.
328    pub fn new() -> Self {
329        Self::default()
330    }
331}
332
333impl OpLog for MemLog {
334    fn append(&mut self, entry: OpEntry) -> Result<ContentHash, LogError> {
335        if entry.parent != self.head {
336            return Err(LogError::HeadMismatch);
337        }
338        let hash = entry.content_hash();
339        self.entries.push(entry);
340        self.head = Some(hash.clone());
341        Ok(hash)
342    }
343
344    fn head(&self) -> Option<ContentHash> {
345        self.head.clone()
346    }
347
348    fn len(&self) -> u64 {
349        self.entries.len() as u64
350    }
351
352    fn get(&self, seq: u64) -> Option<OpEntry> {
353        self.entries.get(seq as usize).cloned()
354    }
355
356    fn last(&self) -> Option<&OpEntry> {
357        self.entries.last()
358    }
359
360    /// Nothing to do: a `MemLog` never outlives its process, so there is
361    /// no weaker state for `sync` to strengthen. The default would serve;
362    /// it is spelled out because "in-memory logs cannot be made durable"
363    /// is the reason, not an oversight.
364    fn sync(&mut self) -> Result<(), LogError> {
365        Ok(())
366    }
367}
368
369/// Second implementation (seam rule: feature-poor is fine, broken is not):
370/// JSON-lines file, append-only, rebuilt head on open.
371pub struct FileLog {
372    /// Buffered so a batch of appends costs one write syscall instead of
373    /// one each. Correctness rests on [`OpLog::sync`]: nothing here is
374    /// durable, or even visible to another reader of the file, until the
375    /// buffer is flushed.
376    file: std::io::BufWriter<std::fs::File>,
377    /// Separate read handle, so [`OpLog::get`] can take `&self` without
378    /// disturbing the writer.
379    reader: std::sync::Mutex<std::fs::File>,
380    /// Byte offset where each entry starts, one `u64` per entry.
381    ///
382    /// This replaces holding every [`OpEntry`] in memory. That cost some
383    /// hundreds of bytes per op and never shrank, so a log grew in RAM
384    /// with the repo's *lifetime* rather than with load — at the Phase-1
385    /// target of 5 ops/s, ~432k entries a day, forever. Eight bytes per op
386    /// instead, and `get` pays a seek and a parse for what is no longer
387    /// resident.
388    ///
389    /// Rebuilt on open and never persisted, so it adds no on-disk format
390    /// and needs no `format_version` or checkpoint record. `open` already
391    /// scanned the whole file to rebuild `head`; this rides along.
392    offsets: Vec<u64>,
393    /// Total bytes handed to the writer, including what is still sitting
394    /// in the `BufWriter`.
395    write_pos: u64,
396    /// Entries appended since the last successful flush. They are not yet
397    /// readable from the file, so [`OpLog::get`] serves them from here.
398    /// Bounded by the sequencer's batch size, which syncs once per batch.
399    pending: std::collections::VecDeque<OpEntry>,
400    head: Option<ContentHash>,
401    /// Bytes of unterminated tail discarded by [`FileLog::open`]; see
402    /// [`FileLog::torn_tail_bytes`]. Zero for a cleanly closed log.
403    torn_tail_bytes: u64,
404    /// Where [`FileLog::open`] put a torn tail's bytes, if there was one.
405    torn_tail_quarantine: Option<std::path::PathBuf>,
406}
407
408impl FileLog {
409    /// Opens (creating if absent) the log file at `path` and replays it to
410    /// rebuild the in-memory index and head.
411    ///
412    /// # Torn tails
413    ///
414    /// A power cut can leave the file ending in a record that was only
415    /// partly written, or — under delayed allocation — in a run of NUL
416    /// bytes. Any final record with no terminating newline is treated as
417    /// such a torn write and truncated away, whether or not it happens to
418    /// decode: a complete record whose newline never landed would
419    /// otherwise be concatenated with the next append into one line that
420    /// never parses again.
421    ///
422    /// Discarding it cannot lose an acknowledged op. The sequencer
423    /// acknowledges only after [`OpLog::sync`] returns, and that call
424    /// returns only once every byte before it is on the platter, so
425    /// anything in an unterminated tail was never acknowledged to anyone.
426    /// The truncation is reported by [`FileLog::torn_tail_bytes`] rather
427    /// than performed silently.
428    ///
429    /// A decode failure in a *newline-terminated* record is not a torn
430    /// write — it is damage to a record that was once written whole — and
431    /// still fails as [`LogError::Corrupt`]. That includes a NUL-filled
432    /// gap followed by further records: refusing to start is the right
433    /// answer there, because the alternative is silently dropping ops from
434    /// the middle of the log.
435    ///
436    /// # Errors
437    ///
438    /// Returns [`LogError::Io`] on filesystem failure and
439    /// [`LogError::Corrupt`] when a terminated line fails to decode.
440    pub fn open(path: &std::path::Path) -> Result<Self, LogError> {
441        use std::io::{BufRead, BufReader};
442        let file = std::fs::OpenOptions::new()
443            .create(true)
444            .read(true)
445            .append(true)
446            .open(path)
447            .map_err(LogError::Io)?;
448        // Replay rebuilds `head` and the offset index together. Reading by
449        // bytes rather than `.lines()` is what makes the offsets available
450        // at all: a line iterator does not say where it was.
451        let mut offsets = Vec::new();
452        let mut head = None;
453        let mut write_pos = 0u64;
454        let mut torn_tail_bytes = 0u64;
455        let mut reader = BufReader::new(&file);
456        let mut line = Vec::new();
457        loop {
458            line.clear();
459            let n = reader.read_until(b'\n', &mut line).map_err(LogError::Io)?;
460            if n == 0 {
461                break;
462            }
463            let Some(body) = line.strip_suffix(b"\n") else {
464                // No newline means `read_until` hit EOF mid-record: a torn
465                // tail. `write_pos` is already the offset of its first
466                // byte, which is where the file has to end.
467                torn_tail_bytes = n as u64;
468                break;
469            };
470            let entry: OpEntry =
471                serde_json::from_slice(body).map_err(|e| LogError::Corrupt(e.to_string()))?;
472            let position = offsets.len() as u64;
473            if entry.format_version != FORMAT_VERSION {
474                return Err(LogError::Corrupt(format!(
475                    "record {position} uses unsupported format version {}; this build supports {FORMAT_VERSION}",
476                    entry.format_version
477                )));
478            }
479            if entry.seq != position {
480                return Err(LogError::Corrupt(format!(
481                    "record at position {position} carries seq {}",
482                    entry.seq
483                )));
484            }
485            if entry.parent != head {
486                return Err(LogError::Corrupt(format!(
487                    "record {position} does not chain to the previous entry"
488                )));
489            }
490            // Recompute the canonical entry hash while replaying. The
491            // next record must name this value as its parent, and the
492            // final value is the rebuilt head returned by `head()`.
493            head = Some(entry.content_hash());
494            offsets.push(write_pos);
495            write_pos += n as u64;
496        }
497        drop(reader);
498        let mut torn_tail_quarantine = None;
499        if torn_tail_bytes > 0 {
500            // Copied out before the file is cut, and synced before the
501            // truncation is issued. These bytes were never acknowledged,
502            // so discarding them loses nothing anyone was promised -- but
503            // "loses nothing" is a claim about the sequencer's protocol,
504            // and the bytes are the only evidence available to anyone
505            // checking it after the fact. Keeping them costs one file.
506            //
507            // Order matters: a crash between the two leaves a spare copy
508            // of bytes still present in the log, which is harmless. The
509            // reverse order would leave a truncation with no copy, which
510            // is the deletion this exists to avoid.
511            torn_tail_quarantine = Some(repair::quarantine_tail(path, &line, write_pos)?);
512            // Cut it off before the writer can append behind it. `sync_all`
513            // rather than `sync_data` because it is the file's *length*
514            // that has to survive here.
515            file.set_len(write_pos).map_err(LogError::Io)?;
516            file.sync_all().map_err(LogError::Io)?;
517        }
518        // The file's own bytes are synced by `sync`, but a fresh file (or a
519        // just-truncated one) is only reachable through its directory
520        // entry, and that is a separate write. Once per open, so the cost
521        // does not appear on the submit path.
522        sync_parent_dir(path)?;
523        let read_handle = std::fs::File::open(path).map_err(LogError::Io)?;
524        Ok(Self {
525            file: std::io::BufWriter::new(file),
526            reader: std::sync::Mutex::new(read_handle),
527            offsets,
528            write_pos,
529            pending: std::collections::VecDeque::new(),
530            head,
531            torn_tail_bytes,
532            torn_tail_quarantine,
533        })
534    }
535
536    /// Bytes of partly written tail that [`FileLog::open`] truncated away,
537    /// or 0 if the log ended on a record boundary.
538    ///
539    /// Non-zero means this process started after an unclean stop. The
540    /// discarded bytes were never acknowledged (see [`FileLog::open`]), so
541    /// this is a fact worth reporting, not a fault — but a caller that
542    /// never reports it turns a crash into a silent one.
543    pub fn torn_tail_bytes(&self) -> u64 {
544        self.torn_tail_bytes
545    }
546
547    /// Where the truncated bytes were saved, if any were.
548    ///
549    /// The truncation is automatic because a node has to come back up
550    /// unattended after a power cut, but the bytes are not thrown away:
551    /// an operator asking "what was lost" gets a file to look at rather
552    /// than a number. `None` means the log ended on a record boundary.
553    pub fn torn_tail_quarantine(&self) -> Option<&std::path::Path> {
554        self.torn_tail_quarantine.as_deref()
555    }
556}
557
558/// Fsyncs the directory holding `path`, so the file's name survives power
559/// loss and not just its contents.
560fn sync_parent_dir(path: &std::path::Path) -> Result<(), LogError> {
561    let parent = match path.parent() {
562        Some(p) if !p.as_os_str().is_empty() => p,
563        // A bare filename lives in the process's working directory.
564        _ => std::path::Path::new("."),
565    };
566    std::fs::File::open(parent)
567        .and_then(|dir| dir.sync_all())
568        .map_err(LogError::Io)
569}
570
571impl Drop for FileLog {
572    /// Last-resort flush. The sequencer syncs per batch, so in normal
573    /// operation this finds an empty buffer; it exists so a log dropped on
574    /// an error path does not silently discard buffered entries. Errors
575    /// are unreportable here, hence the `ok()` — durability is the
576    /// sequencer's job via [`OpLog::sync`], not this.
577    fn drop(&mut self) {
578        use std::io::Write;
579        self.file.flush().ok();
580    }
581}
582
583impl OpLog for FileLog {
584    fn append(&mut self, entry: OpEntry) -> Result<ContentHash, LogError> {
585        use std::io::Write;
586        if entry.parent != self.head {
587            return Err(LogError::HeadMismatch);
588        }
589        let mut line = serde_json::to_vec(&entry).map_err(|e| LogError::Corrupt(e.to_string()))?;
590        line.push(b'\n');
591        self.file.write_all(&line).map_err(LogError::Io)?;
592        let hash = entry.content_hash();
593        self.offsets.push(self.write_pos);
594        self.write_pos += line.len() as u64;
595        // Held only until the next flush makes it readable from the file.
596        self.pending.push_back(entry);
597        self.head = Some(hash.clone());
598        Ok(hash)
599    }
600
601    fn head(&self) -> Option<ContentHash> {
602        self.head.clone()
603    }
604
605    fn len(&self) -> u64 {
606        self.offsets.len() as u64
607    }
608
609    /// Reads one entry back: from the pending buffer if it has not been
610    /// flushed yet, otherwise from the file at its recorded offset.
611    ///
612    /// Returns `None` for an out-of-range `seq` and also for a stored line
613    /// that fails to decode or read. The trait signature has no way to say
614    /// "present but unreadable", and inventing one is a wider change than
615    /// this belongs in — but a corrupt log is a real condition, and `open`
616    /// does report it as [`LogError::Corrupt`], so damage is caught when
617    /// the log is next opened rather than never.
618    fn get(&self, seq: u64) -> Option<OpEntry> {
619        let len = self.offsets.len() as u64;
620        if seq >= len {
621            return None;
622        }
623        // Entries appended since the last flush are not in the file yet.
624        let pending_start = len - self.pending.len() as u64;
625        if seq >= pending_start {
626            return self.pending.get((seq - pending_start) as usize).cloned();
627        }
628
629        let start = self.offsets[seq as usize];
630        // The next entry's offset bounds this one; for the last entry the
631        // bound is everything written so far.
632        let end = self
633            .offsets
634            .get(seq as usize + 1)
635            .copied()
636            .unwrap_or(self.write_pos);
637        let mut buf = vec![0u8; (end - start) as usize];
638
639        use std::io::{Read, Seek, SeekFrom};
640        let mut file = self.reader.lock().ok()?;
641        file.seek(SeekFrom::Start(start)).ok()?;
642        file.read_exact(&mut buf).ok()?;
643        let body = buf.strip_suffix(b"\n").unwrap_or(&buf);
644        serde_json::from_slice(body).ok()
645    }
646
647    fn last(&self) -> Option<&OpEntry> {
648        // The sequencer observes an append before the batch barrier, so
649        // the newest entry is necessarily still in this pending queue.
650        // Returning a borrow avoids cloning the whole signed payload on
651        // every successful submission.
652        self.pending.back()
653    }
654
655    /// Flush the buffer to the OS, then ask the OS to put it on the
656    /// platter. Both halves are required and neither substitutes for the
657    /// other: `flush` alone leaves the bytes in the page cache, and
658    /// `sync_data` alone would sync a buffer that was never written.
659    ///
660    /// `sync_data` rather than `sync_all`: the file's length and contents
661    /// must survive, its mtime need not, and skipping the metadata write
662    /// is the cheaper half of an fsync.
663    fn sync(&mut self) -> Result<(), LogError> {
664        use std::io::Write;
665        self.file.flush().map_err(LogError::Io)?;
666        // Only now are these readable from the file, so only now may the
667        // in-memory copies go. If `flush` failed they are still the only
668        // copy and must be kept.
669        self.pending.clear();
670        self.file.get_ref().sync_data().map_err(LogError::Io)
671    }
672}