1use 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
30pub struct OracleSource {
32 config: OracleSourceConfig,
33 pool: OraclePool,
34 start_bookmark: Mutex<Option<CursorBookmark>>,
35 applied_shard: Mutex<Option<PkShardBounds>>,
36 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
54struct 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
67fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92enum BookmarkBind {
93 Typed,
94 Text,
95}
96
97struct 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
168fn 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 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 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 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 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 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 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}