Skip to main content

faucet_sink_iceberg/
sink.rs

1//! `IcebergSink` — `faucet_core::Sink` implementation for Apache Iceberg.
2//!
3//! Records are accumulated as Arrow `RecordBatch`es, written via the iceberg
4//! writer pipeline to Parquet data files, and committed in one
5//! `Transaction::fast_append` snapshot per `flush()`.
6//!
7//! ## Flush contract
8//!
9//! Callers **must** call `flush()` when they are done writing. Unflushed data
10//! files are abandoned: they are written to object storage but never committed
11//! as an Iceberg snapshot. The pipeline (via `faucet-core`) calls `flush()`
12//! automatically at the end of each `StreamPage`.
13//!
14//! ## Commit failure & conflict handling
15//!
16//! Iceberg commits use optimistic concurrency. `Transaction::commit` in
17//! iceberg-rust 0.9.1 already handles benign races: on a retryable conflict it
18//! reloads the table metadata and re-applies the `fast_append` against the
19//! latest snapshot **without re-uploading the data files**, retrying with
20//! exponential backoff. The retry budget is tunable via the standard
21//! `commit.retry.*` table properties (e.g. `commit.retry.num-retries`), which
22//! can be set through [`IcebergSinkConfig::snapshot_properties`] at table
23//! creation. So a concurrent writer that commits between our load and our
24//! commit does **not** abort the run — it is transparently retried.
25//!
26//! If the commit *definitively* fails after those retries are exhausted (a
27//! competing writer won), the already-uploaded data files are orphaned —
28//! written to object storage but never referenced by any snapshot. By default
29//! the error propagates so the run aborts without advancing the bookmark, and
30//! the orphans remain until you run Iceberg's standard `remove_orphan_files`
31//! maintenance (e.g. via Spark / pyiceberg).
32//!
33//! Set [`IcebergSinkConfig::cleanup_orphans_on_failure`] to delete those
34//! orphans automatically. Cleanup runs **only** on a definitive loss
35//! (`CatalogCommitConflicts` / `DataInvalid`); an *ambiguous* failure
36//! (`Unexpected` / transport error, where the commit may have landed
37//! server-side) is never cleaned up, because deleting then could remove files a
38//! successful-but-unacknowledged commit references.
39//!
40//! ## Schema management
41//!
42//! When `create_if_missing: true` (the default) and no table exists yet, the
43//! Iceberg schema is inferred from the first batch using `infer_arrow_schema`
44//! and `arrow_to_iceberg_schema`. On subsequent batches the table's existing
45//! schema (converted back to Arrow with `iceberg_to_arrow_schema`) is used so
46//! the writer and the table stay in sync.
47//!
48//! When `create_if_missing: false` the table is loaded at `new()` time; a
49//! missing table produces a `FaucetError::Sink` immediately.
50
51use std::str::FromStr;
52use std::sync::Arc;
53
54use async_trait::async_trait;
55use faucet_core::FaucetError;
56use iceberg::io::FileIO;
57use iceberg::spec::{DataFile, Transform, UnboundPartitionSpec};
58use iceberg::table::Table;
59use iceberg::transaction::{AddColumn, ApplyTransactionAction, Transaction};
60use iceberg::{Catalog, ErrorKind, NamespaceIdent, TableCreation, TableIdent};
61use serde_json::Value;
62use tokio::sync::Mutex;
63
64use crate::catalog::build_catalog;
65use crate::config::{IcebergSinkConfig, PartitionField};
66use crate::schema::{
67    arrow_to_iceberg_schema, arrow_to_json_schema, iceberg_to_arrow_schema, infer_arrow_schema,
68    json_to_record_batch,
69};
70use crate::writer::{TableWriter, compression_from_str};
71
72// ── Interior state ────────────────────────────────────────────────────────────
73
74/// Interior-mutable state shared across `Sink` method calls.
75///
76/// All mutation goes through `Mutex<SinkState>`. The `Mutex` is `tokio::sync::Mutex`
77/// so `await` inside the guard compiles without issue.
78struct SinkState {
79    /// `None` until the first `write_batch` call (deferred when `create_if_missing`).
80    /// Set to `Some` on first write or at `new()` when not deferred.
81    table: Option<Table>,
82
83    /// Open writer, if any. `None` between rollovers and between flushes.
84    writer: Option<TableWriter>,
85
86    /// `DataFile`s accumulated from closed writers, awaiting the next commit.
87    pending_files: Vec<DataFile>,
88
89    /// Exactly-once commit token to stamp onto the next committed snapshot.
90    /// Set by `write_batch_idempotent` and consumed (cleared) by `commit_pending`.
91    pending_commit: Option<(String, String)>,
92}
93
94impl SinkState {
95    fn new(preloaded: Option<Table>) -> Self {
96        Self {
97            table: preloaded,
98            writer: None,
99            pending_files: Vec::new(),
100            pending_commit: None,
101        }
102    }
103}
104
105// ── IcebergSink ───────────────────────────────────────────────────────────────
106
107/// An Apache Iceberg sink.
108///
109/// Writes records to an Iceberg table via the `fast_append` transaction action,
110/// creating one snapshot per `flush()` call.
111pub struct IcebergSink {
112    config: IcebergSinkConfig,
113    catalog: Arc<dyn Catalog>,
114    state: Mutex<SinkState>,
115}
116
117impl std::fmt::Debug for IcebergSink {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.debug_struct("IcebergSink")
120            .field("config", &self.config)
121            .finish_non_exhaustive()
122    }
123}
124
125impl IcebergSink {
126    /// Create a new sink from the given configuration.
127    ///
128    /// Validates the config, builds the catalog client, and — when
129    /// `create_if_missing: false` — loads and validates the target table.
130    pub async fn new(config: IcebergSinkConfig) -> Result<Self, FaucetError> {
131        config.validate()?;
132
133        if config.write_mode != faucet_core::WriteMode::Append {
134            return Err(FaucetError::Config(format!(
135                "iceberg sink: write_mode '{}' is not supported (append only; \
136                 upsert is a version-gated follow-up tracked in #179 / #190)",
137                config.write_mode.as_str()
138            )));
139        }
140
141        let catalog = build_catalog(&config.catalog).await?;
142
143        let preloaded: Option<Table> = if !config.create_if_missing {
144            // `create_if_missing = false`: load now so a missing table is caught
145            // immediately at startup rather than silently on first write.
146            let ns = NamespaceIdent::from_strs(config.namespace.iter().map(String::as_str))
147                .map_err(|e| FaucetError::Sink(format!("iceberg: invalid namespace: {e}")))?;
148            let tid = TableIdent::new(ns, config.table.clone());
149            let table = catalog.load_table(&tid).await.map_err(|e| {
150                FaucetError::Sink(format!(
151                    "iceberg: table '{}' does not exist and create_if_missing is false: {e}",
152                    config.table
153                ))
154            })?;
155            Some(table)
156        } else {
157            None
158        };
159
160        Ok(Self {
161            config,
162            catalog,
163            state: Mutex::new(SinkState::new(preloaded)),
164        })
165    }
166
167    // ── Helpers ───────────────────────────────────────────────────────────────
168
169    /// Build an `UnboundPartitionSpec` from the config's `partition_spec` list
170    /// by looking up field IDs in `iceberg_schema`. Returns `None` when the
171    /// partition spec is empty (unpartitioned table).
172    fn build_partition_spec(
173        pfs: &[PartitionField],
174        iceberg_schema: &iceberg::spec::Schema,
175    ) -> Result<Option<UnboundPartitionSpec>, FaucetError> {
176        if pfs.is_empty() {
177            return Ok(None);
178        }
179
180        let struct_type = iceberg_schema.as_struct();
181        let mut builder = UnboundPartitionSpec::builder();
182
183        for pf in pfs {
184            // Look up the source field ID from the iceberg schema by name.
185            let field_ref = struct_type.field_by_name(&pf.source).ok_or_else(|| {
186                FaucetError::Config(format!(
187                    "iceberg: partition source column {:?} not found in schema",
188                    pf.source
189                ))
190            })?;
191
192            let transform = Transform::from_str(&pf.transform).map_err(|e| {
193                FaucetError::Config(format!(
194                    "iceberg: invalid transform {:?}: {e}",
195                    pf.transform
196                ))
197            })?;
198
199            builder = builder
200                .add_partition_field(field_ref.id, pf.source.clone(), transform)
201                .map_err(|e| {
202                    FaucetError::Config(format!(
203                        "iceberg: could not add partition field {:?}: {e}",
204                        pf.source
205                    ))
206                })?;
207        }
208
209        Ok(Some(builder.build()))
210    }
211
212    /// Resolve the table from state, creating it when `create_if_missing` is
213    /// set and no table exists yet.
214    ///
215    /// Returns a reference-counted clone of the resolved `Table`. Mutates
216    /// `state.table` in place on first creation.
217    async fn resolve_table(
218        &self,
219        state: &mut SinkState,
220        records: &[Value],
221    ) -> Result<Table, FaucetError> {
222        if let Some(ref table) = state.table {
223            return Ok(table.clone());
224        }
225
226        // Table not yet resolved — infer + create.
227        let arrow_schema = infer_arrow_schema(records, records.len().min(100))?;
228        let iceberg_schema = arrow_to_iceberg_schema(&arrow_schema)?;
229
230        let ns = NamespaceIdent::from_strs(self.config.namespace.iter().map(String::as_str))
231            .map_err(|e| FaucetError::Sink(format!("iceberg: invalid namespace: {e}")))?;
232        let table_name = self.config.table.clone();
233
234        let table_ident = TableIdent::new(ns.clone(), table_name.clone());
235
236        let table =
237            if self.catalog.table_exists(&table_ident).await.map_err(|e| {
238                FaucetError::Sink(format!("iceberg: table_exists check failed: {e}"))
239            })? {
240                // Table was created between `new()` and first write — just load it.
241                self.catalog
242                    .load_table(&table_ident)
243                    .await
244                    .map_err(|e| FaucetError::Sink(format!("iceberg: load_table failed: {e}")))?
245            } else {
246                // Ensure the namespace exists before creating the table.
247                // Some catalogs (e.g. REST when pre-configured, Glue) auto-create
248                // namespaces; others (SQL, HMS) require an explicit
249                // `create_namespace` call.  We call it unconditionally when
250                // `create_if_missing: true` and swallow `AlreadyExists` errors so
251                // the sink is idempotent whether or not the namespace pre-exists.
252                let ns_exists = self.catalog.namespace_exists(&ns).await.map_err(|e| {
253                    FaucetError::Sink(format!("iceberg: namespace_exists check failed: {e}"))
254                })?;
255                if !ns_exists {
256                    self.catalog
257                        .create_namespace(&ns, std::collections::HashMap::new())
258                        .await
259                        .map_err(|e| {
260                            FaucetError::Sink(format!(
261                                "iceberg: create_namespace {:?} failed: {e}",
262                                self.config.namespace
263                            ))
264                        })?;
265                }
266
267                // Build partition spec from config, resolving source column IDs.
268                let partition_spec =
269                    Self::build_partition_spec(&self.config.partition_spec, &iceberg_schema)?;
270
271                // The `TableCreation` TypedBuilder uses type-state, so the two
272                // branches (with/without partition_spec) produce different builder
273                // types. We fully build in each arm rather than trying to hold a
274                // partially-built builder in a variable.
275                let creation = if let Some(ps) = partition_spec {
276                    TableCreation::builder()
277                        .name(table_name)
278                        .schema(iceberg_schema)
279                        .partition_spec(ps)
280                        .properties(self.config.snapshot_properties.clone())
281                        .build()
282                } else {
283                    TableCreation::builder()
284                        .name(table_name)
285                        .schema(iceberg_schema)
286                        .properties(self.config.snapshot_properties.clone())
287                        .build()
288                };
289
290                self.catalog
291                    .create_table(&ns, creation)
292                    .await
293                    .map_err(|e| FaucetError::Sink(format!("iceberg: create_table failed: {e}")))?
294            };
295
296        state.table = Some(table.clone());
297        Ok(table)
298    }
299
300    /// Ensure the writer in `state` is open for `table`. Opens a new
301    /// `TableWriter` if none is currently open.
302    async fn ensure_writer(&self, state: &mut SinkState, table: &Table) -> Result<(), FaucetError> {
303        if state.writer.is_none() {
304            let compression = compression_from_str(&self.config.parquet.compression)?;
305            let writer =
306                TableWriter::new(table, compression, self.config.target_file_size_mb).await?;
307            state.writer = Some(writer);
308        }
309        Ok(())
310    }
311
312    /// Write a single chunk of `Value` records to the open writer.
313    ///
314    /// Converts `records` to an Arrow `RecordBatch` against the table's arrow
315    /// schema, then calls `writer.write(batch)`.
316    async fn write_chunk(
317        &self,
318        state: &mut SinkState,
319        records: &[Value],
320    ) -> Result<usize, FaucetError> {
321        if records.is_empty() {
322            return Ok(0);
323        }
324
325        let table = self.resolve_table(state, records).await?;
326        self.ensure_writer(state, &table).await?;
327
328        // Convert to Arrow using the table's current schema.
329        let arrow_schema = iceberg_to_arrow_schema(table.metadata().current_schema())?;
330        let batch = json_to_record_batch(records, &arrow_schema)?;
331        let row_count = batch.num_rows();
332
333        let writer = state.writer.as_mut().expect("writer is set above");
334        writer.write(batch).await?;
335
336        Ok(row_count)
337    }
338
339    /// Close the open writer (if any) and collect its `DataFile`s into
340    /// `state.pending_files`. Does nothing when no writer is open.
341    async fn close_writer(state: &mut SinkState) -> Result<(), FaucetError> {
342        if let Some(writer) = state.writer.take() {
343            let files = writer.close().await?;
344            state.pending_files.extend(files);
345        }
346        Ok(())
347    }
348
349    /// Load the table read-only from the catalog, without creating it.
350    ///
351    /// Returns `Ok(None)` if the table does not exist yet (first run before any
352    /// data has been written), `Ok(Some(table))` if it exists, or `Err` on a
353    /// catalog communication failure.
354    async fn load_table_readonly(&self) -> Result<Option<Table>, FaucetError> {
355        let ns = NamespaceIdent::from_strs(self.config.namespace.iter().map(String::as_str))
356            .map_err(|e| FaucetError::Sink(format!("iceberg: invalid namespace: {e}")))?;
357        let tid = TableIdent::new(ns, self.config.table.clone());
358
359        let exists =
360            self.catalog.table_exists(&tid).await.map_err(|e| {
361                FaucetError::Sink(format!("iceberg: table_exists check failed: {e}"))
362            })?;
363
364        if !exists {
365            return Ok(None);
366        }
367
368        let table = self
369            .catalog
370            .load_table(&tid)
371            .await
372            .map_err(|e| FaucetError::Sink(format!("iceberg: load_table failed: {e}")))?;
373
374        Ok(Some(table))
375    }
376
377    /// Commit all pending data files as a single `fast_append` snapshot.
378    ///
379    /// `Transaction::commit` in iceberg-rust 0.9.1 already includes an internal
380    /// retry loop (reload metadata + re-apply the append against the latest
381    /// snapshot, exponential back-off on retryable commit conflicts), so we do
382    /// not add an outer retry. Returns `Ok(())` when the commit succeeds.
383    ///
384    /// On a commit failure the data files this flush uploaded are orphaned. When
385    /// [`IcebergSinkConfig::cleanup_orphans_on_failure`] is set and the failure
386    /// is a *definitive* loss (see [`commit_failure_is_definite_loss`]) those
387    /// files are deleted before the error propagates; an ambiguous failure is
388    /// never cleaned up. Either way the original error is returned so the run
389    /// aborts without advancing the bookmark.
390    async fn commit_pending(&self, state: &mut SinkState) -> Result<(), FaucetError> {
391        let files = std::mem::take(&mut state.pending_files);
392
393        if files.is_empty() {
394            // No data files — do not emit an empty snapshot.
395            return Ok(());
396        }
397
398        let table = state
399            .table
400            .as_ref()
401            .ok_or_else(|| {
402                FaucetError::Sink(
403                    "iceberg: flush called with pending files but no table loaded".to_string(),
404                )
405            })?
406            .clone();
407
408        // Capture the paths of the data files we are about to commit, before
409        // they are moved into the transaction action, so we can clean them up
410        // if the commit fails.
411        let file_paths: Vec<String> = files.iter().map(|f| f.file_path().to_string()).collect();
412
413        let tx = Transaction::new(&table);
414
415        // Merge config snapshot properties with the exactly-once commit token (if any).
416        // The token is taken out of state so it is stamped exactly once, atomically
417        // with the data files in this fast_append commit.
418        let mut props = self.config.snapshot_properties.clone();
419        if let Some((scope, token)) = state.pending_commit.take() {
420            props.insert(
421                faucet_core::idempotency::ICEBERG_SCOPE_PROP.to_string(),
422                scope,
423            );
424            props.insert(
425                faucet_core::idempotency::ICEBERG_TOKEN_PROP.to_string(),
426                token,
427            );
428        }
429
430        let mut action = tx.fast_append().add_data_files(files);
431        if !props.is_empty() {
432            action = action.set_snapshot_properties(props);
433        }
434
435        let tx = match action.apply(tx) {
436            Ok(tx) => tx,
437            Err(e) => {
438                // Building the append failed locally — the data files were
439                // uploaded but definitively never committed.
440                maybe_cleanup_orphans(
441                    table.file_io(),
442                    &self.config.table,
443                    self.config.cleanup_orphans_on_failure,
444                    true,
445                    &file_paths,
446                )
447                .await;
448                return Err(FaucetError::Sink(format!(
449                    "iceberg: fast_append apply failed: {e}"
450                )));
451            }
452        };
453
454        let updated_table = match tx.commit(self.catalog.as_ref()).await {
455            Ok(updated_table) => updated_table,
456            Err(e) => {
457                maybe_cleanup_orphans(
458                    table.file_io(),
459                    &self.config.table,
460                    self.config.cleanup_orphans_on_failure,
461                    commit_failure_is_definite_loss(e.kind()),
462                    &file_paths,
463                )
464                .await;
465                return Err(FaucetError::Sink(format!(
466                    "iceberg: transaction commit failed ({}): {e}",
467                    e.kind()
468                )));
469            }
470        };
471
472        // Update the stored table handle so subsequent writes use the latest
473        // metadata (snapshot ID, manifest list, etc.).
474        state.table = Some(updated_table);
475        Ok(())
476    }
477}
478
479/// Best-effort orphan cleanup after a failed snapshot commit.
480///
481/// No-op (with a one-line warning) when cleanup is disabled (`enabled == false`)
482/// or the failure is ambiguous (`definite_loss == false`); otherwise deletes
483/// `file_paths` via `file_io`. Errors are logged, never propagated — the caller
484/// still returns the original commit error.
485pub(crate) async fn maybe_cleanup_orphans(
486    file_io: &FileIO,
487    table_name: &str,
488    enabled: bool,
489    definite_loss: bool,
490    file_paths: &[String],
491) {
492    if !enabled {
493        tracing::warn!(
494            table = %table_name,
495            orphans = file_paths.len(),
496            "iceberg: commit failed; {} data file(s) orphaned. Set \
497             cleanup_orphans_on_failure to delete them automatically, or run \
498             Iceberg's remove_orphan_files maintenance.",
499            file_paths.len()
500        );
501        return;
502    }
503
504    if !definite_loss {
505        tracing::warn!(
506            table = %table_name,
507            orphans = file_paths.len(),
508            "iceberg: commit outcome ambiguous; NOT deleting {} data file(s) \
509             (a possibly-succeeded commit may reference them). Run \
510             remove_orphan_files if the commit is confirmed failed.",
511            file_paths.len()
512        );
513        return;
514    }
515
516    let (deleted, failed) = delete_data_files(file_io, file_paths).await;
517    tracing::info!(
518        table = %table_name,
519        deleted,
520        failed,
521        "iceberg: cleaned up orphaned data files after a definitive commit failure"
522    );
523}
524
525/// Classify whether a commit failure of `kind` means the commit *definitively*
526/// did not land — so the data files we uploaded are safe to delete — versus an
527/// *ambiguous* outcome where the commit may have succeeded server-side.
528///
529/// `CatalogCommitConflicts` (a competing writer won, after iceberg-rust's
530/// internal retries are exhausted) and `DataInvalid` (the catalog rejected the
531/// commit request) both mean our commit did not apply, so our uploaded files
532/// are safely orphaned. Every other kind — notably `Unexpected` (transport / IO
533/// failure on the catalog update) — is treated as ambiguous and is never
534/// cleaned up.
535pub(crate) fn commit_failure_is_definite_loss(kind: ErrorKind) -> bool {
536    matches!(
537        kind,
538        ErrorKind::CatalogCommitConflicts | ErrorKind::DataInvalid
539    )
540}
541
542/// Map an iceberg error from the schema-evolution transaction (`update_schema`
543/// apply or commit) to a sink error. Shared by both fallible steps so the
544/// error surface is a single covered path.
545fn evolve_schema_err(e: iceberg::Error) -> FaucetError {
546    FaucetError::Sink(format!(
547        "iceberg: schema evolution failed ({}): {e}",
548        e.kind()
549    ))
550}
551
552/// Select the highest commit token recorded for `scope` from a sequence of
553/// snapshot summary `(scope, token)` property pairs.
554///
555/// The authoritative "which page was last committed" ordering is the **token
556/// value** (a monotonic per-page sequence rendered by
557/// [`faucet_core::idempotency::format_token`]), not the snapshot wall-clock
558/// timestamp. This iterates every snapshot, keeps only those whose scope
559/// property equals `scope`, parses each token via
560/// [`faucet_core::idempotency::parse_token`], and returns the original
561/// (formatted) token string for the maximum parsed sequence. Tokens that fail
562/// to parse are ignored. Returns `None` when no snapshot matches the scope (or
563/// every matching snapshot lacks a parseable token).
564pub(crate) fn max_token_for_scope<'a, I>(snapshots: I, scope: &str) -> Option<String>
565where
566    I: IntoIterator<Item = (Option<&'a str>, Option<&'a str>)>,
567{
568    snapshots
569        .into_iter()
570        .filter(|(snap_scope, _)| *snap_scope == Some(scope))
571        .filter_map(|(_, token)| {
572            let token = token?;
573            let seq = faucet_core::idempotency::parse_token(token)?;
574            Some((seq, token.to_string()))
575        })
576        .max_by_key(|(seq, _)| *seq)
577        .map(|(_, token)| token)
578}
579
580/// Delete each path in `paths` via `file_io`, returning `(deleted, failed)`.
581///
582/// Best-effort: a delete error is logged and counted as `failed` but does not
583/// stop the remaining deletes. The data files written by this sink have unique
584/// (UUID-based) names, so deleting them can never remove a file a concurrent
585/// writer references.
586pub(crate) async fn delete_data_files(file_io: &FileIO, paths: &[String]) -> (usize, usize) {
587    let mut deleted = 0usize;
588    let mut failed = 0usize;
589    for path in paths {
590        match file_io.delete(path).await {
591            Ok(()) => deleted += 1,
592            Err(e) => {
593                failed += 1;
594                tracing::warn!(path = %path, error = %e, "iceberg: failed to delete orphaned data file");
595            }
596        }
597    }
598    (deleted, failed)
599}
600
601// ── Sink trait ────────────────────────────────────────────────────────────────
602
603#[async_trait]
604impl faucet_core::Sink for IcebergSink {
605    fn connector_name(&self) -> &'static str {
606        "iceberg"
607    }
608
609    fn config_schema(&self) -> Value {
610        serde_json::to_value(faucet_core::schema_for!(IcebergSinkConfig))
611            .expect("schema serialization infallible")
612    }
613
614    fn dataset_uri(&self) -> String {
615        use crate::config::CatalogConfig;
616        let kind = match &self.config.catalog {
617            CatalogConfig::Rest(_) => "rest",
618            CatalogConfig::Glue(_) => "glue",
619            CatalogConfig::Sql(_) => "sql",
620            CatalogConfig::Hms(_) => "hms",
621        };
622        format!(
623            "iceberg://{}/{}.{}",
624            kind,
625            self.config.namespace.join("."),
626            self.config.table
627        )
628    }
629
630    /// Preflight check (`faucet doctor`).
631    ///
632    /// Probes catalog connectivity and table existence without writing any data.
633    /// Builds the namespace + table ident from config and calls `table_exists`,
634    /// bounded by `ctx.timeout`. A catalog connection failure surfaces as `Fail`.
635    async fn check(
636        &self,
637        ctx: &faucet_core::check::CheckContext,
638    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
639        use faucet_core::check::{CheckReport, Probe};
640
641        let started = std::time::Instant::now();
642
643        let ns_result = NamespaceIdent::from_strs(self.config.namespace.iter().map(String::as_str));
644        let tid_result = ns_result.map(|ns| TableIdent::new(ns, self.config.table.clone()));
645
646        let tid = match tid_result {
647            Err(e) => {
648                return Ok(CheckReport::single(Probe::fail(
649                    "catalog",
650                    started.elapsed(),
651                    format!("iceberg: invalid namespace config: {e}"),
652                )));
653            }
654            Ok(t) => t,
655        };
656
657        let probe_result = tokio::time::timeout(ctx.timeout, self.catalog.table_exists(&tid)).await;
658
659        let probe = match probe_result {
660            // The catalog responded — it's reachable, so the probe passes
661            // whether or not the target table exists yet (create_if_missing
662            // handles a missing table at run time).
663            Ok(Ok(_exists)) => Probe::pass("catalog", started.elapsed()),
664            Ok(Err(e)) => Probe::fail_hint(
665                "catalog",
666                started.elapsed(),
667                format!("iceberg catalog probe failed: {e}"),
668                "Verify the catalog URI, credentials, and network reachability.",
669            ),
670            Err(_elapsed) => Probe::fail_hint(
671                "catalog",
672                started.elapsed(),
673                format!("iceberg catalog probe timed out after {:?}", ctx.timeout),
674                "Check network reachability to the catalog endpoint.",
675            ),
676        };
677
678        Ok(CheckReport::single(probe))
679    }
680
681    fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
682        // Append only — equality-delete upsert is version-gated on iceberg-rust
683        // (tracked as a follow-up to #190 / #179).
684        &[faucet_core::WriteMode::Append]
685    }
686
687    /// Report the live table schema as an `infer_schema`-shaped JSON object so
688    /// the schema-drift policy (issue #194) can diff each page against the real
689    /// destination.
690    ///
691    /// Loads the table read-only via the catalog; a not-yet-created table (or
692    /// any "table absent" case) returns `Ok(None)` so drift handling stays inert
693    /// until the table exists — only a genuine catalog communication failure
694    /// surfaces as `Err`. On success the table's `current_schema()` is converted
695    /// to Arrow (via `iceberg_to_arrow_schema`) and then to the JSON shape.
696    ///
697    async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
698        let table = match self.load_table_readonly().await? {
699            Some(t) => t,
700            None => return Ok(None),
701        };
702        let arrow_schema = iceberg_to_arrow_schema(table.metadata().current_schema())?;
703        Ok(Some(arrow_to_json_schema(&arrow_schema)))
704    }
705
706    /// Additive schema evolution (#255). iceberg-rust 0.10.0 exposes a
707    /// `Transaction::update_schema` action with `add_column`, so `on_drift:
708    /// evolve` can add new (optional) columns to the destination table.
709    fn supports_schema_evolution(&self) -> bool {
710        true
711    }
712
713    /// Apply an additive schema evolution by adding each new column as an
714    /// **optional** field in a single `update_schema` transaction commit.
715    ///
716    /// iceberg-rust 0.10.0's `UpdateSchemaAction` exposes `add_column` /
717    /// `delete_column` but **no** in-place type-promotion or nullability
718    /// relaxation, so `widenings` / `relax_nullability` are not applicable yet
719    /// and are rejected with a typed error rather than silently ignored (which
720    /// would leave the table unable to accept the widened data). Row-level
721    /// overwrite/upsert remain separately blocked upstream (#179 / #225).
722    async fn evolve_schema(
723        &self,
724        evolution: &faucet_core::SchemaEvolution,
725    ) -> Result<(), FaucetError> {
726        if !evolution.widenings.is_empty() || !evolution.relax_nullability.is_empty() {
727            return Err(FaucetError::Sink(format!(
728                "iceberg: additive schema evolution supports new columns only in iceberg-rust \
729                 0.10.0 (no in-place type promotion or nullability relaxation); requested \
730                 {} widening(s) + {} nullability relaxation(s)",
731                evolution.widenings.len(),
732                evolution.relax_nullability.len()
733            )));
734        }
735        if evolution.additions.is_empty() {
736            return Ok(());
737        }
738        // The table must exist to evolve it; if absent (e.g. a race with table
739        // creation), stay inert — the create path lays down the full schema.
740        let table = match self.load_table_readonly().await? {
741            Some(t) => t,
742            None => return Ok(()),
743        };
744
745        let tx = Transaction::new(&table);
746        let mut action = tx.update_schema();
747        for add in &evolution.additions {
748            let field_type = crate::schema::json_fragment_to_iceberg_type(&add.to)?;
749            action = action.add_column(AddColumn::optional(&add.name, field_type));
750        }
751        let tx = action.apply(tx).map_err(evolve_schema_err)?;
752        tx.commit(self.catalog.as_ref())
753            .await
754            .map_err(evolve_schema_err)?;
755        Ok(())
756    }
757
758    fn supports_idempotent_writes(&self) -> bool {
759        true
760    }
761
762    /// Write `records` and durably record the exactly-once `(scope, token)` in
763    /// the same atomic `fast_append` snapshot commit.
764    ///
765    /// The token is stashed on `SinkState::pending_commit` here and merged into
766    /// the snapshot's summary properties inside `commit_pending` (called from
767    /// `flush`). Both the data files and the token properties land in the same
768    /// `Transaction::fast_append` commit, so they are atomic by Iceberg's
769    /// optimistic-concurrency guarantee.
770    async fn write_batch_idempotent(
771        &self,
772        records: &[Value],
773        scope: &str,
774        token: &str,
775    ) -> Result<usize, FaucetError> {
776        let n = self.write_batch(records).await?;
777        let mut state = self.state.lock().await;
778        state.pending_commit = Some((scope.to_string(), token.to_string()));
779        Ok(n)
780    }
781
782    /// Return the last commit token recorded for `scope` in this table's
783    /// snapshot history, or `None` if no token has been committed yet.
784    ///
785    /// All snapshots whose `faucet.commit-scope` property matches `scope` are
786    /// scanned and the **maximum commit token** is returned. Ordering by the
787    /// token value — not by snapshot wall-clock timestamp — is authoritative:
788    /// the commit token is a monotonic per-page sequence
789    /// ([`faucet_core::idempotency::format_token`]), and snapshots can share a
790    /// `timestamp_ms` or be reordered relative to token issuance. Picking the
791    /// newest-timestamp snapshot could return a token smaller than the true
792    /// committed max, causing the pipeline to re-write already-committed pages
793    /// on resume (duplicate rows, silently breaking exactly-once). If the table
794    /// does not yet exist `Ok(None)` is returned immediately.
795    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
796        let table = match self.load_table_readonly().await? {
797            Some(t) => t,
798            None => return Ok(None),
799        };
800
801        let meta = table.metadata();
802        let props = meta.snapshots().map(|s| {
803            let summary = s.summary();
804            (
805                summary
806                    .additional_properties
807                    .get(faucet_core::idempotency::ICEBERG_SCOPE_PROP)
808                    .map(String::as_str),
809                summary
810                    .additional_properties
811                    .get(faucet_core::idempotency::ICEBERG_TOKEN_PROP)
812                    .map(String::as_str),
813            )
814        });
815
816        Ok(max_token_for_scope(props, scope))
817    }
818
819    /// Write a batch of records to the Iceberg table.
820    ///
821    /// When `config.batch_size > 0` the records are re-chunked before writing.
822    /// `batch_size = 0` passes the entire page through as a single chunk.
823    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
824        if records.is_empty() {
825            return Ok(0);
826        }
827
828        let mut state = self.state.lock().await;
829
830        let chunk_size = self.config.batch_size;
831        let mut total = 0usize;
832
833        if chunk_size == 0 || records.len() <= chunk_size {
834            total += self.write_chunk(&mut state, records).await?;
835        } else {
836            for chunk in records.chunks(chunk_size) {
837                total += self.write_chunk(&mut state, chunk).await?;
838            }
839        }
840
841        tracing::debug!(
842            table = %self.config.table,
843            rows = total,
844            "iceberg: write_batch complete"
845        );
846
847        Ok(total)
848    }
849
850    /// Flush buffered data files to a committed Iceberg snapshot.
851    ///
852    /// 1. Close the open writer (if any) → collect `DataFile`s.
853    /// 2. If no `DataFile`s accumulated → **no-op** (empty snapshot not committed).
854    /// 3. Commit via `Transaction::fast_append`.
855    async fn flush(&self) -> Result<(), FaucetError> {
856        let mut state = self.state.lock().await;
857
858        // Step 1: close the open writer and collect its data files.
859        Self::close_writer(&mut state).await?;
860
861        // Step 2 + 3: commit pending files (or no-op when empty).
862        self.commit_pending(&mut state).await?;
863
864        tracing::debug!(table = %self.config.table, "iceberg: flush complete");
865        Ok(())
866    }
867
868    /// Write records, returning a per-row outcome for DLQ routing.
869    ///
870    /// Individual rows that fail Arrow conversion (type mismatch against the
871    /// table schema) become `Err(FaucetError::Sink(...))` outcomes so the DLQ
872    /// router can quarantine them without aborting the batch.
873    ///
874    /// A writer or commit failure (transport-level) fails the whole call as an
875    /// outer `Err` because no rows are committed in that case.
876    ///
877    /// Note: unlike BigQuery's `skipInvalidRows` API, the iceberg writer
878    /// processes a whole `RecordBatch` atomically — the only granularity at
879    /// which we can surface per-row errors is the JSON→Arrow conversion step.
880    /// Rows that fail that step are routed to DLQ; the remainder are written
881    /// as a batch.
882    async fn write_batch_partial(
883        &self,
884        records: &[Value],
885    ) -> Result<Vec<faucet_core::RowOutcome>, FaucetError> {
886        if records.is_empty() {
887            return Ok(Vec::new());
888        }
889
890        let mut state = self.state.lock().await;
891
892        // Resolve table (and potentially create it) from the full record set so
893        // schema inference uses all records.
894        let table = self.resolve_table(&mut state, records).await?;
895        let arrow_schema = iceberg_to_arrow_schema(table.metadata().current_schema())?;
896
897        // Try to convert each record individually so we can give per-row errors.
898        let mut outcomes: Vec<faucet_core::RowOutcome> = Vec::with_capacity(records.len());
899        let mut good_records: Vec<Value> = Vec::with_capacity(records.len());
900        let mut good_indices: Vec<usize> = Vec::with_capacity(records.len());
901
902        for (i, record) in records.iter().enumerate() {
903            match json_to_record_batch(std::slice::from_ref(record), &arrow_schema) {
904                Ok(_) => {
905                    good_records.push(record.clone());
906                    good_indices.push(i);
907                    // We'll fill in Ok(()) after the batch write succeeds.
908                    outcomes.push(Ok(()));
909                }
910                Err(e) => {
911                    outcomes.push(Err(FaucetError::Sink(format!(
912                        "iceberg: row {i} failed Arrow conversion: {e}"
913                    ))));
914                }
915            }
916        }
917
918        // Write the good records as a batch (outer-Err on transport failure).
919        if !good_records.is_empty() {
920            self.ensure_writer(&mut state, &table).await?;
921            let batch = json_to_record_batch(&good_records, &arrow_schema)?;
922            let writer = state.writer.as_mut().expect("writer set above");
923            writer.write(batch).await?;
924        }
925
926        Ok(outcomes)
927    }
928}
929
930// ── Tests ─────────────────────────────────────────────────────────────────────
931
932#[cfg(test)]
933mod tests {
934    use super::*;
935    use faucet_core::FaucetError;
936
937    // dataset_uri test is skipped: IcebergSink::new() requires a live catalog
938    // connection (build_catalog in new()), and no offline constructor exists.
939
940    fn minimal_config() -> IcebergSinkConfig {
941        serde_json::from_value(serde_json::json!({
942            "catalog": { "type": "rest", "uri": "http://localhost:8181" },
943            "namespace": ["analytics"],
944            "table": "events",
945            "create_if_missing": true
946        }))
947        .unwrap()
948    }
949
950    // Verify that attempting to build a sink with a disabled catalog type
951    // (Glue, SQL, HMS in default feature set) returns a Config error, not a
952    // panic. This tests the `new()` code path without network.
953    #[cfg(not(feature = "catalog-glue"))]
954    #[tokio::test]
955    async fn new_with_disabled_catalog_returns_config_error() {
956        let config: IcebergSinkConfig = serde_json::from_value(serde_json::json!({
957            "catalog": { "type": "glue", "warehouse": "s3://lake/wh" },
958            "namespace": ["analytics"],
959            "table": "events"
960        }))
961        .unwrap();
962
963        let err = IcebergSink::new(config).await.unwrap_err();
964        assert!(
965            matches!(err, FaucetError::Config(_)),
966            "expected Config error, got {err:?}"
967        );
968        let msg = err.to_string();
969        assert!(
970            msg.contains("catalog-glue"),
971            "should mention the missing feature: {msg}"
972        );
973    }
974
975    // Verify validate() catches an empty namespace before catalog init.
976    #[tokio::test]
977    async fn new_rejects_empty_namespace() {
978        let config: IcebergSinkConfig = serde_json::from_value(serde_json::json!({
979            "catalog": { "type": "rest", "uri": "http://localhost:8181" },
980            "namespace": [],
981            "table": "events"
982        }))
983        .unwrap();
984
985        let err = IcebergSink::new(config).await.unwrap_err();
986        assert!(
987            matches!(err, FaucetError::Config(_)),
988            "expected Config error, got {err:?}"
989        );
990    }
991
992    // Verify validate() catches an empty table name.
993    #[tokio::test]
994    async fn new_rejects_empty_table_name() {
995        let config: IcebergSinkConfig = serde_json::from_value(serde_json::json!({
996            "catalog": { "type": "rest", "uri": "http://localhost:8181" },
997            "namespace": ["ns"],
998            "table": ""
999        }))
1000        .unwrap();
1001
1002        let err = IcebergSink::new(config).await.unwrap_err();
1003        assert!(matches!(err, FaucetError::Config(_)));
1004    }
1005
1006    // The compression helper is already tested in writer.rs; this test
1007    // ensures config→sink uses the right codec label.
1008    #[test]
1009    fn default_compression_parses_ok() {
1010        let cfg = minimal_config();
1011        assert_eq!(cfg.parquet.compression, "snappy");
1012        assert!(crate::writer::compression_from_str("snappy").is_ok());
1013    }
1014
1015    // Verify the partition spec builder returns None on an empty spec.
1016    #[test]
1017    fn build_partition_spec_empty_returns_none() {
1018        use crate::schema::{arrow_to_iceberg_schema, infer_arrow_schema};
1019        use serde_json::json;
1020
1021        let records = vec![json!({"id": 1, "name": "alice"})];
1022        let arrow_schema = infer_arrow_schema(&records, 10).unwrap();
1023        let iceberg_schema = arrow_to_iceberg_schema(&arrow_schema).unwrap();
1024
1025        let result = IcebergSink::build_partition_spec(&[], &iceberg_schema).unwrap();
1026        assert!(result.is_none(), "empty partition_spec should yield None");
1027    }
1028
1029    // Verify the partition spec builder catches an unknown source column.
1030    #[test]
1031    fn build_partition_spec_unknown_column_errors() {
1032        use crate::schema::{arrow_to_iceberg_schema, infer_arrow_schema};
1033        use serde_json::json;
1034
1035        let records = vec![json!({"id": 1})];
1036        let arrow_schema = infer_arrow_schema(&records, 10).unwrap();
1037        let iceberg_schema = arrow_to_iceberg_schema(&arrow_schema).unwrap();
1038
1039        let pfs = vec![PartitionField {
1040            source: "nonexistent_col".to_string(),
1041            transform: "identity".to_string(),
1042        }];
1043
1044        let err = IcebergSink::build_partition_spec(&pfs, &iceberg_schema).unwrap_err();
1045        assert!(
1046            matches!(err, FaucetError::Config(_)),
1047            "unknown column should give Config error: {err:?}"
1048        );
1049        let msg = err.to_string();
1050        assert!(
1051            msg.contains("nonexistent_col"),
1052            "error should name the bad column: {msg}"
1053        );
1054    }
1055
1056    // ── Orphan cleanup (#193) ───────────────────────────────────────────────
1057
1058    use iceberg::ErrorKind;
1059    use iceberg::io::FileIO;
1060
1061    // Only a definitive loss (our commit certainly did not land) is safe to
1062    // clean up; an ambiguous outcome must never delete files.
1063    #[test]
1064    fn definite_loss_classification() {
1065        assert!(
1066            commit_failure_is_definite_loss(ErrorKind::CatalogCommitConflicts),
1067            "an exhausted commit conflict means our commit definitively lost"
1068        );
1069        assert!(
1070            commit_failure_is_definite_loss(ErrorKind::DataInvalid),
1071            "a catalog-rejected commit definitively did not apply"
1072        );
1073        // Ambiguous / not-our-loss kinds must NOT be treated as definite.
1074        assert!(
1075            !commit_failure_is_definite_loss(ErrorKind::Unexpected),
1076            "a transport error is ambiguous — the commit may have landed"
1077        );
1078        assert!(!commit_failure_is_definite_loss(
1079            ErrorKind::PreconditionFailed
1080        ));
1081        assert!(!commit_failure_is_definite_loss(
1082            ErrorKind::FeatureUnsupported
1083        ));
1084    }
1085
1086    /// Write `n` files via `io` under `dir`, returning their `file://` paths.
1087    async fn seed_files(io: &FileIO, dir: &std::path::Path, n: usize) -> Vec<String> {
1088        let mut paths = Vec::new();
1089        for i in 0..n {
1090            let p = format!("file://{}/orphan-{i}.parquet", dir.display());
1091            io.new_output(&p)
1092                .expect("new_output")
1093                .write(bytes::Bytes::from_static(b"parquet"))
1094                .await
1095                .expect("write orphan file");
1096            assert!(
1097                io.exists(&p).await.expect("exists check"),
1098                "seed file present"
1099            );
1100            paths.push(p);
1101        }
1102        paths
1103    }
1104
1105    // delete_data_files removes every path and reports an accurate count.
1106    #[tokio::test]
1107    async fn delete_data_files_removes_all() {
1108        let dir = tempfile::TempDir::new().expect("tempdir");
1109        let io = FileIO::new_with_fs();
1110        let paths = seed_files(&io, dir.path(), 3).await;
1111
1112        let (deleted, failed) = delete_data_files(&io, &paths).await;
1113        assert_eq!(deleted, 3);
1114        assert_eq!(failed, 0);
1115        for p in &paths {
1116            assert!(!io.exists(p).await.expect("exists check"), "file deleted");
1117        }
1118    }
1119
1120    // Deleting a path that is already gone is idempotent on the local-FS
1121    // backend (`Ok`), so the whole batch is reported as deleted and the present
1122    // file is removed regardless of ordering. (A genuine delete error — e.g. an
1123    // object-store permission failure — is counted toward `failed`; that path
1124    // is exercised against real cloud backends in the S3 integration tests.)
1125    #[tokio::test]
1126    async fn delete_data_files_idempotent_on_missing() {
1127        let dir = tempfile::TempDir::new().expect("tempdir");
1128        let io = FileIO::new_with_fs();
1129        let mut paths = seed_files(&io, dir.path(), 1).await;
1130        let present = paths[0].clone();
1131        paths.push(format!(
1132            "file://{}/never-written.parquet",
1133            dir.path().display()
1134        ));
1135
1136        let (deleted, failed) = delete_data_files(&io, &paths).await;
1137        assert_eq!(
1138            failed, 0,
1139            "idempotent delete of a missing file is not a failure"
1140        );
1141        assert_eq!(deleted, 2);
1142        assert!(
1143            !io.exists(&present).await.expect("exists check"),
1144            "the present file was deleted"
1145        );
1146    }
1147
1148    // Cleanup is a no-op when disabled: files survive.
1149    #[tokio::test]
1150    async fn maybe_cleanup_disabled_keeps_files() {
1151        let dir = tempfile::TempDir::new().expect("tempdir");
1152        let io = FileIO::new_with_fs();
1153        let paths = seed_files(&io, dir.path(), 2).await;
1154
1155        maybe_cleanup_orphans(
1156            &io, "t", /*enabled=*/ false, /*definite=*/ true, &paths,
1157        )
1158        .await;
1159
1160        for p in &paths {
1161            assert!(io.exists(p).await.expect("exists check"), "disabled → kept");
1162        }
1163    }
1164
1165    // Cleanup is a no-op on an ambiguous failure even when enabled: deleting
1166    // could remove files a possibly-succeeded commit references.
1167    #[tokio::test]
1168    async fn maybe_cleanup_ambiguous_keeps_files() {
1169        let dir = tempfile::TempDir::new().expect("tempdir");
1170        let io = FileIO::new_with_fs();
1171        let paths = seed_files(&io, dir.path(), 2).await;
1172
1173        maybe_cleanup_orphans(
1174            &io, "t", /*enabled=*/ true, /*definite=*/ false, &paths,
1175        )
1176        .await;
1177
1178        for p in &paths {
1179            assert!(
1180                io.exists(p).await.expect("exists check"),
1181                "ambiguous → kept"
1182            );
1183        }
1184    }
1185
1186    // Cleanup deletes files only when enabled AND the failure is definitive.
1187    #[tokio::test]
1188    async fn maybe_cleanup_enabled_definite_deletes_files() {
1189        let dir = tempfile::TempDir::new().expect("tempdir");
1190        let io = FileIO::new_with_fs();
1191        let paths = seed_files(&io, dir.path(), 2).await;
1192
1193        maybe_cleanup_orphans(
1194            &io, "t", /*enabled=*/ true, /*definite=*/ true, &paths,
1195        )
1196        .await;
1197
1198        for p in &paths {
1199            assert!(
1200                !io.exists(p).await.expect("exists check"),
1201                "enabled + definite → deleted"
1202            );
1203        }
1204    }
1205
1206    // Verify the partition spec builder succeeds on a valid identity field.
1207    #[test]
1208    fn build_partition_spec_identity_succeeds() {
1209        use crate::schema::{arrow_to_iceberg_schema, infer_arrow_schema};
1210        use serde_json::json;
1211
1212        let records = vec![json!({"id": 1, "ts": "2024-01-01"})];
1213        let arrow_schema = infer_arrow_schema(&records, 10).unwrap();
1214        let iceberg_schema = arrow_to_iceberg_schema(&arrow_schema).unwrap();
1215
1216        let pfs = vec![PartitionField {
1217            source: "id".to_string(),
1218            transform: "identity".to_string(),
1219        }];
1220
1221        let result = IcebergSink::build_partition_spec(&pfs, &iceberg_schema).unwrap();
1222        assert!(result.is_some(), "should produce a partition spec");
1223    }
1224
1225    // ── max_token_for_scope (exactly-once watermark resolution) ─────────────
1226
1227    use faucet_core::idempotency::format_token;
1228
1229    // The bug (F6): resolving by snapshot timestamp could return a token
1230    // SMALLER than the true committed max. This asserts the MAX token wins
1231    // regardless of the order snapshots are scanned — i.e. a snapshot that
1232    // would be "newest" by timestamp but carries a smaller token must NOT win.
1233    #[test]
1234    fn max_token_for_scope_returns_largest_token_ignoring_order() {
1235        let t1 = format_token(1);
1236        let t5 = format_token(5);
1237        let t10 = format_token(10);
1238        // Out of token order on purpose: a smaller token appears last (as if it
1239        // were the newest-timestamp snapshot under the old buggy sort).
1240        let snaps = vec![
1241            (Some("scopeA"), Some(t10.as_str())),
1242            (Some("scopeA"), Some(t1.as_str())),
1243            (Some("scopeA"), Some(t5.as_str())),
1244        ];
1245        assert_eq!(
1246            max_token_for_scope(snaps, "scopeA"),
1247            Some(t10.clone()),
1248            "must return the maximum token, not the last/newest-timestamp one"
1249        );
1250    }
1251
1252    // The exact duplicate-rows scenario: a newer-timestamp snapshot carrying a
1253    // smaller token must lose to the older-timestamp snapshot with the larger
1254    // token. `max_token_for_scope` has no timestamp input, so ordering by token
1255    // is structurally guaranteed — this documents the intent.
1256    #[test]
1257    fn max_token_for_scope_smaller_late_token_does_not_win() {
1258        let big = format_token(99);
1259        let small = format_token(3);
1260        let snaps = vec![
1261            (Some("s"), Some(big.as_str())),   // committed earlier, larger token
1262            (Some("s"), Some(small.as_str())), // committed later, smaller token
1263        ];
1264        assert_eq!(max_token_for_scope(snaps, "s"), Some(big));
1265    }
1266
1267    #[test]
1268    fn max_token_for_scope_isolates_per_scope() {
1269        let a_hi = format_token(7);
1270        let a_lo = format_token(2);
1271        let b_hi = format_token(100);
1272        let snaps = vec![
1273            (Some("a"), Some(a_lo.as_str())),
1274            (Some("b"), Some(b_hi.as_str())),
1275            (Some("a"), Some(a_hi.as_str())),
1276        ];
1277        assert_eq!(max_token_for_scope(snaps.clone(), "a"), Some(a_hi.clone()));
1278        assert_eq!(max_token_for_scope(snaps, "b"), Some(b_hi.clone()));
1279    }
1280
1281    #[test]
1282    fn max_token_for_scope_no_match_returns_none() {
1283        let t = format_token(5);
1284        let snaps = vec![(Some("other"), Some(t.as_str()))];
1285        assert_eq!(max_token_for_scope(snaps, "missing"), None);
1286    }
1287
1288    #[test]
1289    fn max_token_for_scope_single_match() {
1290        let t = format_token(42);
1291        let snaps = vec![(Some("only"), Some(t.as_str()))];
1292        assert_eq!(max_token_for_scope(snaps, "only"), Some(t));
1293    }
1294
1295    #[test]
1296    fn max_token_for_scope_empty_returns_none() {
1297        let snaps: Vec<(Option<&str>, Option<&str>)> = vec![];
1298        assert_eq!(max_token_for_scope(snaps, "any"), None);
1299    }
1300
1301    // Snapshots whose scope matches but token is missing or unparseable are
1302    // skipped; a matching, parseable token still wins.
1303    #[test]
1304    fn max_token_for_scope_skips_missing_and_garbage_tokens() {
1305        let good = format_token(8);
1306        let snaps = vec![
1307            (Some("s"), None),            // matching scope, no token property
1308            (Some("s"), Some("garbage")), // matching scope, unparseable token
1309            (Some("s"), Some(good.as_str())),
1310        ];
1311        assert_eq!(max_token_for_scope(snaps, "s"), Some(good));
1312    }
1313
1314    // A scope match whose only tokens are unparseable yields None (no fallback
1315    // to a wrong token).
1316    #[test]
1317    fn max_token_for_scope_all_unparseable_returns_none() {
1318        let snaps = vec![(Some("s"), Some("xyz")), (Some("s"), None)];
1319        assert_eq!(max_token_for_scope(snaps, "s"), None);
1320    }
1321}