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}
33
34#[derive(serde::Serialize, serde::Deserialize)]
35struct EventUidOffset {
36    event_uid: String,
37    offset: u64,
38}
39
40/// File-backed broker instance.
41pub struct EmbeddedBroker {
42    data_dir: PathBuf,
43    manifest: BridgeManifest,
44    #[cfg(feature = "cold-store")]
45    cold_archive: Option<crate::cold_store::ColdArchive>,
46    // topic -> partitions
47    partitions: HashMap<String, Vec<Mutex<Partition>>>,
48    validators: HashMap<String, jsonschema::Validator>,
49    /// Torn trailing lines dropped during recovery (exposed to metrics).
50    pub recovered_torn_lines: u64,
51}
52
53impl EmbeddedBroker {
54    /// Provision a fresh bridge in `data_dir` from `manifest` alone (R12).
55    /// Fails if the directory already contains a bridge.
56    pub fn provision(data_dir: &Path, manifest: &BridgeManifest) -> Result<Self, BusError> {
57        manifest
58            .validate()
59            .map_err(|e| BusError::Backend(e.to_string()))?;
60        let manifest_path = data_dir.join("manifest.yaml");
61        fs::create_dir_all(data_dir)?;
62        copy_referenced_schemas(data_dir, manifest)?;
63        let yaml = manifest.to_yaml().map_err(|e| BusError::Backend(e.to_string()))?;
64        // Atomic single-winner claim on a fresh provision: `create_new`
65        // refuses to overwrite an existing file with an OS-level
66        // exclusion primitive (O_EXCL), so N concurrent provisions race
67        // on the same directory and exactly one wins. The old shape
68        // (`exists()` check → separate write) had a TOCTOU: two racers
69        // could both see "does not exist", both write, both succeed.
70        //
71        // Any error path — write_all, sync_all, hard_link, os signal —
72        // must clean up the tmp file. TmpGuard's Drop covers the
73        // early-return paths (ENOSPC, EIO on write, permission errors
74        // on sync); the winner path explicitly disarms the guard just
75        // before returning Ok so its finalised link is not deleted.
76        use std::io::Write as _;
77        let tmp = data_dir.join(format!("manifest.yaml.{}.tmp", av_core::new_event_uid()));
78        struct TmpGuard {
79            path: Option<std::path::PathBuf>,
80        }
81        impl TmpGuard {
82            fn disarm(&mut self) {
83                self.path = None;
84            }
85        }
86        impl Drop for TmpGuard {
87            fn drop(&mut self) {
88                if let Some(path) = self.path.take() {
89                    let _ = fs::remove_file(&path);
90                }
91            }
92        }
93        let mut guard = TmpGuard {
94            path: Some(tmp.clone()),
95        };
96        {
97            let mut file = fs::File::create(&tmp)?;
98            file.write_all(yaml.as_bytes())?;
99            file.sync_all()?;
100        }
101        match fs::hard_link(&tmp, &manifest_path) {
102            Ok(_) => {
103                // The tmp file was successfully hard-linked to the
104                // final path; the link itself is now the manifest.
105                // Delete the tmp path entry (the inode stays alive
106                // through the second link) and disarm the guard.
107                let _ = fs::remove_file(&tmp);
108                guard.disarm();
109            }
110            Err(error) => {
111                if error.kind() == std::io::ErrorKind::AlreadyExists {
112                    // Loser arm: tmp is auto-removed by TmpGuard on Err.
113                    // Round-37 F1/F2 class: basename to avoid leaking
114                    // the absolute deployment dir if this BusError
115                    // ever flows through a tracing::warn!(%error)
116                    // path.
117                    return Err(BusError::Backend(format!(
118                        "bridge already provisioned at {}",
119                        av_core::fsutil::basename(data_dir)
120                    )));
121                }
122                return Err(BusError::Io(error));
123            }
124        }
125        av_core::fsutil::sync_directory(data_dir).map_err(BusError::Io)?;
126        for t in &manifest.topics {
127            fs::create_dir_all(data_dir.join("topics").join(&t.name))?;
128        }
129        Self::open(data_dir)
130    }
131
132    /// Open an existing bridge, recovering offsets (and truncating at most one
133    /// torn trailing line per partition) from the segment files.
134    pub fn open(data_dir: &Path) -> Result<Self, BusError> {
135        // Round-22 F4: cap the bridge manifest read. A hostile plant of a
136        // multi-GiB manifest.yaml would OOM the broker at startup before
137        // the YAML parser could complain.
138        let manifest_yaml = av_core::fsutil::read_capped_string(
139            &data_dir.join("manifest.yaml"),
140            av_core::fsutil::MAX_CONTROL_BYTES,
141        )?;
142        let manifest =
143            BridgeManifest::from_yaml(&manifest_yaml).map_err(|e| BusError::Backend(e.to_string()))?;
144        let mut partitions = HashMap::new();
145        let mut torn_total = 0u64;
146        for t in &manifest.topics {
147            let dir = data_dir.join("topics").join(&t.name);
148            fs::create_dir_all(&dir)?;
149            let mut parts = Vec::with_capacity(t.partitions as usize);
150            for p in 0..t.partitions {
151                let path = dir.join(format!("p{p}.jsonl"));
152                let watermark_path = dir.join(format!("p{p}.next-offset"));
153                let idempotency_path = dir.join(format!("p{p}.event-uids.jsonl"));
154                let (segment_offset, torn) = recover_segment(&path)?;
155                let persisted_offset = read_high_water(&watermark_path)?;
156                let mut seen_event_uids = recover_event_uids(&idempotency_path)?;
157                recover_segment_event_uids(&path, &mut seen_event_uids)?;
158                // Post-reconciliation drop: any UID whose offset does
159                // not correspond to a record still present in the
160                // segment (e.g., record purged by retention but the
161                // sidecar rewrite lost the corresponding delete in a
162                // crash between segment rename and sidecar rewrite)
163                // must be evicted. Otherwise `publish_idempotent`
164                // short-circuits to a stale offset and callers fetch
165                // whatever event lives at that offset today, silently
166                // returning the wrong record.
167                //
168                // Mirror `enforce_retention`'s policy for
169                // unparseable-but-kept lines: any UID whose offset
170                // falls in the [min, max] offset range of surviving
171                // parseable records is kept, because it may correspond
172                // to an unparseable-but-authentic line at that offset.
173                // Without this parity, an unparseable segment record
174                // after a crash would drop its sidecar entry, letting
175                // the next publish_idempotent re-append a duplicate
176                // that the following retention pass would choke on.
177                if !seen_event_uids.is_empty() {
178                    let mut live_uids =
179                        std::collections::HashSet::<String>::with_capacity(seen_event_uids.len());
180                    let mut min_offset = u64::MAX;
181                    let mut max_offset = 0u64;
182                    let mut have_parseable_line = false;
183                    if path.exists() {
184                        for line in BufReader::new(fs::File::open(&path)?).lines() {
185                            let Ok(line) = line else {
186                                continue;
187                            };
188                            if line.is_empty() {
189                                continue;
190                            }
191                            if let Ok(event) = serde_json::from_str::<StoredEvent>(&line) {
192                                if let Some(uid) = event_uid_from_value(&event.value) {
193                                    live_uids.insert(uid.to_owned());
194                                }
195                                min_offset = min_offset.min(event.offset);
196                                max_offset = max_offset.max(event.offset);
197                                have_parseable_line = true;
198                            }
199                        }
200                    }
201                    let offset_range = if have_parseable_line {
202                        Some((min_offset, max_offset))
203                    } else {
204                        None
205                    };
206                    let before = seen_event_uids.len();
207                    seen_event_uids.retain(|uid, offset| {
208                        if live_uids.contains(uid) {
209                            return true;
210                        }
211                        match offset_range {
212                            Some((lo, hi)) => *offset >= lo && *offset <= hi,
213                            None => false,
214                        }
215                    });
216                    if seen_event_uids.len() != before {
217                        tracing::warn!(
218                            topic = %t.name,
219                            partition = p,
220                            dropped = before - seen_event_uids.len(),
221                            "sidecar UID→offset entries dropped: corresponding segment records \
222                             absent (likely retention crash between segment rewrite and sidecar rewrite)"
223                        );
224                    }
225                }
226                torn_total += torn;
227                // Track first-time creation so we can fsync the directory
228                // after the file is materialised — `sync_data()` on the
229                // append handle flushes bytes and size, but the directory
230                // entry that names the inode is only durable after a
231                // `sync_directory(parent)`. Without this, N successful
232                // publishes → acked → power loss → boot back with the
233                // segment file absent and every acked event lost until
234                // another directory-fsyncing path runs (the retention
235                // rewrite, or the sidecar-creation fsync in
236                // `publish_with_uid`).
237                let segment_created = !path.exists();
238                let writer = fs::OpenOptions::new().create(true).append(true).open(&path)?;
239                if segment_created {
240                    av_core::fsutil::sync_directory(&dir).map_err(BusError::Io)?;
241                }
242                parts.push(Mutex::new(Partition {
243                    path,
244                    watermark_path,
245                    idempotency_path,
246                    seen_event_uids,
247                    next_offset: segment_offset.max(persisted_offset),
248                    writer,
249                }));
250            }
251            partitions.insert(t.name.clone(), parts);
252        }
253        let validators = load_validators(data_dir, &manifest)?;
254        #[cfg(feature = "cold-store")]
255        let cold_archive = crate::cold_store::ColdArchive::from_manifest_with_pending_default(
256            &manifest,
257            Some(data_dir.join("cold-outbox")),
258        )?;
259        Ok(Self {
260            data_dir: data_dir.to_owned(),
261            manifest,
262            #[cfg(feature = "cold-store")]
263            cold_archive,
264            partitions,
265            validators,
266            recovered_torn_lines: torn_total,
267        })
268    }
269
270    /// The manifest this bridge was provisioned from.
271    pub fn manifest(&self) -> &BridgeManifest {
272        &self.manifest
273    }
274
275    /// Data directory.
276    pub fn data_dir(&self) -> &Path {
277        &self.data_dir
278    }
279
280    /// Enforce per-topic hot retention at time `now_ms`: when
281    /// `retention.cold_uri` is set, each expired record
282    /// is first exported to the cold tier as its own write-once object (via the
283    /// authenticated `ColdArchive` for `scheme://` URIs, or
284    /// `write_cold_event_once` for local directory paths) before being
285    /// removed from the hot segment via atomic rewrite; with `cold_uri`
286    /// unset, expired records are dropped from the hot segment without
287    /// export. Returns the number of
288    /// records expired.
289    pub fn enforce_retention(&self, now_ms: u64) -> Result<u64, BusError> {
290        let mut expired_total = 0u64;
291        for t in &self.manifest.topics {
292            let cutoff =
293                now_ms.saturating_sub(u64::from(t.retention.hot_hours) * av_core::units::MS_PER_HOUR);
294            let Some(parts) = self.partitions.get(&t.name) else {
295                continue;
296            };
297            for p in parts {
298                let mut part = p.lock();
299                let (kept, expired) = split_by_time(&part.path, cutoff)?;
300                if expired.is_empty() {
301                    continue;
302                }
303                expired_total += expired.len() as u64;
304                // Cold export first (never destroy before the copy lands).
305                if let Some(cold) = &t.retention.cold_uri {
306                    if cold.contains("://") {
307                        #[cfg(feature = "cold-store")]
308                        {
309                            let archive = self.cold_archive.as_ref().ok_or_else(|| {
310                                BusError::Backend(format!("cold archive for {:?} is unavailable", t.name))
311                            })?;
312                            for line in &expired {
313                                let event: StoredEvent = serde_json::from_str(line)?;
314                                archive.put(&t.name, &event)?;
315                            }
316                        }
317                        #[cfg(not(feature = "cold-store"))]
318                        return Err(BusError::Backend(format!(
319                            "cold_uri {cold:?} requires feature cold-store"
320                        )));
321                    } else {
322                        let partition = part
323                            .path
324                            .file_stem()
325                            .and_then(std::ffi::OsStr::to_str)
326                            .unwrap_or("partition");
327                        let cold_dir = Path::new(cold).join(&t.name).join(partition);
328                        fs::create_dir_all(&cold_dir)?;
329                        for line in &expired {
330                            let event: StoredEvent = serde_json::from_str(line)?;
331                            write_cold_event_once(&cold_dir, &event)?;
332                        }
333                        av_core::fsutil::sync_directory(&cold_dir)?;
334                    }
335                }
336                persist_high_water(&part.watermark_path, part.next_offset)?;
337                // Atomic hot rewrite. Use a UUID-suffixed tmp name so a
338                // stale tmp from a prior crashed pass isn't reused (and
339                // an external backup/rsync tool can't grab an
340                // in-progress file thinking it's stable data).
341                let tmp = part
342                    .path
343                    .with_extension(format!("jsonl.{}.tmp", av_core::new_event_uid()));
344                // Round-22 F3: RAII guard cleans up the tmp on any early
345                // Err in the write/sync/rename path so a repeatedly-
346                // failing rewrite (ENOSPC/EIO) does not fill the inode
347                // table with UUID-suffixed orphan .tmp files.
348                let mut guard = av_core::fsutil::TempPathGuard::new(tmp.clone());
349                {
350                    let mut f = fs::File::create(&tmp)?;
351                    for line in &kept {
352                        f.write_all(line.as_bytes())?;
353                        f.write_all(b"\n")?;
354                    }
355                    f.sync_all()?;
356                }
357                fs::rename(&tmp, &part.path)?;
358                guard.disarm();
359                if let Some(parent) = part.path.parent() {
360                    av_core::fsutil::sync_directory(parent)?;
361                }
362                // Prune the idempotency map + sidecar of any UID whose offset
363                // was expired: without this, a subsequent `publish_idempotent`
364                // with a still-cached UID returns an ack pointing at data that
365                // no longer exists, and the caller's follow-up `fetch(offset)`
366                // silently returns the wrong event or nothing.
367                //
368                // We compute survivors from parseable lines, but ALSO keep
369                // the range [min_kept_offset, max_kept_offset]: any UID
370                // whose offset falls in that range is potentially attached
371                // to an unparseable-but-kept line, and dropping it would
372                // let the next publish_idempotent re-append a duplicate
373                // record (which retention would then choke on next pass).
374                let survivors: std::collections::HashSet<u64> = kept
375                    .iter()
376                    .filter_map(|line| serde_json::from_str::<StoredEvent>(line).ok())
377                    .map(|event| event.offset)
378                    .collect();
379                let range = if survivors.is_empty() {
380                    None
381                } else {
382                    // A single manual pass gives min/max and avoids
383                    // expect() on the guaranteed-non-empty iterator.
384                    let (mut lo, mut hi) = (u64::MAX, 0u64);
385                    for offset in &survivors {
386                        lo = lo.min(*offset);
387                        hi = hi.max(*offset);
388                    }
389                    Some((lo, hi))
390                };
391                let before = part.seen_event_uids.len();
392                part.seen_event_uids.retain(|_, offset| {
393                    if survivors.contains(offset) {
394                        return true;
395                    }
396                    match range {
397                        Some((lo, hi)) => *offset >= lo && *offset <= hi,
398                        None => false,
399                    }
400                });
401                if part.seen_event_uids.len() != before {
402                    // Sidecar is now stale — rewrite it atomically with only
403                    // the surviving mappings so recovery cannot resurrect a
404                    // just-expired UID.
405                    let mut lines: Vec<(String, u64)> = part
406                        .seen_event_uids
407                        .iter()
408                        .map(|(uid, offset)| (uid.clone(), *offset))
409                        .collect();
410                    lines.sort_by_key(|(_, offset)| *offset);
411                    let mut sidecar = Vec::new();
412                    for (uid, offset) in lines {
413                        let mapping = serde_json::to_string(&EventUidOffset {
414                            event_uid: uid,
415                            offset,
416                        })?;
417                        sidecar.extend_from_slice(mapping.as_bytes());
418                        sidecar.push(b'\n');
419                    }
420                    rewrite_atomic(&part.idempotency_path, &sidecar)?;
421                }
422                part.writer = fs::OpenOptions::new().append(true).open(&part.path)?;
423            }
424        }
425        Ok(expired_total)
426    }
427}
428
429fn write_cold_event_once(directory: &Path, event: &StoredEvent) -> Result<(), BusError> {
430    let path = directory.join(format!("{:020}.json", event.offset));
431    let bytes = serde_json::to_vec(event)?;
432    match fs::OpenOptions::new().write(true).create_new(true).open(&path) {
433        Ok(mut file) => {
434            file.write_all(&bytes)?;
435            file.sync_all()?;
436            Ok(())
437        }
438        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
439            if fs::read(&path)? == bytes {
440                Ok(())
441            } else {
442                // Round-37 F1/F2 class: `write_cold_object_exclusive`
443                // errors bubble into `ColdArchive::commit` /
444                // `stage` in the bus impls and can reach a
445                // tracing::warn!(%error) on the maintenance path.
446                // Basename the absolute cold-outbox path so a
447                // duplicate-object collision doesn't ship the
448                // deployment topology to OTLP.
449                Err(BusError::Backend(format!(
450                    "cold object {} already exists with different content",
451                    av_core::fsutil::basename(&path)
452                )))
453            }
454        }
455        Err(error) => Err(BusError::Io(error)),
456    }
457}
458
459fn copy_referenced_schemas(data_dir: &Path, manifest: &BridgeManifest) -> Result<(), BusError> {
460    for topic in &manifest.topics {
461        let Some(reference) = &topic.schema_ref else {
462            continue;
463        };
464        let value = crate::manifest::schema_document(reference)?;
465        jsonschema::validator_for(&value)
466            .map_err(|error| BusError::Backend(format!("invalid schema {reference:?}: {error}")))?;
467        let destination = data_dir.join(reference);
468        if let Some(parent) = destination.parent() {
469            fs::create_dir_all(parent)?;
470        }
471        fs::write(destination, serde_json::to_vec_pretty(&value)?)?;
472    }
473    Ok(())
474}
475
476fn load_validators(
477    data_dir: &Path,
478    manifest: &BridgeManifest,
479) -> Result<HashMap<String, jsonschema::Validator>, BusError> {
480    let mut validators = HashMap::new();
481    for topic in &manifest.topics {
482        let Some(reference) = &topic.schema_ref else {
483            continue;
484        };
485        let schema = crate::manifest::schema_document(reference).or_else(|_| {
486            // Round-22 F4: cap the schema file read. Schemas are control-
487            // plane data (bounded well below 1 MiB in practice); a hostile
488            // plant of a multi-GiB schema file would OOM the broker before
489            // the JSON parser could complain.
490            let bytes =
491                av_core::fsutil::read_capped(&data_dir.join(reference), av_core::fsutil::MAX_CONTROL_BYTES)?;
492            serde_json::from_slice(&bytes).map_err(BusError::from)
493        })?;
494        let validator = jsonschema::validator_for(&schema)
495            .map_err(|error| BusError::Backend(format!("invalid schema {reference:?}: {error}")))?;
496        validators.insert(topic.name.clone(), validator);
497    }
498    Ok(validators)
499}
500
501/// Count complete lines; truncate a torn trailing line if present.
502fn recover_segment(path: &Path) -> Result<(u64, u64), BusError> {
503    if !path.exists() {
504        return Ok((0, 0));
505    }
506    let bytes = fs::read(path)?;
507    if bytes.is_empty() {
508        return Ok((0, 0));
509    }
510    let complete_len = match bytes.iter().rposition(|b| *b == b'\n') {
511        Some(pos) => pos + 1,
512        None => 0, // single torn line, no newline at all
513    };
514    let torn = usize::from(complete_len < bytes.len());
515    if torn == 1 {
516        // Same fsync-safe rewrite pattern as `persist_high_water`: without
517        // sync_all()+sync_directory a crash during recovery could turn a
518        // torn-tail single-record loss into total-segment loss on
519        // non-ext4 filesystems.
520        rewrite_atomic(path, bytes.get(..complete_len).unwrap_or_default())?;
521    }
522    let complete = bytes.get(..complete_len).unwrap_or_default();
523    let mut next_offset = 0u64;
524    for line in complete
525        .split(|byte| *byte == b'\n')
526        .filter(|line| !line.is_empty())
527    {
528        match serde_json::from_slice::<StoredEvent>(line) {
529            Ok(event) => next_offset = next_offset.max(event.offset.saturating_add(1)),
530            // A single unparseable middle line must not brick the broker.
531            // The corrupted bytes are left on disk as forensic evidence;
532            // subsequent publishes append past the surviving max offset.
533            Err(error) => {
534                tracing::warn!(
535                    %error,
536                    path = %av_core::fsutil::basename(path),
537                    "skipping unparseable segment record during recovery",
538                );
539            }
540        }
541    }
542    Ok((next_offset, torn as u64))
543}
544
545fn recover_event_uids(path: &Path) -> Result<HashMap<String, u64>, BusError> {
546    if !path.exists() {
547        return Ok(HashMap::new());
548    }
549    // Round-22 F4: cap the idempotency sidecar read. The sidecar grows
550    // proportionally to live UIDs (bounded by retention) so MAX_ATIF_BYTES
551    // (64 MiB) is the operationally-sized ceiling; a hostile plant of a
552    // multi-GiB sidecar file would OOM the broker at startup.
553    let bytes = av_core::fsutil::read_capped(path, av_core::fsutil::MAX_ATIF_BYTES)?;
554    let complete_len = bytes
555        .iter()
556        .rposition(|byte| *byte == b'\n')
557        .map_or(0, |position| position + 1);
558    if complete_len < bytes.len() {
559        rewrite_atomic(path, bytes.get(..complete_len).unwrap_or_default())?;
560    }
561    let mut seen = HashMap::new();
562    for line in bytes
563        .get(..complete_len)
564        .unwrap_or_default()
565        .split(|byte| *byte == b'\n')
566        .filter(|line| !line.is_empty())
567    {
568        let mapping: EventUidOffset = match serde_json::from_slice(line) {
569            Ok(mapping) => mapping,
570            // Sidecar corruption also skip-and-log: the segment is the
571            // ground truth (see `recover_segment_event_uids` below), and
572            // an unreadable idempotency line at most costs a duplicate
573            // ack for the same UID.
574            Err(error) => {
575                tracing::warn!(
576                    %error,
577                    path = %av_core::fsutil::basename(path),
578                    "skipping unparseable event-uid sidecar record during recovery",
579                );
580                continue;
581            }
582        };
583        if let Some(existing) = seen.insert(mapping.event_uid.clone(), mapping.offset) {
584            if existing != mapping.offset {
585                // Sidecar (idempotency journal) can legitimately hold
586                // stale UID→offset pairs after a partial retention
587                // (segment rewritten atomically, sidecar rewrite lost
588                // in a crash). The segment on disk is the source of
589                // truth; log and let `recover_segment_event_uids` fix
590                // the mapping. Refusing to open the broker here would
591                // brick the whole tier over a benign inconsistency.
592                tracing::warn!(
593                    event_uid = %mapping.event_uid,
594                    prior_offset = existing,
595                    current_offset = mapping.offset,
596                    "sidecar has duplicate UID entry; segment offset will win after full recovery"
597                );
598            }
599        }
600    }
601    Ok(seen)
602}
603
604fn recover_segment_event_uids(path: &Path, seen: &mut HashMap<String, u64>) -> Result<(), BusError> {
605    if !path.exists() {
606        return Ok(());
607    }
608    for line in BufReader::new(fs::File::open(path)?).lines() {
609        let line = line?;
610        if line.is_empty() {
611            continue;
612        }
613        let event: StoredEvent = match serde_json::from_str(&line) {
614            Ok(event) => event,
615            // Same skip-and-log policy as `recover_segment`: an unreadable
616            // segment record has already been flagged there; here it just
617            // means we cannot reconstruct its UID → offset mapping.
618            Err(error) => {
619                tracing::warn!(
620                    %error,
621                    path = %av_core::fsutil::basename(path),
622                    "skipping unparseable segment record while rebuilding UID index",
623                );
624                continue;
625            }
626        };
627        let Some(uid) = event_uid_from_value(&event.value) else {
628            continue;
629        };
630        if let Some(existing) = seen.insert(uid.to_owned(), event.offset) {
631            if existing != event.offset {
632                // Segment is authoritative — the sidecar was stale
633                // (see recover_event_uids for the crash mode). Log
634                // and keep the segment offset (the last write wins
635                // via insert). Refusing to open would leave the
636                // broker un-openable on a benign inconsistency.
637                tracing::warn!(
638                    event_uid = uid,
639                    prior_offset = existing,
640                    current_offset = event.offset,
641                    "duplicate UID between sidecar and segment; segment offset wins"
642                );
643            }
644        }
645    }
646    Ok(())
647}
648
649/// Fsync-safe replace: `File::create` → `write_all` → `sync_all` → rename
650/// → `sync_directory(parent)`. Same shape as `persist_high_water` and the
651/// hot-segment rewrite in `enforce_retention`.
652fn rewrite_atomic(path: &Path, bytes: &[u8]) -> Result<(), BusError> {
653    let parent = path
654        .parent()
655        .ok_or_else(|| BusError::Backend("atomic rewrite has no parent".to_owned()))?;
656    let tmp = path.with_extension(format!("jsonl.{}.tmp", av_core::new_event_uid()));
657    // Round-22 F3: RAII cleanup on any early Err. Without this, a
658    // failing sync/rename leaves a UUID-suffixed orphan .tmp behind
659    // and repeated retries can exhaust ext4/xfs inodes.
660    let mut guard = av_core::fsutil::TempPathGuard::new(tmp.clone());
661    {
662        let mut file = fs::File::create(&tmp)?;
663        file.write_all(bytes)?;
664        file.sync_all()?;
665    }
666    fs::rename(&tmp, path)?;
667    guard.disarm();
668    av_core::fsutil::sync_directory(parent)?;
669    Ok(())
670}
671
672fn event_uid_from_value(value: &serde_json::Value) -> Option<&str> {
673    value
674        .get("metadata")
675        .and_then(|metadata| metadata.get("uid"))
676        .and_then(serde_json::Value::as_str)
677}
678
679fn read_high_water(path: &Path) -> Result<u64, BusError> {
680    // Round-22 F4: a watermark is at most u64 in decimal (~20 chars). Cap
681    // the read so a hostile plant of a giant p<N>.next-offset cannot OOM
682    // the broker at startup. Use MAX_CONTROL_BYTES (1 MiB) for the
683    // shared trust boundary; a real watermark is orders of magnitude
684    // smaller.
685    match av_core::fsutil::read_capped_string(path, av_core::fsutil::MAX_CONTROL_BYTES) {
686        Ok(value) => match value.trim().parse::<u64>() {
687            Ok(offset) => Ok(offset),
688            // A corrupt watermark file is not fatal: `next_offset` is
689            // recomputed as `segment_offset.max(persisted_offset)` in
690            // `open()`, so falling back to 0 lets the segment be
691            // authoritative. The next successful publish rewrites the
692            // watermark via `persist_high_water` and self-heals.
693            Err(error) => {
694                tracing::warn!(
695                    %error,
696                    path = %av_core::fsutil::basename(path),
697                    "high-watermark file is corrupt; falling back to segment-derived next_offset",
698                );
699                Ok(0)
700            }
701        },
702        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(0),
703        Err(error) => Err(BusError::Io(error)),
704    }
705}
706
707fn persist_high_water(path: &Path, next_offset: u64) -> Result<(), BusError> {
708    let parent = path
709        .parent()
710        .ok_or_else(|| BusError::Backend("high-watermark has no parent".to_owned()))?;
711    let temporary = path.with_extension(format!("{}.tmp", av_core::new_event_uid()));
712    // Round-22 F3: RAII cleanup on any early Err — same discipline as
713    // `rewrite_atomic` above. On a bad-disk day, watermark writes fire
714    // on every publish; orphan tmp accumulation would be fastest here.
715    let mut guard = av_core::fsutil::TempPathGuard::new(temporary.clone());
716    {
717        let mut file = fs::File::create(&temporary)?;
718        file.write_all(next_offset.to_string().as_bytes())?;
719        file.sync_all()?;
720    }
721    fs::rename(&temporary, path)?;
722    guard.disarm();
723    av_core::fsutil::sync_directory(parent)?;
724    Ok(())
725}
726
727/// Partition a segment's lines into (kept, expired) by `stored_at < cutoff`.
728fn split_by_time(path: &Path, cutoff_ms: u64) -> Result<(Vec<String>, Vec<String>), BusError> {
729    let mut kept = Vec::new();
730    let mut expired = Vec::new();
731    if !path.exists() {
732        return Ok((kept, expired));
733    }
734    let reader = BufReader::new(fs::File::open(path)?);
735    for line in reader.lines() {
736        let line = line?;
737        if line.is_empty() {
738            continue;
739        }
740        let is_expired = serde_json::from_str::<StoredEvent>(&line)
741            .map(|e| e.stored_at < cutoff_ms)
742            .unwrap_or(false);
743        if is_expired {
744            expired.push(line);
745        } else {
746            kept.push(line);
747        }
748    }
749    Ok((kept, expired))
750}
751
752impl EmbeddedBroker {
753    fn publish_with_uid(
754        &self,
755        topic: &str,
756        key: &str,
757        value: &serde_json::Value,
758        event_uid: Option<&str>,
759    ) -> Result<PublishAck, BusError> {
760        let parts = self
761            .partitions
762            .get(topic)
763            .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))?;
764        if let Some(validator) = self.validators.get(topic) {
765            let errors: Vec<String> = validator
766                .iter_errors(value)
767                .take(3)
768                .map(|error| error.to_string())
769                .collect();
770            if !errors.is_empty() {
771                return Err(BusError::Backend(format!(
772                    "event rejected by schema for topic {topic:?}: {}",
773                    errors.join("; ")
774                )));
775            }
776        }
777        if let Some(expected) = event_uid {
778            match event_uid_from_value(value) {
779                Some(actual) if expected != actual => {
780                    return Err(BusError::Backend(
781                        "idempotency UID does not match event metadata UID".to_owned(),
782                    ));
783                }
784                // A missing metadata.uid would make crash recovery lose the UID
785                // → sidecar because recover_segment_event_uids scans metadata.uid;
786                // require the UID to be embedded so recovery is deterministic.
787                None => {
788                    return Err(BusError::Backend(
789                        "idempotent publish requires event metadata.uid to match the UID".to_owned(),
790                    ));
791                }
792                _ => {}
793            }
794        }
795        let partition = partition_for(key, u32::try_from(parts.len()).unwrap_or(u32::MAX));
796        let slot = parts
797            .get(partition as usize)
798            .ok_or_else(|| BusError::Backend(format!("partition {partition} out of range")))?;
799        let mut part = slot.lock();
800        if let Some(uid) = event_uid {
801            if let Some(offset) = part.seen_event_uids.get(uid).copied() {
802                return Ok(PublishAck {
803                    topic: topic.to_owned(),
804                    partition,
805                    offset,
806                });
807            }
808        }
809        let offset = part.next_offset;
810        let record = StoredEvent {
811            partition,
812            offset,
813            key: key.to_owned(),
814            value: value.clone(),
815            stored_at: av_core::time::now_ms(),
816        };
817        let line = serde_json::to_string(&record)?;
818        part.writer.write_all(line.as_bytes())?;
819        part.writer.write_all(b"\n")?;
820        part.writer.flush()?;
821        part.writer.sync_data()?;
822        part.next_offset = part
823            .next_offset
824            .checked_add(1)
825            .ok_or_else(|| BusError::Backend("embedded offset overflow".to_owned()))?;
826        if let Some(uid) = event_uid {
827            part.seen_event_uids.insert(uid.to_owned(), offset);
828            let mapping = serde_json::to_string(&EventUidOffset {
829                event_uid: uid.to_owned(),
830                offset,
831            })?;
832            // Directory entry for a first-time-created sidecar is only
833            // durable after sync_directory(parent). Sync the parent when
834            // we discover we're creating the file so a subsequent power
835            // loss cannot lose the whole sidecar (which would silently
836            // convert future publish_idempotent calls into duplicate
837            // appends).
838            let sidecar_created = !part.idempotency_path.exists();
839            let mut idempotency = fs::OpenOptions::new()
840                .create(true)
841                .append(true)
842                .open(&part.idempotency_path)?;
843            idempotency.write_all(mapping.as_bytes())?;
844            idempotency.write_all(b"\n")?;
845            idempotency.sync_data()?;
846            if sidecar_created {
847                if let Some(parent) = part.idempotency_path.parent() {
848                    av_core::fsutil::sync_directory(parent)?;
849                }
850            }
851        }
852        Ok(PublishAck {
853            topic: topic.to_owned(),
854            partition,
855            offset,
856        })
857    }
858}
859
860impl EventBus for EmbeddedBroker {
861    fn set_control_key(&self, _key: [u8; 32]) -> Result<(), BusError> {
862        #[cfg(feature = "cold-store")]
863        if let Some(archive) = &self.cold_archive {
864            archive.set_control_key(_key)?;
865        }
866        Ok(())
867    }
868
869    fn publish(&self, topic: &str, key: &str, value: &serde_json::Value) -> Result<PublishAck, BusError> {
870        self.publish_with_uid(topic, key, value, event_uid_from_value(value))
871    }
872
873    fn publish_idempotent(
874        &self,
875        topic: &str,
876        key: &str,
877        value: &serde_json::Value,
878        event_uid: &str,
879    ) -> Result<PublishAck, BusError> {
880        self.publish_with_uid(topic, key, value, Some(event_uid))
881    }
882
883    fn fetch(
884        &self,
885        topic: &str,
886        partition: u32,
887        offset: u64,
888        max: usize,
889    ) -> Result<Vec<StoredEvent>, BusError> {
890        let parts = self
891            .partitions
892            .get(topic)
893            .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))?;
894        let slot = parts
895            .get(partition as usize)
896            .ok_or_else(|| BusError::Backend(format!("partition {partition} out of range")))?;
897        // Hold the partition lock across the entire read so a concurrent
898        // enforce_retention rename cannot swap the segment mid-read.
899        let part = slot.lock();
900        if max == 0 || !part.path.exists() {
901            return Ok(Vec::new());
902        }
903        let reader = BufReader::new(fs::File::open(&part.path)?);
904        let mut out = Vec::with_capacity(max.min(1024));
905        for line in reader.lines() {
906            let line = line?;
907            if line.is_empty() {
908                continue;
909            }
910            let ev: StoredEvent = serde_json::from_str(&line)?;
911            if ev.offset < offset {
912                continue;
913            }
914            out.push(ev);
915            if out.len() >= max {
916                break;
917            }
918        }
919        Ok(out)
920    }
921
922    fn partitions(&self, topic: &str) -> Result<u32, BusError> {
923        self.partitions
924            .get(topic)
925            .map(|p| u32::try_from(p.len()).unwrap_or(u32::MAX))
926            .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))
927    }
928
929    fn topics(&self) -> Vec<String> {
930        let mut t: Vec<String> = self.partitions.keys().cloned().collect();
931        t.sort();
932        t
933    }
934
935    fn maintenance(&self, now_ms: u64) -> Result<u64, BusError> {
936        EmbeddedBroker::enforce_retention(self, now_ms)
937    }
938}