1use crate::error::FaucetError;
16use crate::traits::{RowOutcome, Sink};
17use chrono::Utc;
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20use serde_json::{Map, Value};
21use std::collections::BTreeMap;
22use std::sync::atomic::{AtomicU64, Ordering};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
26#[serde(rename_all = "snake_case")]
27pub enum MetadataColumn {
28 ExtractedAt,
31 LoadedAt,
33 RunId,
35 Source,
37 Sequence,
39}
40
41impl MetadataColumn {
42 pub fn suffix(self) -> &'static str {
44 match self {
45 MetadataColumn::ExtractedAt => "extracted_at",
46 MetadataColumn::LoadedAt => "loaded_at",
47 MetadataColumn::RunId => "run_id",
48 MetadataColumn::Source => "source",
49 MetadataColumn::Sequence => "sequence",
50 }
51 }
52}
53
54fn default_prefix() -> String {
55 "_faucet".to_owned()
56}
57fn default_true() -> bool {
58 true
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
63#[serde(deny_unknown_fields)]
64pub struct MetadataColumnsSpec {
65 #[serde(default = "default_true")]
67 pub enabled: bool,
68 #[serde(default = "default_prefix")]
70 pub prefix: String,
71 #[serde(default)]
74 pub columns: Vec<MetadataColumn>,
75}
76
77impl Default for MetadataColumnsSpec {
78 fn default() -> Self {
79 Self {
80 enabled: true,
81 prefix: default_prefix(),
82 columns: Vec::new(),
83 }
84 }
85}
86
87const DEFAULT_COLUMNS: &[MetadataColumn] = &[
88 MetadataColumn::ExtractedAt,
89 MetadataColumn::LoadedAt,
90 MetadataColumn::RunId,
91 MetadataColumn::Source,
92];
93
94#[derive(Debug, Clone)]
96pub struct CompiledMetadata {
97 prefix: String,
98 columns: Vec<MetadataColumn>,
99}
100
101impl CompiledMetadata {
102 pub fn compile(spec: &MetadataColumnsSpec) -> Result<Option<Self>, FaucetError> {
105 if !spec.enabled {
106 return Ok(None);
107 }
108 if spec.prefix.trim().is_empty() {
109 return Err(FaucetError::Config(
110 "metadata_columns: `prefix` must not be empty".into(),
111 ));
112 }
113 let columns = if spec.columns.is_empty() {
114 DEFAULT_COLUMNS.to_vec()
115 } else {
116 spec.columns.clone()
117 };
118 Ok(Some(Self {
119 prefix: spec.prefix.clone(),
120 columns,
121 }))
122 }
123
124 fn column_name(&self, col: MetadataColumn) -> String {
125 format!("{}_{}", self.prefix, col.suffix())
126 }
127}
128
129#[derive(Debug, Clone)]
131pub struct MetadataContext {
132 pub run_id: String,
134 pub source: String,
136}
137
138pub struct MetadataSink {
141 inner: Box<dyn Sink>,
142 meta: CompiledMetadata,
143 ctx: MetadataContext,
144 seq: AtomicU64,
145 collided: std::sync::Mutex<std::collections::BTreeSet<String>>,
147 columns_ready: tokio::sync::OnceCell<()>,
150}
151
152impl std::fmt::Debug for MetadataSink {
153 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154 f.debug_struct("MetadataSink")
155 .field("prefix", &self.meta.prefix)
156 .field("columns", &self.meta.columns)
157 .finish()
158 }
159}
160
161impl MetadataSink {
162 pub fn new(inner: Box<dyn Sink>, meta: CompiledMetadata, ctx: MetadataContext) -> Self {
164 Self {
165 inner,
166 meta,
167 ctx,
168 seq: AtomicU64::new(0),
169 collided: std::sync::Mutex::new(std::collections::BTreeSet::new()),
170 columns_ready: tokio::sync::OnceCell::new(),
171 }
172 }
173
174 fn stamp(&self, records: &[Value]) -> Vec<Value> {
177 let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
178 records
179 .iter()
180 .map(|rec| match rec {
181 Value::Object(map) => {
182 let mut map = map.clone();
183 self.inject(&mut map, &now);
184 Value::Object(map)
185 }
186 other => other.clone(),
187 })
188 .collect()
189 }
190
191 fn inject(&self, map: &mut Map<String, Value>, now: &str) {
192 for &col in &self.meta.columns {
193 let name = self.meta.column_name(col);
194 let value = match col {
195 MetadataColumn::ExtractedAt | MetadataColumn::LoadedAt => {
196 Value::String(now.to_owned())
197 }
198 MetadataColumn::RunId => Value::String(self.ctx.run_id.clone()),
199 MetadataColumn::Source => Value::String(self.ctx.source.clone()),
200 MetadataColumn::Sequence => Value::from(self.seq.fetch_add(1, Ordering::Relaxed)),
201 };
202 if map.insert(name.clone(), value).is_some() {
203 self.note_collision(name);
204 }
205 }
206 }
207
208 async fn ensure_columns(&self) -> Result<(), FaucetError> {
216 self.columns_ready
217 .get_or_try_init(|| async {
218 let Some(schema) = self.inner.current_schema().await? else {
219 return Ok(());
220 };
221 let props = schema.get("properties").and_then(Value::as_object);
222 let additions: Vec<crate::drift::ColumnChange> = self
223 .meta
224 .columns
225 .iter()
226 .map(|&c| (self.meta.column_name(c), c))
227 .filter(|(name, _)| props.is_none_or(|p| !p.contains_key(name)))
228 .map(|(name, c)| crate::drift::ColumnChange {
229 name,
230 from: None,
231 to: serde_json::json!({ "type": [
232 if c == MetadataColumn::Sequence { "integer" } else { "string" },
233 "null"
234 ] }),
235 })
236 .collect();
237 if additions.is_empty() {
238 return Ok(());
239 }
240 let names: Vec<String> = additions.iter().map(|a| a.name.clone()).collect();
241 if !self.inner.supports_schema_evolution() {
242 return Err(FaucetError::Config(format!(
243 "metadata_columns: the destination has no column {} and sink '{}' \
244 cannot add columns; add them to the table or disable metadata_columns",
245 names.join(", "),
246 self.inner.connector_name()
247 )));
248 }
249 tracing::info!(columns = ?names, "adding missing metadata columns to the destination");
250 self.inner
251 .evolve_schema(&crate::drift::SchemaEvolution {
252 additions,
253 ..Default::default()
254 })
255 .await
256 })
257 .await
258 .map(|_| ())
259 }
260
261 fn note_collision(&self, name: String) {
264 let Ok(mut seen) = self.collided.lock() else {
265 return;
266 };
267 if seen.insert(name.clone()) {
268 tracing::warn!(
269 column = %name,
270 "a source field has the same name as a metadata column; the metadata \
271 value replaces it (choose another `metadata_columns.prefix` to keep it)"
272 );
273 }
274 }
275
276 #[cfg(test)]
277 fn collisions(&self) -> Vec<String> {
278 self.collided.lock().unwrap().iter().cloned().collect()
279 }
280}
281
282#[async_trait::async_trait]
283impl Sink for MetadataSink {
284 async fn admit_page(&self, records: &[Value]) -> Result<(), FaucetError> {
285 self.inner.admit_page(records).await
286 }
287 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
288 self.ensure_columns().await?;
289 self.inner.write_batch(&self.stamp(records)).await
290 }
291
292 async fn write_batch_partial(&self, records: &[Value]) -> Result<Vec<RowOutcome>, FaucetError> {
293 self.ensure_columns().await?;
294 self.inner.write_batch_partial(&self.stamp(records)).await
295 }
296
297 async fn write_batch_idempotent(
298 &self,
299 records: &[Value],
300 scope: &str,
301 token: &str,
302 ) -> Result<usize, FaucetError> {
303 self.ensure_columns().await?;
304 self.inner
305 .write_batch_idempotent(&self.stamp(records), scope, token)
306 .await
307 }
308
309 async fn flush(&self) -> Result<(), FaucetError> {
310 self.inner.flush().await
311 }
312
313 async fn local_outputs(&self) -> Vec<crate::local_outputs::LocalOutput> {
315 self.inner.local_outputs().await
316 }
317 async fn check(
318 &self,
319 ctx: &crate::check::CheckContext,
320 ) -> Result<crate::check::CheckReport, FaucetError> {
321 self.inner.check(ctx).await
322 }
323 fn supports_cleanup(&self) -> bool {
324 self.inner.supports_cleanup()
325 }
326 async fn cleanup_scope(
327 &self,
328 scope: &BTreeMap<String, Value>,
329 seen: &crate::cleanup::SeenKeys,
330 ) -> Result<u64, FaucetError> {
331 self.inner.cleanup_scope(scope, seen).await
332 }
333 fn supports_idempotent_writes(&self) -> bool {
334 self.inner.supports_idempotent_writes()
335 }
336 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
337 self.inner.last_committed_token(scope).await
338 }
339 fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
340 self.inner.supported_write_modes()
341 }
342 fn dedups_by_key(&self) -> bool {
343 self.inner.dedups_by_key()
344 }
345 fn batch_atomicity(&self) -> crate::dlq::BatchAtomicity {
346 self.inner.batch_atomicity()
347 }
348 fn sink_guarantee(&self) -> crate::idempotency::SinkGuarantee {
349 self.inner.sink_guarantee()
350 }
351 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
352 self.inner.current_schema().await
353 }
354 fn supports_schema_evolution(&self) -> bool {
355 self.inner.supports_schema_evolution()
356 }
357 async fn evolve_schema(
358 &self,
359 evolution: &crate::drift::SchemaEvolution,
360 ) -> Result<(), FaucetError> {
361 self.inner.evolve_schema(evolution).await
362 }
363 fn config_schema(&self) -> Value {
364 self.inner.config_schema()
365 }
366 fn connector_name(&self) -> &'static str {
367 self.inner.connector_name()
368 }
369 fn dataset_uri(&self) -> String {
370 self.inner.dataset_uri()
371 }
372 fn is_overwrite(&self) -> bool {
373 self.inner.is_overwrite()
374 }
375 async fn begin_overwrite(&self) -> Result<(), FaucetError> {
376 self.ensure_columns().await?;
378 self.inner.begin_overwrite().await
379 }
380 async fn commit_overwrite(&self) -> Result<(), FaucetError> {
381 self.inner.commit_overwrite().await
382 }
383 async fn abort_overwrite(&self) -> Result<(), FaucetError> {
384 self.inner.abort_overwrite().await
385 }
386 async fn complete_run(&self) -> Result<(), FaucetError> {
387 self.inner.complete_run().await
388 }
389 fn write_batch_is_replay_safe(&self) -> bool {
390 self.inner.write_batch_is_replay_safe()
391 }
392 fn set_roundtrip_recorder(
393 &self,
394 recorder: std::sync::Arc<crate::observability::RoundtripRecorder>,
395 ) {
396 self.inner.set_roundtrip_recorder(recorder);
397 }
398 fn supports_staged_load(&self) -> bool {
399 self.inner.supports_staged_load()
400 }
401 async fn overwrite_staging_exists(&self) -> Result<Option<bool>, FaucetError> {
402 self.inner.overwrite_staging_exists().await
403 }
404 fn supports_rollback(&self) -> bool {
405 self.inner.supports_rollback()
406 }
407 async fn rollback_run(
408 &self,
409 run_id: &str,
410 opts: &crate::rollback::RollbackOptions,
411 ) -> Result<crate::rollback::RollbackOutcome, FaucetError> {
412 self.inner.rollback_run(run_id, opts).await
413 }
414 async fn forget_run(&self, run_id: &str) -> Result<(), FaucetError> {
415 self.inner.forget_run(run_id).await
416 }
417 async fn rewind_commit_token(
418 &self,
419 scope: &str,
420 token: Option<&str>,
421 ) -> Result<(), FaucetError> {
422 self.inner.rewind_commit_token(scope, token).await
423 }
424 fn readback_source(&self) -> Option<(String, Value)> {
425 self.inner.readback_source()
426 }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 struct Mapped {
434 evolutions: std::sync::Mutex<Vec<Vec<String>>>,
435 evolves: bool,
436 }
437 #[async_trait::async_trait]
438 impl Sink for Mapped {
439 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
440 Ok(records.len())
441 }
442 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
443 Ok(Some(json!({"type": "object", "properties": {
444 "id": {"type": "integer"}, "_faucet_source": {"type": "string"}
445 }})))
446 }
447 fn supports_schema_evolution(&self) -> bool {
448 self.evolves
449 }
450 async fn evolve_schema(
451 &self,
452 evolution: &crate::drift::SchemaEvolution,
453 ) -> Result<(), FaucetError> {
454 self.evolutions.lock().unwrap().push(
455 evolution
456 .additions
457 .iter()
458 .map(|a| format!("{}:{}", a.name, a.to["type"][0]))
459 .collect(),
460 );
461 Ok(())
462 }
463 }
464
465 #[tokio::test]
466 async fn missing_metadata_columns_are_added_once_before_the_first_write() {
467 let meta = CompiledMetadata::compile(&spec(&[
468 MetadataColumn::RunId,
469 MetadataColumn::Source,
470 MetadataColumn::Sequence,
471 ]))
472 .unwrap()
473 .unwrap();
474 let inner = std::sync::Arc::new(Mapped {
475 evolutions: std::sync::Mutex::new(Vec::new()),
476 evolves: true,
477 });
478 struct Shared(std::sync::Arc<Mapped>);
479 #[async_trait::async_trait]
480 impl Sink for Shared {
481 async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
482 self.0.write_batch(r).await
483 }
484 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
485 self.0.current_schema().await
486 }
487 fn supports_schema_evolution(&self) -> bool {
488 self.0.supports_schema_evolution()
489 }
490 async fn evolve_schema(
491 &self,
492 e: &crate::drift::SchemaEvolution,
493 ) -> Result<(), FaucetError> {
494 self.0.evolve_schema(e).await
495 }
496 }
497 let sink = MetadataSink::new(Box::new(Shared(inner.clone())), meta, ctx());
498 sink.write_batch(&[json!({"id": 1})]).await.unwrap();
499 sink.write_batch(&[json!({"id": 2})]).await.unwrap();
500 assert_eq!(
501 *inner.evolutions.lock().unwrap(),
502 vec![vec![
503 "_faucet_run_id:\"string\"".to_string(),
504 "_faucet_sequence:\"integer\"".to_string()
505 ]]
506 );
507
508 let meta = CompiledMetadata::compile(&spec(&[MetadataColumn::RunId]))
509 .unwrap()
510 .unwrap();
511 let rigid = MetadataSink::new(
512 Box::new(Mapped {
513 evolutions: std::sync::Mutex::new(Vec::new()),
514 evolves: false,
515 }),
516 meta,
517 ctx(),
518 );
519 let err = rigid.write_batch(&[json!({"id": 1})]).await.unwrap_err();
520 assert!(err.to_string().contains("_faucet_run_id"), "{err}");
521 }
522
523 #[test]
524 fn a_colliding_source_field_is_reported() {
525 let meta = CompiledMetadata::compile(&spec(&[MetadataColumn::RunId]))
526 .unwrap()
527 .unwrap();
528 let sink = MetadataSink::new(Box::new(CapturingSink::default()), meta, ctx());
529 let out = sink.stamp(&[
530 json!({"_faucet_run_id": "upstream", "a": 1}),
531 json!({"a": 2}),
532 ]);
533 assert_eq!(out[0]["_faucet_run_id"], json!("run-42"));
534 assert_eq!(sink.collisions(), vec!["_faucet_run_id".to_string()]);
535 sink.stamp(&[json!({"_faucet_run_id": "again"})]);
536 assert_eq!(sink.collisions().len(), 1);
537 }
538
539 #[tokio::test]
540 async fn metadata_sink_forwards_every_hook_but_the_unstamped_paths() {
541 let probe = crate::sink_forwarding::HookSink::default();
542 let meta = CompiledMetadata::compile(&spec(&[MetadataColumn::RunId]))
543 .unwrap()
544 .unwrap();
545 let sink = MetadataSink::new(Box::new(probe.clone()), meta, ctx());
546 crate::sink_forwarding::assert_forwards_every_hook(
547 &sink,
548 &probe,
549 &[
550 "supports_columnar",
551 "write_batch_columnar",
552 "native_load_capabilities",
553 "load_native",
554 ],
555 )
556 .await;
557 }
558 use serde_json::json;
559 use std::sync::Mutex;
560
561 #[derive(Debug, Default)]
562 struct CapturingSink {
563 rows: Mutex<Vec<Value>>,
564 }
565 #[async_trait::async_trait]
566 impl Sink for CapturingSink {
567 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
568 self.rows.lock().unwrap().extend_from_slice(records);
569 Ok(records.len())
570 }
571 fn config_schema(&self) -> Value {
572 json!({})
573 }
574 }
575
576 fn spec(cols: &[MetadataColumn]) -> MetadataColumnsSpec {
577 MetadataColumnsSpec {
578 enabled: true,
579 prefix: "_faucet".into(),
580 columns: cols.to_vec(),
581 }
582 }
583
584 fn ctx() -> MetadataContext {
585 MetadataContext {
586 run_id: "run-42".into(),
587 source: "rest".into(),
588 }
589 }
590
591 #[test]
592 fn compile_resolves_defaults_and_respects_disable() {
593 let c = CompiledMetadata::compile(&spec(&[])).unwrap().unwrap();
594 assert_eq!(c.columns, DEFAULT_COLUMNS.to_vec());
595 let disabled = MetadataColumnsSpec {
597 enabled: false,
598 ..Default::default()
599 };
600 assert!(CompiledMetadata::compile(&disabled).unwrap().is_none());
601 let bad = MetadataColumnsSpec {
603 prefix: " ".into(),
604 ..Default::default()
605 };
606 assert!(CompiledMetadata::compile(&bad).is_err());
607 }
608
609 #[tokio::test]
610 async fn stamps_all_column_kinds_with_prefix() {
611 let meta = CompiledMetadata::compile(&spec(&[
612 MetadataColumn::ExtractedAt,
613 MetadataColumn::LoadedAt,
614 MetadataColumn::RunId,
615 MetadataColumn::Source,
616 MetadataColumn::Sequence,
617 ]))
618 .unwrap()
619 .unwrap();
620 let inner = Box::new(CapturingSink::default());
621 let sink = MetadataSink::new(inner, meta, ctx());
622 assert_eq!(
623 sink.batch_atomicity(),
624 crate::dlq::BatchAtomicity::BestEffort
625 );
626 let n = sink
627 .write_batch(&[json!({"id": 1}), json!({"id": 2})])
628 .await
629 .unwrap();
630 assert_eq!(n, 2);
631 let stamped = sink.stamp(&[json!({"id": 1}), json!({"id": 2})]);
634 let r0 = &stamped[0];
635 assert_eq!(r0["id"], 1);
636 assert_eq!(r0["_faucet_run_id"], "run-42");
637 assert_eq!(r0["_faucet_source"], "rest");
638 assert!(r0["_faucet_extracted_at"].is_string());
639 assert!(r0["_faucet_loaded_at"].is_string());
640 assert_eq!(
642 stamped[0]["_faucet_sequence"].as_u64().unwrap() + 1,
643 stamped[1]["_faucet_sequence"].as_u64().unwrap()
644 );
645 }
646
647 #[test]
648 fn non_object_records_pass_through() {
649 let meta = CompiledMetadata::compile(&spec(&[MetadataColumn::RunId]))
650 .unwrap()
651 .unwrap();
652 let sink = MetadataSink::new(Box::new(CapturingSink::default()), meta, ctx());
653 let out = sink.stamp(&[json!("scalar"), json!(42)]);
654 assert_eq!(out, vec![json!("scalar"), json!(42)]);
655 }
656
657 #[tokio::test]
658 async fn delegates_every_sink_method_to_inner() {
659 let meta = CompiledMetadata::compile(&spec(&[MetadataColumn::RunId]))
660 .unwrap()
661 .unwrap();
662 let sink = MetadataSink::new(Box::new(CapturingSink::default()), meta, ctx());
663
664 assert_eq!(sink.write_batch(&[json!({"a": 1})]).await.unwrap(), 1);
666 assert_eq!(
667 sink.write_batch_partial(&[json!({"a": 1})])
668 .await
669 .unwrap()
670 .len(),
671 1
672 );
673 let _ = sink
674 .write_batch_idempotent(&[json!({"a": 1})], "scope", "tok")
675 .await;
676 sink.flush().await.unwrap();
677
678 let _ = sink.check(&crate::check::CheckContext::default()).await;
680 assert!(!sink.supports_cleanup());
681 let _ = sink
682 .cleanup_scope(&BTreeMap::new(), &crate::cleanup::SeenKeys::new())
683 .await;
684 assert!(!sink.supports_idempotent_writes());
685 assert!(sink.last_committed_token("s").await.unwrap().is_none());
686 let _ = sink.supported_write_modes();
687 let _ = sink.dedups_by_key();
688 let _ = sink.sink_guarantee();
689 let _ = sink.current_schema().await.unwrap();
690 let _ = sink.supports_schema_evolution();
691 let _ = sink
692 .evolve_schema(&crate::drift::SchemaEvolution::default())
693 .await;
694 let _ = sink.config_schema();
695 let _ = sink.connector_name();
696 let _ = sink.dataset_uri();
697 assert!(!sink.is_overwrite());
698 let _ = sink.begin_overwrite().await;
699 let _ = sink.commit_overwrite().await;
700 sink.abort_overwrite().await.unwrap();
701 sink.complete_run().await.unwrap();
702
703 assert!(format!("{sink:?}").contains("MetadataSink"));
704 }
705
706 #[test]
707 fn custom_prefix_and_subset() {
708 let meta = CompiledMetadata::compile(&MetadataColumnsSpec {
709 enabled: true,
710 prefix: "_dt".into(),
711 columns: vec![MetadataColumn::RunId],
712 })
713 .unwrap()
714 .unwrap();
715 let sink = MetadataSink::new(Box::new(CapturingSink::default()), meta, ctx());
716 let out = sink.stamp(&[json!({"a": 1})]);
717 assert_eq!(out[0]["_dt_run_id"], "run-42");
718 assert!(out[0].get("_dt_loaded_at").is_none());
719 }
720}