Skip to main content

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}