Event log
An append-only event log in one file for event sourcing and audit trails: streams written with optimistic concurrency, read by stream or from a position for projections, snapshots for long streams, and a hash chain that shows any edited past event.
Version 1.0.0 · 7 functions · 1 crate
Functions
What use event_log.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use elog_append, elog_read_stream from event_log (Quillo 0.6).
elog_append(path: str, stream: str, events: list, expected_version: int?) → anyor an errorAppends events to stream in the log file at path (made when missing): each { type, data, meta }. expected_version: the stream's version you read (0 or -1: it must not exist yet), or null to append whatever it is. Gives { stream, first_seq, last_seq, version }; a stream moved on since you read it is an error (reload and try again), never a lost update.
elog_read_stream(path: str, stream: str, from_version: int, limit: int) → listor an errorA stream's events from from_version (1 for all), at most limit: [{ seq, stream, version, type, at, data, meta }].
elog_read_all(path: str, after_seq: int, limit: int, types: list) → listor an errorThe whole log after position after_seq (0 for the start), at most limit, only types when given (empty: all): what a projection or a subscriber reads, remembering the last seq it saw.
elog_streams(path: str, prefix: str) → listor an errorThe streams and their versions, those starting with prefix: [{ stream, version }].
elog_snapshot(path: str, stream: str, version: int, state: any) → intor an errorSaves a stream's state as of version (what you folded its events into), in a file beside the log, so loading it later replays only the events after it.
elog_load(path: str, stream: str) → anyor an errorWhat rebuilding a stream needs: its latest snapshot ({ version, state }, or null) and the events after it; fold events into snapshot.state to get the present. Also version, the stream's latest, for elog_append's expected_version.
elog_verify(path: str) → anyor an errorChecks the hash chain from the first event: { ok, events, broken_at (the first seq whose hash does not follow), streams }. A changed, removed or reordered past event breaks it.
Get it
$ quillo install event_log # event_log.qwer into this folder$ quillo install event_log --example # and its example, event_log.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/event_log.qwer$ curl -O https://quillolang.org/plugins/files/event_log.qweThen, in your program, all of it or only what you call:
use event_log.qweruse elog_append, elog_read_stream from event_log // Quillo 0.6: only theseIt uses this crate, which cargo fetches the first time the plugin is built:
sha2 = "0.10"Example
// An order service kept as events: each order a stream, written with the version last read (two// clerks cannot overwrite each other), the order rebuilt from a snapshot and the events after it,// a sales projection read from the whole log, and the hash chain catching an edited past line.//// quillo run event_log.qwe then open http://localhost:8080/demouse "event_log.qwer"// An order's state from its events.fn apply(state, e) = match e.type { "OrderPlaced" => { customer: e.data.customer, items: [], total: 0, status: "open" }, "ItemAdded" => merge(state, { items: state.items + [e.data.sku], total: state.total + e.data.price }), "OrderPaid" => merge(state, { status: "paid" }), _ => state,}fn fold(state, events) = reduce(events, state, (s, e) => apply(s, e))route GET /demo { let log = `data/orders-${uuid()}.log` elog_append(log, "order-42", [{ type: "OrderPlaced", data: { customer: "Ada" } }], -1)? let v = elog_append(log, "order-42", [{ type: "ItemAdded", data: { sku: "TEA-01", price: 450 } }, { type: "ItemAdded", data: { sku: "MUG-02", price: 1200 } }], 1)? elog_append(log, "order-43", [{ type: "OrderPlaced", data: { customer: "Grace" } }, { type: "ItemAdded", data: { sku: "TEA-01", price: 450 } }], null)? // A second clerk who read version 1 tries to write: refused. let stale = elog_append(log, "order-42", [{ type: "OrderPaid", data: {} }], 1).error elog_append(log, "order-42", [{ type: "OrderPaid", data: { method: "card" } }], v.version)? let order = fold(null, elog_read_stream(log, "order-42", 1, 100)?) elog_snapshot(log, "order-42", 4, order)? let loaded = elog_load(log, "order-42")? let sold = elog_read_all(log, 0, 1000, ["ItemAdded"])? let before = elog_verify(log)? write_file(log, replace(read_file(log)?, "\"price\":1200", "\"price\":12")) let after = elog_verify(log)? delete_file(log) delete_file(`${log}.snapshots`) { version: v, stale: stale, order: order, loaded: { snapshot_version: loaded.snapshot.version, events_after: len(loaded.events), version: loaded.version }, sales: sum(map(sold, (e) => e.data.price)), verified: before.ok, tampered: after, }}$ quillo run event_log.qwe$ curl 'localhost:8080/demo'Source
//! An append-only event log in one file, for event sourcing and audit trails: events appended to streams ("order-42") with optimistic concurrency (the version you expect), read back by stream or for the whole log from a position (to build projections), snapshots so a long stream need not be replayed from its start, and a hash chain that shows if any past line was changed.//! version: 1.0.0//!//! [dependencies]//! sha2 = "0.10"use serde_json::{json, Map, Value};use sha2::{Digest, Sha256};use std::collections::HashMap;use std::io::{BufRead, Write};use std::sync::{Mutex, OnceLock};struct Index { len: u64, seq: u64, last_hash: String, versions: HashMap<String, u64>,}fn lock() -> &'static Mutex<HashMap<String, Index>> { static L: OnceLock<Mutex<HashMap<String, Index>>> = OnceLock::new(); L.get_or_init(|| Mutex::new(HashMap::new()))}fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{:02x}", x)).collect()}fn utc(ms: i64) -> String { let secs = ms.div_euclid(1000); let days = secs.div_euclid(86400); let rem = secs.rem_euclid(86400); let z = days + 719468; let era = z.div_euclid(146097); let doe = z - era * 146097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 }; format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z", y, m, d, rem / 3600, rem % 3600 / 60, rem % 60, ms.rem_euclid(1000))}/// The log's lines that are whole (a line cut by a crash, without its newline, is left out).fn lines(path: &str) -> Result<(Vec<Value>, u64), String> { let f = match std::fs::File::open(path) { Ok(f) => f, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((vec![], 0)), Err(e) => return Err(format!("{}: {}", path, e)), }; let mut r = std::io::BufReader::new(f); let mut out = vec![]; let mut good = 0u64; let mut buf = String::new(); loop { buf.clear(); let n = r.read_line(&mut buf).map_err(|e| e.to_string())?; if n == 0 || !buf.ends_with('\n') { break; } let v: Value = serde_json::from_str(buf.trim_end()).map_err(|e| format!("{}: line {} is damaged: {}", path, out.len() + 1, e))?; out.push(v); good += n as u64; } Ok((out, good))}/// The index, rebuilt when the file changed outside this process.fn index<'a>(m: &'a mut HashMap<String, Index>, path: &str) -> Result<&'a mut Index, String> { let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); let stale = m.get(path).map_or(true, |i| i.len != len); if stale { let (ls, good) = lines(path)?; if good < len { // A torn last line: cut it off so the next append starts clean. let f = std::fs::OpenOptions::new().write(true).open(path).map_err(|e| e.to_string())?; f.set_len(good).map_err(|e| e.to_string())?; } let mut versions = HashMap::new(); for l in &ls { if let (Some(s), Some(v)) = (l["stream"].as_str(), l["version"].as_u64()) { versions.insert(s.to_string(), v); } } let seq = ls.last().and_then(|l| l["seq"].as_u64()).unwrap_or(0); let last_hash = ls.last().and_then(|l| l["hash"].as_str()).unwrap_or("").to_string(); m.insert(path.to_string(), Index { len: good, seq, last_hash, versions }); } Ok(m.get_mut(path).unwrap())}/// The hash of an event: SHA-256 over the previous hash and the event's own fields.fn chain(prev: &str, e: &Value) -> String { let mut h = Sha256::new(); h.update(prev.as_bytes()); for k in ["seq", "stream", "version", "type", "at"] { h.update(e[k].to_string().as_bytes()); } h.update(e["data"].to_string().as_bytes()); h.update(e["meta"].to_string().as_bytes()); hex(&h.finalize())}/// Appends events to `stream` in the log file at `path` (made when missing): each `{ type, data, meta }`. `expected_version`: the stream's version you read (0 or -1: it must not exist yet), or null to append whatever it is./// Gives `{ stream, first_seq, last_seq, version }`; a stream moved on since you read it is an error (reload and try again), never a lost update.pub fn elog_append(path: &str, stream: &str, events: Vec<serde_json::Value>, expected_version: Option<i64>) -> Result<serde_json::Value, String> { if stream.is_empty() || stream.len() > 200 { return Err("a stream's name is 1 to 200 characters".into()); } if events.is_empty() || events.len() > 10_000 { return Err("1 to 10,000 events at once".into()); } let mut m = lock().lock().map_err(|_| "the log's lock is poisoned")?; let idx = index(&mut m, path)?; let current = idx.versions.get(stream).copied().unwrap_or(0); if let Some(e) = expected_version { let want = e.max(0) as u64; if want != current { return Err(format!("{} is at version {}, not {}: someone else wrote to it; read it again", stream, current, want)); } } if let Some(dir) = std::path::Path::new(path).parent() { if !dir.as_os_str().is_empty() { std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; } } let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0); let mut out = String::new(); let (mut seq, mut ver, mut prev) = (idx.seq, current, idx.last_hash.clone()); let first = seq + 1; for ev in &events { let t = ev["type"].as_str().ok_or("each event has a `type`")?; seq += 1; ver += 1; let mut e = json!({ "seq": seq, "stream": stream, "version": ver, "type": t, "at": utc(now), "data": ev.get("data").cloned().unwrap_or(Value::Null), "meta": ev.get("meta").cloned().unwrap_or(Value::Null) }); let h = chain(&prev, &e); e["hash"] = json!(h); prev = h; out.push_str(&e.to_string()); out.push('\n'); } let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path).map_err(|e| format!("{}: {}", path, e))?; f.write_all(out.as_bytes()).map_err(|e| e.to_string())?; f.sync_data().map_err(|e| e.to_string())?; idx.len += out.len() as u64; idx.seq = seq; idx.last_hash = prev; idx.versions.insert(stream.to_string(), ver); Ok(json!({ "stream": stream, "first_seq": first, "last_seq": seq, "version": ver }))}fn clean(mut e: Value) -> Value { if let Value::Object(m) = &mut e { m.remove("hash"); } e}/// A stream's events from `from_version` (1 for all), at most `limit`: `[{ seq, stream, version, type, at, data, meta }]`.pub fn elog_read_stream(path: &str, stream: &str, from_version: i64, limit: i64) -> Result<Vec<serde_json::Value>, String> { let (ls, _) = lines(path)?; Ok(ls.into_iter().filter(|e| e["stream"] == stream && e["version"].as_i64().unwrap_or(0) >= from_version).take(limit.clamp(1, 1_000_000) as usize).map(clean).collect())}/// The whole log after position `after_seq` (0 for the start), at most `limit`, only `types` when given (empty: all): what a projection or a subscriber reads, remembering the last `seq` it saw.pub fn elog_read_all(path: &str, after_seq: i64, limit: i64, types: Vec<String>) -> Result<Vec<serde_json::Value>, String> { let (ls, _) = lines(path)?; Ok(ls .into_iter() .filter(|e| e["seq"].as_i64().unwrap_or(0) > after_seq && (types.is_empty() || types.iter().any(|t| e["type"] == t.as_str()))) .take(limit.clamp(1, 1_000_000) as usize) .map(clean) .collect())}/// The streams and their versions, those starting with `prefix`: `[{ stream, version }]`.pub fn elog_streams(path: &str, prefix: &str) -> Result<Vec<serde_json::Value>, String> { let mut m = lock().lock().map_err(|_| "the log's lock is poisoned")?; let idx = index(&mut m, path)?; let mut v: Vec<(String, u64)> = idx.versions.iter().filter(|(k, _)| k.starts_with(prefix)).map(|(k, v)| (k.clone(), *v)).collect(); v.sort(); Ok(v.into_iter().map(|(s, n)| json!({ "stream": s, "version": n })).collect())}fn snap_path(path: &str) -> String { format!("{}.snapshots", path)}/// Saves a stream's state as of `version` (what you folded its events into), in a file beside the log, so loading it later replays only the events after it.pub fn elog_snapshot(path: &str, stream: &str, version: i64, state: serde_json::Value) -> Result<i64, String> { let rec = json!({ "stream": stream, "version": version, "state": state }); let mut f = std::fs::OpenOptions::new().create(true).append(true).open(snap_path(path)).map_err(|e| e.to_string())?; f.write_all(format!("{}\n", rec).as_bytes()).map_err(|e| e.to_string())?; f.sync_data().map_err(|e| e.to_string())?; Ok(version)}/// What rebuilding a stream needs: its latest snapshot (`{ version, state }`, or null) and the events after it; fold `events` into `snapshot.state` to get the present. Also `version`, the stream's latest, for `elog_append`'s `expected_version`.pub fn elog_load(path: &str, stream: &str) -> Result<serde_json::Value, String> { let (snaps, _) = lines(&snap_path(path))?; let snap = snaps.into_iter().filter(|s| s["stream"] == stream).max_by_key(|s| s["version"].as_i64().unwrap_or(0)); let from = snap.as_ref().and_then(|s| s["version"].as_i64()).unwrap_or(0) + 1; let events = elog_read_stream(path, stream, from, 1_000_000)?; let version = events.last().and_then(|e| e["version"].as_i64()).unwrap_or(from - 1); Ok(json!({ "snapshot": snap.map(|s| json!({ "version": s["version"], "state": s["state"] })), "events": events, "version": version }))}/// Checks the hash chain from the first event: `{ ok, events, broken_at (the first seq whose hash does not follow), streams }`. A changed, removed or reordered past event breaks it.pub fn elog_verify(path: &str) -> Result<serde_json::Value, String> { let (ls, _) = lines(path)?; let mut prev = String::new(); let mut streams: Map<String, Value> = Map::new(); for (i, e) in ls.iter().enumerate() { let want = chain(&prev, e); if e["hash"].as_str() != Some(want.as_str()) || e["seq"].as_u64() != Some(i as u64 + 1) { return Ok(json!({ "ok": false, "events": ls.len(), "broken_at": e["seq"] })); } prev = want; if let Some(s) = e["stream"].as_str() { streams.insert(s.to_string(), e["version"].clone()); } } Ok(json!({ "ok": true, "events": ls.len(), "broken_at": null, "streams": streams.len() }))}Related
blob_store.qwer · 1.0.0Uploads kept in a folder by their SHA-256, as Git keeps objects: the same file stored once, names pointing at contents, atomic writes, every file verified against its hash, and contents nothing names swept away.6 views · 7 downloadsHash chain hash_chain.qwer · 1.0.0A tamper-evident audit log: each entry's SHA-256 covers the one before, so a changed, removed or reordered entry is found.7 views · 11 downloadsMerkle trees merkle.qwer · 1.0.0Merkle trees as Certificate Transparency makes them: a root for a list or a file, a short proof that one entry is in it, and a proof that a log only grew.6 views · 7 downloadsBackblaze B2 backblaze_b2.qwer · 1.0.0Keep files on Backblaze B2 through its native API: uploads with their SHA-1, large files in parts, upload URLs replaced when a pod is busy, download links and checked downloads, paged listings, and an expired token renewed without the program noticing.4 views · 6 downloadsSupabase supabase.qwer · 1.0.0Supabase from a server: PostgREST filters written as maps and quoted as it wants, exact counts and paging, inserts, upserts and RPC calls under the service key or a user's token, Auth sign-in and refresh, its JWTs checked here, and Storage uploads and signed links.4 views · 8 downloadsTime series store time_series.qwer · 1.0.0Metrics kept in one file: points written by series and time, read back for a range, downsampled into buckets (average, max, percentiles, a counter's rate), several series lined up for a chart, old points compacted and dropped by retention.7 views · 7 downloadsThread 0 posts
On Event log: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.