Skip to main content

rivet/
enrich.rs

1use std::sync::Arc;
2
3use arrow::array::{ArrayRef, Int64Array, TimestampMicrosecondArray};
4use arrow::datatypes::{DataType, Field, Schema, SchemaRef, TimeUnit};
5use arrow::record_batch::RecordBatch;
6
7use crate::config::{MetaColumns, RowHash};
8use crate::error::Result;
9
10pub const COL_EXPORTED_AT: &str = "_rivet_exported_at";
11pub const COL_ROW_HASH: &str = "_rivet_row_hash";
12
13/// The identity of the row hash's rendering, recorded next to the data.
14///
15/// Any change to how the canonical bytes are built — the field framing, the NULL
16/// marker, Arrow's display formatting, the digest, the truncation to `i64` —
17/// produces different values for the same rows. A reader that recomputes the
18/// hash must be able to tell "this column was written by a rendering I know"
19/// from "…by one I do not", and refuse rather than compare different bytes.
20/// Bump this whenever the rendering changes, and never reuse an old one.
21///
22/// `v1` was not injective, in two independent ways, both RED-proven:
23///
24/// * Fields were joined by a bare `\x1f` with no length, so a value could forge
25///   a boundary — `("a\x1f", "b")` and `("a", "\x1fb")` built the same bytes —
26///   and a bare `\x00` NULL marker let a value forge absence.
27/// * Container cells were hashed by Arrow's DISPLAY text, which is lossy: it
28///   joins list elements with `", "` and renders a NULL element as the empty
29///   string, so `["a, b"]` and `["a","b"]` were one value, as were `[NULL]`,
30///   `[""]` and `[]`.
31///
32/// `v2` length-prefixes every field AND builds a container's form from its
33/// children (element count, then each element framed the same way) instead of
34/// from its rendered text, so no value can imitate a boundary, absence, or a
35/// different shape at any depth.
36pub const ROW_HASH_RENDER_ID: &str = "xxh3-128-i64-arrow-display-us-v2";
37
38/// What a run's [`COL_ROW_HASH`] column actually covers, travelling with the
39/// data in the run manifest and the journal.
40///
41/// Without it a reader can only probe that the column EXISTS, and existence
42/// says nothing about coverage: a hash over `(id, status)` compared against a
43/// re-extraction of `(id, status, amount)` is a valid-looking column that
44/// silently attests a different text.
45///
46/// The coverage is recorded as the SPEC, not as a resolved list. `row_hash:
47/// true` means "every column this part projects", and the part's schema is in
48/// the part — resolving it here would duplicate a fact the data already
49/// carries, and would go stale the moment the projection changes.
50#[derive(
51    Debug, serde::Deserialize, serde::Serialize, schemars::JsonSchema, Clone, PartialEq, Eq,
52)]
53pub struct RowHashContract {
54    /// The materialized column's name (always [`COL_ROW_HASH`] today; recorded
55    /// rather than assumed so a reader never has to guess).
56    pub column: String,
57    /// The declared coverage, exactly as configured.
58    pub covered: RowHash,
59    /// The rendering's identity — see [`ROW_HASH_RENDER_ID`].
60    pub render: String,
61}
62
63impl RowHashContract {
64    /// The contract for a run's `meta_columns.row_hash`, or `None` when no hash
65    /// column is written.
66    pub fn of(spec: &RowHash) -> Option<Self> {
67        spec.enabled().then(|| Self {
68            column: COL_ROW_HASH.to_string(),
69            covered: spec.clone(),
70            render: ROW_HASH_RENDER_ID.to_string(),
71        })
72    }
73}
74
75/// Extend an Arrow schema with the columns rivet adds.
76///
77/// [`enrich_batch`] builds its arrays in this same order — the two must not be
78/// able to disagree, which is why both live here rather than being appended by
79/// each caller.
80///
81/// Fallible because `row_hash` may name a column this export does not project;
82/// that refusal belongs at schema time, before a single row is read.
83pub fn enrich_schema(schema: &SchemaRef, meta: &MetaColumns) -> Result<SchemaRef> {
84    if !meta.exported_at && !meta.row_hash.enabled() {
85        return Ok(schema.clone());
86    }
87    // Resolved for its refusal, not its result: a name outside the projection
88    // must fail the run here rather than on the first batch.
89    row_hash_columns(schema, &meta.row_hash)?;
90    let mut fields: Vec<Arc<Field>> = schema.fields().iter().cloned().collect();
91    if meta.exported_at {
92        fields.push(Arc::new(Field::new(
93            COL_EXPORTED_AT,
94            DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
95            false,
96        )));
97    }
98    if meta.row_hash.enabled() {
99        fields.push(Arc::new(Field::new(COL_ROW_HASH, DataType::Int64, false)));
100    }
101    Ok(Arc::new(Schema::new(fields)))
102}
103
104/// Add rivet's columns to a RecordBatch, in [`enrich_schema`]'s order.
105/// `exported_at_us` is a single microsecond-precision UTC timestamp shared by all rows.
106pub fn enrich_batch(
107    batch: &RecordBatch,
108    meta: &MetaColumns,
109    enriched_schema: &SchemaRef,
110    exported_at_us: i64,
111) -> Result<RecordBatch> {
112    if !meta.exported_at && !meta.row_hash.enabled() {
113        return Ok(batch.clone());
114    }
115
116    let n = batch.num_rows();
117    let mut columns: Vec<ArrayRef> = batch.columns().to_vec();
118
119    if meta.exported_at {
120        let ts_array =
121            TimestampMicrosecondArray::from(vec![Some(exported_at_us); n]).with_timezone("UTC");
122        columns.push(Arc::new(ts_array));
123    }
124
125    if meta.row_hash.enabled() {
126        // Resolved per batch rather than threaded from `enrich_schema`: the
127        // lookup is one name scan per covered column, and keeping the two
128        // functions independent means neither can quietly drift from the
129        // other's idea of coverage.
130        let names = row_hash_columns(&batch.schema(), &meta.row_hash)?;
131        columns.push(row_hash_array(batch, &names)?);
132    }
133
134    Ok(RecordBatch::try_new(enriched_schema.clone(), columns)?)
135}
136
137/// The columns `_rivet_row_hash` covers, resolved against a real schema.
138///
139/// `row_hash: true` resolves to every column in projection order — what the
140/// column has always meant. A declared list resolves to itself, and a name
141/// that is not in the projection FAILS here rather than being skipped: a
142/// skipped column would produce hashes that agree while attesting content
143/// that was never hashed, which is the quietest way this feature could lie.
144pub fn row_hash_columns(schema: &SchemaRef, spec: &RowHash) -> Result<Vec<String>> {
145    let available: Vec<String> = schema
146        .fields()
147        .iter()
148        .map(|f| f.name().to_string())
149        .collect();
150    row_hash_columns_of(&available, spec)
151}
152
153/// [`row_hash_columns`] over a bare name list — for the CDC seam, where the
154/// DATA columns are known before any Arrow schema exists, and where "all
155/// columns" must mean all *data* columns.
156///
157/// That distinction is the whole reason this overload exists. The CDC sink's
158/// schema also carries `__op`/`__pos`/`__seq`, which the snapshot leg does not
159/// have; folding them in would give the two legs different hashes for the same
160/// row, and they write the same `__changes` log.
161pub fn row_hash_columns_of(available: &[String], spec: &RowHash) -> Result<Vec<String>> {
162    let Some(declared) = spec.declared() else {
163        return Ok(available.to_vec());
164    };
165    for name in declared {
166        if !available.iter().any(|a| a == name) {
167            anyhow::bail!(
168                "meta_columns.row_hash names column '{name}', which this export does not \
169                 project. Available: {}",
170                available.join(", ")
171            );
172        }
173    }
174    Ok(declared.to_vec())
175}
176
177/// The `_rivet_row_hash` column for a batch, over an already-resolved column
178/// set. Both sinks compute it here so neither can drift from the other's
179/// rendering — the value's only guarantee is that one function produces it.
180pub fn row_hash_array(batch: &RecordBatch, cols: &[String]) -> Result<ArrayRef> {
181    let idx: Vec<usize> = cols
182        .iter()
183        .map(|c| {
184            batch.schema().index_of(c).map_err(|_| {
185                anyhow::anyhow!("row_hash: column '{c}' vanished from the batch schema")
186            })
187        })
188        .collect::<Result<Vec<_>>>()?;
189    hash_column(batch, batch.num_rows(), &idx).map(|a| Arc::new(a) as ArrayRef)
190}
191
192/// Absent value at any depth. Distinct from a PRESENT value of length zero,
193/// which is [`TAG_PRESENT`] with a zero length — that difference is the whole
194/// reason the tag is separate from the content.
195const TAG_NULL: u8 = 0x00;
196const TAG_PRESENT: u8 = 0x01;
197
198/// Whether a type is a CONTAINER, whose canonical form must be built from its
199/// children rather than from its own display text.
200///
201/// Arrow's display for a container is lossy: it joins list elements with `", "`
202/// and renders a NULL element as the EMPTY string, so `["a, b"]` and
203/// `["a","b"]` both render `[a, b]`, and `[NULL]`, `[""]` and `[]` all render
204/// `[]`. Hashing that text would attest that different arrays are the same
205/// array. Reachable, not theoretical: a PostgreSQL `text[]` becomes
206/// `DataType::List` (`types/mapping.rs`, `RivetType::List`).
207///
208/// Listed explicitly rather than with a `_` fallthrough so a new Arrow container
209/// type is a COMPILE error to classify, not a silent "scalar, safe to render" —
210/// the failure mode here is a hash that quietly attests equality.
211fn is_container(dt: &DataType) -> bool {
212    matches!(
213        dt,
214        DataType::List(_)
215            | DataType::LargeList(_)
216            | DataType::ListView(_)
217            | DataType::LargeListView(_)
218            | DataType::FixedSizeList(_, _)
219            | DataType::Struct(_)
220            | DataType::Map(_, _)
221            | DataType::Union(_, _)
222            | DataType::RunEndEncoded(_, _)
223    )
224}
225
226/// Append one LEAF cell: presence tag, then the rendered length, then the bytes.
227///
228/// The length is what makes the encoding injective — a bare separator lets a
229/// value imitate a field boundary (`("a\x1f","b")` vs `("a","\x1fb")` built the
230/// same bytes under render id v1), and a bare NULL marker lets a value imitate
231/// absence (NULL vs a lone `\x00`). Both were RED-proven collisions.
232fn write_leaf(
233    buf: &mut Vec<u8>,
234    array: &dyn arrow::array::Array,
235    fmt: &arrow::util::display::ArrayFormatter,
236    row: usize,
237) {
238    use std::io::Write as IoWrite;
239    if array.is_null(row) {
240        buf.push(TAG_NULL);
241        return;
242    }
243    buf.push(TAG_PRESENT);
244    // Placeholder, back-filled once written: the formatter renders straight into
245    // `buf`, so the length is not known before the write.
246    let len_at = buf.len();
247    buf.extend_from_slice(&0u32.to_le_bytes());
248    // Infallible: `buf` is a `Vec`, whose `io::Write` never errors.
249    let _ = write!(buf, "{}", fmt.value(row));
250    let len = (buf.len() - len_at - 4) as u32;
251    buf[len_at..len_at + 4].copy_from_slice(&len.to_le_bytes());
252}
253
254/// Append the canonical form of one cell, recursing into containers.
255///
256/// `name` is carried only for the error message, so a refusal names the column
257/// the operator wrote rather than an anonymous child field.
258fn write_canon(
259    buf: &mut Vec<u8>,
260    array: &dyn arrow::array::Array,
261    row: usize,
262    name: &str,
263    options: &arrow::util::display::FormatOptions,
264) -> Result<()> {
265    use arrow::array::{FixedSizeListArray, LargeListArray, ListArray, MapArray, StructArray};
266
267    if !is_container(array.data_type()) {
268        let fmt = leaf_formatter(array, name, options)?;
269        write_leaf(buf, array, &fmt, row);
270        return Ok(());
271    }
272    if array.is_null(row) {
273        // A NULL container is absence, distinct from an EMPTY one (which is
274        // PRESENT with element count 0) — the `[NULL]`/`[""]`/`[]` collision
275        // family is exactly this distinction being lost.
276        buf.push(TAG_NULL);
277        return Ok(());
278    }
279    buf.push(TAG_PRESENT);
280    match array.data_type() {
281        DataType::List(_) => {
282            let a = downcast::<ListArray>(array, name)?;
283            write_elements(buf, a.value(row).as_ref(), name, options)
284        }
285        DataType::LargeList(_) => {
286            let a = downcast::<LargeListArray>(array, name)?;
287            write_elements(buf, a.value(row).as_ref(), name, options)
288        }
289        DataType::FixedSizeList(_, _) => {
290            let a = downcast::<FixedSizeListArray>(array, name)?;
291            write_elements(buf, a.value(row).as_ref(), name, options)
292        }
293        DataType::Map(_, _) => {
294            // One entry is a {key, value} struct; the entry COUNT is length-
295            // prefixed like a list's, so `{}` and `{k: NULL}` cannot collide.
296            let a = downcast::<MapArray>(array, name)?;
297            let entries = a.value(row);
298            write_elements(buf, &entries, name, options)
299        }
300        DataType::Struct(_) => {
301            // No count needed — the arity is fixed by the schema — but every
302            // field is written, so a NULL field is still one tag rather than
303            // nothing at all.
304            let a = downcast::<StructArray>(array, name)?;
305            for child in a.columns() {
306                write_canon(buf, child.as_ref(), row, name, options)?;
307            }
308            Ok(())
309        }
310        // Union / RunEndEncoded / the list VIEWS: rivet's type mapping produces
311        // none of them (`RivetType` has no variant that resolves to one), so
312        // rather than guess a canonical form for a shape we cannot generate and
313        // cannot test against real data, refuse — loudly, naming the escape.
314        other => anyhow::bail!(
315            "row_hash: column '{name}' has type {other}, for which rivet has no canonical \
316             form, so hashing it could attest that different rows are equal. Declare the \
317             covered set without it — `meta_columns.row_hash: [col, ...]` — or leave \
318             `row_hash` off for this export."
319        ),
320    }
321}
322
323/// Append a length-prefixed sequence of cells — one list cell's elements, or one
324/// map cell's entries. `elements` is the row's SLICE of the child array, so its
325/// length is this cell's element count.
326///
327/// The count is written even when zero: it is what separates `[]` (count 0) from
328/// `[NULL]` (count 1 + [`TAG_NULL`]) from `[""]` (count 1 + [`TAG_PRESENT`] +
329/// length 0) — three values Arrow's own display renders identically.
330fn write_elements(
331    buf: &mut Vec<u8>,
332    elements: &dyn arrow::array::Array,
333    name: &str,
334    options: &arrow::util::display::FormatOptions,
335) -> Result<()> {
336    buf.extend_from_slice(&(elements.len() as u32).to_le_bytes());
337    if is_container(elements.data_type()) {
338        for i in 0..elements.len() {
339            write_canon(buf, elements, i, name, options)?;
340        }
341        return Ok(());
342    }
343    // One formatter for the whole slice rather than one per element — the
344    // hoisting the top level does, at every depth.
345    let fmt = leaf_formatter(elements, name, options)?;
346    for i in 0..elements.len() {
347        write_leaf(buf, elements, &fmt, i);
348    }
349    Ok(())
350}
351
352fn leaf_formatter<'a>(
353    array: &'a dyn arrow::array::Array,
354    name: &str,
355    options: &'a arrow::util::display::FormatOptions,
356) -> Result<arrow::util::display::ArrayFormatter<'a>> {
357    arrow::util::display::ArrayFormatter::try_new(array, options).map_err(|e| {
358        anyhow::anyhow!(
359            "row_hash: column '{name}' has type {} which Arrow cannot render, so it cannot \
360             be hashed ({e}). Declare the covered set without it — `meta_columns.row_hash: \
361             [col, ...]`.",
362            array.data_type()
363        )
364    })
365}
366
367fn downcast<'a, T: 'static>(array: &'a dyn arrow::array::Array, name: &str) -> Result<&'a T> {
368    array.as_any().downcast_ref::<T>().ok_or_else(|| {
369        anyhow::anyhow!(
370            "row_hash: column '{name}' declares {} but its array is a different kind — \
371             refusing rather than hashing a value read as the wrong shape",
372            array.data_type()
373        )
374    })
375}
376
377/// Compute deterministic 64-bit hashes over `cols` for all rows in a batch.
378/// Hoists one ArrayFormatter per covered scalar column (avoiding per-cell String
379/// allocations) and reuses a single scratch buffer across all rows.
380///
381/// The rendering is xxh3 over Arrow's display formatting, framed injectively
382/// (see [`write_leaf`] and [`write_elements`]). Deliberately NOT reproducible in
383/// SQL, because the only other place this value is computed is rivet itself,
384/// re-reading the sampled rows through the same path (repair-design.md §5h):
385/// agreement is a property of running one piece of code twice.
386///
387/// Fallible for the same reason the column resolution is: a column rivet cannot
388/// canonicalize must FAIL the run, never be silently omitted from the hash.
389/// Omitting it would leave the manifest's `RowHashContract` claiming a coverage
390/// the value does not have — the quietest way this feature could lie.
391fn hash_column(batch: &RecordBatch, n: usize, cols: &[usize]) -> Result<Int64Array> {
392    use xxhash_rust::xxh3::xxh3_128;
393
394    /// A covered column's fast path. `Container` is NOT "skip" — it routes to
395    /// `write_canon`; the enum exists so the scalar hoist stays a hoist without
396    /// an `Option` whose `None` could be mistaken for "omit this column".
397    enum Top<'a> {
398        Scalar(arrow::util::display::ArrayFormatter<'a>),
399        Container,
400    }
401
402    let options = arrow::util::display::FormatOptions::default();
403    let names: Vec<String> = cols
404        .iter()
405        .map(|&i| batch.schema().field(i).name().to_string())
406        .collect();
407    let tops: Vec<Top> = cols
408        .iter()
409        .zip(&names)
410        .map(|(&i, name)| {
411            let array = batch.column(i);
412            if is_container(array.data_type()) {
413                Ok(Top::Container)
414            } else {
415                leaf_formatter(array.as_ref(), name, &options).map(Top::Scalar)
416            }
417        })
418        .collect::<Result<Vec<_>>>()?;
419
420    let mut buf = Vec::with_capacity(256);
421    let mut hashes = Vec::with_capacity(n);
422    for row in 0..n {
423        buf.clear();
424        for ((&col_idx, top), name) in cols.iter().zip(&tops).zip(&names) {
425            let array = batch.column(col_idx);
426            match top {
427                Top::Scalar(fmt) => write_leaf(&mut buf, array.as_ref(), fmt, row),
428                Top::Container => write_canon(&mut buf, array.as_ref(), row, name, &options)?,
429            }
430        }
431        let h = xxh3_128(&buf);
432        hashes.push(h as i64);
433    }
434    Ok(Int64Array::from(hashes))
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use arrow::array::StringArray;
441    use arrow::datatypes::Field;
442
443    fn sample_batch() -> (SchemaRef, RecordBatch) {
444        let schema = Arc::new(Schema::new(vec![
445            Field::new("id", DataType::Int64, false),
446            Field::new("name", DataType::Utf8, true),
447        ]));
448        let batch = RecordBatch::try_new(
449            schema.clone(),
450            vec![
451                Arc::new(Int64Array::from(vec![1, 2, 3])),
452                Arc::new(StringArray::from(vec![
453                    Some("alice"),
454                    None,
455                    Some("charlie"),
456                ])),
457            ],
458        )
459        .unwrap();
460        (schema, batch)
461    }
462
463    /// `enrich_schema` and `enrich_batch` are separate functions walking the
464    /// same list, so a divergence in order would surface as an Arrow "column
465    /// count/type mismatch" at write time — on a live run. Pin the order here
466    /// instead.
467    #[test]
468    fn meta_column_order_is_pinned() {
469        let (schema, batch) = sample_batch();
470        let meta = MetaColumns {
471            exported_at: true,
472            row_hash: RowHash::All(true),
473        };
474        let enriched = enrich_schema(&schema, &meta).unwrap();
475        assert_eq!(
476            enriched
477                .fields()
478                .iter()
479                .map(|f| f.name().as_str())
480                .collect::<Vec<_>>(),
481            vec!["id", "name", COL_EXPORTED_AT, COL_ROW_HASH]
482        );
483        // try_new is what enforces the agreement — this unwrap IS the assertion.
484        enrich_batch(&batch, &meta, &enriched, 0).unwrap();
485    }
486
487    /// The hash covers SOURCE columns only. If it were computed after
488    /// enrichment, `_rivet_exported_at` would enter it under `row_hash: true`
489    /// and the value would change every run, so no audit could ever match it —
490    /// and the two legs, which stamp different instants, could never agree.
491    #[test]
492    fn the_row_hash_ignores_the_meta_columns_it_ships_beside() {
493        let (_, batch) = sample_batch();
494        let without = row_hashes(
495            &MetaColumns {
496                exported_at: false,
497                row_hash: RowHash::All(true),
498            },
499            &batch,
500        );
501        let with = row_hashes(
502            &MetaColumns {
503                exported_at: true,
504                row_hash: RowHash::All(true),
505            },
506            &batch,
507        );
508        assert_eq!(without, with);
509    }
510
511    /// The contract is what a reader consults to decide whether a persisted
512    /// hash is comparable to what it is about to compute. It records the SPEC:
513    /// `true` means "every column this part projects", and the part carries its
514    /// own schema — resolving it here would duplicate a fact the data already
515    /// holds and go stale when the projection changes.
516    #[test]
517    fn the_contract_records_the_spec_and_a_render_id() {
518        assert_eq!(RowHashContract::of(&RowHash::All(false)), None);
519        let c = RowHashContract::of(&RowHash::All(true)).unwrap();
520        assert_eq!(c.column, COL_ROW_HASH);
521        assert_eq!(c.covered, RowHash::All(true));
522        assert_eq!(c.render, ROW_HASH_RENDER_ID);
523
524        let declared = RowHash::Columns(vec!["status".into(), "amount".into()]);
525        let c = RowHashContract::of(&declared).unwrap();
526        assert_eq!(c.covered, declared);
527        assert_ne!(
528            RowHashContract::of(&RowHash::Columns(vec!["a".into(), "b".into()])),
529            RowHashContract::of(&RowHash::Columns(vec!["b".into(), "a".into()])),
530            "column order changes every hash, so it must survive into the contract"
531        );
532    }
533
534    fn row_hashes(meta: &MetaColumns, batch: &RecordBatch) -> Vec<i64> {
535        let schema = enrich_schema(&batch.schema(), meta).unwrap();
536        let out = enrich_batch(batch, meta, &schema, 0).unwrap();
537        let a = out
538            .column_by_name(COL_ROW_HASH)
539            .unwrap()
540            .as_any()
541            .downcast_ref::<Int64Array>()
542            .unwrap();
543        (0..a.len()).map(|i| a.value(i)).collect()
544    }
545
546    fn meta_rh(spec: RowHash) -> MetaColumns {
547        MetaColumns {
548            exported_at: false,
549            row_hash: spec,
550        }
551    }
552
553    /// `row_hash: true` keeps meaning every column, in projection order —
554    /// existing configs must not silently change what they attest.
555    #[test]
556    fn row_hash_true_still_covers_every_column() {
557        let (schema, batch) = sample_batch();
558        assert_eq!(
559            row_hash_columns(&schema, &RowHash::All(true)).unwrap(),
560            vec!["id", "name"]
561        );
562        assert_eq!(
563            row_hashes(&meta_rh(RowHash::All(true)), &batch),
564            row_hashes(
565                &meta_rh(RowHash::Columns(vec!["id".into(), "name".into()])),
566                &batch
567            ),
568            "naming every column must equal asking for all of them"
569        );
570    }
571
572    /// The point of a declared set: a column outside it does not move the
573    /// hash. This is the behaviour an operator is buying when they exclude a
574    /// load timestamp — and equally the reason the covered set has to be
575    /// recorded rather than inferred.
576    #[test]
577    fn a_declared_set_narrows_what_the_hash_attests() {
578        let schema = Arc::new(Schema::new(vec![
579            Field::new("id", DataType::Int64, false),
580            Field::new("name", DataType::Utf8, true),
581        ]));
582        let mk = |name: &str| {
583            RecordBatch::try_new(
584                schema.clone(),
585                vec![
586                    Arc::new(Int64Array::from(vec![1])),
587                    Arc::new(StringArray::from(vec![Some(name)])),
588                ],
589            )
590            .unwrap()
591        };
592        let id_only = meta_rh(RowHash::Columns(vec!["id".into()]));
593        assert_eq!(
594            row_hashes(&id_only, &mk("alice")),
595            row_hashes(&id_only, &mk("bob")),
596            "a column outside the declared set must not move the hash"
597        );
598        let both = meta_rh(RowHash::All(true));
599        assert_ne!(
600            row_hashes(&both, &mk("alice")),
601            row_hashes(&both, &mk("bob")),
602            "...and inside it, it must"
603        );
604    }
605
606    /// Column ORDER is part of what is hashed, so a reorder must change the
607    /// value rather than quietly comparing equal.
608    #[test]
609    fn declared_order_changes_the_hash() {
610        let (_, batch) = sample_batch();
611        assert_ne!(
612            row_hashes(
613                &meta_rh(RowHash::Columns(vec!["id".into(), "name".into()])),
614                &batch
615            ),
616            row_hashes(
617                &meta_rh(RowHash::Columns(vec!["name".into(), "id".into()])),
618                &batch
619            ),
620        );
621    }
622
623    /// A name that is not projected FAILS. Skipping it would produce hashes
624    /// that agree while attesting content that was never hashed.
625    #[test]
626    fn an_unprojected_column_is_refused_with_the_available_list() {
627        let (schema, _) = sample_batch();
628        let err = row_hash_columns(&schema, &RowHash::Columns(vec!["nope".into()]))
629            .unwrap_err()
630            .to_string();
631        assert!(err.contains("nope") && err.contains("id, name"), "{err}");
632    }
633
634    #[test]
635    fn empty_and_duplicate_declared_sets_are_refused() {
636        let err = RowHash::Columns(vec![])
637            .validate("e")
638            .unwrap_err()
639            .to_string();
640        assert!(err.contains("attests nothing"), "{err}");
641        let err = RowHash::Columns(vec!["a".into(), "a".into()])
642            .validate("e")
643            .unwrap_err()
644            .to_string();
645        assert!(err.contains("twice"), "{err}");
646        RowHash::Columns(vec!["a".into()]).validate("e").unwrap();
647        RowHash::All(true).validate("e").unwrap();
648    }
649
650    /// THE invariant §5h rests on: the CDC drain and the snapshot leg must
651    /// produce the same hash for the same row.
652    ///
653    /// They do not see the same batch. The drain's carries `__op`/`__pos`/
654    /// `__seq` in front of the data; the snapshot's does not. So the hash must
655    /// be computed over the resolved DATA columns and be blind to whatever else
656    /// the batch happens to hold — otherwise every backfilled row and every
657    /// streamed row would disagree in the one log they share, and the column
658    /// would be useless for exactly the comparison it exists to serve.
659    #[test]
660    fn cdc_meta_columns_do_not_enter_the_row_hash() {
661        let data_only = Arc::new(Schema::new(vec![
662            Field::new("id", DataType::Int64, false),
663            Field::new("name", DataType::Utf8, true),
664        ]));
665        let snapshot = RecordBatch::try_new(
666            data_only,
667            vec![
668                Arc::new(Int64Array::from(vec![1])),
669                Arc::new(StringArray::from(vec![Some("alice")])),
670            ],
671        )
672        .unwrap();
673
674        // What the CDC sink builds: meta columns first, then the same data.
675        let with_meta = Arc::new(Schema::new(vec![
676            Field::new("__op", DataType::Utf8, false),
677            Field::new("__pos", DataType::Utf8, false),
678            Field::new("__seq", DataType::Int64, false),
679            Field::new("id", DataType::Int64, false),
680            Field::new("name", DataType::Utf8, true),
681        ]));
682        let drain = RecordBatch::try_new(
683            with_meta,
684            vec![
685                Arc::new(StringArray::from(vec![Some("insert")])),
686                Arc::new(StringArray::from(vec![Some("{\"pos\":7}")])),
687                Arc::new(Int64Array::from(vec![7])),
688                Arc::new(Int64Array::from(vec![1])),
689                Arc::new(StringArray::from(vec![Some("alice")])),
690            ],
691        )
692        .unwrap();
693
694        let data: Vec<String> = vec!["id".into(), "name".into()];
695        let read = |b: &RecordBatch| {
696            let a = row_hash_array(b, &data).unwrap();
697            a.as_any().downcast_ref::<Int64Array>().unwrap().value(0)
698        };
699        assert_eq!(read(&snapshot), read(&drain));
700
701        // And "all columns" on the CDC leg resolves to the DATA columns, not to
702        // the sink's own — which is what makes the equality above reachable
703        // from a plain `row_hash: true`.
704        assert_eq!(
705            row_hash_columns_of(&data, &RowHash::All(true)).unwrap(),
706            data
707        );
708    }
709
710    #[test]
711    fn enrich_disabled_is_noop() {
712        let (schema, batch) = sample_batch();
713        let meta = MetaColumns {
714            exported_at: false,
715            row_hash: RowHash::All(false),
716        };
717        let enriched_schema = enrich_schema(&schema, &meta).unwrap();
718        assert_eq!(enriched_schema.fields().len(), 2);
719        let result = enrich_batch(&batch, &meta, &enriched_schema, 0).unwrap();
720        assert_eq!(result.num_columns(), 2);
721    }
722
723    #[test]
724    fn enrich_exported_at_only() {
725        let (schema, batch) = sample_batch();
726        let meta = MetaColumns {
727            exported_at: true,
728            row_hash: RowHash::All(false),
729        };
730        let enriched_schema = enrich_schema(&schema, &meta).unwrap();
731        assert_eq!(enriched_schema.fields().len(), 3);
732        assert_eq!(enriched_schema.field(2).name(), COL_EXPORTED_AT);
733
734        let ts = 1_711_612_800_000_000i64;
735        let result = enrich_batch(&batch, &meta, &enriched_schema, ts).unwrap();
736        assert_eq!(result.num_columns(), 3);
737        assert_eq!(result.num_rows(), 3);
738
739        let ts_col = result
740            .column(2)
741            .as_any()
742            .downcast_ref::<TimestampMicrosecondArray>()
743            .unwrap();
744        assert_eq!(ts_col.value(0), ts);
745        assert_eq!(ts_col.value(2), ts);
746    }
747
748    #[test]
749    fn enrich_row_hash_only() {
750        let (schema, batch) = sample_batch();
751        let meta = MetaColumns {
752            exported_at: false,
753            row_hash: RowHash::All(true),
754        };
755        let enriched_schema = enrich_schema(&schema, &meta).unwrap();
756        assert_eq!(enriched_schema.field(2).name(), COL_ROW_HASH);
757        assert_eq!(*enriched_schema.field(2).data_type(), DataType::Int64);
758
759        let result = enrich_batch(&batch, &meta, &enriched_schema, 0).unwrap();
760        let hash_col = result
761            .column(2)
762            .as_any()
763            .downcast_ref::<Int64Array>()
764            .unwrap();
765
766        // Different rows produce different hashes
767        assert_ne!(hash_col.value(0), hash_col.value(1));
768        assert_ne!(hash_col.value(1), hash_col.value(2));
769    }
770
771    #[test]
772    fn enrich_both_columns() {
773        let (schema, batch) = sample_batch();
774        let meta = MetaColumns {
775            exported_at: true,
776            row_hash: RowHash::All(true),
777        };
778        let enriched_schema = enrich_schema(&schema, &meta).unwrap();
779        assert_eq!(enriched_schema.fields().len(), 4);
780        assert_eq!(enriched_schema.field(2).name(), COL_EXPORTED_AT);
781        assert_eq!(enriched_schema.field(3).name(), COL_ROW_HASH);
782
783        let result = enrich_batch(&batch, &meta, &enriched_schema, 123456).unwrap();
784        assert_eq!(result.num_columns(), 4);
785        assert_eq!(result.num_rows(), 3);
786    }
787
788    #[test]
789    fn hash_is_deterministic() {
790        let (schema, batch) = sample_batch();
791        let meta = MetaColumns {
792            exported_at: false,
793            row_hash: RowHash::All(true),
794        };
795        let enriched_schema = enrich_schema(&schema, &meta).unwrap();
796
797        let r1 = enrich_batch(&batch, &meta, &enriched_schema, 0).unwrap();
798        let r2 = enrich_batch(&batch, &meta, &enriched_schema, 0).unwrap();
799
800        let h1 = r1.column(2).as_any().downcast_ref::<Int64Array>().unwrap();
801        let h2 = r2.column(2).as_any().downcast_ref::<Int64Array>().unwrap();
802        for i in 0..3 {
803            assert_eq!(
804                h1.value(i),
805                h2.value(i),
806                "hash should be deterministic for row {i}"
807            );
808        }
809    }
810
811    #[test]
812    fn hash_distinguishes_null_from_empty() {
813        let schema = Arc::new(Schema::new(vec![Field::new("val", DataType::Utf8, true)]));
814        let batch = RecordBatch::try_new(
815            schema.clone(),
816            vec![Arc::new(StringArray::from(vec![None, Some("")]))],
817        )
818        .unwrap();
819
820        let meta = MetaColumns {
821            exported_at: false,
822            row_hash: RowHash::All(true),
823        };
824        let enriched_schema = enrich_schema(&schema, &meta).unwrap();
825        let result = enrich_batch(&batch, &meta, &enriched_schema, 0).unwrap();
826        let hashes = result
827            .column(1)
828            .as_any()
829            .downcast_ref::<Int64Array>()
830            .unwrap();
831        assert_ne!(
832            hashes.value(0),
833            hashes.value(1),
834            "NULL and empty string should hash differently"
835        );
836    }
837
838    /// Hash a batch of two string columns. TWO columns on purpose: a
839    /// single-column fixture cannot express a field BOUNDARY, and the boundary
840    /// is exactly what the framing has to get right — the same
841    /// fixture-past-the-activation-threshold rule a one-element fold falls foul
842    /// of.
843    fn two_column_hashes(a: Vec<Option<&str>>, b: Vec<Option<&str>>) -> Vec<i64> {
844        let schema = Arc::new(Schema::new(vec![
845            Field::new("a", DataType::Utf8, true),
846            Field::new("b", DataType::Utf8, true),
847        ]));
848        let batch = RecordBatch::try_new(
849            schema.clone(),
850            vec![
851                Arc::new(StringArray::from(a)) as ArrayRef,
852                Arc::new(StringArray::from(b)) as ArrayRef,
853            ],
854        )
855        .unwrap();
856        let meta = meta_rh(RowHash::All(true));
857        let es = enrich_schema(&schema, &meta).unwrap();
858        let out = enrich_batch(&batch, &meta, &es, 0).unwrap();
859        let h = out
860            .column(out.num_columns() - 1)
861            .as_any()
862            .downcast_ref::<Int64Array>()
863            .unwrap();
864        (0..h.len()).map(|i| h.value(i)).collect()
865    }
866
867    /// A value must not be able to forge a field boundary.
868    ///
869    /// RED against the `\x1f`-separator framing (render id v1), which built
870    /// IDENTICAL bytes for these two rows and returned one hash for both:
871    /// `("a\x1f","b")` → `a 1F 1F b 1F`, and `("a","\x1fb")` → `a 1F 1F b 1F`.
872    /// Control characters are legal in every engine's text type, so this is
873    /// reachable data rather than a synthetic edge.
874    #[test]
875    fn a_value_containing_the_old_separator_cannot_forge_a_field_boundary() {
876        let h = two_column_hashes(
877            vec![Some("a\u{1f}"), Some("a")],
878            vec![Some("b"), Some("\u{1f}b")],
879        );
880        assert_ne!(
881            h[0], h[1],
882            "('a\\x1f','b') and ('a','\\x1fb') are DIFFERENT rows and must not share a hash"
883        );
884    }
885
886    /// A NULL must not collide with a value whose rendering IS the null marker.
887    /// RED against v1, where a NULL wrote a bare `\x00` and a one-NUL-byte
888    /// string rendered to the same `\x00`.
889    #[test]
890    fn hash_distinguishes_null_from_a_value_rendering_as_the_null_marker() {
891        let h = two_column_hashes(vec![None, Some("\u{0}")], vec![Some("x"), Some("x")]);
892        assert_ne!(
893            h[0], h[1],
894            "NULL and the one-NUL-byte string must not share a hash"
895        );
896    }
897
898    /// Where the boundary FALLS must matter when the concatenated content is
899    /// identical — ("ab","c") vs ("a","bc"). Pins the half of injectivity that
900    /// a separator alone gives and a length prefix must not lose.
901    #[test]
902    fn hash_distinguishes_where_the_field_boundary_falls() {
903        let h = two_column_hashes(vec![Some("ab"), Some("a")], vec![Some("c"), Some("bc")]);
904        assert_ne!(
905            h[0], h[1],
906            "('ab','c') and ('a','bc') are DIFFERENT rows and must not share a hash"
907        );
908    }
909
910    /// An array column must hash INJECTIVELY, not by Arrow's display text.
911    ///
912    /// Every pair below renders identically under Arrow's own list display — it
913    /// joins elements with ", " and renders a NULL element as the empty string —
914    /// so hashing that text attested that different arrays were the same array.
915    /// A PostgreSQL `text[]` reaches this path (`RivetType::List` →
916    /// `DataType::List`, types/mapping.rs), so a row whose array changed kept its
917    /// old hash and change detection skipped it.
918    ///
919    /// RED-proven before the recursive canonicalization: `[NULL]`, `[""]` and
920    /// `[]` all hashed 3646912262511830962, and `["a, b"]`/`["a","b"]` both
921    /// 579306972153428012. Framing the FIELD cannot fix an ambiguity inside the
922    /// field's own text; the elements themselves have to be framed.
923    ///
924    /// Six cells on purpose — the collisions are between SIBLING shapes, so a
925    /// fixture with one array per case is below the threshold that can catch
926    /// them.
927    #[test]
928    fn an_array_column_hashes_injectively_not_by_its_display_text() {
929        use arrow::array::{ListBuilder, StringBuilder};
930        let mut b = ListBuilder::new(StringBuilder::new());
931        b.values().append_null(); // 0: [NULL]
932        b.append(true);
933        b.values().append_value(""); // 1: [""]
934        b.append(true);
935        b.append(true); // 2: []
936        b.values().append_value("a, b"); // 3: ["a, b"] — one element with the join
937        b.append(true);
938        b.values().append_value("a"); // 4: ["a","b"] — two elements
939        b.values().append_value("b");
940        b.append(true);
941        b.append(false); // 5: NULL cell (absent, not empty)
942        let arr = Arc::new(b.finish()) as ArrayRef;
943
944        let schema = Arc::new(Schema::new(vec![Field::new(
945            "tags",
946            arr.data_type().clone(),
947            true,
948        )]));
949        let batch = RecordBatch::try_new(schema.clone(), vec![arr]).unwrap();
950        let meta = meta_rh(RowHash::All(true));
951        let es = enrich_schema(&schema, &meta).unwrap();
952        let out = enrich_batch(&batch, &meta, &es, 0).expect("an array column is hashable");
953        let h = out
954            .column(out.num_columns() - 1)
955            .as_any()
956            .downcast_ref::<Int64Array>()
957            .unwrap();
958        let got: Vec<i64> = (0..6).map(|i| h.value(i)).collect();
959
960        let mut uniq = got.clone();
961        uniq.sort_unstable();
962        uniq.dedup();
963        assert_eq!(
964            uniq.len(),
965            6,
966            "all six array shapes are DIFFERENT values and must hash differently — \
967             [NULL], [\"\"], [], [\"a, b\"], [\"a\",\"b\"], NULL; got {got:?}"
968        );
969    }
970
971    /// A struct column is framed per field, so moving content between fields
972    /// changes the hash. `{a:\"x\", b:\"\"}` vs `{a:\"x\", b:NULL}` also differ —
973    /// a NULL field is a tag, not nothing.
974    #[test]
975    fn a_struct_column_distinguishes_its_fields_and_their_nulls() {
976        use arrow::array::StructArray;
977        let f = |name: &str, v: Vec<Option<&str>>| {
978            (
979                Arc::new(Field::new(name, DataType::Utf8, true)),
980                Arc::new(StringArray::from(v)) as ArrayRef,
981            )
982        };
983        // rows: 0 = {a:"x", b:""}, 1 = {a:"x", b:NULL}, 2 = {a:"", b:"x"}
984        let arr = Arc::new(StructArray::from(vec![
985            f("a", vec![Some("x"), Some("x"), Some("")]),
986            f("b", vec![Some(""), None, Some("x")]),
987        ])) as ArrayRef;
988
989        let schema = Arc::new(Schema::new(vec![Field::new(
990            "meta",
991            arr.data_type().clone(),
992            true,
993        )]));
994        let batch = RecordBatch::try_new(schema.clone(), vec![arr]).unwrap();
995        let meta = meta_rh(RowHash::All(true));
996        let es = enrich_schema(&schema, &meta).unwrap();
997        let out = enrich_batch(&batch, &meta, &es, 0).expect("a struct column is hashable");
998        let h = out
999            .column(out.num_columns() - 1)
1000            .as_any()
1001            .downcast_ref::<Int64Array>()
1002            .unwrap();
1003        let got: Vec<i64> = (0..3).map(|i| h.value(i)).collect();
1004        let mut uniq = got.clone();
1005        uniq.sort_unstable();
1006        uniq.dedup();
1007        assert_eq!(
1008            uniq.len(),
1009            3,
1010            "an empty field, a NULL field and content moved between fields are three \
1011             different rows; got {got:?}"
1012        );
1013    }
1014
1015    /// An INDEPENDENT oracle for the rendering: `expected` is a literal, not a
1016    /// second call to the code under test, so any change to the framing, the
1017    /// digest or the `i64` truncation fails HERE — beside the assertion that the
1018    /// render id must move with it. Recomputing `expected` via `row_hash_array`
1019    /// would pass for any rendering whatsoever, which is the self-oracle trap.
1020    ///
1021    /// If this fails, the rendering changed: bump [`ROW_HASH_RENDER_ID`] and
1022    /// re-pin the literals in the SAME commit. Never re-pin one alone.
1023    #[test]
1024    fn the_rendering_is_pinned_to_literals_and_to_its_render_id() {
1025        let h = two_column_hashes(vec![Some("a"), None], vec![Some("b"), Some("")]);
1026        assert_eq!(
1027            h,
1028            vec![
1029                -7_527_003_277_948_829_337_i64,
1030                -5_099_907_639_937_407_564_i64
1031            ],
1032            "the row-hash rendering changed — bump ROW_HASH_RENDER_ID and re-pin these \
1033             literals together, never one without the other"
1034        );
1035        assert_eq!(
1036            ROW_HASH_RENDER_ID, "xxh3-128-i64-arrow-display-us-v2",
1037            "the render id must change whenever the rendering above does"
1038        );
1039    }
1040}