av_bridge/embedded.rs
1//! The embedded file-backed broker — the Bridge reference backend.
2//!
3//! Layout: `<data_dir>/manifest.yaml` + per partition under
4//! `<data_dir>/topics/<topic>/`: `p<N>.jsonl` (the JSONL segment),
5//! `p<N>.next-offset` (durable high-water mark), and `p<N>.event-uids.jsonl`
6//! (idempotency sidecar). Offsets are stable logical positions: retention
7//! rewrites keep surviving records under their original offsets, so after
8//! expiry the first file line can carry an offset > 0. Segment appends are
9//! serialized per partition; a crash can leave at most one torn trailing
10//! line, which recovery detects, truncates, and *counts* (never silently
11//! absorbs — D13.16). A torn sidecar tail is truncated without counting and
12//! rebuilt from the recovered segment.
13//!
14//! Scope: single-process access (the harness embeds the broker or fronts it).
15//! Cross-process multi-writer setups use the NATS/Kafka connectors.
16
17use crate::bus::{partition_for, BusError, EventBus, PublishAck, StoredEvent};
18use crate::manifest::BridgeManifest;
19use parking_lot::Mutex;
20use std::collections::HashMap;
21use std::fs;
22use std::io::{BufRead, BufReader, Write};
23use std::path::{Path, PathBuf};
24
25struct Partition {
26 path: PathBuf,
27 watermark_path: PathBuf,
28 idempotency_path: PathBuf,
29 seen_event_uids: HashMap<String, u64>,
30 next_offset: u64,
31 writer: fs::File,
32 /// Set when a failed append could not be repaired by truncating the
33 /// segment back to its last known-good length. Every subsequent
34 /// publish on this partition is refused (fail-closed) — appending
35 /// after torn bytes would merge into the next record and make the
36 /// whole partition unreadable to `fetch`. Cleared only by restart,
37 /// where segment recovery re-establishes a consistent tail.
38 poisoned: Option<String>,
39}
40
41#[derive(serde::Serialize, serde::Deserialize)]
42struct EventUidOffset {
43 event_uid: String,
44 offset: u64,
45}
46
47/// File-backed broker instance.
48pub struct EmbeddedBroker {
49 data_dir: PathBuf,
50 manifest: BridgeManifest,
51 #[cfg(feature = "cold-store")]
52 cold_archive: Option<crate::cold_store::ColdArchive>,
53 // topic -> partitions
54 partitions: HashMap<String, Vec<Mutex<Partition>>>,
55 validators: HashMap<String, jsonschema::Validator>,
56 /// Torn trailing lines dropped during recovery (public field asserted
57 /// by the contract tests; not currently read by any caller or
58 /// exported as a metric).
59 pub recovered_torn_lines: u64,
60}
61
62impl EmbeddedBroker {
63 /// Provision a fresh bridge in `data_dir` from `manifest` alone (R12).
64 /// Fails if the directory already contains a bridge.
65 pub fn provision(data_dir: &Path, manifest: &BridgeManifest) -> Result<Self, BusError> {
66 manifest
67 .validate()
68 .map_err(|e| BusError::Backend(e.to_string()))?;
69 // Reject a scheme-URI cold_uri BEFORE
70 // any filesystem side effect. The prior check lived in
71 // `open()` (after provision had already written manifest.yaml,
72 // per-topic dirs, and schema copies to disk); when
73 // provision-then-open failed here, the operator saw a "bridge
74 // already provisioned" error on retry after fixing the
75 // manifest, because the botched files still sat in data_dir.
76 // Move the check to the top of provision so a failure leaves
77 // data_dir untouched and retry-clean.
78 #[cfg(not(feature = "cold-store"))]
79 reject_cold_uri_without_feature(manifest)?;
80 // Resolve and schema-validate every referenced schema BEFORE the
81 // single-winner claim below: `manifest.validate()` does not check
82 // schema refs, so an unresolvable/invalid ref must fail here —
83 // side-effect-free and retry-clean — not after the claim has
84 // already been taken (see the rollback note further down).
85 let resolved_schemas = resolve_referenced_schemas(manifest)?;
86 let manifest_path = data_dir.join("manifest.yaml");
87 av_core::fsutil::create_dir_all_synced(data_dir)?;
88 let yaml = manifest.to_yaml().map_err(|e| BusError::Backend(e.to_string()))?;
89 // Atomic single-winner claim on a fresh provision: the
90 // `hard_link(2)` below fails with EEXIST if the target already
91 // exists — an OS-level
92 // exclusion primitive, so N concurrent provisions race
93 // on the same directory and exactly one wins. The old shape
94 // (`exists()` check → separate write) had a TOCTOU: two racers
95 // could both see "does not exist", both write, both succeed.
96 //
97 // Any error path — write_all, sync_all, hard_link, os signal —
98 // must clean up the tmp file. TmpGuard's Drop covers the
99 // early-return paths (ENOSPC, EIO on write, permission errors
100 // on sync); the winner path explicitly disarms the guard just
101 // before returning Ok so its finalised link is not deleted.
102 use std::io::Write as _;
103 let tmp = data_dir.join(format!("manifest.yaml.{}.tmp", av_core::new_event_uid()));
104 struct TmpGuard {
105 path: Option<std::path::PathBuf>,
106 }
107 impl TmpGuard {
108 fn disarm(&mut self) {
109 self.path = None;
110 }
111 }
112 impl Drop for TmpGuard {
113 fn drop(&mut self) {
114 if let Some(path) = self.path.take() {
115 let _ = fs::remove_file(&path);
116 }
117 }
118 }
119 let mut guard = TmpGuard {
120 path: Some(tmp.clone()),
121 };
122 {
123 // Explicit 0o600 mode so the manifest doesn't inherit the
124 // ambient umask. `hard_link` creates a second entry with
125 // the SAME inode (and same mode), so the final manifest
126 // path is 0o600 too. Bridge topology, schema refs, and
127 // `cold_uri` are not per-session data, but the same
128 // uniformity discipline the harness spool applies —
129 // durability-critical control files should not ride on
130 // the operator's umask.
131 let mut tmp_options = fs::OpenOptions::new();
132 tmp_options.write(true).create_new(true);
133 #[cfg(unix)]
134 {
135 use std::os::unix::fs::OpenOptionsExt as _;
136 tmp_options.mode(0o600);
137 }
138 let mut file = tmp_options.open(&tmp)?;
139 file.write_all(yaml.as_bytes())?;
140 file.sync_all()?;
141 }
142 match fs::hard_link(&tmp, &manifest_path) {
143 Ok(_) => {
144 // The tmp file was successfully hard-linked to the
145 // final path; the link itself is now the manifest.
146 // Delete the tmp path entry (the inode stays alive
147 // through the second link) and disarm the guard.
148 let _ = fs::remove_file(&tmp);
149 guard.disarm();
150 }
151 Err(error) => {
152 if error.kind() == std::io::ErrorKind::AlreadyExists {
153 // Loser arm: tmp is auto-removed by TmpGuard on Err.
154 // Use the basename to avoid leaking
155 // the absolute deployment dir if this BusError
156 // ever flows through a tracing::warn!(%error)
157 // path.
158 return Err(BusError::Backend(format!(
159 "bridge already provisioned at {}",
160 av_core::fsutil::basename(data_dir)
161 )));
162 }
163 return Err(BusError::Io(error));
164 }
165 }
166 av_core::fsutil::sync_directory(data_dir).map_err(BusError::Io)?;
167 // Schema copies and topic directories are written only AFTER the
168 // hard-link claim above declared this provision the single
169 // winner. Writing them before the claim (the old order) meant a
170 // provision attempt against an ALREADY-provisioned bridge
171 // overwrote the live bridge's schema files under
172 // `data_dir/<schema_ref>` before erroring "bridge already
173 // provisioned" — and `load_validators` falls back to those
174 // copies for non-builtin refs, so the next open() silently
175 // validated against the loser's schemas. It also let two racing
176 // provisions cross-pollute each other's directories.
177 //
178 // The refs themselves were resolved and validated pre-claim, so
179 // only environmental I/O errors (ENOSPC, permissions) can fail
180 // here — and those must roll the claim back, or every retry
181 // fails "bridge already provisioned" against a half-provisioned
182 // directory (the retry-clean invariant).
183 let post_claim = write_referenced_schemas(data_dir, &resolved_schemas).and_then(|()| {
184 for t in &manifest.topics {
185 av_core::fsutil::create_dir_all_synced(&data_dir.join("topics").join(&t.name))?;
186 }
187 Ok(())
188 });
189 if let Err(error) = post_claim {
190 let _ = fs::remove_file(&manifest_path);
191 let _ = av_core::fsutil::sync_directory(data_dir);
192 return Err(error);
193 }
194 Self::open(data_dir)
195 }
196
197 /// Open an existing bridge, recovering offsets (and truncating at most one
198 /// torn trailing line per partition) from the segment files.
199 pub fn open(data_dir: &Path) -> Result<Self, BusError> {
200 // Cap the bridge manifest read. A hostile plant of a
201 // multi-GiB manifest.yaml would OOM the broker at startup before
202 // the YAML parser could complain.
203 let manifest_yaml = av_core::fsutil::read_capped_string(
204 &data_dir.join("manifest.yaml"),
205 av_core::fsutil::MAX_CONTROL_BYTES,
206 )?;
207 let manifest =
208 BridgeManifest::from_yaml(&manifest_yaml).map_err(|e| BusError::Backend(e.to_string()))?;
209 // NOTE: no orphaned-temp sweep here — the daemon performs the
210 // sweep itself at boot (main.rs), where it is the single
211 // writer. `open()` mutates on-disk state during recovery
212 // (torn-tail truncate, sidecar rewrite, append handles), so it
213 // must only ever run in the single-writer daemon; read-side
214 // tooling running beside a live daemon uses
215 // [`Self::fetch_read_only`] instead.
216 let mut partitions = HashMap::new();
217 let mut torn_total = 0u64;
218 for t in &manifest.topics {
219 let dir = data_dir.join("topics").join(&t.name);
220 av_core::fsutil::create_dir_all_synced(&dir)?;
221 let mut parts = Vec::with_capacity(t.partitions as usize);
222 for p in 0..t.partitions {
223 let path = dir.join(format!("p{p}.jsonl"));
224 let watermark_path = dir.join(format!("p{p}.next-offset"));
225 let idempotency_path = dir.join(format!("p{p}.event-uids.jsonl"));
226 let persisted_offset = read_high_water(&watermark_path)?;
227 let (segment_offset, torn) = recover_segment(&path, persisted_offset)?;
228 let mut seen_event_uids = recover_event_uids(&idempotency_path)?;
229 recover_segment_event_uids(&path, &mut seen_event_uids)?;
230 // Post-reconciliation drop: any UID whose offset does
231 // not correspond to a record still present in the
232 // segment (e.g., record purged by retention but the
233 // sidecar rewrite lost the corresponding delete in a
234 // crash between segment rename and sidecar rewrite)
235 // must be evicted. Otherwise `publish_idempotent`
236 // short-circuits to a stale offset and callers fetch
237 // whatever event lives at that offset today, silently
238 // returning the wrong record.
239 //
240 // Mirror `enforce_retention`'s policy for
241 // unparseable-but-kept lines: when unparseable lines
242 // exist, any UID whose offset falls in the surviving
243 // offset range is kept, because it may correspond to an
244 // unparseable-but-authentic line at that offset.
245 // Without this parity, an unparseable segment record
246 // after a crash would drop its sidecar entry, letting
247 // the next publish_idempotent re-append a duplicate
248 // that the following retention pass would choke on.
249 // When the segment is FULLY parseable, `live_uids` is
250 // authoritative — `publish_with_uid` refuses idempotent
251 // publishes whose value lacks `metadata.uid`, so every
252 // surviving record's UID is re-derivable — and the
253 // range must not be consulted at all: keeping a
254 // non-live middle-offset UID (retention crash between
255 // segment rename and sidecar rewrite, e.g. after a
256 // wall-clock regression expired a middle record)
257 // resurrects exactly the stale-ack → wrong-record-fetch
258 // mapping the retention path abandoned this range
259 // heuristic for.
260 if !seen_event_uids.is_empty() {
261 let mut live_uids =
262 std::collections::HashSet::<String>::with_capacity(seen_event_uids.len());
263 let mut min_offset = u64::MAX;
264 let mut max_offset = 0u64;
265 let mut have_parseable_line = false;
266 let mut unparseable_lines = 0u64;
267 if path.exists() {
268 for line in BufReader::new(fs::File::open(&path)?).lines() {
269 let Ok(line) = line else {
270 continue;
271 };
272 if line.is_empty() {
273 continue;
274 }
275 if let Ok(event) = serde_json::from_str::<StoredEvent>(&line) {
276 if let Some(uid) = event_uid_from_value(&event.value) {
277 live_uids.insert(uid.to_owned());
278 }
279 min_offset = min_offset.min(event.offset);
280 max_offset = max_offset.max(event.offset);
281 have_parseable_line = true;
282 } else {
283 unparseable_lines = unparseable_lines.saturating_add(1);
284 }
285 }
286 }
287 // A TRAILING unparseable
288 // complete line occupies an offset ABOVE the max
289 // parseable one, so the previous [min, max]-of-
290 // parseable range dropped its UID — the exact
291 // duplicate-re-append case the range heuristic was
292 // added for, just at the other edge. Extend the
293 // upper bound by the count of unparseable lines
294 // (each occupies exactly one offset). When the
295 // segment has ONLY unparseable lines we have no
296 // offset information at all — keep every sidecar
297 // entry (conservative: at worst a stale ack that
298 // the fetch-side digest checks catch) rather than
299 // dropping evidence. And when there are NO
300 // unparseable lines the range is not consulted:
301 // a fully-parseable segment makes `live_uids`
302 // authoritative, so every non-live UID is
303 // definitively stale and must drop.
304 let offset_range = if unparseable_lines == 0 {
305 None
306 } else if have_parseable_line {
307 Some((min_offset, max_offset.saturating_add(unparseable_lines)))
308 } else {
309 tracing::warn!(
310 topic = %t.name,
311 partition = p,
312 unparseable_lines,
313 "segment has only unparseable lines; keeping all sidecar UID entries"
314 );
315 Some((0, u64::MAX))
316 };
317 let before = seen_event_uids.len();
318 seen_event_uids.retain(|uid, offset| {
319 if live_uids.contains(uid) {
320 return true;
321 }
322 match offset_range {
323 Some((lo, hi)) => *offset >= lo && *offset <= hi,
324 None => false,
325 }
326 });
327 if seen_event_uids.len() != before {
328 tracing::warn!(
329 topic = %t.name,
330 partition = p,
331 dropped = before - seen_event_uids.len(),
332 "sidecar UID→offset entries dropped: corresponding segment records \
333 absent (likely retention crash between segment rewrite and sidecar rewrite)"
334 );
335 }
336 }
337 torn_total += torn;
338 // Track first-time creation so we can fsync the directory
339 // after the file is materialised — `sync_data()` on the
340 // append handle flushes bytes and size, but the directory
341 // entry that names the inode is only durable after a
342 // `sync_directory(parent)`. Without this, N successful
343 // publishes → acked → power loss → boot back with the
344 // segment file absent and every acked event lost until
345 // another directory-fsyncing path runs (the retention
346 // rewrite, or the sidecar-creation fsync in
347 // `publish_with_uid`).
348 let segment_created = std::fs::symlink_metadata(&path).is_err();
349 // Same 0o600 + O_NOFOLLOW discipline the harness spool
350 // applies. Segment JSONL carries full `StoredEvent`
351 // records (instance_uid + event payload + tool/prompt
352 // bytes + cost), which is the same "session-derived
353 // data" class the harness-side R18/R19 fix protected.
354 let mut open_options = fs::OpenOptions::new();
355 open_options.create(true).append(true);
356 #[cfg(unix)]
357 {
358 use std::os::unix::fs::OpenOptionsExt as _;
359 open_options
360 .custom_flags(av_core::fsutil::unix_o_nofollow())
361 .mode(0o600);
362 }
363 let writer = open_options.open(&path)?;
364 if segment_created {
365 av_core::fsutil::sync_directory(&dir).map_err(BusError::Io)?;
366 }
367 parts.push(Mutex::new(Partition {
368 path,
369 watermark_path,
370 idempotency_path,
371 seen_event_uids,
372 next_offset: segment_offset.max(persisted_offset),
373 writer,
374 poisoned: None,
375 }));
376 }
377 partitions.insert(t.name.clone(), parts);
378 }
379 let validators = load_validators(data_dir, &manifest)?;
380 #[cfg(feature = "cold-store")]
381 let cold_archive = crate::cold_store::ColdArchive::from_manifest_with_pending_default(
382 &manifest,
383 Some(data_dir.join("cold-outbox")),
384 )?;
385 // Fail fast at boot on a scheme-URI `cold_uri` in a build without
386 // the `cold-store` feature. Previously the error only surfaced
387 // when the first record actually expired — up to `hot_hours`
388 // (default 168 h) after boot — and, because `enforce_retention`
389 // `?`-propagates out of the per-topic loop, halted retention for
390 // every topic ordered after the misconfigured one. Boot-time
391 // validation matches the fail-fast policy of every other
392 // feature-gated backend (kafka/nats/redis/onnx/qdrant).
393 #[cfg(not(feature = "cold-store"))]
394 reject_cold_uri_without_feature(&manifest)?;
395 Ok(Self {
396 data_dir: data_dir.to_owned(),
397 manifest,
398 #[cfg(feature = "cold-store")]
399 cold_archive,
400 partitions,
401 validators,
402 recovered_torn_lines: torn_total,
403 })
404 }
405
406 /// The manifest this bridge was provisioned from.
407 pub fn manifest(&self) -> &BridgeManifest {
408 &self.manifest
409 }
410
411 /// Data directory.
412 pub fn data_dir(&self) -> &Path {
413 &self.data_dir
414 }
415
416 /// Read-only event fetch for tooling running BESIDE a live daemon
417 /// (`avctl event-tail`). [`Self::open`] is NOT safe for that use:
418 /// its recovery "repairs" a torn segment tail via `set_len` and
419 /// atomically rewrites the idempotency sidecar — racing a daemon
420 /// that is mid-append (between `write_all` of the record bytes and
421 /// the trailing newline), a second process's repair truncates
422 /// bytes the daemon goes on to ack as durable: a silently lost or
423 /// corrupted acked audit event. There is no cross-process lock, so
424 /// the only safe concurrent mode is one that mutates nothing.
425 ///
426 /// This path only reads: the manifest (capped, to validate the
427 /// topic/partition and locate the segment), then a bounded
428 /// streaming scan of the one segment file. An unparseable line is
429 /// skipped (same policy as [`EventBus::fetch`]); an unterminated
430 /// trailing line — an in-flight append or crash-torn tail — is
431 /// ignored, never truncated; a record above the per-line bound
432 /// stops the scan at that point (fail-stop, mutation-free).
433 pub fn fetch_read_only(
434 data_dir: &Path,
435 topic: &str,
436 partition: u32,
437 offset: u64,
438 max: usize,
439 ) -> Result<Vec<StoredEvent>, BusError> {
440 /// Same generous per-record bound as `recover_segment`.
441 const MAX_SEGMENT_LINE_BYTES: u64 = 16 * 1024 * 1024;
442 let manifest_yaml = av_core::fsutil::read_capped_string(
443 &data_dir.join("manifest.yaml"),
444 av_core::fsutil::MAX_CONTROL_BYTES,
445 )?;
446 let manifest =
447 BridgeManifest::from_yaml(&manifest_yaml).map_err(|e| BusError::Backend(e.to_string()))?;
448 let topic_spec = manifest
449 .topics
450 .iter()
451 .find(|t| t.name == topic)
452 .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))?;
453 if partition >= topic_spec.partitions {
454 return Err(BusError::Backend(format!("partition {partition} out of range")));
455 }
456 let path = data_dir
457 .join("topics")
458 .join(topic)
459 .join(format!("p{partition}.jsonl"));
460 let file = match fs::File::open(&path) {
461 Ok(file) => file,
462 // Provisioned topic whose segment has no events yet (the
463 // daemon materialises it lazily at open/publish).
464 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
465 Err(error) => return Err(BusError::Io(error)),
466 };
467 let mut reader = std::io::BufReader::new(file);
468 let mut out = Vec::with_capacity(max.min(1024));
469 let mut buf: Vec<u8> = Vec::new();
470 while out.len() < max {
471 buf.clear();
472 let read = {
473 use std::io::Read as _;
474 let mut limited = (&mut reader).take(MAX_SEGMENT_LINE_BYTES);
475 use std::io::BufRead as _;
476 limited.read_until(b'\n', &mut buf)?
477 };
478 if read == 0 {
479 break;
480 }
481 if buf.last() != Some(&b'\n') {
482 // In-flight append, crash-torn tail, or a record above
483 // the line cap. A writer-side open() decides what to do
484 // about those; a read-only tail just stops before them.
485 break;
486 }
487 let line = buf.get(..buf.len().saturating_sub(1)).unwrap_or_default();
488 if line.is_empty() {
489 continue;
490 }
491 let ev: StoredEvent = match serde_json::from_slice(line) {
492 Ok(ev) => ev,
493 Err(error) => {
494 tracing::warn!(
495 %error,
496 path = %av_core::fsutil::basename(&path),
497 "skipping unparseable segment record during read-only fetch",
498 );
499 continue;
500 }
501 };
502 if ev.offset < offset {
503 continue;
504 }
505 out.push(ev);
506 }
507 Ok(out)
508 }
509}
510
511/// Reject a manifest that names a scheme-URI `cold_uri` when this build
512/// lacks the `cold-store` feature: retention would silently accept the
513/// value at boot and only fail on the first tick that produced expired
514/// records (up to `hot_hours` later), and — because `enforce_retention`
515/// `?`-propagates out of the per-topic loop — halt retention for every
516/// topic ordered after it. Boot-time refusal matches the fail-fast
517/// policy of every other feature-gated backend.
518#[cfg(not(feature = "cold-store"))]
519fn reject_cold_uri_without_feature(manifest: &BridgeManifest) -> Result<(), BusError> {
520 for topic in &manifest.topics {
521 if let Some(cold) = topic.retention.cold_uri.as_deref() {
522 if cold.contains("://") {
523 return Err(BusError::Backend(format!(
524 "topic {:?} configures cold_uri {cold:?} which requires feature cold-store; \
525 rebuild av-bridge with `--features cold-store` (or `cold-store-aws`) or clear \
526 the field",
527 topic.name,
528 )));
529 }
530 }
531 }
532 Ok(())
533}
534
535impl EmbeddedBroker {
536 /// Enforce per-topic hot retention at time `now_ms`: when
537 /// `retention.cold_uri` is set, each expired record
538 /// is first exported to the cold tier as its own write-once object (via the
539 /// authenticated `ColdArchive` for `scheme://` URIs, or
540 /// `write_cold_event_once` for local directory paths) before being
541 /// removed from the hot segment via atomic rewrite; with `cold_uri`
542 /// unset, expired records are dropped from the hot segment without
543 /// export. Returns the number of
544 /// records expired.
545 pub fn enforce_retention(&self, now_ms: u64) -> Result<u64, BusError> {
546 let mut expired_total = 0u64;
547 // Contain per-partition failures. A
548 // persistent error class (divergent cold intent, cold-dir
549 // permission error, content-mismatch) used to `?`-propagate out
550 // of the whole two-level loop — starving retention for that
551 // topic AND every topic after it in manifest order, forever,
552 // until disk filled. Process every partition, then surface the
553 // first error as an aggregate so the maintenance counter still
554 // fires.
555 let mut first_error: Option<(String, BusError)> = None;
556 for t in &self.manifest.topics {
557 let cutoff =
558 now_ms.saturating_sub(u64::from(t.retention.hot_hours) * av_core::units::MS_PER_HOUR);
559 let Some(parts) = self.partitions.get(&t.name) else {
560 continue;
561 };
562 for p in parts {
563 let mut part = p.lock();
564 // A poisoned partition's segment may carry torn bytes from
565 // an unrepaired append failure. Rewriting it here would
566 // preserve the partial line WITH a trailing newline —
567 // converting a restart-repairable torn tail (recover_segment
568 // truncates a no-newline tail) into a permanent corrupt
569 // line that breaks `fetch` forever. Skip until a restart
570 // re-establishes a consistent tail.
571 if let Some(reason) = &part.poisoned {
572 tracing::warn!(
573 topic = %t.name,
574 reason = %reason,
575 "skipping retention on a poisoned partition (restart to recover)"
576 );
577 continue;
578 }
579 let outcome: Result<u64, BusError> = (|| {
580 // Streaming pass 1: bail with zero allocation when
581 // nothing expired (the common hourly case). Offsets are
582 // kept for the post-rename idempotency-map prune.
583 let expired_offsets = expired_offsets(&part.path, cutoff)?;
584 if expired_offsets.is_empty() {
585 return Ok(0);
586 }
587 let expired_count = expired_offsets.len() as u64;
588 // Cold export first (never destroy before the copy lands).
589 // Streaming pass 2: export each expired line as it is
590 // read — no whole-segment buffering.
591 if let Some(cold) = &t.retention.cold_uri {
592 if cold.contains("://") {
593 #[cfg(feature = "cold-store")]
594 {
595 let archive = self.cold_archive.as_ref().ok_or_else(|| {
596 BusError::Backend(format!("cold archive for {:?} is unavailable", t.name))
597 })?;
598 for_each_segment_line(&part.path, cutoff, |line, is_expired| {
599 if is_expired {
600 let event: StoredEvent = serde_json::from_str(line)?;
601 archive.put(&t.name, &event)?;
602 }
603 Ok(())
604 })?;
605 }
606 #[cfg(not(feature = "cold-store"))]
607 return Err(BusError::Backend(format!(
608 "cold_uri {cold:?} requires feature cold-store"
609 )));
610 } else {
611 let partition = part
612 .path
613 .file_stem()
614 .and_then(std::ffi::OsStr::to_str)
615 .unwrap_or("partition");
616 let cold_dir = Path::new(cold).join(&t.name).join(partition);
617 // Sync newly created ancestors too: the hot rewrite
618 // below destroys the only other copy, so the cold
619 // subtree's dirents must be durable before it runs.
620 av_core::fsutil::create_dir_all_synced(&cold_dir)?;
621 for_each_segment_line(&part.path, cutoff, |line, is_expired| {
622 if is_expired {
623 let event: StoredEvent = serde_json::from_str(line)?;
624 write_cold_event_once(&cold_dir, &event)?;
625 }
626 Ok(())
627 })?;
628 av_core::fsutil::sync_directory(&cold_dir)?;
629 }
630 }
631 persist_high_water(&part.watermark_path, part.next_offset)?;
632 // Atomic hot rewrite. Use a UUID-suffixed tmp name so a
633 // stale tmp from a prior crashed pass isn't reused (and
634 // an external backup/rsync tool can't grab an
635 // in-progress file thinking it's stable data).
636 let tmp = part
637 .path
638 .with_extension(format!("jsonl.{}.tmp", av_core::new_event_uid()));
639 // RAII guard cleans up the tmp on any early
640 // Err in the write/sync/rename path so a repeatedly-
641 // failing rewrite (ENOSPC/EIO) does not fill the inode
642 // table with UUID-suffixed orphan .tmp files.
643 let mut guard = av_core::fsutil::TempPathGuard::new(tmp.clone());
644 {
645 // Explicit 0o600 mode so the retention rewrite
646 // doesn't silently ratchet segment permissions
647 // back to 0o644 under the ambient umask — the
648 // previous `File::create` recipe would land at
649 // 0o644 on default-umask hosts and, because
650 // `rename` preserves the tmp's mode, every
651 // retention pass re-opened the confidentiality
652 // gap the initial 0o600 append open closes.
653 let mut tmp_options = fs::OpenOptions::new();
654 tmp_options.write(true).create_new(true);
655 #[cfg(unix)]
656 {
657 use std::os::unix::fs::OpenOptionsExt as _;
658 tmp_options.mode(0o600);
659 }
660 let mut f = tmp_options.open(&tmp)?;
661 // Streaming pass 3: kept lines flow straight to the
662 // tmp segment.
663 {
664 use std::io::Write as _;
665 let mut writer = std::io::BufWriter::new(&mut f);
666 for_each_segment_line(&part.path, cutoff, |line, is_expired| {
667 if !is_expired {
668 writer.write_all(line.as_bytes())?;
669 writer.write_all(b"\n")?;
670 }
671 Ok(())
672 })?;
673 writer.flush()?;
674 }
675 f.sync_all()?;
676 }
677 fs::rename(&tmp, &part.path)?;
678 guard.disarm();
679 // Everything between the rename above and a successful
680 // writer reopen below runs while `part.writer` still holds
681 // the fd of the pre-rewrite (now unlinked) inode. If any of
682 // it fails, propagating the error WITHOUT poisoning the
683 // partition would leave that stale handle live: every
684 // subsequent publish would append to the unlinked inode —
685 // acked as durable, invisible to `fetch`, and irrecoverably
686 // freed at process exit. Poison the partition on any
687 // post-rename failure so appends fail closed until a
688 // restart reopens the renamed segment (same fail-closed
689 // posture as an unrepaired append truncation).
690 let post_rename: Result<(), BusError> = (|| {
691 if let Some(parent) = part.path.parent() {
692 av_core::fsutil::sync_directory(parent)?;
693 }
694 // Prune the idempotency map + sidecar of any UID whose offset
695 // was expired: without this, a subsequent `publish_idempotent`
696 // with a still-cached UID returns an ack pointing at data that
697 // no longer exists, and the caller's follow-up `fetch(offset)`
698 // silently returns the wrong event or nothing.
699 //
700 // Expired offsets were collected in streaming pass 1
701 // (only parseable lines expire, so the set covers
702 // exactly the removed records). The previous
703 // survivors+[min,max] range heuristic was unsound at
704 // both edges: a wall-clock regression could expire a
705 // MIDDLE-offset record whose UID then survived
706 // pruning (stale ack → wrong-record fetch), and a
707 // trailing unparseable kept line's offset fell
708 // OUTSIDE the range so its UID was dropped
709 // (duplicate re-append on the next
710 // publish_idempotent).
711 let before = part.seen_event_uids.len();
712 part.seen_event_uids
713 .retain(|_, offset| !expired_offsets.contains(offset));
714 if part.seen_event_uids.len() != before {
715 // Sidecar is now stale — rewrite it atomically with only
716 // the surviving mappings so recovery cannot resurrect a
717 // just-expired UID.
718 let mut lines: Vec<(String, u64)> = part
719 .seen_event_uids
720 .iter()
721 .map(|(uid, offset)| (uid.clone(), *offset))
722 .collect();
723 lines.sort_by_key(|(_, offset)| *offset);
724 let mut sidecar = Vec::new();
725 for (uid, offset) in lines {
726 let mapping = serde_json::to_string(&EventUidOffset {
727 event_uid: uid,
728 offset,
729 })?;
730 sidecar.extend_from_slice(mapping.as_bytes());
731 sidecar.push(b'\n');
732 }
733 rewrite_atomic(&part.idempotency_path, &sidecar)?;
734 }
735 // Segment reopen after retention rewrite. Use
736 // O_NOFOLLOW so a co-tenant that raced the
737 // rename window with a symlink plant cannot
738 // hijack the next authenticated append (a
739 // spool-plant confused-deputy). No create ⇒
740 // 0o600 mode is decorative but preserved for
741 // uniformity with the initial-open site.
742 let mut reopen = fs::OpenOptions::new();
743 reopen.append(true);
744 #[cfg(unix)]
745 {
746 use std::os::unix::fs::OpenOptionsExt as _;
747 reopen
748 .custom_flags(av_core::fsutil::unix_o_nofollow())
749 .mode(0o600);
750 }
751 part.writer = reopen.open(&part.path)?;
752 Ok(())
753 })();
754 if let Err(error) = post_rename {
755 part.poisoned = Some(format!(
756 "retention post-rename failure ({error}); the append handle may still \
757 reference the replaced segment inode (restart to recover)"
758 ));
759 return Err(error);
760 }
761 Ok(expired_count)
762 })();
763 match outcome {
764 Ok(count) => expired_total += count,
765 Err(error) => {
766 tracing::warn!(
767 topic = %t.name,
768 %error,
769 "retention failed for this partition; remaining topics/partitions continue"
770 );
771 if first_error.is_none() {
772 first_error = Some((t.name.clone(), error));
773 }
774 }
775 }
776 }
777 }
778 match first_error {
779 Some((topic, error)) => Err(BusError::Backend(format!(
780 "retention failed for topic {topic:?} (remaining topics were still processed, \
781 {expired_total} records expired): {error}"
782 ))),
783 None => Ok(expired_total),
784 }
785 }
786}
787
788fn write_cold_event_once(directory: &Path, event: &StoredEvent) -> Result<(), BusError> {
789 let path = directory.join(format!("{:020}.json", event.offset));
790 let bytes = serde_json::to_vec(event)?;
791 if path.exists() {
792 return if fs::read(&path)? == bytes {
793 // Idempotent re-export (e.g. a retention pass that crashed
794 // after the cold copy landed but before the hot rewrite).
795 Ok(())
796 } else {
797 // Path-leak hazard: `write_cold_event_once`
798 // errors bubble up through `enforce_retention` and can
799 // reach a
800 // tracing::warn!(%error) on the maintenance path.
801 // Basename the absolute cold-destination path so a
802 // duplicate-object collision doesn't ship the
803 // deployment topology to OTLP.
804 Err(BusError::Backend(format!(
805 "cold object {} already exists with different content",
806 av_core::fsutil::basename(&path)
807 )))
808 };
809 }
810 // Crash-atomic write-once. Writing directly at the final name
811 // (create_new → write_all → sync_all) meant a crash/ENOSPC mid-write
812 // left a torn file there, and every subsequent retention pass hit
813 // AlreadyExists + content mismatch → enforce_retention aborted for
814 // all topics, forever. Stage in a UUID-suffixed tmp, fsync, then
815 // rename — the final path only ever holds complete objects (same
816 // shape as `rewrite_atomic`; the caller fsyncs the directory after
817 // the batch). True conflicts are still detected by the pre-existing
818 // content comparison above.
819 let tmp = path.with_extension(format!("json.{}.tmp", av_core::new_event_uid()));
820 let mut guard = av_core::fsutil::TempPathGuard::new(tmp.clone());
821 {
822 // Explicit 0o600 mode — cold objects carry full `StoredEvent`
823 // records (same session-data class as the hot segment). The
824 // previous `File::create` recipe landed at 0o644 under the
825 // ambient umask; `rename` preserves the tmp's mode, so cold
826 // storage inherited world-readable posture without
827 // announcement.
828 let mut tmp_options = fs::OpenOptions::new();
829 tmp_options.write(true).create_new(true);
830 #[cfg(unix)]
831 {
832 use std::os::unix::fs::OpenOptionsExt as _;
833 tmp_options.mode(0o600);
834 }
835 let mut file = tmp_options.open(&tmp)?;
836 file.write_all(&bytes)?;
837 file.sync_all()?;
838 }
839 fs::rename(&tmp, &path)?;
840 guard.disarm();
841 Ok(())
842}
843
844/// Pre-claim phase: resolve and schema-validate every referenced schema
845/// without touching the filesystem, so a bad `schema_ref` fails provision
846/// retry-clean (before the single-winner claim).
847fn resolve_referenced_schemas(
848 manifest: &BridgeManifest,
849) -> Result<Vec<(String, serde_json::Value)>, BusError> {
850 let mut resolved = Vec::new();
851 for topic in &manifest.topics {
852 let Some(reference) = &topic.schema_ref else {
853 continue;
854 };
855 let value = crate::manifest::schema_document(reference)?;
856 jsonschema::options()
857 .should_validate_formats(true)
858 .build(&value)
859 .map_err(|error| BusError::Backend(format!("invalid schema {reference:?}: {error}")))?;
860 resolved.push((reference.clone(), value));
861 }
862 Ok(resolved)
863}
864
865/// Post-claim phase: persist the pre-resolved schema copies.
866fn write_referenced_schemas(
867 data_dir: &Path,
868 resolved: &[(String, serde_json::Value)],
869) -> Result<(), BusError> {
870 for (reference, value) in resolved {
871 let destination = data_dir.join(reference);
872 if let Some(parent) = destination.parent() {
873 av_core::fsutil::create_dir_all_synced(parent)?;
874 }
875 // Fsync-safe atomic replace: a crash mid-`fs::write` (the old
876 // shape) left torn JSON that made the `load_validators` disk
877 // fallback fail `open()` permanently for non-builtin refs.
878 rewrite_atomic(&destination, &serde_json::to_vec_pretty(value)?)?;
879 }
880 Ok(())
881}
882
883fn load_validators(
884 data_dir: &Path,
885 manifest: &BridgeManifest,
886) -> Result<HashMap<String, jsonschema::Validator>, BusError> {
887 let mut validators = HashMap::new();
888 for topic in &manifest.topics {
889 let Some(reference) = &topic.schema_ref else {
890 continue;
891 };
892 let schema = crate::manifest::schema_document(reference).or_else(|_| {
893 // Cap the schema file read. Schemas are control-
894 // plane data (bounded well below 1 MiB in practice); a hostile
895 // plant of a multi-GiB schema file would OOM the broker before
896 // the JSON parser could complain.
897 let bytes =
898 av_core::fsutil::read_capped(&data_dir.join(reference), av_core::fsutil::MAX_CONTROL_BYTES)?;
899 serde_json::from_slice(&bytes).map_err(BusError::from)
900 })?;
901 let validator = jsonschema::options()
902 .should_validate_formats(true)
903 .build(&schema)
904 .map_err(|error| BusError::Backend(format!("invalid schema {reference:?}: {error}")))?;
905 validators.insert(topic.name.clone(), validator);
906 }
907 Ok(validators)
908}
909
910/// Count complete lines; truncate a torn trailing line if present.
911///
912/// Streams the segment (same pattern as
913/// `recover_event_uids`) instead of `fs::read`-ing it whole: segments
914/// grow with accepted event traffic and are only time-bounded by
915/// retention, so recovery of a large partition otherwise loaded an
916/// arbitrarily large file fully into memory — the one unbounded
917/// allocation left in startup recovery. Each line is capped; a line
918/// above the cap fails the open loudly (fail closed, like a corrupt
919/// watermark) rather than risking the allocation.
920fn recover_segment(path: &Path, persisted_offset: u64) -> Result<(u64, u64), BusError> {
921 /// Generous per-record bound: the harness caps request bodies at
922 /// 16 MiB, so no legitimately published event line approaches this.
923 const MAX_SEGMENT_LINE_BYTES: u64 = 16 * 1024 * 1024;
924 use std::io::BufRead as _;
925 let file = match fs::File::open(path) {
926 Ok(file) => file,
927 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((0, 0)),
928 Err(error) => return Err(BusError::Io(error)),
929 };
930 let mut reader = std::io::BufReader::new(file);
931 let mut buf: Vec<u8> = Vec::new();
932 let mut complete_len: u64 = 0;
933 let mut next_offset = 0u64;
934 let mut parseable_lines = 0u64;
935 let mut unparseable_lines = 0u64;
936 let mut torn = false;
937 loop {
938 buf.clear();
939 let read = {
940 use std::io::Read as _;
941 let mut limited = (&mut reader).take(MAX_SEGMENT_LINE_BYTES);
942 limited.read_until(b'\n', &mut buf)?
943 };
944 if read == 0 {
945 break;
946 }
947 if buf.last() != Some(&b'\n') {
948 if read as u64 == MAX_SEGMENT_LINE_BYTES {
949 return Err(BusError::Backend(format!(
950 "segment {} contains a record above {MAX_SEGMENT_LINE_BYTES} bytes; refusing to open the partition",
951 av_core::fsutil::basename(path)
952 )));
953 }
954 // Torn trailing line (crash mid-append).
955 torn = true;
956 break;
957 }
958 complete_len = complete_len.saturating_add(read as u64);
959 let line = buf.get(..buf.len().saturating_sub(1)).unwrap_or_default();
960 if line.is_empty() {
961 continue;
962 }
963 match serde_json::from_slice::<StoredEvent>(line) {
964 Ok(event) => {
965 parseable_lines = parseable_lines.saturating_add(1);
966 next_offset = next_offset.max(event.offset.saturating_add(1));
967 }
968 // A single unparseable middle line must not brick the broker.
969 // The corrupted bytes are left on disk as forensic evidence;
970 // subsequent publishes append past the surviving max offset.
971 // The line still occupies one logical offset (segment lines are
972 // appended in offset order), so advance next_offset past it —
973 // computing next_offset from parseable lines only let a corrupt
974 // *trailing* line regress next_offset, and the next publish
975 // reused that line's live offset (offset collision in the
976 // audit stream).
977 Err(error) => {
978 unparseable_lines = unparseable_lines.saturating_add(1);
979 next_offset = next_offset.saturating_add(1);
980 tracing::warn!(
981 %error,
982 path = %av_core::fsutil::basename(path),
983 "skipping unparseable segment record during recovery",
984 );
985 }
986 }
987 }
988 // Unparseable lines advance `next_offset` RELATIVE to the running
989 // value while parseable lines anchor it to the ABSOLUTE offsets
990 // stored in the records. Segment lines are appended in contiguous
991 // offset order, so mixing the two is exact whenever at least one
992 // line parses — but a segment with ONLY unparseable complete lines
993 // yields a bare line count with no anchor. When retention has ever
994 // rewritten this segment (persisted watermark > 0) the true first
995 // offset is unknowable: `max(count, watermark)` masks any records
996 // appended after the last watermark persist, and the next publish
997 // would re-stamp an acknowledged offset (offset collision in the
998 // audit stream, with the stale sidecar UID re-homing acks onto the
999 // new record). Fail closed like the oversized-record arm above —
1000 // the operator moves the segment aside for forensics and the
1001 // partition reopens at the watermark. A never-rewritten segment
1002 // (watermark 0) starts at absolute offset 0, so the count IS the
1003 // anchor and recovery stays exact.
1004 if unparseable_lines > 0 && parseable_lines == 0 && persisted_offset > 0 {
1005 return Err(BusError::Backend(format!(
1006 "segment {} contains only unparseable records ({unparseable_lines}) but the \
1007 partition has a retention watermark ({persisted_offset}); absolute offsets are \
1008 unrecoverable — refusing to open the partition (move the segment aside to repair)",
1009 av_core::fsutil::basename(path)
1010 )));
1011 }
1012 if torn {
1013 // In-place truncate + fsync: unlike the old whole-file
1014 // `rewrite_atomic` (which required buffering the entire segment),
1015 // `set_len` touches only file metadata — a crash mid-truncate
1016 // leaves either the torn or the truncated length, never an
1017 // empty/renamed-away segment, so the total-segment-loss hazard
1018 // the old comment guarded against cannot occur.
1019 //
1020 // O_NOFOLLOW: this reopens a path derived from a broker
1021 // partition name; a pre-planted symlink at that path would
1022 // otherwise let a co-tenant redirect the `set_len` +
1023 // `sync_all` to an arbitrary file the daemon UID can touch
1024 // (canonical confused-deputy).
1025 let mut reopen = fs::OpenOptions::new();
1026 reopen.write(true);
1027 #[cfg(unix)]
1028 {
1029 use std::os::unix::fs::OpenOptionsExt as _;
1030 reopen.custom_flags(av_core::fsutil::unix_o_nofollow());
1031 }
1032 let file = reopen.open(path)?;
1033 file.set_len(complete_len)?;
1034 file.sync_all()?;
1035 }
1036 Ok((next_offset, u64::from(torn)))
1037}
1038
1039fn recover_event_uids(path: &Path) -> Result<HashMap<String, u64>, BusError> {
1040 if !path.exists() {
1041 return Ok(HashMap::new());
1042 }
1043 // Stream the sidecar with BufReader rather than
1044 // reading it whole and capping at MAX_ATIF_BYTES. The sidecar grows
1045 // one line per idempotent publish, compacted only when retention
1046 // expires UIDs — at 30-day default hot retention, ~1M live events
1047 // per partition (≈0.4 ev/s) crosses 64 MiB. The previous hard cap
1048 // would then fail `open()` on every restart, permanently bricking
1049 // the broker even though the segment on disk is authoritative and
1050 // `recover_segment_event_uids` will fix the map either way. Bound
1051 // per-line via `read_line` (line length is only limited by
1052 // `EventUidOffset`'s JSON encoding at ~200 B); an oversize line is
1053 // treated as sidecar corruption and skipped, matching the parse
1054 // failure discipline just below.
1055 let file = std::fs::File::open(path)?;
1056 let mut reader = std::io::BufReader::new(file);
1057 let mut seen = HashMap::new();
1058 let mut line_buffer = Vec::new();
1059 let mut valid_bytes: u64 = 0;
1060 let mut has_torn_tail = false;
1061 let mut logged_oversize_line = false;
1062 // Sibling once-per-file guards for the parse-failure and
1063 // duplicate-UID branches below. A crashed or hostile writer can
1064 // leave sidecars with millions of mangled-JSON lines (up to
1065 // hot-retention × events/partition, ≈ 1M per partition); without
1066 // per-branch guards the recovery pass would emit one warn per
1067 // corrupt line during the very restart it's diagnosing, bricking
1068 // the log pipeline. Same discipline as `logged_oversize_line`
1069 // above.
1070 let mut logged_parse_failure = false;
1071 let mut logged_duplicate_uid = false;
1072 // A single oversized-line hard cap: 4 KiB is 20x the fattest
1073 // sidecar record we produce. The reader is `take`-bounded to the
1074 // cap (+1 to detect overshoot) so a planted multi-GiB single line
1075 // cannot grow the buffer to file size; the overflow tail is
1076 // drained in cap-sized chunks and the whole line is skipped,
1077 // matching the parse-failure discipline below.
1078 const MAX_SIDECAR_LINE: usize = 4096;
1079 loop {
1080 line_buffer.clear();
1081 let bytes_read = {
1082 use std::io::Read as _;
1083 let mut limited = (&mut reader).take(MAX_SIDECAR_LINE as u64 + 1);
1084 match limited.read_until(b'\n', &mut line_buffer) {
1085 Ok(0) => break,
1086 Ok(bytes) => bytes,
1087 Err(error) => return Err(BusError::Io(error)),
1088 }
1089 };
1090 let terminated = line_buffer.last() == Some(&b'\n');
1091 if !terminated && bytes_read > MAX_SIDECAR_LINE {
1092 // Oversized line cut off by the `take` bound: drain the
1093 // rest of it in bounded chunks so the skip stays O(cap)
1094 // in memory whatever the line length on disk.
1095 let mut drained: u64 = bytes_read as u64;
1096 let mut found_newline = false;
1097 loop {
1098 line_buffer.clear();
1099 use std::io::Read as _;
1100 let mut limited = (&mut reader).take(MAX_SIDECAR_LINE as u64 + 1);
1101 match limited.read_until(b'\n', &mut line_buffer) {
1102 Ok(0) => break,
1103 Ok(bytes) => {
1104 drained = drained.saturating_add(bytes as u64);
1105 if line_buffer.last() == Some(&b'\n') {
1106 found_newline = true;
1107 break;
1108 }
1109 }
1110 Err(error) => return Err(BusError::Io(error)),
1111 }
1112 }
1113 if !found_newline {
1114 // Oversized AND unterminated: the classic torn tail,
1115 // just fat. Truncate to the last complete line.
1116 has_torn_tail = true;
1117 break;
1118 }
1119 if !logged_oversize_line {
1120 tracing::warn!(
1121 path = %av_core::fsutil::basename(path),
1122 line_bytes = drained,
1123 "oversized event-uid sidecar record skipped during recovery"
1124 );
1125 logged_oversize_line = true;
1126 }
1127 valid_bytes = valid_bytes.saturating_add(drained);
1128 continue;
1129 }
1130 if !terminated {
1131 // Trailing partial line (crash between newline appends);
1132 // the atomic-append discipline in `publish_with_uid`
1133 // guarantees any complete line is fully synced before the
1134 // next one starts, so this is the classic torn tail.
1135 has_torn_tail = true;
1136 break;
1137 }
1138 if bytes_read > MAX_SIDECAR_LINE {
1139 if !logged_oversize_line {
1140 tracing::warn!(
1141 path = %av_core::fsutil::basename(path),
1142 line_bytes = bytes_read,
1143 "oversized event-uid sidecar record skipped during recovery"
1144 );
1145 logged_oversize_line = true;
1146 }
1147 valid_bytes = valid_bytes.saturating_add(bytes_read as u64);
1148 continue;
1149 }
1150 valid_bytes = valid_bytes.saturating_add(bytes_read as u64);
1151 let payload = line_buffer.strip_suffix(b"\n").unwrap_or(&line_buffer);
1152 if payload.is_empty() {
1153 continue;
1154 }
1155 let mapping: EventUidOffset = match serde_json::from_slice(payload) {
1156 Ok(mapping) => mapping,
1157 // Sidecar corruption skip-and-log: the segment is the
1158 // ground truth (see `recover_segment_event_uids` below), and
1159 // an unreadable idempotency line at most costs a duplicate
1160 // ack for the same UID.
1161 Err(error) => {
1162 if !logged_parse_failure {
1163 tracing::warn!(
1164 %error,
1165 path = %av_core::fsutil::basename(path),
1166 "skipping unparseable event-uid sidecar record during recovery; \
1167 subsequent parse failures on this sidecar suppressed to avoid log storm"
1168 );
1169 logged_parse_failure = true;
1170 }
1171 continue;
1172 }
1173 };
1174 if let Some(existing) = seen.insert(mapping.event_uid.clone(), mapping.offset) {
1175 if existing != mapping.offset {
1176 // Sidecar (idempotency journal) can legitimately hold
1177 // stale UID→offset pairs after a partial retention
1178 // (segment rewritten atomically, sidecar rewrite lost
1179 // in a crash). The segment on disk is the source of
1180 // truth; log and let `recover_segment_event_uids` fix
1181 // the mapping. Refusing to open the broker here would
1182 // brick the whole tier over a benign inconsistency.
1183 if !logged_duplicate_uid {
1184 tracing::warn!(
1185 event_uid = %mapping.event_uid,
1186 prior_offset = existing,
1187 current_offset = mapping.offset,
1188 path = %av_core::fsutil::basename(path),
1189 "sidecar has duplicate UID entry; segment offset will win after \
1190 full recovery. Subsequent duplicate-UID entries on this sidecar \
1191 suppressed to avoid log storm"
1192 );
1193 logged_duplicate_uid = true;
1194 }
1195 }
1196 }
1197 }
1198 if has_torn_tail {
1199 // Truncate to the last complete line so future appends land
1200 // cleanly (the on-disk record set is unchanged). A transient
1201 // I/O error on `read_valid_prefix` (bit-rot, ENOENT race
1202 // with a concurrent unlink between the earlier BufReader
1203 // open and this reopen) must NOT collapse to an
1204 // `unwrap_or_default()` empty Vec — that atomically renames
1205 // a zero-byte file on top of the sidecar, permanently
1206 // destroying every persisted `EventUidOffset` mapping under
1207 // the guise of "torn-tail repair". Fail closed instead —
1208 // the caller retries recovery on the next daemon boot with
1209 // the sidecar bytes still intact. Same posture as
1210 // `read_high_water` (line 1132), which explicitly refused
1211 // its old "fail-open Ok(0)" for the identical rationale.
1212 let valid_prefix = read_valid_prefix(path, valid_bytes).map_err(BusError::Io)?;
1213 rewrite_atomic(path, &valid_prefix)?;
1214 }
1215 Ok(seen)
1216}
1217
1218fn read_valid_prefix(path: &Path, len: u64) -> std::io::Result<Vec<u8>> {
1219 use std::io::Read as _;
1220 let file = std::fs::File::open(path)?;
1221 let mut reader = file.take(len);
1222 let mut out = Vec::with_capacity(len.try_into().unwrap_or(usize::MAX));
1223 reader.read_to_end(&mut out)?;
1224 Ok(out)
1225}
1226
1227fn recover_segment_event_uids(path: &Path, seen: &mut HashMap<String, u64>) -> Result<(), BusError> {
1228 if !path.exists() {
1229 return Ok(());
1230 }
1231 for line in BufReader::new(fs::File::open(path)?).lines() {
1232 let line = line?;
1233 if line.is_empty() {
1234 continue;
1235 }
1236 let event: StoredEvent = match serde_json::from_str(&line) {
1237 Ok(event) => event,
1238 // Same skip-and-log policy as `recover_segment`: an unreadable
1239 // segment record has already been flagged there; here it just
1240 // means we cannot reconstruct its UID → offset mapping.
1241 Err(error) => {
1242 tracing::warn!(
1243 %error,
1244 path = %av_core::fsutil::basename(path),
1245 "skipping unparseable segment record while rebuilding UID index",
1246 );
1247 continue;
1248 }
1249 };
1250 let Some(uid) = event_uid_from_value(&event.value) else {
1251 continue;
1252 };
1253 if let Some(existing) = seen.insert(uid.to_owned(), event.offset) {
1254 if existing != event.offset {
1255 // Segment is authoritative — the sidecar was stale
1256 // (see recover_event_uids for the crash mode). Log
1257 // and keep the segment offset (the last write wins
1258 // via insert). Refusing to open would leave the
1259 // broker un-openable on a benign inconsistency.
1260 tracing::warn!(
1261 event_uid = uid,
1262 prior_offset = existing,
1263 current_offset = event.offset,
1264 "duplicate UID between sidecar and segment; segment offset wins"
1265 );
1266 }
1267 }
1268 }
1269 Ok(())
1270}
1271
1272/// Fsync-safe replace. Do not hand-roll the atomic-write recipe here:
1273/// a hand-rolled copy diverged from the
1274/// canonical helper by using `File::create` instead of `create_new`.
1275/// Delegate to `av_core::fsutil::write_atomic` — one recipe, one set
1276/// of durability invariants (create_new tmp, sync_all, rename,
1277/// best-effort parent dirent fsync, RAII orphan cleanup).
1278fn rewrite_atomic(path: &Path, bytes: &[u8]) -> Result<(), BusError> {
1279 av_core::fsutil::write_atomic(path, bytes).map_err(BusError::from)
1280}
1281
1282fn event_uid_from_value(value: &serde_json::Value) -> Option<&str> {
1283 value
1284 .get("metadata")
1285 .and_then(|metadata| metadata.get("uid"))
1286 .and_then(serde_json::Value::as_str)
1287}
1288
1289fn read_high_water(path: &Path) -> Result<u64, BusError> {
1290 // A watermark is at most u64 in decimal (~20 chars). Cap
1291 // the read so a hostile plant of a giant p<N>.next-offset cannot OOM
1292 // the broker at startup. Use MAX_CONTROL_BYTES (1 MiB) for the
1293 // shared trust boundary; a real watermark is orders of magnitude
1294 // smaller.
1295 match av_core::fsutil::read_capped_string(path, av_core::fsutil::MAX_CONTROL_BYTES) {
1296 Ok(value) => match value.trim().parse::<u64>() {
1297 Ok(offset) => Ok(offset),
1298 // A corrupt watermark file IS fatal. The old fail-open
1299 // fallback (`Ok(0)`) claimed the segment is authoritative
1300 // and "the next successful publish rewrites the watermark
1301 // and self-heals" — both false: `persist_high_water` runs
1302 // only from `enforce_retention`, and the watermark exists
1303 // precisely for the case where retention has emptied the
1304 // segment. Falling back to 0 there restarts `next_offset`
1305 // at 0, reusing offsets of already-cold-exported records
1306 // (offset collisions in the audit stream, permanent
1307 // "cold object … already exists with different content"
1308 // conflicts). Fail closed like every other corrupt
1309 // control file in this crate; NotFound below stays Ok(0)
1310 // because a missing watermark is a legitimate fresh state.
1311 Err(error) => Err(BusError::Backend(format!(
1312 "high-watermark file {} is corrupt ({error}); refusing to open the partition",
1313 av_core::fsutil::basename(path)
1314 ))),
1315 },
1316 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(0),
1317 Err(error) => Err(BusError::Io(error)),
1318 }
1319}
1320
1321fn persist_high_water(path: &Path, next_offset: u64) -> Result<(), BusError> {
1322 let parent = path
1323 .parent()
1324 .ok_or_else(|| BusError::Backend("high-watermark has no parent".to_owned()))?;
1325 let temporary = path.with_extension(format!("{}.tmp", av_core::new_event_uid()));
1326 // RAII cleanup on any early Err — same discipline as
1327 // `rewrite_atomic` above. On a bad-disk day, watermark writes fire
1328 // on every publish; orphan tmp accumulation would be fastest here.
1329 let mut guard = av_core::fsutil::TempPathGuard::new(temporary.clone());
1330 {
1331 // Explicit 0o600 mode — the watermark leaks throughput
1332 // pattern (a next-offset u64), which is minor per-file but
1333 // adds up over a busy broker. Cheap uniformity with the
1334 // segment/sidecar posture; removes umask-dependence from
1335 // the durability-critical control files.
1336 let mut tmp_options = fs::OpenOptions::new();
1337 tmp_options.write(true).create_new(true);
1338 #[cfg(unix)]
1339 {
1340 use std::os::unix::fs::OpenOptionsExt as _;
1341 tmp_options.mode(0o600);
1342 }
1343 let mut file = tmp_options.open(&temporary)?;
1344 file.write_all(next_offset.to_string().as_bytes())?;
1345 file.sync_all()?;
1346 }
1347 fs::rename(&temporary, path)?;
1348 guard.disarm();
1349 av_core::fsutil::sync_directory(parent)?;
1350 Ok(())
1351}
1352
1353/// Partition a segment's lines into (kept, expired) by `stored_at < cutoff`.
1354/// Stream every line of a segment through `visit(line, is_expired)`,
1355/// with per-line buffers bounded at 16 MiB (the segment line cap used by
1356/// the read paths). Replaces the old `split_by_time`, which buffered the
1357/// ENTIRE segment into two `Vec<String>`s on every hourly retention pass
1358/// — even when nothing had expired — so a large hot partition (or one
1359/// hostile giant line, unbounded under `BufRead::lines`) could OOM the
1360/// maintenance task. Lines that fail to parse as `StoredEvent` are
1361/// treated as unexpired, exactly like the old shape. A line at the cap
1362/// with no newline is refused as corrupt (the partition lock is held, so
1363/// there is no in-flight append to race).
1364fn for_each_segment_line(
1365 path: &Path,
1366 cutoff_ms: u64,
1367 mut visit: impl FnMut(&str, bool) -> Result<(), BusError>,
1368) -> Result<(), BusError> {
1369 const MAX_SEGMENT_LINE_BYTES: u64 = 16 * 1024 * 1024;
1370 if !path.exists() {
1371 return Ok(());
1372 }
1373 let mut reader = BufReader::new(fs::File::open(path)?);
1374 let mut buf: Vec<u8> = Vec::new();
1375 loop {
1376 buf.clear();
1377 let read = {
1378 use std::io::BufRead as _;
1379 use std::io::Read as _;
1380 let mut limited = (&mut reader).take(MAX_SEGMENT_LINE_BYTES);
1381 limited.read_until(b'\n', &mut buf)?
1382 };
1383 if read == 0 {
1384 return Ok(());
1385 }
1386 if buf.last() != Some(&b'\n') && read as u64 >= MAX_SEGMENT_LINE_BYTES {
1387 return Err(BusError::Backend(format!(
1388 "segment line exceeds the {MAX_SEGMENT_LINE_BYTES}-byte cap in {}",
1389 path.display()
1390 )));
1391 }
1392 let line = std::str::from_utf8(&buf)
1393 .map_err(|error| BusError::Backend(format!("segment line is not UTF-8: {error}")))?
1394 .trim_end_matches('\n');
1395 if line.is_empty() {
1396 continue;
1397 }
1398 let is_expired = serde_json::from_str::<StoredEvent>(line)
1399 .map(|e| e.stored_at < cutoff_ms)
1400 .unwrap_or(false);
1401 visit(line, is_expired)?;
1402 }
1403}
1404
1405/// Streaming pass 1 of retention: collect the OFFSETS of expired
1406/// records (for the idempotency-map prune) without buffering lines —
1407/// 8 bytes per expired record instead of the whole segment.
1408fn expired_offsets(path: &Path, cutoff_ms: u64) -> Result<std::collections::HashSet<u64>, BusError> {
1409 let mut offsets = std::collections::HashSet::new();
1410 for_each_segment_line(path, cutoff_ms, |line, is_expired| {
1411 if is_expired {
1412 if let Ok(event) = serde_json::from_str::<StoredEvent>(line) {
1413 offsets.insert(event.offset);
1414 }
1415 }
1416 Ok(())
1417 })?;
1418 Ok(offsets)
1419}
1420
1421impl EmbeddedBroker {
1422 fn publish_with_uid(
1423 &self,
1424 topic: &str,
1425 key: &str,
1426 value: &serde_json::Value,
1427 event_uid: Option<&str>,
1428 ) -> Result<PublishAck, BusError> {
1429 let parts = self
1430 .partitions
1431 .get(topic)
1432 .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))?;
1433 if let Some(validator) = self.validators.get(topic) {
1434 let errors: Vec<String> = validator
1435 .iter_errors(value)
1436 .take(3)
1437 .map(|error| error.to_string())
1438 .collect();
1439 if !errors.is_empty() {
1440 return Err(BusError::Backend(format!(
1441 "event rejected by schema for topic {topic:?}: {}",
1442 errors.join("; ")
1443 )));
1444 }
1445 }
1446 if let Some(expected) = event_uid {
1447 match event_uid_from_value(value) {
1448 Some(actual) if expected != actual => {
1449 return Err(BusError::Backend(
1450 "idempotency UID does not match event metadata UID".to_owned(),
1451 ));
1452 }
1453 // A missing metadata.uid would make crash recovery lose the UID
1454 // → sidecar because recover_segment_event_uids scans metadata.uid;
1455 // require the UID to be embedded so recovery is deterministic.
1456 None => {
1457 return Err(BusError::Backend(
1458 "idempotent publish requires event metadata.uid to match the UID".to_owned(),
1459 ));
1460 }
1461 _ => {}
1462 }
1463 }
1464 let partition = partition_for(key, u32::try_from(parts.len()).unwrap_or(u32::MAX));
1465 let slot = parts
1466 .get(partition as usize)
1467 .ok_or_else(|| BusError::Backend(format!("partition {partition} out of range")))?;
1468 let mut part = slot.lock();
1469 if let Some(reason) = &part.poisoned {
1470 return Err(BusError::Backend(format!(
1471 "partition {partition} of topic {topic:?} is poisoned by an unrepaired append failure \
1472 (restart to recover): {reason}"
1473 )));
1474 }
1475 if let Some(uid) = event_uid {
1476 if let Some(offset) = part.seen_event_uids.get(uid).copied() {
1477 return Ok(PublishAck {
1478 topic: topic.to_owned(),
1479 partition,
1480 offset,
1481 });
1482 }
1483 }
1484 let offset = part.next_offset;
1485 let record = StoredEvent {
1486 partition,
1487 offset,
1488 key: key.to_owned(),
1489 value: value.clone(),
1490 stored_at: av_core::time::now_ms(),
1491 };
1492 let line = serde_json::to_string(&record)?;
1493 // Compute the successor BEFORE any bytes touch the disk: at the
1494 // (tamper-shaped) u64::MAX boundary the old post-append check
1495 // returned an error AFTER the record was durably fsynced without
1496 // ever advancing `next_offset`, so every retry appended another
1497 // record stamped with the same colliding offset.
1498 let next_after_append = part
1499 .next_offset
1500 .checked_add(1)
1501 .ok_or_else(|| BusError::Backend("embedded offset overflow".to_owned()))?;
1502 // Capture the durable length before appending so a failed append can
1503 // be repaired. Without the truncate-on-error below, two failure
1504 // modes leave the partition permanently inconsistent while the same
1505 // writer stays live:
1506 // 1. a torn write (ENOSPC/EIO mid-`write_all`) leaves partial
1507 // bytes that the NEXT publish appends after, merging both into
1508 // one unparseable line that ends in '\n' — `fetch` then errors
1509 // on every read, and startup recovery only repairs torn
1510 // *trailing* lines (no newline at EOF), not mid-file ones;
1511 // 2. a failed `sync_data` after a complete write leaves the
1512 // record durable while `next_offset` was never advanced, so
1513 // the next publish stamps a different record with the same
1514 // offset (duplicate offsets hidden by max(offset)+1 recovery).
1515 let known_good = part.writer.metadata()?.len();
1516 let append = (|| {
1517 part.writer.write_all(line.as_bytes())?;
1518 part.writer.write_all(b"\n")?;
1519 part.writer.flush()?;
1520 part.writer.sync_data()
1521 })();
1522 if let Err(error) = append {
1523 if let Err(repair) = part
1524 .writer
1525 .set_len(known_good)
1526 .and_then(|()| part.writer.sync_data())
1527 {
1528 // The segment may still carry torn bytes; refuse further
1529 // appends (fail-closed) rather than corrupt the next record.
1530 part.poisoned = Some(format!("append failed ({error}); truncation failed ({repair})"));
1531 }
1532 return Err(BusError::Io(error));
1533 }
1534 part.next_offset = next_after_append;
1535 if let Some(uid) = event_uid {
1536 part.seen_event_uids.insert(uid.to_owned(), offset);
1537 // The sidecar is a rebuildable cache: `open()` reconciles it
1538 // from the segment itself (`recover_segment_event_uids` —
1539 // idempotent publishes are required to embed metadata.uid),
1540 // and the in-memory map updated above already serves
1541 // same-process retries. The record is durable (fsynced append
1542 // above), so a sidecar failure must NOT fail the publish:
1543 // reporting an error for an operation whose primary effect
1544 // succeeded invites the caller to re-mint the event under a
1545 // fresh UID — a genuine duplicate. Log loudly and return the
1546 // ack instead.
1547 let sidecar_write = (|| -> Result<(), BusError> {
1548 let mapping = serde_json::to_string(&EventUidOffset {
1549 event_uid: uid.to_owned(),
1550 offset,
1551 })?;
1552 // Directory entry for a first-time-created sidecar is only
1553 // durable after sync_directory(parent). Sync the parent when
1554 // we discover we're creating the file so a subsequent power
1555 // loss cannot lose the whole sidecar (which would silently
1556 // convert future publish_idempotent calls into duplicate
1557 // appends).
1558 let sidecar_created = std::fs::symlink_metadata(&part.idempotency_path).is_err();
1559 // Same 0o600 + O_NOFOLLOW discipline the segment append
1560 // gets — sidecar entries are (event_uid, offset) pairs,
1561 // and event_uids are session-derived identifiers.
1562 let mut sidecar_options = fs::OpenOptions::new();
1563 sidecar_options.create(true).append(true);
1564 #[cfg(unix)]
1565 {
1566 use std::os::unix::fs::OpenOptionsExt as _;
1567 sidecar_options
1568 .custom_flags(av_core::fsutil::unix_o_nofollow())
1569 .mode(0o600);
1570 }
1571 let mut idempotency = sidecar_options.open(&part.idempotency_path)?;
1572 idempotency.write_all(mapping.as_bytes())?;
1573 idempotency.write_all(b"\n")?;
1574 idempotency.sync_data()?;
1575 if sidecar_created {
1576 if let Some(parent) = part.idempotency_path.parent() {
1577 av_core::fsutil::sync_directory(parent)?;
1578 }
1579 }
1580 Ok(())
1581 })();
1582 if let Err(error) = sidecar_write {
1583 tracing::warn!(
1584 %error,
1585 topic,
1586 partition,
1587 offset,
1588 "idempotency sidecar append failed; the publish is durable and the \
1589 UID index will be rebuilt from the segment at the next open"
1590 );
1591 }
1592 }
1593 Ok(PublishAck {
1594 topic: topic.to_owned(),
1595 partition,
1596 offset,
1597 })
1598 }
1599}
1600
1601impl EventBus for EmbeddedBroker {
1602 fn set_control_key(&self, _key: [u8; 32]) -> Result<(), BusError> {
1603 #[cfg(feature = "cold-store")]
1604 if let Some(archive) = &self.cold_archive {
1605 archive.set_control_key(_key)?;
1606 }
1607 Ok(())
1608 }
1609
1610 fn publish(&self, topic: &str, key: &str, value: &serde_json::Value) -> Result<PublishAck, BusError> {
1611 self.publish_with_uid(topic, key, value, event_uid_from_value(value))
1612 }
1613
1614 fn publish_idempotent(
1615 &self,
1616 topic: &str,
1617 key: &str,
1618 value: &serde_json::Value,
1619 event_uid: &str,
1620 ) -> Result<PublishAck, BusError> {
1621 self.publish_with_uid(topic, key, value, Some(event_uid))
1622 }
1623
1624 /// O(1) event-UID lookup via the partition's in-memory
1625 /// `seen_event_uids` map. The default trait impl at
1626 /// `bus.rs::find_event_by_uid` linearly scans the segment from
1627 /// offset 0 with `fetch`, which for `EmbeddedBroker` re-opens
1628 /// the segment file each call and streams from the head with
1629 /// a `BufReader` — so each fetch page for offset > 0 also walks
1630 /// the whole prefix. `resolve_lifecycle_ack` in the harness
1631 /// calls this on EVERY `emit_bridge_event` (twice per session
1632 /// close — RECEIPT + SESSION_CLOSE), which meant close latency
1633 /// grew O(total events per partition²) amortised. The Kafka
1634 /// and NATS bus implementations both override with O(1) lookups;
1635 /// only the embedded broker was still on the trait default.
1636 fn find_event_by_uid(
1637 &self,
1638 topic: &str,
1639 key: &str,
1640 event_uid: &str,
1641 ) -> Result<Option<PublishAck>, BusError> {
1642 let parts = self
1643 .partitions
1644 .get(topic)
1645 .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))?;
1646 let partition = partition_for(key, u32::try_from(parts.len()).unwrap_or(u32::MAX));
1647 let slot = parts
1648 .get(partition as usize)
1649 .ok_or_else(|| BusError::Backend(format!("partition {partition} out of range")))?;
1650 let part = slot.lock();
1651 Ok(part
1652 .seen_event_uids
1653 .get(event_uid)
1654 .copied()
1655 .map(|offset| PublishAck {
1656 topic: topic.to_owned(),
1657 partition,
1658 offset,
1659 }))
1660 }
1661
1662 fn fetch(
1663 &self,
1664 topic: &str,
1665 partition: u32,
1666 offset: u64,
1667 max: usize,
1668 ) -> Result<Vec<StoredEvent>, BusError> {
1669 let parts = self
1670 .partitions
1671 .get(topic)
1672 .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))?;
1673 let slot = parts
1674 .get(partition as usize)
1675 .ok_or_else(|| BusError::Backend(format!("partition {partition} out of range")))?;
1676 // Hold the partition lock across the entire read so a concurrent
1677 // enforce_retention rename cannot swap the segment mid-read.
1678 let part = slot.lock();
1679 if max == 0 || !part.path.exists() {
1680 return Ok(Vec::new());
1681 }
1682 let reader = BufReader::new(fs::File::open(&part.path)?);
1683 let mut out = Vec::with_capacity(max.min(1024));
1684 for line in reader.lines() {
1685 let line = line?;
1686 if line.is_empty() {
1687 continue;
1688 }
1689 let ev: StoredEvent = match serde_json::from_str(&line) {
1690 Ok(ev) => ev,
1691 // Same policy as `recover_segment`: a single unparseable
1692 // complete line (bit-rot, tamper, a crash mode recovery
1693 // does not repair) must not brick the read side. Retention
1694 // keeps unparseable lines forever (`split_by_time` treats
1695 // them as non-expired), so erroring here made every fetch
1696 // spanning the line fail permanently while publishes kept
1697 // acking events that could never be delivered.
1698 Err(error) => {
1699 tracing::warn!(
1700 %error,
1701 path = %av_core::fsutil::basename(&part.path),
1702 "skipping unparseable segment record during fetch",
1703 );
1704 continue;
1705 }
1706 };
1707 if ev.offset < offset {
1708 continue;
1709 }
1710 out.push(ev);
1711 if out.len() >= max {
1712 break;
1713 }
1714 }
1715 Ok(out)
1716 }
1717
1718 fn partitions(&self, topic: &str) -> Result<u32, BusError> {
1719 self.partitions
1720 .get(topic)
1721 .map(|p| u32::try_from(p.len()).unwrap_or(u32::MAX))
1722 .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))
1723 }
1724
1725 fn topics(&self) -> Vec<String> {
1726 let mut t: Vec<String> = self.partitions.keys().cloned().collect();
1727 t.sort();
1728 t
1729 }
1730
1731 fn maintenance(&self, now_ms: u64) -> Result<u64, BusError> {
1732 let expired = EmbeddedBroker::enforce_retention(self, now_ms)?;
1733 // Drain cold-export intents that a previous `ColdArchive::commit`
1734 // converted from a transient remote failure into a durable
1735 // "queued for retry" file. Only the Kafka/NATS maintenance paths
1736 // used to drain the queue; the embedded broker never did, so the
1737 // retry promise was never fulfilled — a silent permanent
1738 // cold-tier gap after the record left the hot segment.
1739 #[cfg(feature = "cold-store")]
1740 if let Some(archive) = &self.cold_archive {
1741 archive.retry_pending_with(|pending| {
1742 // Embedded intents are staged and committed inside one
1743 // `put()` call during retention, so a surviving
1744 // offset-None intent means a crash landed between stage
1745 // and commit while the record was still hot (the segment
1746 // rewrite only happens after export). Resolve the offset
1747 // from the partition's idempotency map instead of
1748 // re-publishing, which would append a duplicate record.
1749 let parts = self
1750 .partitions
1751 .get(&pending.topic)
1752 .ok_or_else(|| BusError::UnknownTopic(pending.topic.clone()))?;
1753 let slot = parts.get(pending.partition as usize).ok_or_else(|| {
1754 BusError::Backend(format!("partition {} out of range", pending.partition))
1755 })?;
1756 let offset = slot
1757 .lock()
1758 .seen_event_uids
1759 .get(&pending.event_uid)
1760 .copied()
1761 .ok_or_else(|| {
1762 BusError::Backend(format!(
1763 "cold intent {:?} has no broker offset and no live segment \
1764 record to resolve it from; refusing to fabricate one",
1765 pending.event_uid
1766 ))
1767 })?;
1768 Ok(PublishAck {
1769 topic: pending.topic.clone(),
1770 partition: pending.partition,
1771 offset,
1772 })
1773 })?;
1774 }
1775 Ok(expired)
1776 }
1777}
1778
1779#[cfg(test)]
1780mod tests {
1781 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1782
1783 use super::*;
1784
1785 fn stored(offset: u64) -> StoredEvent {
1786 StoredEvent {
1787 partition: 0,
1788 offset,
1789 key: "inst".to_owned(),
1790 value: serde_json::json!({"n": offset}),
1791 stored_at: 1,
1792 }
1793 }
1794
1795 /// A corrupt complete line occupies one logical offset. If it is the
1796 /// *last* line, next_offset must not regress below it — computing
1797 /// next_offset from parseable lines only made the next publish reuse
1798 /// the corrupt line's live offset.
1799 #[test]
1800 fn recover_segment_counts_a_corrupt_trailing_line_as_one_offset() {
1801 let dir = tempfile::tempdir().unwrap();
1802 let path = dir.path().join("p0.jsonl");
1803 let mut lines = String::new();
1804 for offset in 0..2u64 {
1805 lines.push_str(&serde_json::to_string(&stored(offset)).unwrap());
1806 lines.push('\n');
1807 }
1808 lines.push_str("{corrupt-but-complete\n");
1809 fs::write(&path, &lines).unwrap();
1810 let (next_offset, torn) = recover_segment(&path, 0).unwrap();
1811 assert_eq!(torn, 0, "a complete corrupt line is kept, not a torn tail");
1812 assert_eq!(next_offset, 3, "the corrupt line at offset 2 must be counted");
1813 }
1814
1815 /// A corrupt *middle* line must not overshoot: the running counter
1816 /// advances by one for the corrupt line and the trailing parseable
1817 /// line still governs.
1818 #[test]
1819 fn recover_segment_keeps_next_offset_exact_with_a_corrupt_middle_line() {
1820 let dir = tempfile::tempdir().unwrap();
1821 let path = dir.path().join("p0.jsonl");
1822 let mut lines = String::new();
1823 lines.push_str(&serde_json::to_string(&stored(0)).unwrap());
1824 lines.push_str("\n{corrupt-but-complete\n");
1825 lines.push_str(&serde_json::to_string(&stored(2)).unwrap());
1826 lines.push('\n');
1827 fs::write(&path, &lines).unwrap();
1828 let (next_offset, torn) = recover_segment(&path, 0).unwrap();
1829 assert_eq!(torn, 0);
1830 assert_eq!(next_offset, 3);
1831 }
1832
1833 /// A segment whose complete lines are ALL unparseable yields a bare
1834 /// line count with no absolute anchor. When retention has rewritten
1835 /// the segment before (watermark > 0) the count can mask records
1836 /// appended after the last watermark persist — `max(count, watermark)`
1837 /// would re-stamp an acknowledged offset on the next publish. That
1838 /// state must refuse to open. A never-rewritten segment (watermark 0)
1839 /// starts at absolute offset 0, so the count is exact and recovery
1840 /// proceeds.
1841 #[test]
1842 fn recover_segment_refuses_all_corrupt_lines_behind_a_retention_watermark() {
1843 let dir = tempfile::tempdir().unwrap();
1844 let path = dir.path().join("p0.jsonl");
1845 fs::write(&path, "{corrupt-one\n{corrupt-two\n").unwrap();
1846
1847 let error = recover_segment(&path, 100).unwrap_err();
1848 assert!(
1849 error.to_string().contains("only unparseable records"),
1850 "expected the ambiguity refusal, got: {error}"
1851 );
1852
1853 let (next_offset, torn) = recover_segment(&path, 0).unwrap();
1854 assert_eq!(torn, 0);
1855 assert_eq!(
1856 next_offset, 2,
1857 "with no watermark the segment starts at absolute 0 and the count is exact"
1858 );
1859 }
1860
1861 /// The sidecar recovery's oversized-line skip must stay O(cap) in
1862 /// memory: the reader is `take`-bounded, the overflow tail is
1863 /// drained in chunks, and a well-formed record after the giant
1864 /// line is still recovered. An oversized UNTERMINATED tail is the
1865 /// classic torn tail and truncates back to the last complete line.
1866 #[test]
1867 fn recover_event_uids_skips_oversized_lines_with_bounded_reads() {
1868 let dir = tempfile::tempdir().unwrap();
1869 let path = dir.path().join("p0.event-uids.jsonl");
1870 let good_before = "{\"event_uid\":\"uid-before\",\"offset\":1}\n";
1871 // Well above the 4 KiB cap (and above cap+1 so the drain loop runs).
1872 let oversized = format!("{{\"event_uid\":\"{}\",\"offset\":2}}\n", "x".repeat(20 * 1024));
1873 let good_after = "{\"event_uid\":\"uid-after\",\"offset\":3}\n";
1874 fs::write(&path, format!("{good_before}{oversized}{good_after}")).unwrap();
1875 let seen = recover_event_uids(&path).unwrap();
1876 assert_eq!(seen.get("uid-before"), Some(&1));
1877 assert_eq!(
1878 seen.get("uid-after"),
1879 Some(&3),
1880 "records after the giant line must survive"
1881 );
1882 assert_eq!(seen.len(), 2, "the oversized record itself is skipped");
1883 // Complete lines are never rewritten by a skip-only pass.
1884 assert!(
1885 fs::read_to_string(&path).unwrap().ends_with(good_after),
1886 "no truncation without a torn tail"
1887 );
1888
1889 // Oversized AND unterminated: torn tail — truncate to the last
1890 // complete line, exactly like a small torn tail.
1891 let torn_path = dir.path().join("p1.event-uids.jsonl");
1892 let torn_tail = format!("{{\"event_uid\":\"{}\",\"off", "y".repeat(20 * 1024));
1893 fs::write(&torn_path, format!("{good_before}{torn_tail}")).unwrap();
1894 let seen = recover_event_uids(&torn_path).unwrap();
1895 assert_eq!(seen.get("uid-before"), Some(&1));
1896 assert_eq!(seen.len(), 1);
1897 assert_eq!(
1898 fs::read_to_string(&torn_path).unwrap(),
1899 good_before,
1900 "torn oversized tail must truncate to the valid prefix"
1901 );
1902 }
1903
1904 /// Crash-atomic write-once contract: identical re-export is
1905 /// idempotent, divergent content is refused loudly, and no tmp
1906 /// staging file survives either path (a torn own-write can therefore
1907 /// never appear at the final name and poison retention forever).
1908 #[test]
1909 fn write_cold_event_once_is_idempotent_and_refuses_divergent_content() {
1910 let dir = tempfile::tempdir().unwrap();
1911 let event = stored(7);
1912 write_cold_event_once(dir.path(), &event).unwrap();
1913 // Identical re-export: idempotent.
1914 write_cold_event_once(dir.path(), &event).unwrap();
1915 // Divergent content at the same offset: refused, original kept.
1916 let mut divergent = stored(7);
1917 divergent.value = serde_json::json!({"tampered": true});
1918 let err = write_cold_event_once(dir.path(), &divergent).unwrap_err();
1919 assert!(
1920 err.to_string().contains("already exists with different content"),
1921 "{err}"
1922 );
1923 let kept: StoredEvent =
1924 serde_json::from_slice(&fs::read(dir.path().join("00000000000000000007.json")).unwrap()).unwrap();
1925 assert_eq!(kept.value, event.value, "conflict must not overwrite");
1926 // No staging residue: exactly the one final object remains.
1927 let names: Vec<String> = fs::read_dir(dir.path())
1928 .unwrap()
1929 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
1930 .collect();
1931 assert_eq!(names, vec!["00000000000000000007.json".to_owned()], "{names:?}");
1932 }
1933}