use std::fmt;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use arrow::datatypes::SchemaRef;
use arrow::record_batch::RecordBatch;
use datafusion::error::{DataFusionError, Result as DFResult};
use datafusion::execution::TaskContext;
use datafusion::physical_expr::EquivalenceProperties;
use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
use datafusion::physical_plan::{
DisplayAs, DisplayFormatType, ExecutionPlan, Partitioning, PlanProperties,
SendableRecordBatchStream,
};
use futures::stream::{self, StreamExt};
use super::engine::RssEngine;
use super::schema::{feeds_schema, items_schema};
const MAX_SCAN_TIMEOUT: Duration = Duration::from_secs(60 * 60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RssTableKind {
Feeds,
Items,
}
impl RssTableKind {
pub fn schema(self) -> SchemaRef {
match self {
Self::Feeds => feeds_schema(),
Self::Items => items_schema(),
}
}
fn as_str(self) -> &'static str {
match self {
Self::Feeds => "feeds",
Self::Items => "items",
}
}
}
struct ScanShared {
emitted: AtomicUsize,
deadline: Instant,
}
impl ScanShared {
fn gate_open(&self, limit: Option<usize>) -> bool {
limit.is_none_or(|rows| self.emitted.load(Ordering::SeqCst) < rows)
}
}
pub struct RssScanExec {
engine: Arc<RssEngine>,
kind: RssTableKind,
feeds: Vec<String>,
projection: Option<Vec<usize>>,
limit: Option<usize>,
schema: SchemaRef,
properties: PlanProperties,
shared: Arc<ScanShared>,
}
impl fmt::Debug for RssScanExec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RssScanExec")
.field("kind", &self.kind)
.field("feeds", &self.feeds.len())
.field("projection", &self.projection)
.field("limit", &self.limit)
.finish()
}
}
impl RssScanExec {
pub fn new(
engine: Arc<RssEngine>,
kind: RssTableKind,
feeds: Vec<String>,
projection: Option<Vec<usize>>,
limit: Option<usize>,
) -> DFResult<Self> {
let full_schema = kind.schema();
let schema = match &projection {
Some(indices) => Arc::new(full_schema.project(indices)?),
None => full_schema,
};
let properties = PlanProperties::new(
EquivalenceProperties::new(Arc::clone(&schema)),
Partitioning::UnknownPartitioning(feeds.len().max(1)),
EmissionType::Incremental,
Boundedness::Bounded,
);
let configured_timeout = engine.scan_timeout();
if configured_timeout > MAX_SCAN_TIMEOUT {
tracing::warn!(
configured_scan_timeout_seconds = configured_timeout.as_secs(),
effective_scan_timeout_seconds = MAX_SCAN_TIMEOUT.as_secs(),
"rss scan_timeout_seconds clamped to the exec layer's ceiling"
);
}
let shared = Arc::new(ScanShared {
emitted: AtomicUsize::new(0),
deadline: scan_deadline(Instant::now(), configured_timeout),
});
Ok(Self {
engine,
kind,
feeds,
projection,
limit,
schema,
properties,
shared,
})
}
}
impl DisplayAs for RssScanExec {
fn fmt_as(&self, _t: DisplayFormatType, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"RssScanExec: kind={} feeds={} limit={:?}",
self.kind.as_str(),
self.feeds.len(),
self.limit
)
}
}
impl ExecutionPlan for RssScanExec {
fn name(&self) -> &str {
"RssScanExec"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn properties(&self) -> &PlanProperties {
&self.properties
}
fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
vec![]
}
fn with_new_children(
self: Arc<Self>,
children: Vec<Arc<dyn ExecutionPlan>>,
) -> DFResult<Arc<dyn ExecutionPlan>> {
if children.is_empty() {
Ok(self)
} else {
Err(DataFusionError::Internal(
"RssScanExec is a leaf plan and takes no children".to_string(),
))
}
}
fn reset_state(self: Arc<Self>) -> DFResult<Arc<dyn ExecutionPlan>> {
Ok(Arc::new(Self::new(
Arc::clone(&self.engine),
self.kind,
self.feeds.clone(),
self.projection.clone(),
self.limit,
)?))
}
fn execute(
&self,
partition: usize,
_context: Arc<TaskContext>,
) -> DFResult<SendableRecordBatchStream> {
let partitions = self.properties.partitioning.partition_count();
if partition >= partitions {
return Err(DataFusionError::Internal(format!(
"RssScanExec has {partitions} partitions, got partition {partition}"
)));
}
let Some(feed) = self.feeds.get(partition).cloned() else {
return Ok(Box::pin(RecordBatchStreamAdapter::new(
Arc::clone(&self.schema),
stream::empty(),
)));
};
let engine = Arc::clone(&self.engine);
let shared = Arc::clone(&self.shared);
let kind = self.kind;
let limit = self.limit;
let projection = self.projection.clone();
let served = async move {
let batch = match kind {
RssTableKind::Feeds => Some(engine.feeds_row(&feed)),
RssTableKind::Items => serve_items(&engine, &feed, limit, &shared).await,
};
batch.map(|batch| project(batch, projection.as_deref()))
};
let stream = stream::once(served).flat_map(stream::iter);
Ok(Box::pin(RecordBatchStreamAdapter::new(
Arc::clone(&self.schema),
stream,
)))
}
}
async fn serve_items(
engine: &RssEngine,
feed: &str,
limit: Option<usize>,
shared: &ScanShared,
) -> Option<RecordBatch> {
if !shared.gate_open(limit) {
return None;
}
let deadline = tokio::time::Instant::from_std(shared.deadline);
match tokio::time::timeout_at(
deadline,
engine.serve_feed(feed, || shared.gate_open(limit)),
)
.await
{
Ok(batch) => {
if let Some(batch) = &batch {
shared.emitted.fetch_add(batch.num_rows(), Ordering::SeqCst);
}
batch
}
Err(_elapsed) => {
tracing::warn!(
feed,
scan_timeout_seconds = engine.scan_timeout().as_secs(),
"rss scan deadline reached"
);
None
}
}
}
fn project(batch: RecordBatch, projection: Option<&[usize]>) -> DFResult<RecordBatch> {
match projection {
Some(indices) => batch.project(indices).map_err(DataFusionError::from),
None => Ok(batch),
}
}
fn scan_deadline(now: Instant, timeout: Duration) -> Instant {
now.checked_add(timeout.min(MAX_SCAN_TIMEOUT))
.unwrap_or(now)
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use datafusion::common::stats::Precision;
use super::*;
use crate::sources::providers::open_connector::testutil::{CapturedEvent, capture_events};
use crate::sources::providers::rss::schema::WINDOW_STATUS_IDX;
use crate::sources::providers::rss::testutil::{
MockFeedServer, MockResponse, MockResponseExt, RSS2_MINIMAL, collect_stream, feed_urls,
str_col, test_engine, total_rows,
};
fn ctx() -> Arc<TaskContext> {
Arc::new(TaskContext::default())
}
fn offline_engine(feeds: &[&str]) -> Arc<RssEngine> {
let urls: Vec<(String, String)> = feeds
.iter()
.map(|name| {
(
(*name).to_string(),
format!("http://feed.invalid/{name}.xml"),
)
})
.collect();
Arc::new(test_engine(&urls, |_| {}))
}
fn items_exec(
engine: Arc<RssEngine>,
feeds: &[&str],
projection: Option<Vec<usize>>,
limit: Option<usize>,
) -> RssScanExec {
RssScanExec::new(
engine,
RssTableKind::Items,
feeds.iter().map(|f| (*f).to_string()).collect(),
projection,
limit,
)
.expect("build the items exec")
}
fn events_with_message(
events: &Arc<Mutex<Vec<CapturedEvent>>>,
message: &str,
) -> Vec<CapturedEvent> {
events
.lock()
.unwrap_or_else(|p| p.into_inner())
.iter()
.filter(|event| event.message == message)
.cloned()
.collect()
}
#[tokio::test]
async fn items_partitions_stream_independently_and_stamp_status() {
let server = MockFeedServer::start(|req| match req.path.as_str() {
"/a.xml" => MockResponse::xml(RSS2_MINIMAL),
_ => MockResponse::status(500),
})
.await;
let engine = Arc::new(test_engine(
&feed_urls(&server, &[("a", "/a.xml"), ("b", "/b.xml")]),
|_| {},
));
let exec = items_exec(Arc::clone(&engine), &["a", "b"], None, None);
assert_eq!(exec.properties().partitioning.partition_count(), 2);
let a = collect_stream(exec.execute(0, ctx())).await;
let b = collect_stream(exec.execute(1, ctx())).await;
assert_eq!(total_rows(&a), 1, "the healthy feed serves its window");
assert_eq!(str_col(&a[0], "feed"), vec!["a"]);
assert_eq!(str_col(&a[0], "window_status"), vec!["fresh"]);
assert!(
b.is_empty(),
"the 500 feed has no window to serve, and degrades to zero rows rather than \
failing the partition"
);
assert_eq!(
str_col(&engine.feeds_row("b"), "last_status"),
vec!["error"],
"the failure is data on the feeds table, not a scan error"
);
}
#[tokio::test]
async fn limit_satisfied_stops_launching_fetches() {
let server = MockFeedServer::start(|_| MockResponse::xml(RSS2_MINIMAL)).await;
let engine = Arc::new(test_engine(
&feed_urls(&server, &[("a", "/a"), ("b", "/b"), ("c", "/c")]),
|config| config.max_concurrent = 1,
));
let exec = items_exec(Arc::clone(&engine), &["a", "b", "c"], None, Some(1));
let mut rows = 0;
for partition in 0..3 {
rows += total_rows(&collect_stream(exec.execute(partition, ctx())).await);
}
assert_eq!(rows, 1);
assert_eq!(server.requests().len(), 1, "one fetch for one row of LIMIT");
for feed in ["b", "c"] {
assert_eq!(
str_col(&engine.feeds_row(feed), "last_status"),
vec!["never"],
"a gated-off feed is neither fetched nor health-refreshed"
);
}
}
#[tokio::test]
async fn a_queued_partition_is_stopped_by_the_gate_after_the_permit() {
let server = MockFeedServer::start(|_| {
MockResponse::xml(RSS2_MINIMAL).with_delay(Duration::from_millis(50))
})
.await;
let engine = Arc::new(test_engine(
&feed_urls(&server, &[("a", "/a.xml"), ("b", "/b.xml")]),
|config| config.max_concurrent = 1,
));
let exec = items_exec(Arc::clone(&engine), &["a", "b"], None, Some(1));
let (a, b) = tokio::join!(
biased;
collect_stream(exec.execute(0, ctx())),
collect_stream(exec.execute(1, ctx())),
);
assert_eq!(total_rows(&a), 1);
assert!(b.is_empty(), "the queued partition serves nothing");
let paths: Vec<String> = server
.requests()
.iter()
.map(|request| request.path.clone())
.collect();
assert_eq!(
paths,
vec!["/a.xml".to_string()],
"the queued partition's fetch must never be launched"
);
assert_eq!(
str_col(&engine.feeds_row("b"), "last_status"),
vec!["never"]
);
}
#[tokio::test]
async fn empty_projection_preserves_row_count() {
let server = MockFeedServer::start(|_| MockResponse::xml(RSS2_MINIMAL)).await;
let engine = Arc::new(test_engine(&feed_urls(&server, &[("a", "/a.xml")]), |_| {}));
let exec = items_exec(engine, &["a"], Some(vec![]), None);
assert_eq!(exec.schema().fields().len(), 0);
let batches = collect_stream(exec.execute(0, ctx())).await;
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].num_columns(), 0);
assert_eq!(
batches[0].num_rows(),
1,
"a zero-column batch must still carry the window's row count"
);
assert_eq!(
batches[0].schema(),
exec.schema(),
"the emitted batch's schema is the one the plan advertises, metadata included"
);
}
#[tokio::test]
async fn feeds_kind_never_fetches() {
let server = MockFeedServer::start(|_| MockResponse::xml(RSS2_MINIMAL)).await;
let engine = Arc::new(test_engine(
&feed_urls(&server, &[("a", "/a.xml"), ("b", "/b.xml")]),
|_| {},
));
let exec = RssScanExec::new(
engine,
RssTableKind::Feeds,
vec!["a".to_string(), "b".to_string()],
None,
None,
)
.expect("build the feeds exec");
assert_eq!(exec.schema(), feeds_schema());
let a = collect_stream(exec.execute(0, ctx())).await;
let b = collect_stream(exec.execute(1, ctx())).await;
assert_eq!(total_rows(&a), 1);
assert_eq!(total_rows(&b), 1);
assert_eq!(str_col(&a[0], "name"), vec!["a"]);
assert_eq!(str_col(&a[0], "last_status"), vec!["never"]);
assert_eq!(str_col(&b[0], "name"), vec!["b"]);
assert!(
server.requests().is_empty(),
"a feeds scan issues no requests"
);
}
#[tokio::test]
async fn projection_prunes_columns() {
let server = MockFeedServer::start(|_| MockResponse::xml(RSS2_MINIMAL)).await;
let engine = Arc::new(test_engine(&feed_urls(&server, &[("a", "/a.xml")]), |_| {}));
let exec = items_exec(engine, &["a"], Some(vec![0, WINDOW_STATUS_IDX]), None);
let schema = exec.schema();
let names: Vec<&str> = schema
.fields()
.iter()
.map(|field| field.name().as_str())
.collect();
assert_eq!(names, vec!["feed", "window_status"]);
let batches = collect_stream(exec.execute(0, ctx())).await;
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].num_columns(), 2);
assert_eq!(batches[0].schema(), exec.schema());
assert_eq!(str_col(&batches[0], "feed"), vec!["a"]);
assert_eq!(str_col(&batches[0], "window_status"), vec!["fresh"]);
}
#[tokio::test]
async fn the_scan_deadline_degrades_one_partition_to_zero_rows() {
let server = MockFeedServer::start(|_| {
MockResponse::xml(RSS2_MINIMAL).with_delay(Duration::from_secs(3))
})
.await;
let engine = Arc::new(test_engine(
&feed_urls(&server, &[("a", "/a.xml")]),
|config| config.scan_timeout_seconds = 1,
));
let exec = items_exec(Arc::clone(&engine), &["a"], None, None);
let (_guard, events) = capture_events();
let batches = tokio::time::timeout(
Duration::from_secs(20),
collect_stream(exec.execute(0, ctx())),
)
.await
.expect("the partition must end at its deadline rather than hang");
assert!(
batches.is_empty(),
"a partition past the scan deadline emits zero rows"
);
assert_eq!(
server.requests().len(),
1,
"the fetch was launched and then abandoned, so it was the deadline that cut it"
);
let warns = events_with_message(&events, "rss scan deadline reached");
assert_eq!(warns.len(), 1, "one warning for the degraded partition");
assert_eq!(warns[0].level, tracing::Level::WARN);
assert_eq!(warns[0].field("feed"), Some("a"));
assert_eq!(
str_col(&engine.feeds_row("a"), "last_status"),
vec!["never"],
"an abandoned fetch writes no health state"
);
}
#[tokio::test]
async fn an_absurd_scan_timeout_is_clamped_rather_than_overflowing() {
let server = MockFeedServer::start(|_| MockResponse::xml(RSS2_MINIMAL)).await;
let engine = Arc::new(test_engine(
&feed_urls(&server, &[("a", "/a.xml")]),
|config| config.scan_timeout_seconds = u64::MAX,
));
let (_guard, events) = capture_events();
let exec = items_exec(engine, &["a"], None, None);
assert_eq!(total_rows(&collect_stream(exec.execute(0, ctx())).await), 1);
let clamped = events_with_message(
&events,
"rss scan_timeout_seconds clamped to the exec layer's ceiling",
);
assert_eq!(clamped.len(), 1, "one warning per plan built");
assert_eq!(clamped[0].level, tracing::Level::WARN);
assert_eq!(
clamped[0].field("configured_scan_timeout_seconds"),
Some(u64::MAX.to_string().as_str())
);
assert_eq!(
clamped[0].field("effective_scan_timeout_seconds"),
Some(MAX_SCAN_TIMEOUT.as_secs().to_string().as_str())
);
}
#[tokio::test]
async fn a_zero_limit_closes_the_gate_for_every_partition() {
let server = MockFeedServer::start(|_| MockResponse::xml(RSS2_MINIMAL)).await;
let engine = Arc::new(test_engine(
&feed_urls(&server, &[("a", "/a.xml"), ("b", "/b.xml")]),
|_| {},
));
let exec = items_exec(Arc::clone(&engine), &["a", "b"], None, Some(0));
for partition in 0..2 {
assert!(
collect_stream(exec.execute(partition, ctx()))
.await
.is_empty(),
"partition {partition} serves nothing under LIMIT 0"
);
}
assert!(server.requests().is_empty(), "and fetches nothing");
for feed in ["a", "b"] {
assert_eq!(
str_col(&engine.feeds_row(feed), "last_status"),
vec!["never"]
);
}
}
#[tokio::test]
async fn empty_projection_over_feeds_preserves_row_count() {
let server = MockFeedServer::start(|_| MockResponse::xml(RSS2_MINIMAL)).await;
let engine = Arc::new(test_engine(&feed_urls(&server, &[("a", "/a.xml")]), |_| {}));
let exec = RssScanExec::new(
engine,
RssTableKind::Feeds,
vec!["a".to_string()],
Some(vec![]),
None,
)
.expect("build the feeds exec");
let batches = collect_stream(exec.execute(0, ctx())).await;
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].num_columns(), 0);
assert_eq!(batches[0].num_rows(), 1);
assert_eq!(batches[0].schema(), exec.schema());
assert!(server.requests().is_empty());
}
#[tokio::test]
async fn a_deadlined_partition_releases_the_permit_for_the_next_scan() {
let server = MockFeedServer::start(|req| match req.path.as_str() {
"/slow.xml" => MockResponse::xml(RSS2_MINIMAL).with_delay(Duration::from_secs(3)),
_ => MockResponse::xml(RSS2_MINIMAL),
})
.await;
let engine = Arc::new(test_engine(
&feed_urls(&server, &[("slow", "/slow.xml"), ("fast", "/fast.xml")]),
|config| {
config.scan_timeout_seconds = 1;
config.max_concurrent = 1;
},
));
let (_guard, events) = capture_events();
let deadlined = items_exec(Arc::clone(&engine), &["slow"], None, None);
assert!(
collect_stream(deadlined.execute(0, ctx())).await.is_empty(),
"the slow feed outruns the scan deadline"
);
let warns = events_with_message(&events, "rss scan deadline reached");
assert_eq!(warns.len(), 1);
assert_eq!(warns[0].field("feed"), Some("slow"));
let next = items_exec(Arc::clone(&engine), &["fast"], None, None);
let batches = tokio::time::timeout(
Duration::from_secs(20),
collect_stream(next.execute(0, ctx())),
)
.await
.expect("a leaked permit would park this scan forever");
assert_eq!(total_rows(&batches), 1);
assert_eq!(
str_col(&engine.feeds_row("fast"), "last_status"),
vec!["fresh"]
);
}
#[tokio::test]
async fn reset_state_rebuilds_a_scan_whose_limit_was_already_satisfied() {
let server = MockFeedServer::start(|_| MockResponse::xml(RSS2_MINIMAL)).await;
let engine = Arc::new(test_engine(
&feed_urls(&server, &[("a", "/a.xml")]),
|config| {
config.ttl_seconds = 0;
},
));
let exec: Arc<dyn ExecutionPlan> =
Arc::new(items_exec(Arc::clone(&engine), &["a"], None, Some(1)));
assert_eq!(
total_rows(&collect_stream(exec.execute(0, ctx())).await),
1,
"the first execution fills the LIMIT"
);
assert_eq!(server.requests().len(), 1);
assert!(
collect_stream(Arc::clone(&exec).execute(0, ctx()))
.await
.is_empty(),
"re-executing the same plan object sees its own satisfied LIMIT"
);
assert_eq!(server.requests().len(), 1, "and issues no request");
let reset = Arc::clone(&exec)
.reset_state()
.expect("reset_state rebuilds the plan");
assert_eq!(
total_rows(&collect_stream(reset.execute(0, ctx())).await),
1,
"the reset plan serves again: its counter and deadline are fresh"
);
assert_eq!(
server.requests().len(),
2,
"and it actually fetched rather than replaying anything"
);
}
#[test]
fn scan_deadline_clamps_to_the_ceiling() {
let now = Instant::now();
assert_eq!(
scan_deadline(now, Duration::from_secs(30)),
now + Duration::from_secs(30)
);
assert_eq!(scan_deadline(now, Duration::MAX), now + MAX_SCAN_TIMEOUT);
}
#[tokio::test]
async fn an_empty_feed_list_is_one_partition_serving_nothing() {
let exec = items_exec(offline_engine(&["a"]), &[], None, None);
assert_eq!(exec.properties().partitioning.partition_count(), 1);
assert!(collect_stream(exec.execute(0, ctx())).await.is_empty());
}
#[tokio::test]
async fn executing_a_partition_past_the_last_feed_is_an_internal_error() {
let exec = items_exec(offline_engine(&["a", "b"]), &["a", "b"], None, None);
let message = match exec.execute(2, ctx()) {
Ok(_) => panic!("there is no third partition to execute"),
Err(error) => error.to_string(),
};
assert!(
message.contains("RssScanExec has 2 partitions, got partition 2"),
"the error names the bound it broke: {message}"
);
}
#[tokio::test]
async fn with_new_children_rejects_children() {
let exec: Arc<RssScanExec> =
Arc::new(items_exec(offline_engine(&["a"]), &["a"], None, None));
let child: Arc<dyn ExecutionPlan> = Arc::clone(&exec) as Arc<dyn ExecutionPlan>;
let error = Arc::clone(&exec)
.with_new_children(vec![child])
.expect_err("a leaf plan takes no children");
let message = error.to_string();
assert!(
message.contains("RssScanExec is a leaf plan and takes no children"),
"the error says why: {message}"
);
assert!(
Arc::clone(&exec).with_new_children(vec![]).is_ok(),
"an empty child list is the identity"
);
}
#[tokio::test]
async fn an_out_of_range_projection_is_rejected_at_construction() {
let error = RssScanExec::new(
offline_engine(&["a"]),
RssTableKind::Items,
vec!["a".to_string()],
Some(vec![99]),
None,
)
.expect_err("99 is not a column of items");
let message = error.to_string();
assert!(
message.contains("project index 99 out of bounds"),
"the error names the bad index: {message}"
);
}
#[tokio::test]
async fn a_scan_advertises_no_row_count() {
for kind in [RssTableKind::Items, RssTableKind::Feeds] {
let exec = RssScanExec::new(
offline_engine(&["a", "b"]),
kind,
vec!["a".to_string(), "b".to_string()],
None,
None,
)
.expect("build the exec");
let stats = exec.partition_statistics(None).expect("plan statistics");
assert_eq!(stats.num_rows, Precision::Absent, "{kind:?}");
let partition = exec.partition_statistics(Some(1)).expect("partition stats");
assert_eq!(partition.num_rows, Precision::Absent, "{kind:?}");
}
}
#[tokio::test]
async fn display_names_the_kind_feed_count_and_limit() {
let exec = items_exec(
offline_engine(&["a", "b", "c"]),
&["a", "b", "c"],
None,
Some(1),
);
let plan: Arc<dyn ExecutionPlan> = Arc::new(exec);
let display = datafusion::physical_plan::displayable(plan.as_ref())
.one_line()
.to_string();
assert_eq!(
display.trim_end(),
"RssScanExec: kind=items feeds=3 limit=Some(1)"
);
}
}