1use std::fmt;
13use std::sync::Arc;
14
15use datafusion::arrow::datatypes::SchemaRef;
16use datafusion::arrow::record_batch::RecordBatch;
17use datafusion::error::Result;
18use datafusion::execution::TaskContext;
19use datafusion::physical_expr::EquivalenceProperties;
20use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
21use datafusion::physical_plan::metrics::{BaselineMetrics, ExecutionPlanMetricsSet, MetricsSet};
22use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
23use datafusion::physical_plan::{
24 DisplayAs, DisplayFormatType, ExecutionPlan, Partitioning, PlanProperties, SendableRecordBatchStream,
25};
26use futures::StreamExt;
27use serde_json::Value;
28
29use crate::client::OpenSearchClient;
30use crate::schema::build_batch;
31
32const KEEP_ALIVE: &str = "1m";
34
35#[derive(Debug)]
37pub struct OpenSearchExec {
38 inner: Arc<Inner>,
39 properties: Arc<PlanProperties>,
40 metrics: ExecutionPlanMetricsSet,
41}
42
43#[derive(Clone, Debug)]
44struct Inner {
45 client: OpenSearchClient,
46 index: String,
47 body: Value,
49 schema: SchemaRef,
50 page_size: usize,
51 fetch: Option<usize>,
53 single_page: bool,
55 partitions: usize,
57}
58
59impl OpenSearchExec {
60 pub fn new(client: OpenSearchClient, index: String, body: Value, schema: SchemaRef) -> Self {
64 let inner = Inner {
65 client,
66 index,
67 body,
68 schema,
69 page_size: crate::table::DEFAULT_PAGE_SIZE,
70 fetch: None,
71 single_page: false,
72 partitions: 1,
73 };
74 Self::from_inner(inner)
75 }
76
77 fn from_inner(inner: Inner) -> Self {
78 let partitions = if inner.fetch.is_some() || inner.single_page {
80 1
81 } else {
82 inner.partitions.max(1)
83 };
84 let inner = Inner { partitions, ..inner };
85 let properties = Arc::new(PlanProperties::new(
86 EquivalenceProperties::new(inner.schema.clone()),
87 Partitioning::UnknownPartitioning(partitions),
88 EmissionType::Incremental,
89 Boundedness::Bounded,
90 ));
91 Self {
92 inner: Arc::new(inner),
93 properties,
94 metrics: ExecutionPlanMetricsSet::new(),
95 }
96 }
97
98 fn rebuild(&self, f: impl FnOnce(&mut Inner)) -> Self {
99 let mut inner = self.inner.as_ref().clone();
100 f(&mut inner);
101 Self::from_inner(inner)
102 }
103
104 pub fn with_page_size(&self, n: usize) -> Self {
106 self.rebuild(|i| i.page_size = n.max(1))
107 }
108
109 pub fn with_fetch_limit(&self, fetch: Option<usize>) -> Self {
111 self.rebuild(|i| i.fetch = fetch)
112 }
113
114 pub fn with_single_page(&self, single: bool) -> Self {
116 self.rebuild(|i| i.single_page = single)
117 }
118
119 pub fn with_partitions(&self, n: usize) -> Self {
121 self.rebuild(|i| i.partitions = n.max(1))
122 }
123
124 pub fn index(&self) -> &str {
126 &self.inner.index
127 }
128
129 pub fn body(&self) -> &Value {
131 &self.inner.body
132 }
133}
134
135impl DisplayAs for OpenSearchExec {
136 fn fmt_as(&self, t: DisplayFormatType, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137 let i = &self.inner;
138 match t {
139 DisplayFormatType::Default | DisplayFormatType::TreeRender => {
140 write!(
141 f,
142 "OpenSearchExec: index={}, partitions={}, fetch={:?}",
143 i.index, i.partitions, i.fetch
144 )
145 }
146 DisplayFormatType::Verbose => write!(
147 f,
148 "OpenSearchExec: index={}, partitions={}, fetch={:?}, page_size={}, body={}",
149 i.index, i.partitions, i.fetch, i.page_size, i.body
150 ),
151 }
152 }
153}
154
155struct PageState {
156 inner: Arc<Inner>,
157 partition: usize,
158 scroll_id: Option<String>,
159 remaining: Option<usize>,
160 started: bool,
161 done: bool,
162}
163
164async fn next_page(mut st: PageState) -> Result<Option<(RecordBatch, PageState)>> {
165 if st.done || st.remaining == Some(0) {
166 if let Some(id) = st.scroll_id.take() {
167 st.inner.client.clear_scroll(&id).await;
168 }
169 return Ok(None);
170 }
171 let size = st.remaining.map_or(st.inner.page_size, |r| r.min(st.inner.page_size));
172 let page = if !st.started {
173 st.started = true;
174 let mut body = st.inner.body.clone();
175 body["size"] = Value::from(size);
176 if st.inner.partitions > 1 {
177 body["slice"] = serde_json::json!({ "id": st.partition, "max": st.inner.partitions });
178 }
179 let keep_alive = if st.inner.single_page { None } else { Some(KEEP_ALIVE) };
180 st.inner.client.search_page(&st.inner.index, &body, keep_alive).await?
181 } else {
182 match &st.scroll_id {
183 Some(id) => st.inner.client.scroll(id, KEEP_ALIVE).await?,
184 None => return Ok(None),
185 }
186 };
187 if page.scroll_id.is_some() {
188 st.scroll_id = page.scroll_id;
189 }
190 if page.hits.is_empty() {
191 st.done = true;
192 if let Some(id) = st.scroll_id.take() {
193 st.inner.client.clear_scroll(&id).await;
194 }
195 return Ok(None);
196 }
197 let hits = truncate_to_fetch(page.hits, st.remaining);
200 if let Some(r) = st.remaining.as_mut() {
201 *r = r.saturating_sub(hits.len());
202 }
203 if st.inner.single_page || hits.len() < size || st.remaining == Some(0) {
205 st.done = true;
206 }
207 let batch = build_batch(&st.inner.schema, &hits)?;
208 Ok(Some((batch, st)))
209}
210
211fn truncate_to_fetch(mut hits: Vec<Value>, remaining: Option<usize>) -> Vec<Value> {
213 if let Some(r) = remaining {
214 hits.truncate(r);
215 }
216 hits
217}
218
219impl ExecutionPlan for OpenSearchExec {
220 fn name(&self) -> &str {
221 "OpenSearchExec"
222 }
223
224 fn properties(&self) -> &Arc<PlanProperties> {
225 &self.properties
226 }
227
228 fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
229 vec![]
230 }
231
232 fn with_new_children(self: Arc<Self>, _children: Vec<Arc<dyn ExecutionPlan>>) -> Result<Arc<dyn ExecutionPlan>> {
233 Ok(self)
234 }
235
236 fn execute(&self, partition: usize, _context: Arc<TaskContext>) -> Result<SendableRecordBatchStream> {
237 if partition >= self.inner.partitions {
238 return Err(datafusion::error::DataFusionError::Internal(format!(
239 "OpenSearchExec: partition {partition} requested of {}",
240 self.inner.partitions
241 )));
242 }
243 let state = PageState {
244 inner: self.inner.clone(),
245 partition,
246 scroll_id: None,
247 remaining: self.inner.fetch,
248 started: false,
249 done: false,
250 };
251 let baseline = BaselineMetrics::new(&self.metrics, partition);
252 let stream = futures::stream::try_unfold(state, next_page)
253 .map(move |r| {
254 if let Ok(batch) = &r {
255 baseline.record_output(batch.num_rows());
256 }
257 r
258 })
259 .boxed();
260 Ok(Box::pin(RecordBatchStreamAdapter::new(
261 self.inner.schema.clone(),
262 stream,
263 )))
264 }
265
266 fn metrics(&self) -> Option<MetricsSet> {
267 Some(self.metrics.clone_inner())
268 }
269
270 fn supports_limit_pushdown(&self) -> bool {
271 true
272 }
273
274 fn with_fetch(&self, limit: Option<usize>) -> Option<Arc<dyn ExecutionPlan>> {
275 let fetch = match (self.inner.fetch, limit) {
276 (Some(a), Some(b)) => Some(a.min(b)),
277 (a, b) => a.or(b),
278 };
279 Some(Arc::new(self.with_fetch_limit(fetch)))
280 }
281
282 fn fetch(&self) -> Option<usize> {
283 self.inner.fetch
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use datafusion::arrow::datatypes::{DataType, Field, Schema};
291 use serde_json::json;
292
293 fn exec(fetch: Option<usize>) -> OpenSearchExec {
294 partitioned(fetch, 1)
295 }
296
297 fn partitioned(fetch: Option<usize>, partitions: usize) -> OpenSearchExec {
298 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Utf8, true)]));
299 OpenSearchExec::new(
300 OpenSearchClient::new("http://x:9200"),
301 "idx".into(),
302 json!({ "query": { "match_all": {} } }),
303 schema,
304 )
305 .with_page_size(500)
306 .with_fetch_limit(fetch)
307 .with_partitions(partitions)
308 }
309
310 #[test]
311 fn properties_single_bounded_incremental_partition() {
312 let e = exec(None);
313 assert_eq!(e.properties().partitioning.partition_count(), 1);
314 assert_eq!(e.properties().emission_type, EmissionType::Incremental);
315 assert_eq!(e.properties().boundedness, Boundedness::Bounded);
316 assert_eq!(e.schema().fields().len(), 1);
317 assert!(e.children().is_empty());
318 }
319
320 #[test]
321 fn fetch_narrows_but_never_widens() {
322 let e = exec(Some(100));
323 assert_eq!(e.fetch(), Some(100));
324 assert!(e.supports_limit_pushdown());
325 let tighter = e.with_fetch(Some(10)).unwrap();
326 assert_eq!(tighter.fetch(), Some(10));
327 let looser = e.with_fetch(Some(1000)).unwrap();
328 assert_eq!(looser.fetch(), Some(100));
329 assert_eq!(exec(None).with_fetch(Some(7)).unwrap().fetch(), Some(7));
330 }
331
332 #[test]
333 fn the_last_page_is_truncated_to_the_fetch() {
334 let hits: Vec<Value> = (0..5).map(|i| json!({ "n": i })).collect();
335 assert_eq!(truncate_to_fetch(hits.clone(), Some(2)).len(), 2);
336 assert_eq!(truncate_to_fetch(hits.clone(), Some(9)).len(), 5);
337 assert_eq!(truncate_to_fetch(hits.clone(), None).len(), 5);
338 assert!(truncate_to_fetch(hits, Some(0)).is_empty());
339 }
340
341 #[test]
342 fn display_names_the_index() {
343 let e = exec(Some(3));
344 let s = datafusion::physical_plan::displayable(&e).one_line().to_string();
345 assert!(
346 s.contains("OpenSearchExec: index=idx, partitions=1, fetch=Some(3)"),
347 "{s}"
348 );
349 assert_eq!(e.index(), "idx");
350 assert_eq!(e.body()["query"], json!({ "match_all": {} }));
351 }
352}