1use 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
36pub struct OracleCdcSource {
38 shared: Arc<Shared>,
39 pending: Mutex<Option<Position>>,
40 emitted: Mutex<Option<u64>>,
41 lag_warned: AtomicBool,
42 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
59fn 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
81fn 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 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 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 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 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
469pub(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
487pub(crate) const RAC_CLAMP_WARN_AFTER: Duration = Duration::from_secs(60);
489
490pub(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
499pub(crate) const AGG_PAGE_RECORDS: usize = 100_000;
501
502pub(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 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 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 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 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 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
840fn 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
848fn 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}