Skip to main content

cognite/api/core/time_series/
datapoints_stream.rs

1use std::{
2    collections::{HashMap, VecDeque},
3    pin::Pin,
4    sync::Arc,
5};
6
7use futures::{stream::FuturesUnordered, Stream, StreamExt, TryStream};
8use pin_project::pin_project;
9
10use crate::{
11    send_helper::CondBoxFuture,
12    time_series::{
13        DataPointListItem, DataPointListResponse, DatapointAggregate, DatapointDouble,
14        DatapointString, DatapointsFilter, DatapointsQuery, InstanceId, ListDatapointType,
15        TimeSeriesResource,
16    },
17    Identity, IdentityOrInstance,
18};
19
20/// A datapoint of either type.
21pub enum EitherDataPoint {
22    /// A numeric datapoint.
23    Numeric(DatapointDouble),
24    /// A string datapoint.
25    String(DatapointString),
26    /// An aggregate datapoint.
27    Aggregate(DatapointAggregate),
28}
29
30struct TimeSeriesRef {
31    id: i64,
32    external_id: Option<String>,
33    instance_id: Option<InstanceId>,
34    original_id: IdentityOrInstance,
35    is_string: bool,
36    is_step: bool,
37    unit: Option<String>,
38    unit_external_id: Option<String>,
39}
40
41/// A datapoint containing a reference to its timeseries metadata.
42/// Used in streaming responses to avoid cloning timeseries info for every datapoint.
43pub struct DataPointRef {
44    // This is an Arc to avoid cloning the timeseries information for every datapoint,
45    // which can be a considerable amount of data for larger requests.
46    timeseries: Arc<TimeSeriesRef>,
47    datapoint: EitherDataPoint,
48}
49
50impl DataPointRef {
51    /// Get the internal ID of the timeseries this datapoint belongs to.
52    pub fn id(&self) -> i64 {
53        self.timeseries.id
54    }
55
56    /// Get the external ID of the timeseries this datapoint belongs to, if it has one.
57    pub fn external_id(&self) -> Option<&str> {
58        self.timeseries.external_id.as_deref()
59    }
60
61    /// Get the data modelling instance ID of the timeseries this datapoint belongs to, if it has one.
62    pub fn instance_id(&self) -> Option<&InstanceId> {
63        self.timeseries.instance_id.as_ref()
64    }
65
66    /// Get the original ID used to identify the timeseries this datapoint belongs to in the request.
67    pub fn original_id(&self) -> &IdentityOrInstance {
68        &self.timeseries.original_id
69    }
70
71    /// Check if the timeseries this datapoint belongs to is of string type.
72    pub fn is_string(&self) -> bool {
73        self.timeseries.is_string
74    }
75
76    /// Check if the timeseries this datapoint belongs to is a step timeseries.
77    pub fn is_step(&self) -> bool {
78        self.timeseries.is_step
79    }
80
81    /// Get the unit of the timeseries this datapoint belongs to, if it has one.
82    pub fn unit(&self) -> Option<&str> {
83        self.timeseries.unit.as_deref()
84    }
85
86    /// Get the external ID of the unit of the timeseries this datapoint belongs to, if it has one.
87    pub fn unit_external_id(&self) -> Option<&str> {
88        self.timeseries.unit_external_id.as_deref()
89    }
90
91    /// Consume the reference and return the underlying datapoint, to avoid cloning.
92    pub fn into_datapoint(self) -> EitherDataPoint {
93        self.datapoint
94    }
95
96    /// Get a reference to the underlying datapoint.
97    pub fn datapoint(&self) -> &EitherDataPoint {
98        &self.datapoint
99    }
100
101    /// Get a reference to the underlying datapoint as numeric, if it is of that type.
102    pub fn as_numeric(&self) -> Option<&DatapointDouble> {
103        match &self.datapoint {
104            EitherDataPoint::Numeric(dp) => Some(dp),
105            _ => None,
106        }
107    }
108
109    /// Get a reference to the underlying datapoint as string, if it is of that type.
110    pub fn as_string(&self) -> Option<&DatapointString> {
111        match &self.datapoint {
112            EitherDataPoint::String(dp) => Some(dp),
113            _ => None,
114        }
115    }
116
117    /// Get a reference to the underlying datapoint as aggregate, if it is of that type.
118    pub fn as_aggregate(&self) -> Option<&DatapointAggregate> {
119        match &self.datapoint {
120            EitherDataPoint::Aggregate(dp) => Some(dp),
121            _ => None,
122        }
123    }
124}
125
126struct FetchResult {
127    query_items: Vec<DatapointsQuery>,
128    response: DataPointListResponse,
129}
130
131/// Options for configuring the behavior of a `DatapointsStream`.
132#[derive(Clone, Debug)]
133pub struct DatapointsStreamOptions {
134    /// The maximum number of timeseries to include in each request. Default is 100.
135    pub batch_size: usize,
136    /// The maximum number of requests to have in flight at any given time. Default is 4.
137    pub parallelism: usize,
138}
139
140impl Default for DatapointsStreamOptions {
141    fn default() -> Self {
142        Self {
143            batch_size: 100,
144            parallelism: 4,
145        }
146    }
147}
148
149pub(super) struct DatapointsStream<'a> {
150    timeseries: &'a TimeSeriesResource,
151    filter: DatapointsFilter,
152    queries: VecDeque<DatapointsQuery>,
153    options: DatapointsStreamOptions,
154    known_timeseries: HashMap<i64, Arc<TimeSeriesRef>>,
155    // Technically, if we had existential types, we could avoid the box here.
156    // In practice it really doesn't matter, the overhead of a network request is much larger
157    // than anything from boxing.
158    futures: FuturesUnordered<CondBoxFuture<'a, Result<FetchResult, crate::Error>>>,
159}
160
161impl<'a> DatapointsStream<'a> {
162    pub(super) fn new(
163        timeseries: &'a TimeSeriesResource,
164        mut filter: DatapointsFilter,
165        options: DatapointsStreamOptions,
166    ) -> Self {
167        Self {
168            timeseries,
169            queries: std::mem::take(&mut filter.items).into(),
170            filter,
171            options,
172            known_timeseries: HashMap::new(),
173            futures: FuturesUnordered::new(),
174        }
175    }
176
177    async fn fetch_batch(
178        timeseries: &'a TimeSeriesResource,
179        filter: DatapointsFilter,
180    ) -> Result<FetchResult, crate::Error> {
181        let response = timeseries.retrieve_datapoints_proto(&filter).await?;
182        Ok(FetchResult {
183            query_items: filter.items,
184            response,
185        })
186    }
187
188    fn update_known_timeseries_from_batch(
189        &mut self,
190        response: &DataPointListItem,
191        query: &DatapointsQuery,
192    ) {
193        // We've already seen this timeseries, nothing to do.
194        if self.known_timeseries.contains_key(&response.id) {
195            return;
196        }
197
198        self.known_timeseries.insert(
199            response.id,
200            Arc::new(TimeSeriesRef {
201                id: response.id,
202                external_id: if !response.external_id.is_empty() {
203                    Some(response.external_id.clone())
204                } else {
205                    None
206                },
207                instance_id: response.instance_id.clone(),
208                original_id: query.id.clone(),
209                is_string: response.is_string,
210                is_step: response.is_step,
211                unit: if !response.unit.is_empty() {
212                    Some(response.unit.clone())
213                } else {
214                    None
215                },
216                unit_external_id: if !response.unit_external_id.is_empty() {
217                    Some(response.unit_external_id.clone())
218                } else {
219                    None
220                },
221            }),
222        );
223    }
224
225    fn equals_identity(id: &IdentityOrInstance, response_item: &DataPointListItem) -> bool {
226        match id {
227            IdentityOrInstance::Identity(Identity::Id { id }) => response_item.id == *id,
228            IdentityOrInstance::Identity(Identity::ExternalId { external_id }) => {
229                response_item.external_id == *external_id
230            }
231            IdentityOrInstance::InstanceId { instance_id } => response_item
232                .instance_id
233                .as_ref()
234                .is_some_and(|i| i == instance_id),
235        }
236    }
237
238    async fn stream_batches_inner(
239        &mut self,
240        maintain_internal_state: bool,
241    ) -> Result<Option<DataPointListResponse>, crate::Error> {
242        // If there's room for more requests, spawn them immediately.
243        while self.futures.len() < self.options.parallelism && !self.queries.is_empty() {
244            let mut batch = Vec::with_capacity(self.options.batch_size.min(self.queries.len()));
245            while batch.len() < self.options.batch_size {
246                if let Some(query) = self.queries.pop_front() {
247                    batch.push(query);
248                } else {
249                    break;
250                }
251            }
252            let filter = DatapointsFilter {
253                items: batch,
254                ..self.filter.clone()
255            };
256            let timeseries = self.timeseries;
257            self.futures
258                .push(Box::pin(Self::fetch_batch(timeseries, filter)));
259        }
260
261        // Wait for the next request to complete.
262        let Some(result) = self.futures.next().await else {
263            // No more requests in flight, we're done.
264            return Ok(None);
265        };
266        let mut fetch_result = result?;
267        let mut query_iter = fetch_result.query_items.into_iter();
268
269        // Update queries from the result, then re-queue them.
270        for response_item in &mut fetch_result.response.items {
271            // Datapoints are returned in order, so we check if the next query has an ID matching the
272            // response item. If not, we keep going until we find it.
273            let Some(mut query) = query_iter.next() else {
274                return Err(crate::Error::Other(
275                    "Internal logic error: more response items than query items".to_string(),
276                ));
277            };
278
279            while !Self::equals_identity(&query.id, response_item) {
280                // This query had no datapoints in the response, so we don't need to do anything
281                // special with it. Just move on to the next one.
282                let Some(next_query) = query_iter.next() else {
283                    return Err(crate::Error::Other(
284                        "Internal logic error: response item does not match any query item"
285                            .to_string(),
286                    ));
287                };
288                query = next_query;
289            }
290
291            // If we're maintaining internal state, record information about
292            // the timeseries we've seen.
293            if maintain_internal_state {
294                self.update_known_timeseries_from_batch(response_item, &query);
295            }
296
297            if !response_item.next_cursor.is_empty() {
298                // Take the cursor. There's no reason to allow the caller to
299                // see it or use it.
300                query.cursor = Some(std::mem::take(&mut response_item.next_cursor));
301                self.queries.push_back(query);
302            }
303        }
304
305        Ok(Some(fetch_result.response))
306    }
307
308    pub fn stream_batches(
309        self,
310    ) -> impl Stream<Item = Result<DataPointListResponse, crate::Error>> + 'a {
311        futures::stream::try_unfold(self, move |mut state| async move {
312            Ok(state.stream_batches_inner(false).await?.map(|v| (v, state)))
313        })
314    }
315
316    pub fn stream_datapoints(self) -> impl Stream<Item = Result<DataPointRef, crate::Error>> + 'a {
317        FlatIterStream::new(futures::stream::try_unfold(
318            self,
319            move |mut state| async move {
320                let Some(batch) = state.stream_batches_inner(true).await? else {
321                    return Ok(None);
322                };
323                let mut res = Vec::new();
324
325                for item in batch.items {
326                    let timeseries = state
327                        .known_timeseries
328                        .get(&item.id)
329                        .ok_or_else(|| {
330                            crate::Error::Other(format!(
331                                "Internal logic error: timeseries with id {} not found in known_timeseries",
332                                item.id
333                            ))
334                        })?
335                        .clone();
336                    match item.datapoint_type {
337                        None => continue,
338                        Some(ListDatapointType::AggregateDatapoints(dps)) => {
339                            res.extend(dps.datapoints.into_iter().map(move |dp| DataPointRef {
340                                timeseries: timeseries.clone(),
341                                datapoint: EitherDataPoint::Aggregate(dp.into()),
342                            }));
343                        }
344                        Some(ListDatapointType::StringDatapoints(dps)) => {
345                            res.extend(dps.datapoints.into_iter().map(move |dp| DataPointRef {
346                                timeseries: timeseries.clone(),
347                                datapoint: EitherDataPoint::String(dp.into()),
348                            }));
349                        }
350                        Some(ListDatapointType::NumericDatapoints(dps)) => {
351                            res.extend(dps.datapoints.into_iter().map(move |dp| DataPointRef {
352                                timeseries: timeseries.clone(),
353                                datapoint: EitherDataPoint::Numeric(dp.into()),
354                            }));
355                        }
356                    }
357                }
358
359                Ok(Some((res.into_iter(), state)))
360            },
361        ))
362    }
363}
364
365#[pin_project]
366/// Simple stream adapter that flattens a stream of iterables into a stream of items.
367struct FlatIterStream<R>
368where
369    R: TryStream,
370    R::Ok: IntoIterator,
371{
372    #[pin]
373    inner: R,
374    current: Option<<R::Ok as IntoIterator>::IntoIter>,
375}
376
377impl<R> FlatIterStream<R>
378where
379    R: TryStream,
380    R::Ok: IntoIterator,
381{
382    fn new(stream: R) -> Self {
383        Self {
384            inner: stream,
385            current: None,
386        }
387    }
388}
389
390impl<R: TryStream> Stream for FlatIterStream<R>
391where
392    R: TryStream,
393    R::Ok: IntoIterator,
394{
395    type Item = Result<<R::Ok as IntoIterator>::Item, R::Error>;
396
397    fn poll_next(
398        self: Pin<&mut Self>,
399        cx: &mut std::task::Context<'_>,
400    ) -> std::task::Poll<Option<Self::Item>> {
401        let mut this = self.project();
402        loop {
403            if let Some(current) = this.current.as_mut() {
404                if let Some(item) = current.next() {
405                    return std::task::Poll::Ready(Some(Ok(item)));
406                } else {
407                    *this.current = None;
408                }
409            }
410            match this.inner.as_mut().try_poll_next(cx)? {
411                std::task::Poll::Ready(Some(next_iter)) => {
412                    *this.current = Some(next_iter.into_iter());
413                }
414                std::task::Poll::Ready(None) => return std::task::Poll::Ready(None),
415                std::task::Poll::Pending => return std::task::Poll::Pending,
416            }
417        }
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use crate::{
424        api::core::time_series::datapoints_stream::FlatIterStream, time_series::StatusCode,
425    };
426    #[test]
427    fn test_datapoint_ref() {
428        use super::*;
429        let ts_ref = Arc::new(TimeSeriesRef {
430            id: 42,
431            external_id: Some("ts1".to_string()),
432            instance_id: None,
433            original_id: IdentityOrInstance::from("ts1"),
434            is_string: false,
435            is_step: false,
436            unit: Some("°C".to_string()),
437            unit_external_id: None,
438        });
439        let dp = DataPointRef {
440            timeseries: ts_ref.clone(),
441            datapoint: EitherDataPoint::Numeric(DatapointDouble {
442                timestamp: 1625079600000,
443                value: Some(23.5),
444                status: Some(StatusCode::Good),
445            }),
446        };
447        assert_eq!(dp.id(), 42);
448        assert_eq!(dp.external_id(), Some("ts1"));
449        assert!(!dp.is_string());
450        assert_eq!(dp.unit(), Some("°C"));
451        if let EitherDataPoint::Numeric(n) = dp.datapoint() {
452            assert_eq!(n.value, Some(23.5));
453        } else {
454            panic!("Expected numeric datapoint");
455        }
456    }
457    #[test]
458    fn test_flat_iter_stream() {
459        use futures::stream;
460        use futures::StreamExt;
461        let s = stream::iter(vec![
462            Ok::<_, crate::Error>(vec![1, 2, 3]),
463            Ok(vec![4, 5]),
464            Ok(vec![6]),
465        ]);
466        let mut flat_stream = FlatIterStream::new(s);
467        let mut results = Vec::new();
468        while let Some(item) = futures::executor::block_on(flat_stream.next()) {
469            results.push(item.unwrap());
470        }
471        assert_eq!(results, vec![1, 2, 3, 4, 5, 6]);
472    }
473}