jcp-wire: one writer per journal, or the replay spends twice #346
2 changed files with 176 additions and 1 deletions
18
jcp/SPEC.md
18
jcp/SPEC.md
|
|
@ -540,6 +540,24 @@ the recorded decisions through the gate** is RECOMMENDED over restoring a stored
|
|||
total: a replay cannot produce a spend the gate would not have produced, and a
|
||||
stored total is one more figure that can be wrong or tampered with.
|
||||
|
||||
**One writer per record (MUST).** The replay above re-establishes spend *from*
|
||||
the record, so two enforcement points sharing one record each re-establish the
|
||||
**same** spend, each decide against it, and each append. The budget is spent
|
||||
twice and every entry still verifies — the requirement above is defeated one
|
||||
layer beneath itself, and nothing in the record shows it. An implementation
|
||||
**MUST** therefore ensure a single writer per record: it **MUST** take
|
||||
exclusive access when it opens the record, and **MUST** fail to start rather
|
||||
than proceed where another writer already holds it.
|
||||
|
||||
An exclusive claim an operating system releases when the holding process dies
|
||||
is preferable to one an implementation must clean up, because a crash then
|
||||
leaves nothing to recover. Where a platform cannot provide exclusivity at all,
|
||||
the implementation **MUST** fail to open the record rather than proceed —
|
||||
deciding against a record another endpoint may also be writing is the overspend
|
||||
this requirement exists to prevent, and a platform limit is not a licence for
|
||||
it. An implementation that instead proceeds **MUST** make that fact checkable
|
||||
at run time rather than leave the type to imply a property it does not hold.
|
||||
|
||||
**The decision is recorded before the action runs.** Recording it afterwards
|
||||
loses the debit for any action interrupted between the two — the grant was
|
||||
debited when the decision was made, so a record written after the fact reports a
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
//! gate when it restarts, so a restart gives no budget back, unrevokes
|
||||
//! nothing, and forgets no request it may still be shown again.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::fs::{File, OpenOptions, TryLockError};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::Path;
|
||||
|
||||
|
|
@ -65,6 +65,11 @@ impl FileJournal {
|
|||
/// records it already holds, for [`Endpoint::with_journal`](crate::Endpoint::with_journal)
|
||||
/// to replay.
|
||||
///
|
||||
/// Takes an **exclusive** lock first, so a second process cannot hold the
|
||||
/// same journal. Fails with [`io::ErrorKind::WouldBlock`] where the
|
||||
/// journal is already held, and fails rather than proceeding where the
|
||||
/// platform cannot provide a lock at all.
|
||||
///
|
||||
/// A final record cut short is an append the endpoint was interrupted in.
|
||||
/// It is dropped and cut from the file, which is safe for both kinds: a
|
||||
/// request never recorded was never decided, and a revocation never
|
||||
|
|
@ -72,6 +77,10 @@ impl FileJournal {
|
|||
/// error, because a journal that cannot be read cannot re-establish state.
|
||||
pub fn open(path: impl AsRef<Path>) -> io::Result<(Self, Vec<JournalRecord>)> {
|
||||
let mut file = OpenOptions::new().read(true).append(true).create(true).open(path)?;
|
||||
// Before the read, and before the truncation below. A would-be second
|
||||
// writer must be refused *before* it can cut a torn trailing record
|
||||
// out from under the holder's appends.
|
||||
lock_exclusively(&file)?;
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)?;
|
||||
let mut records = Vec::new();
|
||||
|
|
@ -96,6 +105,44 @@ impl FileJournal {
|
|||
}
|
||||
}
|
||||
|
||||
/// Take an exclusive lock on the journal file, so a second endpoint cannot
|
||||
/// hold it.
|
||||
///
|
||||
/// **What this prevents is a double-spend, not a corrupt file.** Two endpoints
|
||||
/// on one journal each replay it, each derive the *same* remaining budget, each
|
||||
/// admit against it and each append. The budget is spent twice and every record
|
||||
/// still verifies — the replay that makes spend durable is defeated one layer
|
||||
/// below itself. [`FileJournal::open`] also truncates a torn trailing record,
|
||||
/// so a concurrent opener can cut the file out from under an appender.
|
||||
///
|
||||
/// The lock is the operating system's, held by the open file and released when
|
||||
/// it closes **or the process dies**, so a crash leaves nothing to recover. A
|
||||
/// lock-file sidecar would need a story for the crash case, and that story is
|
||||
/// where such schemes go wrong.
|
||||
fn lock_exclusively(file: &File) -> io::Result<()> {
|
||||
lock_outcome(file.try_lock())
|
||||
}
|
||||
|
||||
/// Turn a lock attempt into an outcome for [`FileJournal::open`].
|
||||
///
|
||||
/// Separate from the I/O so every arm is testable. The
|
||||
/// [`TryLockError::Error`] arm cannot be reached on a platform whose locks
|
||||
/// work, and an unreachable arm is exactly where a fail-**open** would sit
|
||||
/// unnoticed.
|
||||
fn lock_outcome(attempt: Result<(), TryLockError>) -> io::Result<()> {
|
||||
match attempt {
|
||||
Ok(()) => Ok(()),
|
||||
Err(TryLockError::WouldBlock) => Err(io::Error::new(
|
||||
io::ErrorKind::WouldBlock,
|
||||
"journal already held by another writer: a second one would spend the same budget twice",
|
||||
)),
|
||||
// The platform could not provide exclusivity. Failing to open is the
|
||||
// safe direction: the alternative is deciding against a record another
|
||||
// endpoint may also be writing.
|
||||
Err(TryLockError::Error(e)) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
impl Journal for FileJournal {
|
||||
fn append(&mut self, record: &JournalRecord) -> io::Result<()> {
|
||||
let body = canonical_cbor(record).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
|
||||
|
|
@ -106,3 +153,113 @@ impl Journal for FileJournal {
|
|||
self.file.sync_data()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A unique path per test, in the process's temp dir.
|
||||
fn tmp_path(tag: &str) -> std::path::PathBuf {
|
||||
static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let n = N.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let p = std::env::temp_dir()
|
||||
.join(format!("jcp-wire-journal-{}-{tag}-{n}.log", std::process::id()));
|
||||
let _ = std::fs::remove_file(&p);
|
||||
p
|
||||
}
|
||||
|
||||
/// A second endpoint must not be able to hold the journal.
|
||||
///
|
||||
/// `std::fs::File::try_lock` is the operating system's lock, so this needs
|
||||
/// no platform caveat and no `unsafe`: where a platform cannot provide one
|
||||
/// at all, `open` fails rather than proceeding.
|
||||
#[test]
|
||||
fn the_single_writer_claim_matches_what_open_does() {
|
||||
let path = tmp_path("second-open");
|
||||
let (_held, records) = FileJournal::open(&path).expect("the first open");
|
||||
assert!(records.is_empty());
|
||||
|
||||
let err = FileJournal::open(&path)
|
||||
.err()
|
||||
.expect("a second open of a held journal must be refused");
|
||||
assert_eq!(err.kind(), io::ErrorKind::WouldBlock, "{err}");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Every arm of the lock policy, including the one `open` cannot reach on
|
||||
/// a platform whose locks work. A platform that cannot lock **MUST NOT**
|
||||
/// yield an open journal: the alternative is deciding against a record
|
||||
/// another endpoint may also be writing.
|
||||
#[test]
|
||||
fn a_platform_that_cannot_lock_fails_closed() {
|
||||
assert!(lock_outcome(Ok(())).is_ok(), "an acquired lock opens the journal");
|
||||
|
||||
let contended = lock_outcome(Err(TryLockError::WouldBlock))
|
||||
.expect_err("a contended lock must not open the journal");
|
||||
assert_eq!(contended.kind(), io::ErrorKind::WouldBlock);
|
||||
|
||||
let unsupported =
|
||||
lock_outcome(Err(TryLockError::Error(io::Error::from(io::ErrorKind::Unsupported))))
|
||||
.expect_err("an unsupported lock must not open the journal");
|
||||
assert_eq!(
|
||||
unsupported.kind(),
|
||||
io::ErrorKind::Unsupported,
|
||||
"the platform's own error is propagated, not replaced"
|
||||
);
|
||||
}
|
||||
|
||||
/// The lock is the kernel's, held by the descriptor, so dropping the
|
||||
/// journal frees it. A crash frees it the same way, which is why this is
|
||||
/// an advisory lock rather than a lock file.
|
||||
#[test]
|
||||
fn dropping_the_journal_releases_the_lock() {
|
||||
let path = tmp_path("release");
|
||||
{
|
||||
let (_held, _) = FileJournal::open(&path).expect("the first open");
|
||||
} // dropped here
|
||||
let (_again, records) = FileJournal::open(&path).expect("reopen after drop");
|
||||
assert!(records.is_empty());
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// **The lock is taken before the truncation.** `open` cuts a torn
|
||||
/// trailing record from the file, so an opener refused *after* that point
|
||||
/// would already have cut the holder's file. A refused open must leave the
|
||||
/// journal byte-for-byte as it was.
|
||||
///
|
||||
/// Raw bytes rather than a real record: the property under test is the
|
||||
/// ordering of the lock against the truncation, and a torn frame is all
|
||||
/// that takes.
|
||||
#[test]
|
||||
fn a_refused_open_does_not_truncate_the_holders_journal() {
|
||||
let path = tmp_path("no-truncate");
|
||||
let (held, _) = FileJournal::open(&path).expect("the first open");
|
||||
|
||||
// A torn frame appended behind the holder's back: a length header
|
||||
// promising 64 bytes with 3 following, which `open` truncates away.
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut raw = OpenOptions::new().append(true).open(&path).expect("raw append");
|
||||
raw.write_all(&[0, 0, 0, 64]).expect("torn header");
|
||||
raw.write_all(&[1, 2, 3]).expect("short body");
|
||||
raw.sync_data().expect("sync");
|
||||
}
|
||||
let before = std::fs::metadata(&path).expect("stat").len();
|
||||
assert_eq!(before, 7, "the torn frame is on disk");
|
||||
|
||||
assert!(FileJournal::open(&path).is_err(), "the second open must be refused");
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path).expect("stat").len(),
|
||||
before,
|
||||
"a refused open truncated the file it was refused from"
|
||||
);
|
||||
drop(held);
|
||||
|
||||
// Once the holder is gone the torn frame is cut, as it always was —
|
||||
// the truncation still happens, just never from a refused writer.
|
||||
let (_now, records) = FileJournal::open(&path).expect("reopen");
|
||||
assert!(records.is_empty(), "the torn frame yields no record");
|
||||
assert_eq!(std::fs::metadata(&path).expect("stat").len(), 0, "and is cut");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue