Skip to main content

faucet_source_oracle_cdc/
stream.rs

1//! The LogMiner [`Source`]. A fetch cycle runs on one blocking thread that
2//! owns the mining session and hands each committed transaction to the async
3//! stream as its own page (bookmarked), so a transaction is never split and
4//! the pipeline persists progress only after the sink confirms it.
5
6use std::collections::HashMap;
7use std::pin::Pin;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::{Arc, Mutex};
10use std::time::{Duration, Instant};
11
12use async_trait::async_trait;
13use faucet_common_oracle::oracle::Connection;
14use faucet_common_oracle::{
15    NLS_SESSION_SQL, OraclePool, Side, TypeFamily, blocking, call_timeout, checkout, connect_pool,
16    ora_code, ora_err,
17};
18use faucet_core::check::{CheckContext, CheckReport, Probe};
19use faucet_core::{FaucetError, Source, SourceLag, Stream, StreamPage};
20use serde_json::Value;
21use tokio::sync::mpsc::Sender;
22
23use crate::config::{OracleCdcSourceConfig, StartPosition};
24use crate::lag::{LagPlan, commit_scn, from_age};
25use crate::logs::{LogFile, oldest_available_scn, plan_log_files};
26use crate::miner::{Assembler, LogRow, Miner, TableMeta, to_envelope};
27use crate::sql::{
28    ADD_LOGFILE_SQL, ARCHIVED_LOGS_SQL, CONTAINER_SQL, CURRENT_SCN_SQL, DatabaseLogging,
29    END_LOGMNR_SQL, LOGFILE_ADD, LOGFILE_NEW, LoggingReport, ONLINE_LOGS_SQL, PDB_INCARNATION_SQL,
30    POSITION_LOCAL_SQL, POSITION_SQL, RESETLOGS_SQL, SCN_AGE_SQL, START_LOGMNR_SQL,
31    SUPPLEMENTAL_SQL, THREADS_SQL, columns_sql, contents_sql, flush_sql, flush_table_ddl, in_pdb,
32    log_groups_sql, logging_report, needs_logfiles,
33};
34use crate::state::Position;
35
36/// Oracle LogMiner change-data-capture source.
37pub struct OracleCdcSource {
38    shared: Arc<Shared>,
39    pending: Mutex<Option<Position>>,
40    emitted: Mutex<Option<u64>>,
41    lag_warned: AtomicBool,
42    /// Backend-call context (#880): the run's deadline, cancel token and
43    /// round-trip recorder.
44    calls: faucet_core::CallSlot,
45}
46
47struct Shared {
48    config: OracleCdcSourceConfig,
49    pool: OraclePool,
50    tables: Vec<(String, String)>,
51    state_key: String,
52    add_logfiles: bool,
53}
54
55fn src_err(context: &str, e: &faucet_common_oracle::oracle::Error) -> FaucetError {
56    ora_err(Side::Source, context, e)
57}
58
59/// The current PDB incarnation SCN when the session is in a pluggable
60/// database. A view the user may not read degrades to no check, with a warning.
61fn pdb_incarnation(conn: &Connection) -> Option<u64> {
62    let read = || -> Result<Option<u64>, faucet_common_oracle::oracle::Error> {
63        let (cdb, container): (String, String) = conn.query_row_as(CONTAINER_SQL, &[])?;
64        if !in_pdb(&cdb, &container) {
65            return Ok(None);
66        }
67        let (scn,): (Option<u64>,) = conn.query_row_as(PDB_INCARNATION_SQL, &[])?;
68        Ok(scn)
69    };
70    read().unwrap_or_else(|e| {
71        tracing::warn!(
72            connector = "oracle-cdc",
73            error = %e,
74            "V$PDB_INCARNATION is not readable, so a PDB point-in-time recovery or flashback \
75             cannot be detected; grant SELECT on it to enable the check"
76        );
77        None
78    })
79}
80
81/// A LogMiner start failure; a missing log (ORA-01291) is lost redo.
82fn start_error(e: &faucet_common_oracle::oracle::Error, from: u64, to: u64) -> FaucetError {
83    classify_start_error(faucet_common_oracle::ora_code(e), &e.to_string(), from, to)
84}
85
86fn classify_start_error(code: Option<i32>, message: &str, from: u64, to: u64) -> FaucetError {
87    match code {
88        Some(1291) => FaucetError::Source(missing_redo_message(from, to)),
89        _ => FaucetError::Source(format!("oracle start LogMiner: {message}")),
90    }
91}
92
93fn missing_redo_message(from: u64, to: u64) -> String {
94    format!(
95        "oracle-cdc: the redo for SCN {from}..={to} is not available to LogMiner (ORA-01291 \
96         missing log file). Run the database in ARCHIVELOG mode and keep archived logs until \
97         they are mined; changes in a recycled range cannot be recovered — re-snapshot, then \
98         restart capture"
99    )
100}
101
102fn i64_scn(scn: u64) -> i64 {
103    i64::try_from(scn).unwrap_or(i64::MAX)
104}
105
106fn table_binds(tables: &[(String, String)]) -> Vec<String> {
107    tables
108        .iter()
109        .flat_map(|(o, t)| [o.clone(), t.clone()])
110        .collect()
111}
112
113impl Shared {
114    fn conn(&self) -> Result<Connection, FaucetError> {
115        checkout(
116            &self.pool,
117            Side::Source,
118            call_timeout(self.config.statement_timeout_secs),
119        )
120    }
121
122    fn load_meta(&self, conn: &Connection) -> Result<TableMeta, FaucetError> {
123        let binds = table_binds(&self.tables);
124        let refs: Vec<&dyn faucet_common_oracle::oracle::sql_type::ToSql> =
125            binds.iter().map(|b| b as _).collect();
126        let rows = conn
127            .query_as::<(String, String, String, String, Option<i64>)>(
128                &columns_sql(self.tables.len()),
129                &refs,
130            )
131            .map_err(|e| src_err("column metadata", &e))?;
132        let mut meta = TableMeta::new();
133        for r in rows {
134            let (o, t, c, ty, scale) = r.map_err(|e| src_err("column metadata", &e))?;
135            let family = TypeFamily::from_data_type(&ty, scale);
136            let digits = matches!(family, TypeFamily::Timestamp | TypeFamily::TimestampTz)
137                .then(|| scale.and_then(|s| u8::try_from(s).ok()))
138                .flatten();
139            meta.entry((o, t))
140                .or_default()
141                .insert(c, crate::miner::ColumnType { family, digits });
142        }
143        Ok(meta)
144    }
145
146    fn position_now(&self, conn: &Connection) -> Result<Position, FaucetError> {
147        let (current, oldest): (u64, Option<u64>) = match conn.query_row_as(POSITION_SQL, &[]) {
148            Ok(row) => row,
149            Err(e) => {
150                tracing::warn!(
151                    error = %e,
152                    "oracle-cdc: GV$TRANSACTION is not readable; looking for open \
153                     transactions on the connected instance only. On RAC, grant \
154                     SELECT_CATALOG_ROLE so a transaction open on another instance \
155                     is captured from its start"
156                );
157                conn.query_row_as(POSITION_LOCAL_SQL, &[])
158                    .map_err(|e| src_err("current SCN", &e))?
159            }
160        };
161        Ok(Position::resume_from(current, oldest))
162    }
163
164    fn list_logs(&self, conn: &Connection, lo: u64, hi: u64) -> Result<Vec<LogFile>, FaucetError> {
165        let mut out = Vec::new();
166        let (lo, hi) = (i64_scn(lo), i64_scn(hi));
167        let window: [&dyn faucet_common_oracle::oracle::sql_type::ToSql; 2] = [&lo, &hi];
168        for (sql, archived, params) in [
169            (ARCHIVED_LOGS_SQL, true, &window[..]),
170            (ONLINE_LOGS_SQL, false, &[][..]),
171        ] {
172            let rows = conn
173                .query_as::<(String, i64, i64, u64, u64)>(sql, params)
174                .map_err(|e| src_err("redo log listing", &e))?;
175            for r in rows {
176                let (name, thread, sequence, first_change, next_change) =
177                    r.map_err(|e| src_err("redo log listing", &e))?;
178                out.push(LogFile {
179                    name,
180                    thread,
181                    sequence,
182                    first_change,
183                    next_change,
184                    archived,
185                });
186            }
187        }
188        Ok(out)
189    }
190
191    fn initial_position(&self, conn: &Connection) -> Result<Position, FaucetError> {
192        match self.config.start_position {
193            StartPosition::Current => self.position_now(conn),
194            StartPosition::Earliest => {
195                let logs = self.list_logs(conn, 0, u64::MAX)?;
196                let oldest = oldest_available_scn(&logs).ok_or_else(|| {
197                    FaucetError::Source("oracle-cdc: no redo logs are visible".into())
198                })?;
199                Ok(Position::at(oldest.saturating_sub(1), oldest))
200            }
201        }
202    }
203
204    fn flush(&self, conn: &Connection, scn: u64) -> Result<(), FaucetError> {
205        conn.execute(
206            &flush_sql(&self.config.flush_table),
207            &[&i64_scn(scn), &i64_scn(scn)],
208        )
209        .map_err(|e| src_err("redo flush", &e))?;
210        conn.commit().map_err(|e| src_err("redo flush", &e))
211    }
212
213    /// Mine `(from, to]`, feeding every row to `on_row`. The session is always
214    /// ended, even when a row handler fails.
215    fn mine(
216        &self,
217        conn: &Connection,
218        from: u64,
219        to: u64,
220        on_row: &mut dyn FnMut(LogRow) -> Result<bool, FaucetError>,
221    ) -> Result<(), FaucetError> {
222        let logs = plan_log_files(&self.list_logs(conn, from, to)?, from + 1, to)
223            .map_err(|m| FaucetError::Source(format!("oracle-cdc: {m}")))?;
224        if self.add_logfiles {
225            for (i, log) in logs.iter().enumerate() {
226                let opt = if i == 0 { LOGFILE_NEW } else { LOGFILE_ADD };
227                conn.execute(ADD_LOGFILE_SQL, &[&log.name, &opt])
228                    .map_err(|e| src_err("add log file", &e))?;
229            }
230        }
231        conn.execute(START_LOGMNR_SQL, &[&i64_scn(from + 1), &i64_scn(to)])
232            .map_err(|e| start_error(&e, from + 1, to))?;
233        let result = self.read_contents(conn, from, to, on_row);
234        let _ = conn.execute(END_LOGMNR_SQL, &[]);
235        result
236    }
237
238    fn read_contents(
239        &self,
240        conn: &Connection,
241        from: u64,
242        to: u64,
243        on_row: &mut dyn FnMut(LogRow) -> Result<bool, FaucetError>,
244    ) -> Result<(), FaucetError> {
245        let mut binds: Vec<Box<dyn faucet_common_oracle::oracle::sql_type::ToSql>> =
246            vec![Box::new(i64_scn(from)), Box::new(i64_scn(to))];
247        for b in table_binds(&self.tables) {
248            binds.push(Box::new(b));
249        }
250        let refs: Vec<&dyn faucet_common_oracle::oracle::sql_type::ToSql> =
251            binds.iter().map(|b| b.as_ref()).collect();
252        let mut stmt = conn
253            .statement(&contents_sql(self.tables.len()))
254            .fetch_array_size(1000)
255            .build()
256            .map_err(|e| src_err("mine", &e))?;
257        type Raw = (
258            u64,
259            i64,
260            String,
261            Option<String>,
262            Option<String>,
263            Option<String>,
264            i64,
265            Option<String>,
266            i64,
267            Option<String>,
268            Option<String>,
269        );
270        let rows = stmt
271            .query_as::<Raw>(&refs)
272            .map_err(|e| src_err("mine", &e))?;
273        let mut assembler = Assembler::default();
274        for r in rows {
275            let (scn, op, xid, owner, table, redo, csf, row_id, rollback, ts, info) =
276                r.map_err(|e| src_err("mine", &e))?;
277            let row = LogRow {
278                scn,
279                op,
280                xid,
281                owner,
282                table,
283                redo: redo.unwrap_or_default(),
284                row_id,
285                rollback: rollback == 1,
286                timestamp: ts,
287                info,
288            };
289            if let Some(row) = assembler.push(row, csf == 1)
290                && !on_row(row)?
291            {
292                return Ok(());
293            }
294        }
295        Ok(())
296    }
297
298    /// One fetch cycle: mine windows until idle, emitting a page per committed
299    /// transaction. Returns early when the consumer goes away.
300    fn capture(
301        &self,
302        start: Option<Position>,
303        per_txn: bool,
304        tx: Sender<StreamPage>,
305        calls: faucet_core::CallContext,
306    ) -> Result<(), FaucetError> {
307        let runtime = tokio::runtime::Handle::current();
308        let conn = self.conn()?;
309        conn.execute(NLS_SESSION_SQL, &[])
310            .map_err(|e| src_err("session setup", &e))?;
311        let mut meta = self.load_meta(&conn)?;
312        let send = |page: StreamPage| tx.blocking_send(page).is_ok();
313        let (resetlogs,): (u64,) = conn
314            .query_row_as(RESETLOGS_SQL, &[])
315            .map_err(|e| src_err("incarnation check", &e))?;
316        let pdb = pdb_incarnation(&conn);
317        let mut position = match start {
318            Some(mut p) => {
319                p.bind_incarnation(resetlogs)?;
320                p.bind_pdb_incarnation(pdb)?;
321                p
322            }
323            None => {
324                let mut p = self.initial_position(&conn)?;
325                p.bind_incarnation(resetlogs)?;
326                p.bind_pdb_incarnation(pdb)?;
327                if per_txn
328                    && !send(StreamPage {
329                        records: Vec::new(),
330                        bookmark: Some(p.to_value()),
331                    })
332                {
333                    return Ok(());
334                }
335                p
336            }
337        };
338        let mut miner = Miner::new(self.config.on_unsupported, self.config.max_staged_records)
339            .with_max_staged_total(self.config.max_staged_records_total);
340        let mut from = position.restart_scn.saturating_sub(1);
341        let mut agg: Vec<Value> = Vec::new();
342        let mut agg_since = Instant::now();
343        let mut last_activity = Instant::now();
344        let mut clamped_since: Option<Instant> = None;
345        let mut clamp_warned = false;
346        loop {
347            let current = self.position_now(&conn)?.commit_scn;
348            self.flush(&conn, current)?;
349            let (threads, floor): (u64, Option<u64>) = conn
350                .query_row_as(THREADS_SQL, &[])
351                .map_err(|e| src_err("redo thread check", &e))?;
352            match rac_clamp_lag(current, threads, floor) {
353                Some(lag) if floor.is_some_and(|f| f <= from) => {
354                    let since = *clamped_since.get_or_insert_with(Instant::now);
355                    if !clamp_warned && since.elapsed() >= RAC_CLAMP_WARN_AFTER {
356                        clamp_warned = true;
357                        tracing::warn!(
358                            open_threads = threads,
359                            scns_held_back = lag,
360                            held_secs = since.elapsed().as_secs(),
361                            "oracle-cdc: capture is waiting for the lowest RAC thread checkpoint \
362                             (V$THREAD.CHECKPOINT_CHANGE#), which advances only on a log switch \
363                             or checkpoint — a quiet instance holds back every instance's \
364                             changes. Set ARCHIVE_LAG_TARGET (e.g. 60) to bound the delay"
365                        );
366                    }
367                }
368                _ => clamped_since = None,
369            }
370            let to = window_end(current, from, self.config.max_scn_window, threads, floor);
371            let reachable = window_end(current, from, u64::MAX, threads, floor);
372            let mut captured = false;
373            if to > from {
374                let before = position.clone();
375                let mut open = true;
376                self.mine(&conn, from, to, &mut |row| {
377                    let applied = miner.apply(&row)?;
378                    // Reload at the DDL row so later commits in this window are typed by
379                    // the new columns, not the ones loaded before it.
380                    if !miner.take_ddl_tables().is_empty() {
381                        meta = self.load_meta(&conn)?;
382                    }
383                    let Some(txn) = applied else {
384                        return Ok(true);
385                    };
386                    if txn.events.is_empty() || position.already_emitted(txn.commit_scn, &txn.xid) {
387                        return Ok(true);
388                    }
389                    let records: Vec<Value> = txn
390                        .events
391                        .iter()
392                        .map(|e| to_envelope(e, &txn, &meta))
393                        .collect();
394                    position.record_commit(txn.commit_scn, &txn.xid, miner.oldest_open());
395                    captured = true;
396                    if per_txn {
397                        open = send(StreamPage {
398                            records,
399                            bookmark: Some(position.to_value()),
400                        });
401                    } else {
402                        agg.extend(records);
403                        if agg_page_due(agg.len(), agg_since.elapsed(), self.config.idle_timeout) {
404                            open = send(StreamPage {
405                                records: std::mem::take(&mut agg),
406                                bookmark: Some(position.to_value()),
407                            });
408                            agg_since = Instant::now();
409                        }
410                    }
411                    Ok(open)
412                })?;
413                if !open {
414                    return Ok(());
415                }
416                position.advance_to(to, miner.oldest_open());
417                from = to;
418                if per_txn
419                    && position != before
420                    && !send(StreamPage {
421                        records: Vec::new(),
422                        bookmark: Some(position.to_value()),
423                    })
424                {
425                    return Ok(());
426                }
427            }
428            if keeps_cycle_alive(captured, to, reachable) {
429                last_activity = Instant::now();
430            }
431            if last_activity.elapsed() >= self.config.idle_timeout {
432                break;
433            }
434            if to >= reachable {
435                // The poll interval ends early on the run's cancel and deadline.
436                runtime.block_on(calls.wait(self.config.poll_interval))?;
437            }
438        }
439        if !per_txn {
440            send(StreamPage {
441                records: agg,
442                bookmark: Some(position.to_value()),
443            });
444        }
445        tracing::info!(state_key = %self.state_key, "oracle-cdc fetch cycle complete");
446        Ok(())
447    }
448
449    fn check_supplemental(&self, conn: &Connection) -> Result<LoggingReport, FaucetError> {
450        let (min, pk, all): (String, String, String) = conn
451            .query_row_as(SUPPLEMENTAL_SQL, &[])
452            .map_err(|e| src_err("supplemental logging check", &e))?;
453        let binds = table_binds(&self.tables);
454        let refs: Vec<&dyn faucet_common_oracle::oracle::sql_type::ToSql> =
455            binds.iter().map(|b| b as _).collect();
456        let groups = conn
457            .query_as::<(String, String, String)>(&log_groups_sql(self.tables.len()), &refs)
458            .map_err(|e| src_err("supplemental logging check", &e))?
459            .collect::<Result<Vec<_>, _>>()
460            .map_err(|e| src_err("supplemental logging check", &e))?;
461        Ok(logging_report(
462            &DatabaseLogging { min, pk, all },
463            &self.tables,
464            &groups,
465        ))
466    }
467}
468
469/// The end of the next mining window: `current`, at most `max_window` past
470/// `from`, and — with several open redo threads (RAC) — no later than the
471/// lowest thread checkpoint, since the flush reaches only the local instance
472/// and a commit on another thread above that point may not be in the redo yet.
473pub(crate) fn window_end(
474    current: u64,
475    from: u64,
476    max_window: u64,
477    open_threads: u64,
478    checkpoint_floor: Option<u64>,
479) -> u64 {
480    let end = current.min(from.saturating_add(max_window));
481    match checkpoint_floor {
482        Some(floor) if open_threads > 1 => end.min(floor),
483        _ => end,
484    }
485}
486
487/// How long the RAC checkpoint floor may hold the window back before a warning.
488pub(crate) const RAC_CLAMP_WARN_AFTER: Duration = Duration::from_secs(60);
489
490/// The SCNs the RAC checkpoint floor is holding back from `current`, when it
491/// is the limit (several open threads and a floor below `current`).
492pub(crate) fn rac_clamp_lag(current: u64, open_threads: u64, floor: Option<u64>) -> Option<u64> {
493    match floor {
494        Some(f) if open_threads > 1 && f < current => Some(current - f),
495        _ => None,
496    }
497}
498
499/// Records a `batch_size: 0` cycle buffers before it emits a page anyway.
500pub(crate) const AGG_PAGE_RECORDS: usize = 100_000;
501
502/// Whether a `batch_size: 0` cycle must emit what it holds now: at
503/// [`AGG_PAGE_RECORDS`] records, or once records have waited `idle_timeout` —
504/// a database that never goes idle would otherwise grow the buffer without
505/// bound and never persist a bookmark.
506pub(crate) fn agg_page_due(len: usize, waited: Duration, idle_timeout: Duration) -> bool {
507    len >= AGG_PAGE_RECORDS || (len > 0 && waited >= idle_timeout)
508}
509
510impl OracleCdcSource {
511    /// Validate, connect, create the flush table and run the preflight: the
512    /// captured tables must exist and minimal supplemental logging must be on.
513    pub async fn new(config: OracleCdcSourceConfig) -> Result<Self, FaucetError> {
514        config.validate()?;
515        let pool = connect_pool(&config.connection, config.max_connections).await?;
516        let tables = config.table_pairs();
517        let state_key = config.resolved_state_key();
518        let probe = Shared {
519            config: config.clone(),
520            pool: pool.clone(),
521            tables: tables.clone(),
522            state_key: state_key.clone(),
523            add_logfiles: true,
524        };
525        let add_logfiles = blocking(move || {
526            let conn = probe.conn()?;
527            let (cdb, container): (String, String) = conn
528                .query_row_as(CONTAINER_SQL, &[])
529                .map_err(|e| src_err("container detection", &e))?;
530            let meta = probe.load_meta(&conn)?;
531            let missing: Vec<String> = probe
532                .tables
533                .iter()
534                .filter(|t| !meta.contains_key(*t))
535                .map(|(o, t)| format!("{o}.{t}"))
536                .collect();
537            if !missing.is_empty() {
538                return Err(FaucetError::Config(format!(
539                    "oracle-cdc: tables {missing:?} are not visible to this user (check the \
540                     names' case and SELECT grants)"
541                )));
542            }
543            let report = probe.check_supplemental(&conn)?;
544            if !report.fatal.is_empty() {
545                return Err(FaucetError::Config(format!(
546                    "oracle-cdc: {}",
547                    report.fatal.join("; ")
548                )));
549            }
550            if !report.partial.is_empty() {
551                tracing::warn!(
552                    fix = ?report.partial,
553                    "oracle-cdc: all-column supplemental logging is off, so update events carry \
554                     only key and changed columns"
555                );
556            }
557            conn.execute(&flush_table_ddl(&probe.config.flush_table), &[])
558                .map_err(|e| src_err("create flush table", &e))?;
559            Ok(needs_logfiles(&cdb, &container))
560        })
561        .await?;
562        Ok(Self {
563            shared: Arc::new(Shared {
564                config,
565                pool,
566                tables,
567                state_key,
568                add_logfiles,
569            }),
570            pending: Mutex::new(None),
571            emitted: Mutex::new(None),
572            lag_warned: AtomicBool::new(false),
573            calls: faucet_core::CallSlot::new(faucet_core::CallContext::new().without_timeout()),
574        })
575    }
576
577    fn set_emitted(&self, scn: u64) {
578        if let Ok(mut g) = self.emitted.lock() {
579            *g = Some(scn);
580        }
581    }
582
583    /// The commit SCN capture has reached: the last emitted bookmark, else
584    /// the applied start bookmark.
585    fn lag_position(&self) -> Option<u64> {
586        self.emitted.lock().ok().and_then(|g| *g).or_else(|| {
587            self.pending
588                .lock()
589                .ok()
590                .and_then(|g| g.as_ref().map(|p| p.commit_scn))
591        })
592    }
593
594    async fn measure_lag(&self, position: u64) -> Result<Option<SourceLag>, FaucetError> {
595        let shared = self.shared.clone();
596        let measured = blocking(move || {
597            let conn = shared.conn()?;
598            let current: u64 = conn
599                .query_row_as(CURRENT_SCN_SQL, &[])
600                .map_err(|e| src_err("lag current SCN", &e))?;
601            match crate::lag::plan(position, current) {
602                LagPlan::CaughtUp => Ok(Some(SourceLag::seconds(0.0))),
603                LagPlan::Measure { current, position } => {
604                    match conn
605                        .query_row_as::<f64>(SCN_AGE_SQL, &[&i64_scn(current), &i64_scn(position)])
606                    {
607                        Ok(age) => Ok(Some(SourceLag::seconds(age))),
608                        Err(e) => {
609                            from_age(Err(ora_code(&e))).map_err(|_| src_err("lag SCN age", &e))
610                        }
611                    }
612                }
613            }
614        });
615        self.calls.call_once("query", measured).await
616    }
617
618    fn pages(
619        &self,
620        per_txn: bool,
621    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + '_>> {
622        let start = self.pending.lock().expect("pending mutex").take();
623        if let Some(p) = &start {
624            self.set_emitted(p.commit_scn);
625        }
626        let shared = self.shared.clone();
627        let calls = self.calls.get();
628        Box::pin(async_stream::try_stream! {
629            let (tx, mut rx) = tokio::sync::mpsc::channel::<StreamPage>(4);
630            let capture_calls = calls.clone();
631            let handle =
632                tokio::task::spawn_blocking(move || shared.capture(start, per_txn, tx, capture_calls));
633            // The run's deadline and cancel token end the wait for the next
634            // page with an error (#880); dropping the receiver stops capture.
635            let steps = calls.with_roundtrip_metering(false);
636            while let Some(page) = steps.call_once("poll", async { Ok(rx.recv().await) }).await? {
637                if let Some(scn) = page.bookmark.as_ref().and_then(commit_scn) {
638                    self.set_emitted(scn);
639                }
640                yield page;
641            }
642            handle
643                .await
644                .map_err(|e| FaucetError::Source(format!("oracle-cdc capture task failed: {e}")))??;
645        })
646    }
647}
648
649#[async_trait]
650impl Source for OracleCdcSource {
651    fn set_call_context(&self, ctx: faucet_core::CallContext) {
652        self.calls.install(ctx);
653    }
654
655    fn set_roundtrip_recorder(
656        &self,
657        recorder: std::sync::Arc<faucet_core::observability::RoundtripRecorder>,
658    ) {
659        self.calls.install_recorder(recorder);
660    }
661
662    async fn fetch_with_context(
663        &self,
664        _ctx: &HashMap<String, Value>,
665    ) -> Result<Vec<Value>, FaucetError> {
666        use futures::StreamExt;
667        let mut pages = self.pages(false);
668        let mut all = Vec::new();
669        while let Some(page) = pages.next().await {
670            all.extend(page?.records);
671        }
672        Ok(all)
673    }
674
675    fn stream_pages<'a>(
676        &'a self,
677        _ctx: &'a HashMap<String, Value>,
678        _batch_size: usize,
679    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
680        self.pages(self.shared.config.batch_size != 0)
681    }
682
683    fn config_schema(&self) -> Value {
684        serde_json::to_value(faucet_core::schema_for!(OracleCdcSourceConfig))
685            .expect("schema serialization")
686    }
687
688    fn state_key(&self) -> Option<String> {
689        Some(self.shared.state_key.clone())
690    }
691
692    fn legacy_state_keys(&self) -> Vec<String> {
693        self.shared.config.legacy_state_keys()
694    }
695
696    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
697        *self.pending.lock().expect("pending mutex") = Some(Position::from_value(&bookmark)?);
698        Ok(())
699    }
700
701    /// The current SCN (reaching back to open transactions), for anchoring CDC
702    /// before a snapshot.
703    async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
704        let shared = self.shared.clone();
705        self.calls
706            .call_once(
707                "query",
708                blocking(move || {
709                    let conn = shared.conn()?;
710                    Ok(Some(shared.position_now(&conn)?.to_value()))
711                }),
712            )
713            .await
714    }
715
716    /// Seconds of commit time between the capture position and the current
717    /// SCN (`SCN_TO_TIMESTAMP`); `None` before a position exists or when the
718    /// position is older than the SCN-to-time mapping (ORA-08181). A failed
719    /// query is logged once and reported as no lag.
720    async fn lag(&self) -> Result<Option<SourceLag>, FaucetError> {
721        let Some(position) = self.lag_position() else {
722            return Ok(None);
723        };
724        match self.measure_lag(position).await {
725            Ok(lag) => Ok(lag),
726            Err(e) => {
727                if !self.lag_warned.swap(true, Ordering::Relaxed) {
728                    tracing::warn!(error = %e, "oracle-cdc: lag query failed; lag is not reported (logged once)");
729                }
730                Ok(None)
731            }
732        }
733    }
734
735    fn capabilities(&self) -> faucet_core::Capabilities {
736        faucet_core::Capabilities::of_source(self)
737            .to_builder()
738            .reports_lag(true)
739            .table_routing(true)
740            .read_side_effects(true)
741            .build()
742    }
743
744    fn supports_exactly_once(&self) -> bool {
745        true
746    }
747
748    fn record_table(&self, record: &Value) -> Option<String> {
749        schema_table(record)
750    }
751
752    fn position_le(&self, a: &Value, b: &Value) -> Option<bool> {
753        crate::state::position_le(a, b)
754    }
755
756    fn record_covered(&self, record: &Value, position: &Value) -> Option<bool> {
757        crate::state::record_covered(record, position)
758    }
759
760    fn connector_name(&self) -> &'static str {
761        "oracle-cdc"
762    }
763
764    fn dataset_uri(&self) -> String {
765        format!(
766            "{}?tables={}",
767            self.shared.config.connection.display_uri(),
768            self.shared.config.tables.join(",")
769        )
770    }
771
772    async fn check(&self, ctx: &CheckContext) -> Result<CheckReport, FaucetError> {
773        let start = Instant::now();
774        let shared = self.shared.clone();
775        let run = blocking(move || {
776            let conn = shared.conn()?;
777            let report = shared.check_supplemental(&conn)?;
778            let logs = shared.list_logs(&conn, 0, u64::MAX)?;
779            Ok((report, logs.len()))
780        });
781        let probes = match tokio::time::timeout(ctx.timeout, run).await {
782            Err(_) => vec![Probe::fail_hint(
783                "connection",
784                start.elapsed(),
785                "timed out",
786                "the database did not respond within the check timeout",
787            )],
788            Ok(Err(e)) => vec![Probe::fail_hint(
789                "connection",
790                start.elapsed(),
791                e.to_string(),
792                "check the connection settings and that the user can read V$DATABASE, V$LOG, \
793                 V$ARCHIVED_LOG and ALL_LOG_GROUPS (grant SELECT_CATALOG_ROLE, LOGMINING)",
794            )],
795            Ok(Ok((report, logs))) => {
796                let supplemental = if report.fatal.is_empty() {
797                    Probe::pass("supplemental-logging", start.elapsed())
798                } else {
799                    Probe::fail_hint(
800                        "supplemental-logging",
801                        start.elapsed(),
802                        "supplemental logging does not cover the captured tables",
803                        report.fatal.join("; "),
804                    )
805                };
806                let images = if report.partial.is_empty() {
807                    Probe::pass("full-row-images", start.elapsed())
808                } else {
809                    Probe::skip(
810                        "full-row-images",
811                        format!(
812                            "updates will carry only key and changed columns; to capture full \
813                             rows run: {}",
814                            report.partial.join(" ")
815                        ),
816                    )
817                };
818                let redo = if logs > 0 {
819                    Probe::pass("redo-logs", start.elapsed())
820                } else {
821                    Probe::fail_hint(
822                        "redo-logs",
823                        start.elapsed(),
824                        "no redo logs are visible",
825                        "grant SELECT on V_$LOG, V_$LOGFILE and V_$ARCHIVED_LOG",
826                    )
827                };
828                vec![
829                    Probe::pass("connection", start.elapsed()),
830                    supplemental,
831                    images,
832                    redo,
833                ]
834            }
835        };
836        Ok(CheckReport { probes })
837    }
838}
839
840/// `OWNER.TABLE` of a change envelope, the name the `oracle` source's
841/// discovery reports for the same table.
842fn schema_table(record: &Value) -> Option<String> {
843    let schema = record.get("schema")?.as_str()?;
844    let table = record.get("table")?.as_str()?;
845    Some(format!("{schema}.{table}"))
846}
847
848/// Whether a window resets the idle clock: it captured a commit, or mining has
849/// not yet reached the current SCN. Each cycle restarts at `restart_scn` — pinned
850/// by the oldest open transaction anywhere in the database — and re-mines redo
851/// it already emitted; counting that catch-up as idle broke the cycle before it
852/// reached new redo, every cycle, so capture never progressed (SQL-29).
853fn keeps_cycle_alive(captured: bool, to: u64, current: u64) -> bool {
854    captured || to < current
855}
856
857#[cfg(test)]
858mod tests {
859
860    #[test]
861    fn catching_up_is_never_idle() {
862        assert!(keeps_cycle_alive(false, 100, 200), "re-mining old redo");
863        assert!(keeps_cycle_alive(true, 200, 200), "captured a commit");
864        assert!(!keeps_cycle_alive(false, 200, 200), "caught up and quiet");
865        assert!(!keeps_cycle_alive(false, 250, 200));
866    }
867
868    #[test]
869    fn window_end_respects_the_rac_checkpoint_floor() {
870        assert_eq!(window_end(1000, 100, 50, 1, Some(10)), 150);
871        assert_eq!(window_end(120, 100, 50, 1, Some(10)), 120);
872        assert_eq!(window_end(1000, 100, 50, 2, Some(130)), 130);
873        assert_eq!(window_end(1000, 100, 50, 2, Some(500)), 150);
874        assert_eq!(window_end(1000, 100, 50, 2, None), 150);
875        assert_eq!(window_end(1000, 100, u64::MAX, 3, Some(700)), 700);
876    }
877
878    #[test]
879    fn the_rac_floor_lag_is_reported_only_when_it_limits_the_window() {
880        assert_eq!(rac_clamp_lag(1000, 2, Some(400)), Some(600));
881        assert_eq!(rac_clamp_lag(1000, 1, Some(400)), None);
882        assert_eq!(rac_clamp_lag(1000, 2, Some(1000)), None);
883        assert_eq!(rac_clamp_lag(1000, 2, None), None);
884    }
885
886    #[test]
887    fn agg_pages_are_bounded() {
888        let idle = std::time::Duration::from_secs(30);
889        assert!(!agg_page_due(0, idle * 2, idle));
890        assert!(!agg_page_due(10, idle / 2, idle));
891        assert!(agg_page_due(10, idle, idle));
892        assert!(agg_page_due(
893            AGG_PAGE_RECORDS,
894            std::time::Duration::ZERO,
895            idle
896        ));
897    }
898
899    #[test]
900    fn routes_by_owner_table() {
901        assert_eq!(
902            schema_table(&serde_json::json!({"schema": "APP", "table": "ORDERS"})),
903            Some("APP.ORDERS".into())
904        );
905        assert_eq!(schema_table(&serde_json::json!({"table": "ORDERS"})), None);
906    }
907    use super::*;
908
909    #[test]
910    fn helpers() {
911        assert_eq!(i64_scn(5), 5);
912        assert_eq!(i64_scn(u64::MAX), i64::MAX);
913        assert_eq!(
914            table_binds(&[("A".into(), "T".into())]),
915            vec!["A".to_string(), "T".to_string()]
916        );
917        assert!(missing_redo_message(1, 2).contains("SCN 1..=2"));
918        assert!(
919            classify_start_error(Some(1291), "x", 3, 4)
920                .to_string()
921                .contains("SCN 3..=4")
922        );
923        let other = classify_start_error(Some(1017), "ORA-01017", 3, 4).to_string();
924        assert!(
925            other.contains("ORA-01017") && !other.contains("ARCHIVELOG"),
926            "{other}"
927        );
928    }
929}