use async_trait::async_trait;
use faucet_core::{FaucetError, RowOutcome, Sink, Source, StreamPage};
use futures::{Stream, StreamExt};
use serde_json::Value;
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use crate::lifecycle::InferredSchema;
pub struct SampleState {
cap: usize,
count: AtomicU64,
sample: Mutex<Vec<Value>>,
}
impl SampleState {
pub fn new(cap: usize) -> Self {
Self {
cap,
count: AtomicU64::new(0),
sample: Mutex::new(Vec::new()),
}
}
pub fn count(&self) -> u64 {
self.count.load(Ordering::Relaxed)
}
fn observe(&self, records: &[Value]) {
self.count
.fetch_add(records.len() as u64, Ordering::Relaxed);
if self.cap == 0 {
return;
}
let mut s = self.sample.lock().unwrap();
for r in records {
if s.len() >= self.cap {
break;
}
s.push(r.clone());
}
}
pub fn samples(&self) -> Vec<Value> {
self.sample.lock().unwrap().clone()
}
pub fn inferred_schema(&self) -> InferredSchema {
let sample = self.sample.lock().unwrap();
if sample.is_empty() {
return InferredSchema::default();
}
let mut order: Vec<String> = Vec::new();
let mut types: HashMap<String, String> = HashMap::new();
for rec in sample.iter() {
if let Value::Object(map) = rec {
for (k, v) in map {
if !types.contains_key(k) {
order.push(k.clone());
}
types
.entry(k.clone())
.or_insert_with(|| ol_type_of(v).to_string());
}
}
}
InferredSchema {
fields: order
.into_iter()
.map(|k| {
let t = types.remove(&k).unwrap_or_else(|| "string".into());
(k, t)
})
.collect(),
}
}
}
fn ol_type_of(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(n) if n.is_i64() || n.is_u64() => "integer",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
pub struct SamplingSink {
inner: Box<dyn Sink>,
state: std::sync::Arc<SampleState>,
}
impl SamplingSink {
pub fn new(inner: Box<dyn Sink>, state: std::sync::Arc<SampleState>) -> Self {
Self { inner, state }
}
}
#[async_trait]
impl Sink for SamplingSink {
async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
let n = self.inner.write_batch(records).await?;
self.state.observe(records);
Ok(n)
}
async fn write_batch_partial(&self, records: &[Value]) -> Result<Vec<RowOutcome>, FaucetError> {
let outcomes = self.inner.write_batch_partial(records).await?;
self.state.observe(records);
Ok(outcomes)
}
async fn flush(&self) -> Result<(), FaucetError> {
self.inner.flush().await
}
fn connector_name(&self) -> &'static str {
self.inner.connector_name()
}
fn dataset_uri(&self) -> String {
self.inner.dataset_uri()
}
fn supports_idempotent_writes(&self) -> bool {
self.inner.supports_idempotent_writes()
}
fn sink_guarantee(&self) -> faucet_core::SinkGuarantee {
self.inner.sink_guarantee()
}
fn dedups_by_key(&self) -> bool {
self.inner.dedups_by_key()
}
fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
self.inner.supported_write_modes()
}
async fn write_batch_idempotent(
&self,
records: &[Value],
scope: &str,
token: &str,
) -> Result<usize, FaucetError> {
let n = self
.inner
.write_batch_idempotent(records, scope, token)
.await?;
self.state.observe(records);
Ok(n)
}
async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
self.inner.last_committed_token(scope).await
}
async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
self.inner.current_schema().await
}
fn supports_schema_evolution(&self) -> bool {
self.inner.supports_schema_evolution()
}
async fn evolve_schema(
&self,
evolution: &faucet_core::SchemaEvolution,
) -> Result<(), FaucetError> {
self.inner.evolve_schema(evolution).await
}
}
pub struct SamplingSource {
inner: Box<dyn Source>,
state: std::sync::Arc<SampleState>,
}
impl SamplingSource {
pub fn new(inner: Box<dyn Source>, state: std::sync::Arc<SampleState>) -> Self {
Self { inner, state }
}
}
#[async_trait]
impl Source for SamplingSource {
async fn fetch_with_context(
&self,
ctx: &HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
self.inner.fetch_with_context(ctx).await
}
fn stream_pages<'a>(
&'a self,
ctx: &'a HashMap<String, Value>,
batch_size: usize,
) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
let state = std::sync::Arc::clone(&self.state);
let inner = self.inner.stream_pages(ctx, batch_size);
Box::pin(faucet_core::async_stream::try_stream! {
let mut inner = inner;
while let Some(page) = inner.next().await {
let page = page?;
state.observe(&page.records);
yield page;
}
})
}
fn connector_name(&self) -> &'static str {
self.inner.connector_name()
}
fn dataset_uri(&self) -> String {
self.inner.dataset_uri()
}
fn state_key(&self) -> Option<String> {
self.inner.state_key()
}
async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
self.inner.apply_start_bookmark(bookmark).await
}
async fn fetch_with_context_incremental(
&self,
ctx: &HashMap<String, Value>,
) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
self.inner.fetch_with_context_incremental(ctx).await
}
fn supports_exactly_once(&self) -> bool {
self.inner.supports_exactly_once()
}
fn replay_guarantee(&self) -> faucet_core::ReplayGuarantee {
self.inner.replay_guarantee()
}
async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
self.inner.capture_resume_position().await
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use faucet_core::{FaucetError, Sink};
use serde_json::{Value, json};
use std::sync::Arc;
struct CollectSink(std::sync::Mutex<Vec<Value>>);
#[async_trait]
impl Sink for CollectSink {
async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
self.0.lock().unwrap().extend(records.iter().cloned());
Ok(records.len())
}
fn connector_name(&self) -> &'static str {
"collect"
}
}
#[tokio::test]
async fn sink_counts_and_samples_first_n() {
let shared = Arc::new(SampleState::new(2));
let inner: Box<dyn Sink> = Box::new(CollectSink(Default::default()));
let s = SamplingSink::new(inner, Arc::clone(&shared));
s.write_batch(&[json!({"id":1,"name":"a"})]).await.unwrap();
s.write_batch(&[json!({"id":2}), json!({"id":3})])
.await
.unwrap();
assert_eq!(shared.count(), 3);
let schema = shared.inferred_schema();
let names: Vec<&str> = schema.fields.iter().map(|(n, _)| n.as_str()).collect();
assert!(names.contains(&"id"));
assert!(names.contains(&"name"));
}
struct TwoRowSource;
#[async_trait]
impl faucet_core::Source for TwoRowSource {
async fn fetch_with_context(
&self,
_: &std::collections::HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
Ok(vec![json!({"id": 1}), json!({"id": 2})])
}
fn connector_name(&self) -> &'static str {
"tworow"
}
}
#[tokio::test]
async fn source_samples_streamed_pages_without_buffering_override() {
use faucet_core::Source as _;
use futures::StreamExt as _;
let shared = Arc::new(SampleState::new(10));
let s = SamplingSource::new(Box::new(TwoRowSource), Arc::clone(&shared));
let ctx = std::collections::HashMap::new();
let mut pages = s.stream_pages(&ctx, 1000);
while let Some(p) = pages.next().await {
let _ = p.unwrap();
}
assert_eq!(shared.count(), 2);
let schema = shared.inferred_schema();
let names: Vec<&str> = schema.fields.iter().map(|(n, _)| n.as_str()).collect();
assert!(names.contains(&"id"));
}
struct IdemSink;
#[async_trait]
impl Sink for IdemSink {
async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
Ok(records.len())
}
fn connector_name(&self) -> &'static str {
"idem"
}
fn supports_idempotent_writes(&self) -> bool {
true
}
fn dedups_by_key(&self) -> bool {
true
}
fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
&[
faucet_core::WriteMode::Append,
faucet_core::WriteMode::Upsert,
]
}
async fn write_batch_idempotent(
&self,
records: &[Value],
_scope: &str,
_token: &str,
) -> Result<usize, FaucetError> {
Ok(records.len())
}
async fn last_committed_token(&self, _scope: &str) -> Result<Option<String>, FaucetError> {
Ok(Some("tok".into()))
}
fn supports_schema_evolution(&self) -> bool {
true
}
async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
Ok(Some(json!({"type": "object", "properties": {}})))
}
}
#[tokio::test]
async fn sink_forwards_capabilities_and_samples_idempotent_writes() {
let shared = Arc::new(SampleState::new(10));
let s = SamplingSink::new(Box::new(IdemSink), Arc::clone(&shared));
assert!(s.supports_idempotent_writes());
assert!(s.dedups_by_key());
assert_eq!(
s.sink_guarantee(),
faucet_core::SinkGuarantee::AtomicWatermark
);
assert!(
s.supported_write_modes()
.contains(&faucet_core::WriteMode::Upsert)
);
assert!(s.supports_schema_evolution());
assert!(s.current_schema().await.unwrap().is_some());
assert_eq!(
s.last_committed_token("k").await.unwrap(),
Some("tok".into())
);
s.write_batch_idempotent(&[json!({"id": 1})], "k", "t")
.await
.unwrap();
assert_eq!(shared.count(), 1);
}
struct BookmarkedSource;
#[async_trait]
impl faucet_core::Source for BookmarkedSource {
async fn fetch_with_context(
&self,
_: &std::collections::HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
Ok(vec![json!({"id": 1})])
}
async fn fetch_with_context_incremental(
&self,
_: &std::collections::HashMap<String, Value>,
) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
Ok((vec![json!({"id": 1})], Some(json!("bm"))))
}
fn supports_exactly_once(&self) -> bool {
true
}
async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
Ok(Some(json!("pos")))
}
fn connector_name(&self) -> &'static str {
"bookmarked"
}
}
#[tokio::test]
async fn source_forwards_bookmarks_and_capabilities() {
use faucet_core::Source as _;
let shared = Arc::new(SampleState::new(10));
let s = SamplingSource::new(Box::new(BookmarkedSource), Arc::clone(&shared));
let (_, bm) = s
.fetch_with_context_incremental(&std::collections::HashMap::new())
.await
.unwrap();
assert_eq!(bm, Some(json!("bm")));
assert!(s.supports_exactly_once());
assert_eq!(
s.replay_guarantee(),
faucet_core::ReplayGuarantee::Deterministic
);
assert_eq!(
s.capture_resume_position().await.unwrap(),
Some(json!("pos"))
);
}
}