Skip to main content

faucet_core/
metadata.rs

1//! Optional `_faucet_*` run/lineage metadata columns (#510).
2//!
3//! An opt-in, connector-agnostic **sink decorator** that stamps a small set of
4//! metadata columns onto every row — the faucet-native analogue of Singer's
5//! `_sdc_*` columns, for freshness/lineage and debugging. Because it's a
6//! decorator (like [`CleanupTracker`](crate::cleanup::CleanupTracker)) it works
7//! for every sink without per-connector code.
8//!
9//! ```yaml
10//! metadata_columns:
11//!   prefix: "_faucet"                       # column-name prefix (default)
12//!   columns: [extracted_at, loaded_at, run_id, source]
13//! ```
14
15use 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/// A metadata column to stamp onto every row.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
26#[serde(rename_all = "snake_case")]
27pub enum MetadataColumn {
28    /// When the record entered the pipeline (ingest time). In the sink-decorator
29    /// model this is captured just before the write, so it's ≈ `loaded_at`.
30    ExtractedAt,
31    /// Sink write time.
32    LoadedAt,
33    /// The pipeline run id.
34    RunId,
35    /// The source connector kind / stream id.
36    Source,
37    /// A monotonic per-run row ordinal.
38    Sequence,
39}
40
41impl MetadataColumn {
42    /// The column-name suffix (appended to the prefix as `{prefix}_{suffix}`).
43    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/// The `metadata_columns:` config block.
62#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
63#[serde(deny_unknown_fields)]
64pub struct MetadataColumnsSpec {
65    /// Master switch (default `true` when the block is present).
66    #[serde(default = "default_true")]
67    pub enabled: bool,
68    /// Column-name prefix (default `_faucet`).
69    #[serde(default = "default_prefix")]
70    pub prefix: String,
71    /// Columns to stamp. Empty = the default set
72    /// (`extracted_at`, `loaded_at`, `run_id`, `source`).
73    #[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/// A validated metadata-columns policy.
95#[derive(Debug, Clone)]
96pub struct CompiledMetadata {
97    prefix: String,
98    columns: Vec<MetadataColumn>,
99}
100
101impl CompiledMetadata {
102    /// Compile a spec: resolve the default column set and validate the prefix.
103    /// Returns `Ok(None)` when disabled (so the caller skips the decorator).
104    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/// Per-run values the decorator stamps.
130#[derive(Debug, Clone)]
131pub struct MetadataContext {
132    /// The pipeline run id.
133    pub run_id: String,
134    /// The source connector kind / stream id.
135    pub source: String,
136}
137
138/// A [`Sink`] decorator that stamps `_faucet_*` metadata columns onto every row
139/// before delegating the write.
140pub struct MetadataSink {
141    inner: Box<dyn Sink>,
142    meta: CompiledMetadata,
143    ctx: MetadataContext,
144    seq: AtomicU64,
145    /// Source fields a metadata column replaced (each warned about once).
146    collided: std::sync::Mutex<std::collections::BTreeSet<String>>,
147    /// The destination was checked for the metadata columns (and they were
148    /// added where missing).
149    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    /// Wrap a sink so every written row carries the configured metadata columns.
163    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    /// Return a copy of `records` with the metadata columns injected. Non-object
175    /// records pass through unchanged (nowhere to stamp).
176    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    /// Make sure a column-mapped destination has every metadata column before
209    /// the first write. The drift pass compares pages *before* this decorator
210    /// stamps them, so it never adds these columns, and an auto-mapping sink
211    /// writes only the columns that exist — the metadata would be dropped
212    /// silently. Missing columns are added through `evolve_schema`; a sink
213    /// that cannot evolve fails instead. A schemaless destination, or one not
214    /// created yet (it is created from stamped records), needs nothing.
215    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    /// A source record already held a field named like a metadata column; the
262    /// metadata value replaced it. Warned once per column, never silent.
263    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    // ── Pure forwarding ──────────────────────────────────────────────────────
314    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        // Before the staging clone is taken from the target.
377        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        // disabled → None
596        let disabled = MetadataColumnsSpec {
597            enabled: false,
598            ..Default::default()
599        };
600        assert!(CompiledMetadata::compile(&disabled).unwrap().is_none());
601        // empty prefix → error
602        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        // Re-wrap to read the captured rows would need the inner; instead assert
632        // via a fresh capturing sink shared by Arc — simpler: re-stamp directly.
633        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        // Sequence is monotonic across records.
641        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        // Write paths stamp + forward.
665        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        // Pure forwards (inner uses trait defaults).
679        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}