1use super::column::Profiler;
9use crate::error::FaucetError;
10use crate::traits::{RowOutcome, Sink};
11use async_trait::async_trait;
12use serde_json::Value;
13use std::sync::{Arc, Mutex};
14
15pub struct ProfilingSink {
17 inner: Box<dyn Sink>,
18 profiler: Arc<Mutex<Profiler>>,
19}
20
21impl ProfilingSink {
22 pub fn new(inner: Box<dyn Sink>, profiler: Arc<Mutex<Profiler>>) -> Self {
23 Self { inner, profiler }
24 }
25
26 fn observe(&self, records: &[Value]) {
27 if let Ok(mut p) = self.profiler.lock() {
28 p.observe_page(records);
29 }
30 }
31}
32
33#[async_trait]
34impl Sink for ProfilingSink {
35 crate::forward_sink!(inner; except
38 write_batch, write_batch_reported, write_governed, write_batch_partial,
39 write_batch_idempotent, write_batch_idempotent_reported,
40 write_batch_columnar, native_load_capabilities, load_native);
41
42 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
43 let n = self.inner.write_batch(records).await?;
44 self.observe(records);
45 Ok(n)
46 }
47
48 async fn write_batch_reported(
49 &self,
50 records: &[Value],
51 ) -> Result<crate::accounting::WriteReport, FaucetError> {
52 let r = self.inner.write_batch_reported(records).await?;
53 self.observe(records);
54 Ok(r)
55 }
56
57 async fn write_governed(
58 &self,
59 page: &crate::governance::GovernedPage<'_>,
60 ) -> Result<crate::accounting::WriteReport, FaucetError> {
61 let r = self.inner.write_governed(page).await?;
62 self.observe(page.records());
63 Ok(r)
64 }
65
66 async fn write_batch_partial(&self, records: &[Value]) -> Result<Vec<RowOutcome>, FaucetError> {
69 let outcomes = self.inner.write_batch_partial(records).await?;
70 if outcomes.iter().all(|o| o.is_ok()) {
71 self.observe(records);
72 } else {
73 let accepted: Vec<Value> = records
74 .iter()
75 .zip(&outcomes)
76 .filter(|(_, o)| o.is_ok())
77 .map(|(r, _)| r.clone())
78 .collect();
79 self.observe(&accepted);
80 }
81 Ok(outcomes)
82 }
83
84 #[allow(deprecated)] async fn write_batch_idempotent(
86 &self,
87 records: &[Value],
88 scope: &str,
89 token: &str,
90 ) -> Result<usize, FaucetError> {
91 let n = self
92 .inner
93 .write_batch_idempotent(records, scope, token)
94 .await?;
95 self.observe(records);
96 Ok(n)
97 }
98
99 #[allow(deprecated)] async fn write_batch_idempotent_reported(
101 &self,
102 records: &[Value],
103 scope: &str,
104 token: &str,
105 ) -> Result<crate::accounting::WriteReport, FaucetError> {
106 let r = self
107 .inner
108 .write_batch_idempotent_reported(records, scope, token)
109 .await?;
110 self.observe(records);
111 Ok(r)
112 }
113
114 #[cfg(feature = "arrow")]
115 async fn write_batch_columnar(
116 &self,
117 batch: &arrow::array::RecordBatch,
118 ) -> Result<usize, FaucetError> {
119 let n = self.inner.write_batch_columnar(batch).await?;
120 if let Ok(values) = crate::columnar::record_batch_to_values(batch) {
121 self.observe(&values);
122 }
123 Ok(n)
124 }
125}
126
127#[allow(deprecated)] #[cfg(test)]
129mod tests {
130 use super::*;
131 use crate::profiling::ProfilingSpec;
132 use serde_json::json;
133
134 struct Partial;
135 #[async_trait]
136 impl Sink for Partial {
137 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
138 Ok(records.len())
139 }
140 async fn write_batch_partial(
141 &self,
142 records: &[Value],
143 ) -> Result<Vec<RowOutcome>, FaucetError> {
144 Ok(records
145 .iter()
146 .map(|r| {
147 if r["bad"].as_bool() == Some(true) {
148 Err(FaucetError::Sink("rejected".into()))
149 } else {
150 Ok(())
151 }
152 })
153 .collect())
154 }
155 fn connector_name(&self) -> &'static str {
156 "partial"
157 }
158 fn supports_idempotent_writes(&self) -> bool {
159 true
160 }
161 fn readback_source(&self) -> Option<(String, Value)> {
162 Some(("stub".into(), json!({})))
163 }
164 }
165
166 fn wrapped() -> (ProfilingSink, Arc<Mutex<Profiler>>) {
167 let profiler = Arc::new(Mutex::new(Profiler::new(ProfilingSpec::default())));
168 (
169 ProfilingSink::new(Box::new(Partial), Arc::clone(&profiler)),
170 profiler,
171 )
172 }
173
174 #[tokio::test]
175 async fn profiles_accepted_rows_only() {
176 let (sink, profiler) = wrapped();
177 sink.write_batch(&[json!({"a": 1}), json!({"a": 2})])
178 .await
179 .unwrap();
180 let outcomes = sink
181 .write_batch_partial(&[json!({"a": 3}), json!({"a": null, "bad": true})])
182 .await
183 .unwrap();
184 assert!(outcomes[1].is_err());
185 sink.write_batch_idempotent(&[json!({"a": 4})], "scope", "t")
186 .await
187 .unwrap();
188 let profile = profiler.lock().unwrap().finish();
189 assert_eq!(profile.rows, 4, "the rejected row is not profiled");
190 assert_eq!(profile.columns["a"].nulls, 0);
191 assert!(!profile.columns.contains_key("bad"));
192 }
193
194 #[tokio::test]
195 async fn all_accepted_partial_page_is_profiled_whole() {
196 let (sink, profiler) = wrapped();
197 sink.write_batch_partial(&[json!({"a": 1}), json!({"a": 2})])
198 .await
199 .unwrap();
200 assert_eq!(profiler.lock().unwrap().rows(), 2);
201 }
202
203 #[tokio::test]
204 async fn capabilities_forward_to_the_inner_sink() {
205 let (sink, _) = wrapped();
206 assert_eq!(sink.connector_name(), "partial");
207 assert!(sink.supports_idempotent_writes());
208 assert!(sink.readback_source().is_some());
209 assert!(sink.flush().await.is_ok());
210 assert!(sink.local_outputs().await.is_empty());
211 assert!(!sink.dedups_by_key());
212 assert_eq!(
213 sink.batch_atomicity(),
214 crate::dlq::BatchAtomicity::BestEffort
215 );
216 assert!(!sink.is_overwrite());
217 assert!(!sink.supports_rollback());
218 assert!(!sink.supports_cleanup());
219 assert!(!sink.supports_staged_load());
220 assert!(!sink.supports_schema_evolution());
221 assert!(sink.current_schema().await.unwrap().is_none());
222 assert!(sink.last_committed_token("s").await.unwrap().is_none());
223 assert!(sink.begin_overwrite().await.is_err(), "the default rejects");
224 assert!(sink.commit_overwrite().await.is_err());
225 assert!(sink.abort_overwrite().await.is_ok());
226 assert!(sink.complete_run().await.is_ok());
227 assert!(sink.forget_run("r").await.is_ok());
228 assert!(
229 sink.rewind_commit_token("s", None).await.is_err(),
230 "the default rejects"
231 );
232 let opts = crate::rollback::RollbackOptions {
233 run_id_column: "_faucet_run_id".into(),
234 mode: crate::rollback::RollbackMode::Append,
235 force: false,
236 dry_run: true,
237 later_runs: false,
238 };
239 assert!(sink.rollback_run("r", &opts).await.is_err());
240 assert!(
241 sink.cleanup_scope(&Default::default(), &crate::cleanup::SeenKeys::default())
242 .await
243 .is_err()
244 );
245 assert!(
246 sink.check(&crate::check::CheckContext::default())
247 .await
248 .is_ok()
249 );
250 assert_eq!(sink.dataset_uri(), Partial.dataset_uri());
251 assert_eq!(
252 sink.supported_write_modes(),
253 &[crate::write_mode::WriteMode::Append]
254 );
255 assert!(sink.native_load_capabilities().is_empty());
257 }
258
259 #[tokio::test]
260 async fn profiling_sink_forwards_every_hook_but_native() {
261 let probe = crate::forwarding::HookSink::default();
262 let sink = ProfilingSink::new(
263 Box::new(probe.clone()),
264 Arc::new(Mutex::new(Profiler::new(ProfilingSpec::default()))),
265 );
266 crate::forwarding::assert_sink_forwards_every_hook(
267 &sink,
268 &probe,
269 &["native_load_capabilities", "load_native"],
270 )
271 .await;
272 }
273}