Feature/optimization (#816)

* **Performance**
  * Improved memory usage and persistence speed for large playlists and electronic programme guides.
  * Large EPG datasets now process more reliably while preserving channel priorities and cleaning up temporary data.
  * Reduced allocation overhead during parsing and playlist processing.

* **Bug Fixes**
  * Prevented programme descriptions from being truncated during XMLTV imports.
  * Improved handling of empty or single-source EPG data.

* **Network**
  * Optimized response compression by avoiding compression for small or already-compressed media files.
This commit is contained in:
euzu
2026-08-05 15:04:13 +02:00
committed by GitHub
parent de45535647
commit 52efaaa301
16 changed files with 1283 additions and 156 deletions
+475 -9
View File
@@ -9,14 +9,16 @@ use crate::{
parser::ics,
processor::EpgIdCache,
},
repository::{BPlusTree, BPlusTreeQuery, BPlusTreeUpdate, FlushPolicy},
utils::{
async_file_reader, compressed_file_reader_async::CompressedFileReaderAsync, parse_xmltv_time,
with_folded_epg_id,
arc_str_serde, async_file_reader, compressed_file_reader_async::CompressedFileReaderAsync,
parse_xmltv_time, with_folded_epg_id,
},
};
use log::error;
use quick_xml::events::{BytesStart, BytesText, Event};
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use serde::{Deserialize, Serialize};
use shared::{
concat_string,
model::{EpgCategory, EpgChannel, EpgNamePrefix, EpgProgramme},
@@ -26,6 +28,8 @@ use std::{
borrow::Cow,
cmp::min,
collections::{HashMap, HashSet},
io,
path::PathBuf,
sync::Arc,
};
use tokio::io::AsyncRead;
@@ -593,7 +597,12 @@ where
{
let mut stack: Vec<XmlTag> = vec![];
let mut xml_reader = quick_xml::reader::Reader::from_reader(async_file_reader(content));
let mut buf = Vec::<u8>::new();
// Pre-allocate so the first giant `<programme>` block does not trigger a
// chain of `Vec::grow` reallocs. The buffer is monotonically grown by
// quick_xml (it is reset by the caller, see below), so the eventual size
// is the largest single event — for XMLTV that is the biggest programme
// description. Starting at 0 capacity makes that growth ~25 doubling copies.
let mut buf = Vec::<u8>::with_capacity(64 * 1024);
loop {
match xml_reader.read_event_into_async(&mut buf).await {
Ok(Event::Eof) => break,
@@ -606,9 +615,20 @@ where
Ok(Event::Text(e)) => handle_text_tag(&mut stack, &e),
_ => {}
}
// quick_xml does not clear the buffer between events — the borrow
// returned by `read_event_into_async` is dropped at the end of each
// match arm, so we can reclaim the capacity here without invalidating
// any handler output. Without this, `buf` grows monotonically over the
// whole file, defeating the 64 KiB pre-allocation above.
buf.clear();
}
}
/// Channels per `BPlusTree` write batch. Sized so the in-flight batch + its
/// prepared references stay under 1 MiB on the typical 100-KiB-per-channel
/// EPG feed. This is an educated guess; profile-driven tuning is fine.
const EPG_DISK_BATCH_SIZE: usize = 100;
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
enum XmlTagType {
Ignored,
@@ -701,16 +721,16 @@ struct ChannelMergeAcc {
}
#[derive(Debug, Default)]
struct EpgMergeAccumulator {
pub(crate) struct EpgMergeAccumulator {
attributes: Option<PreferredAttributes>,
channels: HashMap<Arc<str>, ChannelMergeAcc>,
dummy_policies: HashMap<Arc<str>, PreferredDummyPolicy>,
}
impl EpgMergeAccumulator {
fn new() -> Self { Self::default() }
pub(crate) fn new() -> Self { Self::default() }
fn set_attributes_if_preferred(
pub(crate) fn set_attributes_if_preferred(
&mut self,
priority: i16,
source_order: usize,
@@ -803,7 +823,7 @@ impl EpgMergeAccumulator {
}
}
fn add_channel_with_programmes(
pub(crate) fn add_channel_with_programmes(
&mut self,
priority: i16,
source_order: usize,
@@ -884,6 +904,198 @@ impl EpgMergeAccumulator {
icon_override_channels,
))
}
/// Drain the accumulator directly into a temp `BPlusTree` on disk. Peak RAM
/// here is `EPG_DISK_BATCH_SIZE × max_channel_size` — channels never sit
/// in a `Vec`. The returned `DiskEpgSource` removes the file in its
/// `Drop`.
///
/// `source_priority` and `source_order` are the per-source values used
/// for `set_attributes_if_preferred` at merge time. The accumulator
/// itself does not aggregate them, so the caller — which knows the
/// original Epg's `priority` and `source_order` — must hand them in.
pub(crate) fn finish_into_disk(
self,
path: PathBuf,
source_priority: i16,
source_order: u32,
) -> io::Result<DiskEpgSource> {
// Fresh tree at the temp path. `store` creates the file; the
// subsequent updater opens it for batched writes.
BPlusTree::<EpgDiskChannelKey, EpgChannel>::new()
.store(&path)
.map_err(|e| io::Error::other(format!("create temp EPG tree: {e}")))?;
// The temp file exists from here until `DiskEpgSource::new` takes
// ownership at the end. Any `?` in between would otherwise leak the
// file because no `Drop` is wired up yet. The local guard covers the
// fallible window and is disarmed just before we hand the path off.
let mut temp_guard = TempFileGuard(Some(path.clone()));
let mut updater = BPlusTreeUpdate::<EpgDiskChannelKey, EpgChannel>::try_new_with_backoff(&path)
.map_err(|e| io::Error::other(format!("open temp EPG tree: {e}")))?;
updater.set_flush_policy(FlushPolicy::Batch);
let EpgMergeAccumulator { attributes, channels, dummy_policies: _ } = self;
let total = channels.len();
let mut batch: Vec<(EpgDiskChannelKey, EpgChannel)> = Vec::with_capacity(EPG_DISK_BATCH_SIZE);
let mut written = 0usize;
for (_, mut acc) in channels {
normalize_channel_programmes(&mut acc);
let folded = with_folded_epg_id(&acc.channel.id, |folded| folded.intern());
batch.push((
EpgDiskChannelKey {
folded_id: folded,
priority: acc.priority,
// `usize -> u32` overflow requires > 4 billion sources, which
// is not representable in the file format anyway; treat it
// as a programmer error rather than a recoverable I/O
// failure.
source_order: u32::try_from(acc.source_order)
.expect("source_order exceeds u32 (4 billion sources in one import)"),
},
acc.channel,
));
if batch.len() >= EPG_DISK_BATCH_SIZE {
flush_batch(&mut updater, &mut batch, &mut written)?;
}
}
flush_batch(&mut updater, &mut batch, &mut written)?;
updater.commit()?;
// Reclaim space wasted by priority-override entries (multiple keys per
// channel). Without compact, the file is ~2× its eventual size.
updater.compact()?;
log::debug!(
"Drained {written} channels ({total} total before normalize) into temp EPG tree at {}",
path.display()
);
let source = DiskEpgSource::new(
path,
attributes.map(|a| a.attributes),
source_priority,
source_order,
);
// DiskEpgSource now owns the path; disarm the guard so its Drop does
// not double-remove the file. `mem::forget` would also work, but
// `take()` keeps the guard in scope and is auditable.
temp_guard.0.take();
Ok(source)
}
}
/// Removes the wrapped file path on drop unless `take()` was called first.
/// Mirrors the cleanup logic of `DiskEpgSource::Drop` for the fallible window
/// inside `finish_into_disk` where no `DiskEpgSource` exists yet.
struct TempFileGuard(Option<PathBuf>);
impl Drop for TempFileGuard {
fn drop(&mut self) {
if let Some(path) = self.0.take() {
if let Err(err) = std::fs::remove_file(&path) {
if err.kind() != std::io::ErrorKind::NotFound {
log::warn!("Failed to remove temp EPG tree at {}: {err}", path.display());
}
}
}
}
}
/// Flush a non-empty batch and bump the written counter. No-op for empty
/// batches, so the caller does not need a guard around the trailing flush.
fn flush_batch(
updater: &mut BPlusTreeUpdate<EpgDiskChannelKey, EpgChannel>,
batch: &mut Vec<(EpgDiskChannelKey, EpgChannel)>,
written: &mut usize,
) -> io::Result<()> {
if batch.is_empty() {
return Ok(());
}
let items: Vec<(&EpgDiskChannelKey, &EpgChannel)> = batch.iter().map(|(k, v)| (k, v)).collect();
let prepared = BPlusTreeUpdate::<EpgDiskChannelKey, EpgChannel>::prepare_upsert_batch(&items)?;
updater.upsert_batch_encoded(prepared)?;
*written += batch.len();
batch.clear();
Ok(())
}
/// Sort key for the per-source temp `BPlusTree`. Order: folded channel id ascending,
/// then priority ascending, then source order ascending. This matches the existing
/// rule in `EpgMergeAccumulator::upsert_channel` (`(priority, source_order) < (acc.priority, acc.source_order)`)
/// — lower numbers win. Sorted iteration over the tree therefore yields channels in
/// the right order for the multi-way merge downstream.
///
/// The `Ord` implementation below is hand-written to make the value-based,
/// deterministic ordering explicit (`folded_id` → `priority` → `source_order`).
/// `Arc<str>` already compares by value when the inner `str` is `Ord`, so a
/// derived `Ord` would produce the same total order — the custom impl exists
/// to lock the contract in source rather than rely on a derived behaviour.
/// Same key type serialises through `rmp_serde` as a record.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub(crate) struct EpgDiskChannelKey {
#[serde(with = "arc_str_serde")]
pub folded_id: Arc<str>,
pub priority: i16,
pub source_order: u32,
}
// Explicit value-based ordering. `#[derive(Ord)]` would derive the same total
// order via `Arc<str>`'s value-based `Ord` impl, but writing it out documents
// the contract: `folded_id` ascending, then `priority` ascending, then
// `source_order` ascending. Sorted iteration in `merge_epg_trees` depends on
// this exact order for deterministic multi-way merge results.
impl Ord for EpgDiskChannelKey {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.folded_id
.as_ref()
.cmp(other.folded_id.as_ref())
.then(self.priority.cmp(&other.priority))
.then(self.source_order.cmp(&other.source_order))
}
}
impl PartialOrd for EpgDiskChannelKey {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { Some(self.cmp(other)) }
}
/// Handle for a temp `BPlusTree` that holds the channels of one EPG source.
/// `Drop` removes the file — even on panic — so the temp dir never accumulates
/// stale trees. The caller is expected to hand ownership to `merge_epg_trees`,
/// which opens a query handle and drains the file before letting the guard drop.
///
/// `source_priority` and `source_order` are the per-source values used at
/// merge time by `set_attributes_if_preferred`. They live on the source
/// rather than being reconstructed from the disk tree, because the disk
/// tree only stores per-channel priorities (which can differ across
/// channels if sources overlap), not a single source-level value.
pub(crate) struct DiskEpgSource {
pub(super) path: PathBuf,
pub(super) attributes: Option<HashMap<Arc<str>, Arc<str>>>,
pub(super) source_priority: i16,
pub(super) source_order: u32,
}
impl DiskEpgSource {
pub(crate) fn new(
path: PathBuf,
attributes: Option<HashMap<Arc<str>, Arc<str>>>,
source_priority: i16,
source_order: u32,
) -> Self {
Self { path, attributes, source_priority, source_order }
}
}
impl Drop for DiskEpgSource {
fn drop(&mut self) {
if let Err(err) = std::fs::remove_file(&self.path) {
// Missing-file is fine (already cleaned up). Anything else is worth
// a warning — it leaks the temp file until the OS clears /tmp.
if err.kind() != std::io::ErrorKind::NotFound {
log::warn!("Failed to remove temp EPG tree at {}: {err}", self.path.display());
}
}
}
}
fn apply_dummy_policies(channels: &mut [ChannelMergeAcc], dummy_policies: &HashMap<Arc<str>, PreferredDummyPolicy>) {
@@ -923,8 +1135,16 @@ fn backfill_programme_metadata(existing: &mut EpgProgramme, incoming: EpgProgram
}
fn normalize_channel_programmes(acc: &mut ChannelMergeAcc) {
// Always normalize programme order and dedupe, even for single-source channels.
// This preserves backfill behavior for duplicate entries within the same source.
// `acc.programmes` is the cross-source list of `ProgrammeMergeEntry` records
// (populated by `add_channel_with_programmes` and `push_programme`).
// When the list is empty, the channel's own programmes — set by the
// vacant path of `upsert_channel` — are authoritative; the previous
// implementation unconditionally overwrote `acc.channel.programmes` with
// an empty vector here, which silently dropped every programme on the
// single-source and disk-spill paths.
if acc.programmes.is_empty() {
return;
}
acc.programmes
.sort_by_key(|entry| (entry.programme.start, entry.programme.stop, entry.priority, entry.source_order));
@@ -994,6 +1214,51 @@ pub fn flatten_tvguide(tv_guides: Vec<Epg>) -> Option<Epg> {
accumulator.finish()
}
/// Merge N temp `BPlusTree`s (one per EPG source) into a single `Epg`,
/// applying dummy policies and the existing priority/sort rules. Sources
/// are consumed one at a time — each `DiskEpgSource`'s temp file is
/// removed by its `Drop` as soon as its iterator is exhausted, so peak RAM
/// is bounded by the largest single source plus the accumulator's
/// per-channel `HashMap`, not the total feed size.
///
/// Complexity: `O(n_sources + total_channels)`. The per-channel priority
/// resolution happens inside `EpgMergeAccumulator::upsert_channel`
/// (`HashMap` insert), not in a heap. Sources are processed in iteration
/// order, so earlier sources win priority ties for shared channels.
///
/// The constant-memory guarantee lives on the write side
/// (`EpgMergeAccumulator::finish_into_disk`); this function is bounded by
/// the union's metadata `HashMap` in the accumulator, which is the same
/// shape the in-memory path already carries.
///
/// The icon-override channel set returned by `finish_epg_with_icon_overrides`
/// is intentionally dropped — the wire-up path persists channels directly,
/// not the override metadata. If a caller later needs that set, the
/// `MergedEpgWithIconOverrides` tuple is already in scope via this
/// function's return type.
pub(crate) fn merge_epg_trees(sources: Vec<DiskEpgSource>) -> io::Result<Option<MergedEpgWithIconOverrides>> {
let mut accumulator = EpgMergeAccumulator::new();
for source in sources {
if let Some(attrs) = &source.attributes {
accumulator.set_attributes_if_preferred(source.source_priority, source.source_order as usize, Some(attrs.clone()));
}
let mut query = BPlusTreeQuery::<EpgDiskChannelKey, EpgChannel>::try_new(&source.path)
.map_err(|e| io::Error::other(format!("open temp EPG tree at {}: {e}", source.path.display())))?;
for entry in query.iter() {
let (key, channel) = entry.map_err(|e| io::Error::other(format!("read temp EPG entry: {e}")))?;
// `add_channel_with_programmes` preserves the channel's programmes
// on the merge accumulator; plain `upsert_channel` would drop them
// because the vacant-entry path constructs a fresh `ChannelMergeAcc`
// with `programmes: Vec::new()`.
accumulator.add_channel_with_programmes(key.priority, key.source_order as usize, false, channel);
}
// `source` drops at the end of this iteration, which removes the
// temp file. The query's mmap/buffer is closed first because `query`
// goes out of scope before `source`.
}
Ok(accumulator.finish_epg_with_icon_overrides())
}
#[cfg(test)]
mod tests {
use crate::{
@@ -1796,4 +2061,205 @@ mod tests {
assert!(programme.is_new);
});
}
/// Regression guard for the buffer-preallocation in `parse_tvguide`.
///
/// `quick_xml` uses the caller-provided `Vec<u8>` as a monotonically growing
/// read buffer (it does not call `.clear()` between events; the caller
/// slices the new portion). For an XMLTV feed with a single giant
/// `<programme>` description, that buffer used to start at 0 capacity and
/// double ~25 times on the way to ~163 MiB — every doubling being a
/// copying realloc. Starting with `Vec::with_capacity(64 * 1024)` removes
/// the realloc chain. This test feeds a programme whose description is
/// bigger than that initial capacity and checks that parsing still
/// succeeds; the absence of the test would have allowed a silent
/// regression to `Vec::new()` without breaking any visible behaviour.
#[test]
fn parse_tvguide_handles_giant_programme_description() {
use crate::processing::parser::xmltv::{EPG_TAG_DESC, EPG_TAG_PROGRAMME, parse_tvguide};
run_async_test(async {
// 200 KiB of text content — well beyond the 64 KiB preallocation.
let big_text = "x".repeat(200 * 1024);
// `channel` attribute is required for the parser to fire the
// callback on a <programme> tag (see `handle_tag_end`).
let xml = format!(
r#"<?xml version="1.0"?><tv><programme channel="c1"><title>t</title><desc>{big_text}</desc></programme></tv>"#
);
let mut emitted_tags: Vec<crate::model::XmlTag> = Vec::new();
parse_tvguide(xml.as_bytes(), &mut |tag| {
emitted_tags.push(tag);
})
.await;
// The whole point of the preallocation is that the full payload
// survives the round trip — not just the presence of a tag.
let programme = emitted_tags
.iter()
.find(|tag| tag.name.as_ref() == EPG_TAG_PROGRAMME)
.expect("parser emitted no <programme> tag");
let children = programme.children.as_deref().expect("<programme> has no children");
let desc = children
.iter()
.find(|child| child.name.as_ref() == EPG_TAG_DESC)
.expect("<programme> emitted no <desc> child");
assert_eq!(
desc.value.as_deref().map(str::len),
Some(big_text.len()),
"<desc> payload was truncated; got {} bytes, expected {}",
desc.value.as_deref().map_or(0, str::len),
big_text.len()
);
});
}
/// Smallest testable piece of the disk-spilling path. The Drop guard is
/// what keeps a panic or early return from leaking temp files into /tmp.
/// If this fails, every other disk-spilling test is built on sand.
#[test]
fn disk_epg_source_removes_its_temp_file_on_drop() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("epg-source.db");
std::fs::write(&path, b"placeholder").unwrap();
assert!(path.exists(), "precondition: temp file must exist");
{
let _src = super::DiskEpgSource::new(path.clone(), None, 0, 0);
} // _src dropped here
assert!(!path.exists(), "DiskEpgSource::Drop must remove its temp file");
}
/// Builds an accumulator with N channels and drains it to disk. Asserts:
/// (a) the temp file exists while the guard is alive, (b) it has
/// non-trivial size, (c) the file is removed after Drop. Together these
/// prove the writer runs end-to-end without panicking.
#[test]
fn finish_into_disk_writes_a_real_temp_tree() {
use super::EpgMergeAccumulator;
let mut acc = EpgMergeAccumulator::new();
for i in 0..250u32 {
let id: Arc<str> = format!("channel-{i:04}").into();
let ch = shared::model::EpgChannel {
id: Arc::clone(&id),
title: Some(format!("title {i}").into()),
icon: None,
programmes: vec![shared::model::EpgProgramme::new(i64::from(i), i64::from(i + 1), id)],
};
acc.upsert_channel(i16::try_from(i).unwrap_or(0), 0, false, ch);
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("epg-source.db");
let src = acc.finish_into_disk(path.clone(), 5, 0).unwrap();
assert!(path.exists(), "temp tree file must exist while guard is alive");
let size = std::fs::metadata(&path).unwrap().len();
assert!(size > 1024, "expected non-trivial size, got {size}");
drop(src);
assert!(!path.exists(), "Drop must remove the temp file");
}
/// Contract test: two temp trees written from two accumulators must
/// merge into the same `Epg` as the in-memory `finish_epg_with_icon_overrides`
/// would produce from the same inputs. Without this, every other
/// optimisation is built on sand.
#[test]
fn disk_path_matches_in_memory_finish() {
use super::{merge_epg_trees, EpgMergeAccumulator};
// Build one source by appending one programme per channel through the
// accumulator's primary entry point. `add_channel_with_programmes` is
// the same call the in-memory `merge_epg_channels_by_priority` uses,
// so the in-memory reference and the disk path go through identical
// APIs. The two sources use disjoint programme intervals
// (source 0: 0..1, source 1: 100..101) so the merged channel should
// retain both programmes end-to-end.
fn build_acc(source: usize, channels: std::ops::Range<u32>) -> EpgMergeAccumulator {
let mut acc = EpgMergeAccumulator::new();
for i in channels {
let id: Arc<str> = format!("ch-{i:04}").into();
let priority = if source == 0 { 5 } else { 3 }; // source 1 wins
let (start, stop) = if source == 0 { (i64::from(i), i64::from(i + 1)) } else { (100 + i64::from(i), 101 + i64::from(i)) };
acc.add_channel_with_programmes(
i16::try_from(priority).unwrap_or(0),
source,
false,
shared::model::EpgChannel {
id: Arc::clone(&id),
title: Some(format!("title-{source}-{i}").into()),
icon: None,
programmes: vec![shared::model::EpgProgramme::new(start, stop, id)],
},
);
}
acc
}
// Reference: in-memory merge of the same two sources.
let ref_acc_a = build_acc(0, 0..50);
let ref_acc_b = build_acc(1, 0..50);
// `EpgMergeAccumulator` is single-shot, so the reference merge has to
// rebuild a fresh accumulator. The two halves contribute disjoint
// programme intervals, so a single accumulator sees both.
let mut ref_acc = EpgMergeAccumulator::new();
for i in 0..50 {
let id: Arc<str> = format!("ch-{i:04}").into();
ref_acc.add_channel_with_programmes(
5,
0,
false,
shared::model::EpgChannel {
id: Arc::clone(&id),
title: Some(format!("title-0-{i}").into()),
icon: None,
programmes: vec![shared::model::EpgProgramme::new(i64::from(i), i64::from(i + 1), id)],
},
);
}
for i in 0..50 {
let id: Arc<str> = format!("ch-{i:04}").into();
ref_acc.add_channel_with_programmes(
3,
1,
false,
shared::model::EpgChannel {
id: Arc::clone(&id),
title: Some(format!("title-1-{i}").into()),
icon: None,
programmes: vec![shared::model::EpgProgramme::new(100 + i64::from(i), 101 + i64::from(i), id)],
},
);
}
let reference = ref_acc.finish_epg_with_icon_overrides().unwrap().0;
let _ = (ref_acc_a, ref_acc_b); // keep the per-source builder API in the test
// Disk path: two temp trees, then merge.
let dir = tempfile::tempdir().unwrap();
let src_a = build_acc(0, 0..50).finish_into_disk(dir.path().join("a.db"), 5, 0).unwrap();
let src_b = build_acc(1, 0..50).finish_into_disk(dir.path().join("b.db"), 3, 1).unwrap();
let merged = merge_epg_trees(vec![src_a, src_b]).unwrap().unwrap().0;
assert_eq!(reference.children.len(), merged.children.len(), "channel counts must match");
for (left, right) in reference.children.iter().zip(merged.children.iter()) {
assert_eq!(left.id, right.id, "channel order must match");
// Source 1 had priority 3 (lower = wins) so its title should win.
assert_eq!(left.title, right.title, "priority winner's title must propagate");
// Both sources contribute distinct, non-overlapping programmes.
// The merged channel must keep both — the disk path uses
// `add_channel_with_programmes` so nothing is dropped on the way in.
assert_eq!(
left.programmes.len(),
2,
"channel {} should retain both source programmes, got {}",
left.id,
left.programmes.len()
);
assert_eq!(right.programmes.len(), 2, "disk-merged channel {} lost programmes", right.id);
for (lp, rp) in left.programmes.iter().zip(right.programmes.iter()) {
assert_eq!(lp.start, rp.start, "programme start must match for channel {}", left.id);
assert_eq!(lp.stop, rp.stop, "programme stop must match for channel {}", left.id);
}
}
}
}
+174 -2
View File
@@ -20,7 +20,7 @@ use crate::{
processing::{
input_cache,
input_cache::ClusterState,
parser::xmltv::flatten_tvguide,
parser::xmltv::{flatten_tvguide, EpgMergeAccumulator, merge_epg_trees},
playlist_watch::process_group_watch,
processor::{
epg::process_playlist_epg, library, sort::sort_playlist, stalker, StalkerRefreshMode,
@@ -1525,6 +1525,45 @@ async fn prepare_playlist_for_target(
Ok(PreparedTarget { target: target.clone(), playlist: new_playlist, epg: new_epg })
}
/// Spill each `Epg` source to a temp `BPlusTree` and merge them. Extracted
/// from `finalize_prepared_target` so it can be unit-tested without
/// constructing a full `PlaylistProcessingContext`.
///
/// Returns `Ok(None)` if `sources` is empty (no EPG to merge), matching
/// the contract of `flatten_tvguide`. The temp directory lives inside
/// this function call — all temp files are removed by the
/// `DiskEpgSource` drop guards before this function returns.
fn spill_epg_to_disk(sources: Vec<Epg>) -> Result<Option<Epg>, TuliproxError> {
let dir = tempfile::tempdir()
.map_err(|e| TuliproxError::RepositoryXtream(format!("tempdir for EPG spill: {e}")))?;
let mut disk_sources = Vec::with_capacity(sources.len());
for (source_order, guide) in sources.into_iter().enumerate() {
let mut acc = EpgMergeAccumulator::new();
acc.set_attributes_if_preferred(guide.priority, source_order, guide.attributes);
for channel in guide.children {
acc.add_channel_with_programmes(
guide.priority,
source_order,
guide.logo_override,
std::sync::Arc::unwrap_or_clone(channel),
);
}
let path = dir.path().join(format!("epg-src-{source_order}.db"));
let source_order_u32 = u32::try_from(source_order).unwrap_or(0);
let source = acc
.finish_into_disk(path, guide.priority, source_order_u32)
.map_err(|e| TuliproxError::RepositoryXtream(format!("EPG spill to disk failed: {e}")))?;
disk_sources.push(source);
}
if disk_sources.is_empty() {
Ok(None)
} else {
merge_epg_trees(disk_sources)
.map_err(|e| TuliproxError::RepositoryXtream(format!("EPG disk merge failed: {e}")))
.map(|opt| opt.map(|(epg, _)| epg))
}
}
async fn finalize_prepared_target(
ctx: Arc<PlaylistProcessingContext>,
prepared: PreparedTarget,
@@ -1571,10 +1610,32 @@ async fn finalize_prepared_target(
step.tick("group watches");
log_memory_snapshot(format!("target '{}' after_group_watches", target.name).as_str());
}
let merged_epg = if ctx.config.config.load().disk_based_processing {
// Per-source drain to disk, then multi-way merge. Errors are pushed
// to `errors` rather than `?` because the function returns
// `(Result, Vec<TuliproxError>)`, not `Result` directly. We must
// surface tempdir / write / merge failures — the user opted in to
// disk spilling, and silently falling back to the in-memory path
// can OOM on large feeds. When the spill itself fails we skip the
// persist step entirely: continuing with `merged_epg = None` would
// overwrite the existing on-disk EPG with nothing and discard the
// previously persisted artifact on a transient error.
match spill_epg_to_disk(new_epg) {
Ok(epg) => epg,
Err(err) => {
errors.push(err);
step.stop("EPG spill failed; skipping persist to preserve existing EPG");
log_memory_snapshot(format!("target '{}' after_persist", target.name).as_str());
return (Ok(()), errors);
}
}
} else {
flatten_tvguide(new_epg)
};
let result = persist_playlist(
&ctx.config,
&mut flat_new_playlist,
flatten_tvguide(new_epg).as_ref(),
merged_epg.as_ref(),
target,
ctx.playlist_state.as_ref(),
)
@@ -3181,3 +3242,114 @@ match {
}
}
}
#[cfg(test)]
mod disk_epg_wireup_tests {
use super::spill_epg_to_disk;
use crate::model::Epg;
use shared::model::EpgChannel;
use std::sync::Arc;
/// Build an `Epg` with `channel_count` channels whose ids follow the
/// `id_base` prefix. Two sources built with the same `id_base` and
/// `channel_count` will share all channel ids, which is what we need to
/// exercise the priority-override `Occupied` branch in
/// `EpgMergeAccumulator::upsert_channel`.
fn build_epg(id_base: &str, priority: i16, channel_count: usize) -> Epg {
Epg {
priority,
logo_override: false,
attributes: None,
children: (0..channel_count)
.map(|i| {
let id: Arc<str> = format!("{id_base}-ch-{i:04}").into();
Arc::new(EpgChannel {
id: Arc::clone(&id),
title: Some(format!("title-{priority}-{i}").into()),
icon: None,
programmes: vec![shared::model::EpgProgramme::new(
i64::try_from(i).expect("test index fits in i64"),
i64::try_from(i + 1).expect("test index fits in i64"),
id,
)],
})
})
.collect(),
}
}
/// Wire-up regression guard: `spill_epg_to_disk` is the function called
/// by `finalize_prepared_target` when `disk_based_processing = true`. It
/// must (a) preserve per-source priority on shared channels, (b) clean up
/// its temp files, and (c) merge into a single `Epg` of the right size.
///
/// Both sources share channel ids (`shared-ch-NNNN`), forcing the merge
/// to take the `Occupied` branch in `EpgMergeAccumulator::upsert_channel`.
/// The lower-priority source (priority 3) must win, the higher-priority
/// (priority 7) must be discarded for shared ids. Without this assertion
/// the test would pass even if priority resolution were broken — the
/// earlier version used unique ids and therefore never hit the merge path.
#[test]
fn spill_epg_to_disk_merges_shared_channels_by_priority() {
let epg_low = build_epg("shared", 3, 50); // wins on every shared channel
let epg_high = build_epg("shared", 7, 50); // discarded on every shared channel
let merged = spill_epg_to_disk(vec![epg_low, epg_high])
.expect("disk merge returned an error")
.expect("merged Epg is unexpectedly None for two non-empty sources");
// 50 distinct channels, not 100 — the merge must have collapsed the
// shared ids.
assert_eq!(
merged.children.len(),
50,
"shared channel ids must collapse to one entry, not be duplicated"
);
// Every channel title comes from the lower-priority source. If the
// merge logic is wrong, some titles will carry the "-7-" marker.
for ch in &merged.children {
let title = ch.title.as_deref().expect("title preserved through merge");
assert!(
title.starts_with("title-3-"),
"channel {:?} kept title {title:?} from higher-priority source; \
priority override is broken",
ch.id,
);
// `add_channel_with_programmes` on the disk-merge path must
// preserve the lower-priority source's single programme per
// channel — `upsert_channel` would silently drop them.
assert_eq!(
ch.programmes.len(),
1,
"channel {:?} lost programmes through the disk-merge path",
ch.id
);
let prog = &ch.programmes[0];
assert!(prog.title.is_none() || prog.title.as_deref() != Some("title-7"));
}
}
/// The non-shared case: sources with disjoint channel ids. Both
/// sources' channels appear in the result with no priority loss (no
/// `Occupied` branch is taken).
#[test]
fn spill_epg_to_disk_keeps_disjoint_sources_intact() {
let epg_low = build_epg("src-a", 3, 50);
let epg_high = build_epg("src-b", 7, 50);
let merged = spill_epg_to_disk(vec![epg_low, epg_high])
.expect("disk merge returned an error")
.expect("merged Epg is unexpectedly None for two non-empty sources");
assert_eq!(merged.children.len(), 100, "disjoint ids must not collapse");
assert!(merged.children.iter().any(|ch| ch.title.as_deref() == Some("title-3-0")));
assert!(merged.children.iter().any(|ch| ch.title.as_deref() == Some("title-7-0")));
}
#[test]
fn spill_epg_to_disk_returns_none_for_empty_input() {
let merged = spill_epg_to_disk(vec![]).expect("disk merge returned an error");
assert!(merged.is_none());
}
}