Skip to main content

faucet_source_oracle/
stream.rs

1//! The Oracle [`Source`] — the only module that talks to the driver. Rows are
2//! fetched on a blocking thread and handed to the async stream one page at a
3//! time over a bounded channel, so memory stays at O(batch_size).
4
5use std::collections::HashMap;
6use std::pin::Pin;
7use std::sync::Mutex;
8use std::time::Duration;
9
10use async_trait::async_trait;
11use faucet_common_oracle::oracle::sql_type::ToSql;
12use faucet_common_oracle::{
13    OraclePool, Side, blocking, call_timeout, checkout, connect_pool, ora_err, quote_ident_oracle,
14    row_to_json, shard_bounds_query, shard_wrap, trim_statement,
15};
16use faucet_core::check::{CheckContext, CheckReport, Probe};
17use faucet_core::replication::cursor::CursorBookmark;
18use faucet_core::shard::{PkShardBounds, ShardSpec, parse_pk_shard, pk_shards_from_bounds};
19use faucet_core::{FaucetError, Source, StreamPage};
20use futures::Stream;
21use serde_json::Value;
22
23use crate::config::{OracleReplication, OracleSourceConfig};
24use crate::query::{
25    CatalogRow, IncrementalCtx, OwnedBind, PlannedQuery, cursor_wrap, default_state_key,
26    descriptors_from_catalog, is_datetime_conversion_error, owned_binds, plan_query, resolve_binds,
27    text_binds,
28};
29
30/// Oracle Database query source.
31pub struct OracleSource {
32    config: OracleSourceConfig,
33    pool: OraclePool,
34    start_bookmark: Mutex<Option<CursorBookmark>>,
35    applied_shard: Mutex<Option<PkShardBounds>>,
36    /// Backend-call context (#880): the run's deadline, cancel token and
37    /// round-trip recorder.
38    calls: faucet_core::CallSlot,
39}
40
41static NULL_TEXT: Option<String> = None;
42
43fn as_tosql(b: &OwnedBind) -> &dyn ToSql {
44    match b {
45        OwnedBind::Null => &NULL_TEXT,
46        OwnedBind::Int(i) => i,
47        OwnedBind::Float(f) => f,
48        OwnedBind::Text(s) => s,
49        OwnedBind::Timestamp(t) => t,
50        OwnedBind::TimestampTz(t) => t,
51    }
52}
53
54/// Everything a blocking fetch needs, owned so it can cross into
55/// `spawn_blocking`.
56struct QueryJob {
57    pool: OraclePool,
58    sql: String,
59    params: Vec<Value>,
60    bookmark: Option<Value>,
61    array_size: u32,
62    chunk: usize,
63    json_columns: Vec<String>,
64    timeout: Option<Duration>,
65}
66
67/// Run a query, handing each full page to `emit`; stops early when `emit`
68/// returns `false` (the consumer went away).
69///
70/// An ISO-shaped `:bookmark` binds as a timestamp first (SQL-41). When the
71/// cursor column is text holding ISO strings, Oracle converts the *column* to a
72/// timestamp and rejects the `T` before any row is returned; the query then
73/// runs once more with the bookmark bound as text (#862 M-067).
74fn run_query(job: QueryJob, mut emit: impl FnMut(Vec<Value>) -> bool) -> Result<(), FaucetError> {
75    let conn = checkout(&job.pool, Side::Source, job.timeout)?;
76    let mut emitted = false;
77    match run_query_on(&conn, &job, BookmarkBind::Typed, &mut emit, &mut emitted) {
78        Err(Failure {
79            message,
80            retry_as_text: true,
81        }) if !emitted => {
82            tracing::debug!(error = %message, "oracle: retrying with the bookmark bound as text");
83            run_query_on(&conn, &job, BookmarkBind::Text, &mut emit, &mut emitted)
84                .map_err(|f| f.into_error())
85        }
86        other => other.map_err(|f| f.into_error()),
87    }
88}
89
90/// How `:bookmark` is bound.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92enum BookmarkBind {
93    Typed,
94    Text,
95}
96
97/// A failed attempt, and whether it is the text-cursor conversion failure a
98/// text-bound bookmark avoids.
99struct Failure {
100    message: FaucetError,
101    retry_as_text: bool,
102}
103
104impl Failure {
105    fn into_error(self) -> FaucetError {
106        self.message
107    }
108}
109
110fn run_query_on(
111    conn: &faucet_common_oracle::oracle::Connection,
112    job: &QueryJob,
113    mode: BookmarkBind,
114    emit: &mut impl FnMut(Vec<Value>) -> bool,
115    emitted: &mut bool,
116) -> Result<(), Failure> {
117    let plain = |message: FaucetError| Failure {
118        message,
119        retry_as_text: false,
120    };
121    let mut stmt = conn
122        .statement(&job.sql)
123        .fetch_array_size(job.array_size)
124        .build()
125        .map_err(|e| plain(ora_err(Side::Source, "prepare", &e)))?;
126    let names: Vec<String> = stmt.bind_names().iter().map(|s| s.to_string()).collect();
127    let binds = resolve_binds(&names, &job.params, job.bookmark.as_ref()).map_err(plain)?;
128    let owned = match mode {
129        BookmarkBind::Typed => owned_binds(binds),
130        BookmarkBind::Text => text_binds(binds),
131    };
132    let typed_bookmark = owned
133        .iter()
134        .any(|(_, b)| matches!(b, OwnedBind::Timestamp(_) | OwnedBind::TimestampTz(_)));
135    let classify = |message: FaucetError, raw: &str| Failure {
136        retry_as_text: typed_bookmark && is_datetime_conversion_error(raw),
137        message,
138    };
139    let named: Vec<(&str, &dyn ToSql)> = owned
140        .iter()
141        .map(|(n, b)| (n.as_str(), as_tosql(b)))
142        .collect();
143    let rows = stmt.query_named(&named).map_err(|e| {
144        let raw = e.to_string();
145        classify(query_error(&raw), &raw)
146    })?;
147    let mut buf = Vec::with_capacity(job.chunk.min(job.array_size as usize));
148    for row in rows {
149        let row = row.map_err(|e| {
150            let raw = e.to_string();
151            classify(ora_err(Side::Source, "fetch", &e), &raw)
152        })?;
153        buf.push(row_to_json(&row, &job.json_columns, Side::Source).map_err(plain)?);
154        if buf.len() >= job.chunk {
155            *emitted = true;
156            if !emit(std::mem::take(&mut buf)) {
157                return Ok(());
158            }
159        }
160    }
161    if !buf.is_empty() {
162        *emitted = true;
163        emit(buf);
164    }
165    Ok(())
166}
167
168/// A query failure, with the fix spelled out when the driver refused a native
169/// `JSON` column.
170fn query_error(message: &str) -> FaucetError {
171    if message.contains("unsupported Oracle type JSON") {
172        FaucetError::Source(format!(
173            "oracle query: {message}; native JSON columns cannot be fetched directly — select \
174             JSON_SERIALIZE(<col> RETURNING CLOB) AS <col> and list the column under `json_columns`"
175        ))
176    } else {
177        FaucetError::Source(format!("oracle query: {message}"))
178    }
179}
180
181impl OracleSource {
182    /// Validate the config and connect (fails fast on bad credentials).
183    pub async fn new(config: OracleSourceConfig) -> Result<Self, FaucetError> {
184        config.validate()?;
185        let pool = connect_pool(&config.connection, config.max_connections).await?;
186        Ok(Self {
187            config,
188            pool,
189            start_bookmark: Mutex::new(None),
190            applied_shard: Mutex::new(None),
191            calls: faucet_core::CallSlot::new(faucet_core::CallContext::new().without_timeout()),
192        })
193    }
194
195    fn current_start(&self) -> Option<CursorBookmark> {
196        self.start_bookmark.lock().expect("bookmark mutex").clone()
197    }
198
199    fn job(&self, planned: &PlannedQuery) -> QueryJob {
200        let sql = trim_statement(&planned.sql).to_string();
201        let sql = match &*self.applied_shard.lock().expect("shard mutex") {
202            Some(bounds) => shard_wrap(bounds, &sql),
203            None => sql,
204        };
205        let sql = cursor_wrap(&sql, planned.incremental.as_ref(), true);
206        let batch = self.config.batch_size;
207        QueryJob {
208            pool: self.pool.clone(),
209            sql,
210            params: planned.params.clone(),
211            bookmark: planned.bookmark.clone(),
212            array_size: if batch == 0 {
213                1000
214            } else {
215                batch.min(u32::MAX as usize) as u32
216            },
217            chunk: if batch == 0 { usize::MAX } else { batch },
218            json_columns: self.config.json_columns.clone(),
219            timeout: call_timeout(self.config.statement_timeout_secs),
220        }
221    }
222
223    /// Every page of an incremental read, and its last bookmark.
224    async fn collect_incremental(
225        &self,
226        context: &HashMap<String, Value>,
227    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
228        use futures::TryStreamExt as _;
229        let mut pages = self.stream_pages(context, 0);
230        let mut records = Vec::new();
231        let mut bookmark = None;
232        while let Some(page) = pages.try_next().await? {
233            records.extend(page.records);
234            bookmark = page.bookmark.or(bookmark);
235        }
236        Ok((records, bookmark))
237    }
238
239    /// Run a full-replication query in one call.
240    async fn collect_all(
241        &self,
242        context: &HashMap<String, Value>,
243    ) -> Result<Vec<Value>, FaucetError> {
244        let planned = plan_query(&self.config, context, None);
245        let mut job = self.job(&planned);
246        job.chunk = usize::MAX;
247        let records = self
248            .calls
249            .call_once(
250                "query",
251                blocking(move || {
252                    let mut all = Vec::new();
253                    run_query(job, |page| {
254                        all.extend(page);
255                        true
256                    })?;
257                    Ok(all)
258                }),
259            )
260            .await?;
261        Ok(records)
262    }
263}
264
265#[async_trait]
266impl Source for OracleSource {
267    fn set_call_context(&self, ctx: faucet_core::CallContext) {
268        self.calls.install(ctx);
269    }
270
271    fn set_roundtrip_recorder(
272        &self,
273        recorder: std::sync::Arc<faucet_core::observability::RoundtripRecorder>,
274    ) {
275        self.calls.install_recorder(recorder);
276    }
277
278    async fn fetch_with_context(
279        &self,
280        context: &HashMap<String, Value>,
281    ) -> Result<Vec<Value>, FaucetError> {
282        Ok(self.fetch_with_context_incremental(context).await?.0)
283    }
284
285    async fn fetch_with_context_incremental(
286        &self,
287        context: &HashMap<String, Value>,
288    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
289        if matches!(self.config.replication, OracleReplication::Full) {
290            return Ok((self.collect_all(context).await?, None));
291        }
292        self.collect_incremental(context).await
293    }
294
295    fn stream_pages<'a>(
296        &'a self,
297        context: &'a HashMap<String, Value>,
298        _batch_size: usize,
299    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
300        let start = self.current_start();
301        let planned = plan_query(&self.config, context, start.as_ref());
302        let job = self.job(&planned);
303        Box::pin(async_stream::try_stream! {
304            let (tx, mut rx) = tokio::sync::mpsc::channel::<Vec<Value>>(2);
305            let handle = tokio::task::spawn_blocking(move || {
306                run_query(job, |page| tx.blocking_send(page).is_ok())
307            });
308            let calls = self.calls.get();
309            calls.record("query");
310            // A page of the running query is not a round trip of its own; the
311            // run's deadline and cancel token end the wait for it (#880).
312            let steps = calls.with_roundtrip_metering(false);
313            let mut tracker = planned.incremental.as_ref().map(IncrementalCtx::tracker);
314            let mut total = 0usize;
315            while let Some(page) = steps.call_once("query", async { Ok(rx.recv().await) }).await? {
316                let kept: Vec<Value> = match tracker.as_mut() {
317                    None => page,
318                    Some(t) => page.into_iter().filter(|r| t.admit(r)).collect(),
319                };
320                total += kept.len();
321                let bookmark = tracker.as_ref().map(|t| t.bookmark().to_value());
322                if !kept.is_empty() || bookmark.is_some() {
323                    yield StreamPage { records: kept, bookmark };
324                }
325            }
326            handle
327                .await
328                .map_err(|e| FaucetError::Source(format!("oracle fetch task failed: {e}")))??;
329            if let Some(t) = tracker.as_ref() {
330                // The final page carries the position even when it is empty.
331                yield StreamPage { records: Vec::new(), bookmark: Some(t.bookmark().to_value()) };
332                if t.missing() > 0 {
333                    tracing::warn!(
334                        missing = t.missing(),
335                        "oracle incremental read: rows without a cursor value were emitted \
336                         without moving the bookmark"
337                    );
338                }
339            }
340            tracing::info!(rows = total, query = %self.config.query, "oracle source stream complete");
341        })
342    }
343
344    fn config_schema(&self) -> Value {
345        serde_json::to_value(faucet_core::schema_for!(OracleSourceConfig))
346            .expect("schema serialization")
347    }
348
349    fn connector_name(&self) -> &'static str {
350        "oracle"
351    }
352
353    fn dataset_uri(&self) -> String {
354        format!(
355            "{}?query={}",
356            self.config.connection.display_uri(),
357            self.config.query
358        )
359    }
360
361    fn state_key(&self) -> Option<String> {
362        match &self.config.replication {
363            OracleReplication::Full => None,
364            OracleReplication::Incremental { .. } => Some(
365                self.config
366                    .state_key
367                    .clone()
368                    .unwrap_or_else(|| default_state_key(&self.config)),
369            ),
370        }
371    }
372
373    fn legacy_state_keys(&self) -> Vec<String> {
374        match (&self.config.replication, &self.config.state_key) {
375            (OracleReplication::Incremental { .. }, None) => {
376                crate::query::legacy_state_keys(&self.config)
377            }
378            _ => Vec::new(),
379        }
380    }
381
382    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
383        let start = CursorBookmark::from_state(bookmark)?;
384        *self.start_bookmark.lock().expect("bookmark mutex") = Some(start);
385        Ok(())
386    }
387
388    async fn check(&self, ctx: &CheckContext) -> Result<CheckReport, FaucetError> {
389        let started = std::time::Instant::now();
390        let pool = self.pool.clone();
391        let probe = blocking(move || {
392            let conn = checkout(&pool, Side::Source, None)?;
393            conn.query_row("SELECT 1 FROM DUAL", &[])
394                .map_err(|e| ora_err(Side::Source, "probe", &e))?;
395            Ok(())
396        });
397        let hint = "check the connection settings / credentials and that the listener is reachable";
398        let probe = match tokio::time::timeout(ctx.timeout, probe).await {
399            Ok(Ok(())) => Probe::pass("connect", started.elapsed()),
400            Ok(Err(e)) => Probe::fail_hint("connect", started.elapsed(), e.to_string(), hint),
401            Err(_) => Probe::fail_hint("connect", started.elapsed(), "timed out", hint),
402        };
403        Ok(CheckReport::single(probe))
404    }
405
406    fn supports_discover(&self) -> bool {
407        true
408    }
409
410    /// Enumerate tables in non-Oracle-maintained schemas from `ALL_TAB_COLUMNS`,
411    /// with row estimates from optimizer statistics (`ALL_TABLES.NUM_ROWS`).
412    async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
413        const SQL: &str = "SELECT c.OWNER, c.TABLE_NAME, c.COLUMN_NAME, c.DATA_TYPE, \
414                c.DATA_SCALE, c.NULLABLE, t.NUM_ROWS \
415           FROM ALL_TAB_COLUMNS c \
416           JOIN ALL_TABLES t ON t.OWNER = c.OWNER AND t.TABLE_NAME = c.TABLE_NAME \
417          WHERE c.OWNER IN (SELECT USERNAME FROM ALL_USERS WHERE ORACLE_MAINTAINED = 'N') \
418            AND t.TEMPORARY = 'N' AND t.NESTED = 'NO' AND t.SECONDARY = 'N' \
419          ORDER BY c.OWNER, c.TABLE_NAME, c.COLUMN_ID";
420        const KEY_SQL: &str = "SELECT c.OWNER || '.' || c.TABLE_NAME, cc.COLUMN_NAME \
421           FROM ALL_CONSTRAINTS c \
422           JOIN ALL_CONS_COLUMNS cc \
423             ON cc.OWNER = c.OWNER AND cc.CONSTRAINT_NAME = c.CONSTRAINT_NAME \
424          WHERE c.CONSTRAINT_TYPE = 'P' \
425            AND c.OWNER IN (SELECT USERNAME FROM ALL_USERS WHERE ORACLE_MAINTAINED = 'N') \
426          ORDER BY c.OWNER, c.TABLE_NAME, cc.POSITION";
427        let pool = self.pool.clone();
428        let timeout = call_timeout(self.config.statement_timeout_secs);
429        let (rows, keys) = self
430            .calls
431            .call_once(
432                "discover",
433                blocking(move || {
434                    let conn = checkout(&pool, Side::Source, timeout)?;
435                    let mut keys: Vec<(String, String)> = Vec::new();
436                    for r in conn
437                        .query_as::<(String, String)>(KEY_SQL, &[])
438                        .map_err(|e| ora_err(Side::Source, "primary-key discovery", &e))?
439                    {
440                        keys.push(
441                            r.map_err(|e| ora_err(Side::Source, "primary-key discovery", &e))?,
442                        );
443                    }
444                    let rs = conn
445                        .query_as::<(
446                            String,
447                            String,
448                            String,
449                            String,
450                            Option<i64>,
451                            String,
452                            Option<i64>,
453                        )>(SQL, &[])
454                        .map_err(|e| ora_err(Side::Source, "catalog discovery", &e))?;
455                    let mut out = Vec::new();
456                    for r in rs {
457                        let (owner, table, column, data_type, scale, nullable, num_rows) =
458                            r.map_err(|e| ora_err(Side::Source, "catalog discovery", &e))?;
459                        out.push(CatalogRow {
460                            owner,
461                            table,
462                            column,
463                            data_type,
464                            scale,
465                            nullable: nullable != "N",
466                            num_rows,
467                        });
468                    }
469                    Ok((out, keys))
470                }),
471            )
472            .await?;
473        Ok(faucet_core::attach_primary_keys(
474            descriptors_from_catalog(rows)?,
475            keys,
476        ))
477    }
478
479    fn is_shardable(&self) -> bool {
480        self.config.shard.is_some()
481    }
482
483    async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
484        let Some(shard_cfg) = self.config.shard.clone() else {
485            return Ok(vec![ShardSpec::whole()]);
486        };
487        let start = self.current_start();
488        let planned = plan_query(&self.config, &HashMap::new(), start.as_ref());
489        let key = quote_ident_oracle(&shard_cfg.key)?;
490        let base = cursor_wrap(
491            trim_statement(&planned.sql),
492            planned.incremental.as_ref(),
493            false,
494        );
495        let sql = shard_bounds_query(&base, &key);
496        let pool = self.pool.clone();
497        let timeout = call_timeout(self.config.statement_timeout_secs);
498        let (lo, hi) = self.calls.call_once("query", blocking(move || {
499            let conn = checkout(&pool, Side::Source, timeout)?;
500            let stmt_err = |e: &faucet_common_oracle::oracle::Error| {
501                FaucetError::Source(format!(
502                    "oracle: shard bounds for key {:?} failed (it must be an integer-valued \
503                     output column): {e}",
504                    shard_cfg.key
505                ))
506            };
507            let mut stmt = conn.statement(&sql).build().map_err(|e| stmt_err(&e))?;
508            let names: Vec<String> = stmt.bind_names().iter().map(|s| s.to_string()).collect();
509            let binds = resolve_binds(&names, &planned.params, planned.bookmark.as_ref())?;
510            let owned = owned_binds(binds);
511            let named: Vec<(&str, &dyn ToSql)> = owned
512                .iter()
513                .map(|(n, b)| (n.as_str(), as_tosql(b)))
514                .collect();
515            stmt.query_row_as_named::<(Option<i64>, Option<i64>)>(&named)
516                .map_err(|e| stmt_err(&e))
517        }))
518        .await?;
519        Ok(pk_shards_from_bounds(
520            &self.config.shard.as_ref().expect("checked").key,
521            lo,
522            hi,
523            target,
524        ))
525    }
526
527    async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
528        let bounds = parse_pk_shard(shard, "oracle")?;
529        if let Some(b) = &bounds {
530            quote_ident_oracle(&b.key)?;
531        }
532        *self.applied_shard.lock().expect("shard mutex") = bounds;
533        Ok(())
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    #[test]
542    fn query_error_hints_native_json() {
543        let e = query_error("DPI-xxxx: unsupported Oracle type JSON");
544        assert!(e.to_string().contains("JSON_SERIALIZE"), "{e}");
545        let e = query_error("ORA-00942: table or view does not exist");
546        assert!(!e.to_string().contains("JSON_SERIALIZE"), "{e}");
547    }
548
549    #[test]
550    fn owned_binds_convert_to_driver_values() {
551        for b in [
552            OwnedBind::Null,
553            OwnedBind::Int(1),
554            OwnedBind::Float(1.5),
555            OwnedBind::Text("x".into()),
556            OwnedBind::Timestamp(chrono::DateTime::UNIX_EPOCH.naive_utc()),
557            OwnedBind::TimestampTz(chrono::DateTime::UNIX_EPOCH.fixed_offset()),
558        ] {
559            let _ = as_tosql(&b);
560        }
561    }
562}