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}