knut-bifrost 0.3.0

Bifröst — watch lakehouse tables (Iceberg via Spark Connect) and materialise them as a FalkorDB property graph
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
//! **Table** sinks — the `TableSink` family, sibling of [`super::graph`].
//!
//! A table sink delivers one CDC *window* (a snapshot pair plus the rows that
//! changed between them) into a row-and-column store. It shares nothing with
//! [`GraphSink`](super::graph::GraphSink) but the crate's error type, and that
//! is deliberate — see `.nornir/doris-sink-design.md` f2.
//!
//! # The one invariant this module exists to enforce
//!
//! **The delivery cursor advances strictly after the sink acknowledges.**
//!
//! * Load, then commit: a crash in between replays the window. The label a sink
//!   derives from the snapshot pair is byte-identical on the replay, so the
//!   store rejects it and the replay is a no-op.
//! * Commit, then load: a crash in between **silently loses the window**. No
//!   error, no retry, no lag metric — the rows are simply not there, and nobody
//!   finds out until an answer is wrong.
//!
//! `listener.rs` gets this right for the graph path, but by comment
//! (`listener.rs:301`). A comment is not a mechanism, so the table path spells
//! it out in the type system:
//!
//! * [`LoadAck`] has no public constructor. The only way to make one is
//!   [`AckToken::ack`], which **consumes** the token.
//! * The only [`AckToken`] in existence is the one [`deliver_window`] mints and
//!   hands to [`TableSink::apply`].
//! * [`deliver_window`] is the only function in this crate that advances a
//!   table-delivery cursor, and it does so from [`LoadAck::to_snapshot`] — never
//!   from the window it was given.
//!
//! So an edit that tries to advance the cursor early has to first invent a
//! `LoadAck`, and it cannot: the token it would need is not reachable from
//! anywhere except inside a live `apply` call. That is the whole point.

use std::fmt::Write as _;

use arrow_array::RecordBatch;
use arrow_schema::Schema;
use async_trait::async_trait;

use crate::{
    error::{BifrostError, Result},
    state::SyncState,
};

// ── the window ──────────────────────────────────────────────────────────────

/// The half-open snapshot range a window covers: changes strictly after `from`,
/// up to and including `to`. `from = None` means "from the beginning".
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SnapshotWindow {
    pub from: Option<i64>,
    pub to: i64,
}

impl SnapshotWindow {
    pub const fn new(from: Option<i64>, to: i64) -> Self {
        Self { from, to }
    }
}

/// One CDC window, ready to deliver.
///
/// `upserts` / `deletes` are the output of
/// [`partition_change_rows`](crate::transform::partition_change_rows), which is
/// reused rather than reimplemented — that is what makes the four-tag mapping
/// correct here for free (`INSERT` / `UPDATE_AFTER` / folded `UPDATE` upsert,
/// `DELETE` deletes, `UPDATE_BEFORE` is dropped because its paired
/// `UPDATE_AFTER` fully overwrites the row, and an unknown tag is a hard error).
/// The `_change_type` sentinel column is already stripped.
#[derive(Debug, Clone, Copy)]
pub struct TableWindow<'a> {
    /// The **source** table this window came from (the bifröst/skade table key).
    /// Also the key the cursor is stored under. Not the destination table.
    pub table: &'a str,
    pub snapshots: SnapshotWindow,
    pub upserts: &'a [RecordBatch],
    pub deletes: &'a [RecordBatch],
    /// [`skade::DeltaPlan::needs_full_reload`] for this window: the additive
    /// delta cannot express what happened (an overwrite/replace commit, or a
    /// position-delete file), so the only correct answer is "re-read
    /// everything".
    ///
    /// [`deliver_window`] **refuses** such a window. It never loads it and never
    /// moves the cursor. On a 2 B-row table a silent fallback turns a streaming
    /// job into an 81 GB rewrite at whatever hour the writer happened to run.
    ///
    /// This is a backstop, not the primary gate — by the time a window exists
    /// the expensive read has already happened, because
    /// `skade::Table::read_changelog` handles a full-reload window by re-reading
    /// the whole `to` snapshot itself. Call [`refuse_full_reload`] against
    /// `read_delta`'s plan *before* calling `read_changelog`.
    pub needs_full_reload: bool,
    /// The Iceberg **data sequence number** of `snapshots.to` (a one-snapshot
    /// window), when known. A Unique Key destination with a sequence column
    /// versions every row of the window by it, so a replayed or late window can
    /// never overwrite newer data (Doris: `function_column.sequence_col`).
    /// `None` = unversioned (tests, sources without snapshots).
    pub sequence: Option<i64>,
    /// The row identity (primary-key column names) when known — from the
    /// Iceberg table's identifier fields / equality-delete ids, or configured.
    /// A Unique Key sink uses it to drop a delete whose key is upserted in the
    /// same window, and to ship identity-only deletes. Empty = unknown.
    pub key_columns: &'a [String],
}

impl TableWindow<'_> {
    pub fn upsert_rows(&self) -> usize {
        self.upserts.iter().map(RecordBatch::num_rows).sum()
    }

    pub fn delete_rows(&self) -> usize {
        self.deletes.iter().map(RecordBatch::num_rows).sum()
    }

    pub fn is_empty(&self) -> bool {
        self.upsert_rows() == 0 && self.delete_rows() == 0
    }

    /// The schema of the rows in this window, if it has any.
    pub fn schema(&self) -> Option<arrow_schema::SchemaRef> {
        self.upserts
            .first()
            .or_else(|| self.deletes.first())
            .map(RecordBatch::schema)
    }
}

// ── the unforgeable acknowledgement ─────────────────────────────────────────

/// A capability handed to [`TableSink::apply`] and consumed to build the
/// [`LoadAck`] that proves the load happened.
///
/// Zero-sized, not `Clone`, no public constructor. [`deliver_window`] mints
/// exactly one per call.
#[derive(Debug)]
pub struct AckToken(());

impl AckToken {
    /// Turn the token into the proof. Consuming `self` is the point: an `apply`
    /// implementation gets one token per call and can therefore produce at most
    /// one `LoadAck` per call.
    #[must_use]
    pub fn ack(self, to_snapshot: i64, labels: Vec<String>) -> LoadAck {
        LoadAck {
            to_snapshot,
            labels,
            rows_loaded: 0,
            rows_deleted: 0,
            rows_rejected_duplicate: 0,
        }
    }
}

/// Proof that a window was durably accepted by a sink.
///
/// There is no public constructor: the only way to obtain one is
/// [`AckToken::ack`], and the only source of an [`AckToken`] is
/// [`deliver_window`]. Advancing a delivery cursor requires one of these, so the
/// "cursor after ack" ordering cannot be inverted by a later edit without
/// deleting this type.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadAck {
    /// The snapshot the cursor may now advance to. The cursor reads this, never
    /// the window it was handed.
    to_snapshot: i64,
    /// Every store-side label this window consumed, in chunk order.
    pub labels: Vec<String>,
    /// Rows the store accepted.
    pub rows_loaded: usize,
    /// Delete-marked rows the store accepted.
    pub rows_deleted: usize,
    /// Rows the store refused because their label had already been seen — a
    /// replay working exactly as designed, not an error.
    pub rows_rejected_duplicate: usize,
}

impl LoadAck {
    /// The snapshot the cursor may advance to.
    pub const fn to_snapshot(&self) -> i64 {
        self.to_snapshot
    }

    #[must_use]
    pub fn with_counts(mut self, loaded: usize, deleted: usize, duplicate: usize) -> Self {
        self.rows_loaded = loaded;
        self.rows_deleted = deleted;
        self.rows_rejected_duplicate = duplicate;
        self
    }
}

// ── the trait ───────────────────────────────────────────────────────────────

/// A place table-shaped rows go.
#[async_trait]
pub trait TableSink: Send {
    /// Fail fast on a schema the sink cannot accept.
    ///
    /// Called once before the first window. Discovering a column-order or type
    /// difference mid-stream, after a partial load, is far worse than refusing
    /// to boot — so a mismatch here is an error, never a warning.
    async fn probe(&mut self, schema: &Schema) -> Result<()>;

    /// Apply exactly one window, then consume `token` to acknowledge it.
    ///
    /// **Must be idempotent**: the same window may arrive twice after a crash
    /// between the load and the cursor commit. The intended mechanism is a label
    /// derived from the snapshot pair (see [`stream_load_label`]) which the
    /// store rejects on replay — not a dedup table on this side.
    async fn apply(&mut self, window: &TableWindow<'_>, token: AckToken) -> Result<LoadAck>;
}

// ── the choke point ─────────────────────────────────────────────────────────

/// Deliver one window and advance the cursor — **in that order**.
///
/// This is the only function in the crate that advances a table-delivery
/// cursor. It cannot advance it without a [`LoadAck`], and it cannot obtain one
/// without a successful [`TableSink::apply`].
///
/// A failing `apply` leaves the cursor exactly where it was, so the next round
/// recomputes the same window, derives the same labels, and the store rejects
/// whatever already landed.
pub async fn deliver_window<S: TableSink + ?Sized>(
    sink: &mut S,
    state: &mut SyncState,
    window: &TableWindow<'_>,
) -> Result<LoadAck> {
    // 0. Refuse before anything else. A full-reload window must never be loaded
    //    by accident, and must never advance the cursor.
    refuse_full_reload(window.table, window.needs_full_reload)?;

    // 1. The load. Nothing below this line runs if it fails.
    let ack = sink.apply(window, AckToken(())).await?;

    // A sink that acks a different snapshot than the one it was asked to load
    // would silently skip or re-do a window. Refuse rather than trust it.
    if ack.to_snapshot != window.snapshots.to {
        return Err(BifrostError::Other(format!(
            "{}: sink acked snapshot {} but the window ended at {} — refusing to \
             move the cursor",
            window.table, ack.to_snapshot, window.snapshots.to,
        )));
    }

    // 2. Only now the cursor, and only from the ack.
    let mut next = state.get(window.table);
    next.snapshot_id = Some(ack.to_snapshot);
    state.advance_staged(window.table, next).await?;
    state.flush()?;

    tracing::info!(
        target: "bifrost::sink",
        event = "table_window_delivered",
        table = %window.table,
        from = ?window.snapshots.from,
        to = ack.to_snapshot,
        labels = ack.labels.len(),
        rows = ack.rows_loaded,
        deleted = ack.rows_deleted,
        duplicate = ack.rows_rejected_duplicate,
        "window loaded, then cursor advanced",
    );
    crate::testmatrix::emit(
        crate::testmatrix::COMPONENT,
        "table_sink::cursor_after_ack",
        true,
        &format!(
            "{} advanced to {} after {} label(s)",
            window.table,
            ack.to_snapshot,
            ack.labels.len()
        ),
    );
    Ok(ack)
}

// ── the full-reload refusal ─────────────────────────────────────────────────

/// **The pre-check.** Call this with [`skade::DeltaPlan::needs_full_reload`],
/// obtained from `read_delta`, *before* calling `read_changelog`.
///
/// Why the order matters, and it is not obvious: `read_changelog` handles a
/// full-reload window by re-reading the whole `to` snapshot as a pure `INSERT`
/// stream (skade `read.rs:3045-3062`). Checking the flag on the batch it returns
/// means the expensive read has already been paid for. `read_delta` returns
/// early with no rows and the flag set (skade `read.rs:2793`), so this check
/// costs a snapshot-lineage walk and nothing else.
///
/// It also catches a quieter hazard: a driver that used `read_delta` alone on
/// such a window would apply **zero rows** and believe it had succeeded. The
/// refusal is what turns that silence into an error.
///
/// Resolution is an explicit, operator-initiated one-shot reload. Never
/// automatic.
pub fn refuse_full_reload(table: &str, needs_full_reload: bool) -> Result<()> {
    if !needs_full_reload {
        return Ok(());
    }
    crate::testmatrix::emit(
        crate::testmatrix::COMPONENT,
        "table_sink::full_reload_refused",
        false,
        &format!("{table}: window needs a full reload — refused, cursor held"),
    );
    tracing::error!(
        target: "bifrost::sink",
        event = "full_reload_refused",
        table = %table,
        "window needs a full reload — refusing (cursor not advanced)",
    );
    Err(BifrostError::Other(format!(
        "{table}: this window needs a FULL RELOAD (an overwrite/replace commit or \
         a position-delete file defeats the incremental delta). Refusing: a silent \
         fallback would rewrite the whole table. Run an explicit one-shot reload, \
         then resume."
    )))
}

// ── the label ───────────────────────────────────────────────────────────────

/// Doris caps a load label at 128 characters. Other stores are more generous;
/// this is the tightest constraint the builder has to satisfy.
pub const MAX_LABEL_LEN: usize = 128;

/// Build the store-side load label for one chunk of one window.
///
/// ```text
/// skade-<db>:<table>-<from|init>-<to>-<chunk>
/// ```
///
/// **The character set is enforced by Doris, and it was measured, not guessed.**
/// Doris validates a load label against `^[\-_A-Za-z0-9:\p{L}]{1,128}$` and
/// rejects anything else with `[ANALYSIS_ERROR] ... Label format error`. A `.`
/// is **not** allowed — an earlier draft of this used `<db>.<table>` and every
/// load failed. Hence the `:`, which is in the permitted set.
///
/// **Derived, never generated.** A replay of the same window produces the same
/// label with no state kept anywhere, which is what makes the crash story a
/// one-liner: the store rejects the duplicate and the replay is a no-op.
///
/// **Collision-free by construction.** `-` is the structural separator and `:`
/// joins database to table, so neither is in the character set allowed inside a
/// name — every character
/// outside `[A-Za-z0-9_]` becomes `_`, including the `.` between database and
/// table. A label therefore always splits into exactly five `-`-separated
/// fields, and two different `(db, table, from, to, chunk)` tuples cannot render
/// the same string. (Allowing `-` through would let `("x", "y-1", 2, 3)` collide
/// with `("x", "y", 1, 2)`-chunk-3.)
///
/// When the name portion would push the label past [`MAX_LABEL_LEN`] it is
/// truncated **and** suffixed with a hash of the full name, so truncation cannot
/// reintroduce a collision. The snapshot and chunk fields are never truncated.
pub fn stream_load_label(
    db: &str,
    table: &str,
    from: Option<i64>,
    to: i64,
    chunk: usize,
) -> String {
    let mut tail = String::new();
    match from {
        Some(f) => write!(tail, "-{f}-{to}-{chunk}").expect("string write"),
        None => write!(tail, "-init-{to}-{chunk}").expect("string write"),
    }

    let full_name = format!("{}:{}", label_component(db), label_component(table));
    // "skade-" + name + tail
    let budget = MAX_LABEL_LEN.saturating_sub("skade-".len() + tail.len());
    let name = if full_name.len() <= budget {
        full_name
    } else {
        let digest = format!("_{:016x}", fnv1a64(full_name.as_bytes()));
        let keep = budget.saturating_sub(digest.len());
        let mut short: String = full_name.chars().take(keep).collect();
        short.push_str(&digest);
        short
    };
    format!("skade-{name}{tail}")
}

/// One name (a database or a table) rendered into the label's character set,
/// **injectively**.
///
/// Everything outside `[A-Za-z0-9_]` becomes `_`. `-` and `:` are excluded on
/// purpose: `-` is the label's field separator and `:` joins database to table
/// (see [`stream_load_label`]). Replacement
/// alone is *not* injective: `personer.2g` and `personer_2g` are different
/// tables that would render the same string, and a shared label between two
/// tables is a silently skipped load. So whenever a character actually had to be
/// replaced, a hash of the original is appended, which restores injectivity
/// while leaving well-behaved names (the overwhelming majority) untouched and
/// readable.
///
/// RED-when-broken: drop the suffix and `tests::labels_do_not_collide` fails on
/// exactly that pair — it did, which is why the suffix exists.
fn label_component(s: &str) -> String {
    if s.is_empty() {
        return "_e".into();
    }
    let mut changed = false;
    let sanitized: String = s
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '_' {
                c
            } else {
                changed = true;
                '_'
            }
        })
        .collect();
    if changed {
        format!("{sanitized}_{:08x}", fnv1a64(s.as_bytes()) as u32)
    } else {
        sanitized
    }
}

/// Doris validates every load label against this regex, and rejects anything
/// else with `[ANALYSIS_ERROR] ... Label format error`:
///
/// ```text
/// ^[\-_A-Za-z0-9:\p{L}]{1,128}$
/// ```
///
/// Checked here by hand rather than with a regex dependency. `\p{L}` is any
/// Unicode letter; [`stream_load_label`] only ever emits ASCII, so the check is
/// strictly wider than what the builder produces — which is the point: it
/// verifies the *builder*, not the regex.
pub fn is_valid_doris_label(label: &str) -> bool {
    !label.is_empty()
        && label.chars().count() <= MAX_LABEL_LEN
        && label.chars().all(|c| {
            c == '-' || c == '_' || c == ':' || c.is_ascii_alphanumeric() || c.is_alphabetic()
        })
}

/// FNV-1a, 64-bit. Used only to keep a truncated label collision-free; no
/// dependency, deterministic across runs and platforms.
fn fnv1a64(bytes: &[u8]) -> u64 {
    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
    for b in bytes {
        h ^= u64::from(*b);
        h = h.wrapping_mul(0x0000_0100_0000_01b3);
    }
    h
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashSet;
    use std::sync::{Arc, Mutex};

    use arrow_array::{Int64Array, StringArray};
    use arrow_schema::{DataType, Field};
    use std::sync::Arc as StdArc;

    fn batch(ids: Vec<i64>) -> RecordBatch {
        let n = ids.len();
        RecordBatch::try_new(
            StdArc::new(Schema::new(vec![
                Field::new("id", DataType::Int64, false),
                Field::new("name", DataType::Utf8, false),
            ])),
            vec![
                StdArc::new(Int64Array::from(ids)),
                StdArc::new(StringArray::from(
                    (0..n).map(|i| format!("n{i}")).collect::<Vec<_>>(),
                )),
            ],
        )
        .unwrap()
    }

    /// What a fake sink did, in order, so a test can assert *sequence* rather
    /// than trust a comment.
    #[derive(Debug, Clone, PartialEq, Eq)]
    enum Event {
        /// `apply` ran; carries the cursor value it observed **on disk** at that
        /// moment. `None` = never advanced.
        Applied(Option<i64>),
    }

    struct SpySink {
        log: Arc<Mutex<Vec<Event>>>,
        /// Path of the file-backed ledger, read from inside `apply`.
        state_path: std::path::PathBuf,
        fail: bool,
        /// Snapshot to ack; `None` = ack the window's own `to` (the honest case).
        ack_snapshot: Option<i64>,
    }

    #[async_trait]
    impl TableSink for SpySink {
        async fn probe(&mut self, _schema: &Schema) -> Result<()> {
            Ok(())
        }

        async fn apply(&mut self, window: &TableWindow<'_>, token: AckToken) -> Result<LoadAck> {
            // Read the DURABLE cursor from inside the load. If the cursor were
            // committed first, this would already show the new snapshot.
            let seen = std::fs::read_to_string(&self.state_path)
                .ok()
                .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
                .and_then(|v| {
                    v.get(window.table)
                        .and_then(|t| t.get("snapshot_id"))
                        .and_then(serde_json::Value::as_i64)
                });
            self.log.lock().unwrap().push(Event::Applied(seen));

            if self.fail {
                return Err(BifrostError::Other("sink refused".into()));
            }
            let to = self.ack_snapshot.unwrap_or(window.snapshots.to);
            Ok(token.ack(to, vec!["l0".into()]).with_counts(
                window.upsert_rows(),
                window.delete_rows(),
                0,
            ))
        }
    }

    fn spy(dir: &std::path::Path, fail: bool) -> (SpySink, Arc<Mutex<Vec<Event>>>) {
        let log = Arc::new(Mutex::new(Vec::new()));
        (
            SpySink {
                log: Arc::clone(&log),
                state_path: dir.join("state.json"),
                fail,
                ack_snapshot: None,
            },
            log,
        )
    }

    /// **The data-loss bug, asserted directly.** The sink observes the durable
    /// cursor from *inside* `apply`; it must still be at the OLD value. Then,
    /// after `deliver_window` returns, it must be at the new one.
    ///
    /// RED-when-broken: swap the two statements in `deliver_window` and the
    /// in-apply observation becomes `Some(1043)` — which is exactly the state a
    /// crash would make permanent while the rows never landed.
    #[tokio::test]
    async fn cursor_is_committed_strictly_after_the_load() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("state.json");
        let mut state = SyncState::open(&path).unwrap();
        let (mut sink, log) = spy(dir.path(), false);

        // Round 1: nothing delivered yet.
        let ups = [batch(vec![1, 2, 3])];
        let w = TableWindow {
            table: "bench.doris2g",
            snapshots: SnapshotWindow::new(Some(1042), 1043),
            upserts: &ups,
            deletes: &[],
            needs_full_reload: false,
            sequence: None,
            key_columns: &[],
        };
        let ack = deliver_window(&mut sink, &mut state, &w).await.unwrap();
        assert_eq!(ack.to_snapshot(), 1043);
        assert_eq!(ack.rows_loaded, 3);
        assert_eq!(
            log.lock().unwrap().as_slice(),
            &[Event::Applied(None)],
            "the load must run while the cursor is still unset"
        );
        assert_eq!(state.get("bench.doris2g").snapshot_id, Some(1043));

        // Round 2: the load must observe 1043, not 1044.
        let w2 = TableWindow {
            table: "bench.doris2g",
            snapshots: SnapshotWindow::new(Some(1043), 1044),
            upserts: &ups,
            deletes: &[],
            needs_full_reload: false,
            sequence: None,
            key_columns: &[],
        };
        deliver_window(&mut sink, &mut state, &w2).await.unwrap();
        assert_eq!(
            log.lock().unwrap()[1],
            Event::Applied(Some(1043)),
            "the load must run BEFORE the cursor moves to 1044"
        );
        assert_eq!(state.get("bench.doris2g").snapshot_id, Some(1044));

        crate::testmatrix::emit(
            crate::testmatrix::COMPONENT,
            "table_sink::cursor_strictly_after_load",
            state.get("bench.doris2g").snapshot_id == Some(1044),
            "apply observed the old cursor both rounds",
        );
    }

    /// A failing load must leave the cursor untouched, so the next round
    /// recomputes the identical window (and the identical labels).
    #[tokio::test]
    async fn a_failed_load_does_not_advance_the_cursor() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("state.json");
        let mut state = SyncState::open(&path).unwrap();
        let (mut sink, _log) = spy(dir.path(), true);

        let ups = [batch(vec![1])];
        let w = TableWindow {
            table: "t",
            snapshots: SnapshotWindow::new(Some(7), 8),
            upserts: &ups,
            deletes: &[],
            needs_full_reload: false,
            sequence: None,
            key_columns: &[],
        };
        assert!(deliver_window(&mut sink, &mut state, &w).await.is_err());
        assert_eq!(
            state.get("t").snapshot_id,
            None,
            "a refused load must not move the cursor"
        );
        crate::testmatrix::emit(
            crate::testmatrix::COMPONENT,
            "table_sink::failed_load_holds_cursor",
            state.get("t").snapshot_id.is_none(),
            "cursor unchanged after a failed apply",
        );
    }

    /// A sink that acks a snapshot other than the one it was asked to load would
    /// silently skip or re-do a window. Refuse it.
    #[tokio::test]
    async fn a_mismatched_ack_is_refused() {
        let dir = tempfile::tempdir().unwrap();
        let mut state = SyncState::open(dir.path().join("state.json")).unwrap();
        let (mut sink, _log) = spy(dir.path(), false);
        sink.ack_snapshot = Some(9999);

        let ups = [batch(vec![1])];
        let w = TableWindow {
            table: "t",
            snapshots: SnapshotWindow::new(Some(7), 8),
            upserts: &ups,
            deletes: &[],
            needs_full_reload: false,
            sequence: None,
            key_columns: &[],
        };
        let err = deliver_window(&mut sink, &mut state, &w)
            .await
            .expect_err("mismatched ack must be refused");
        assert!(
            err.to_string().contains("refusing to move the cursor"),
            "{err}"
        );
        assert_eq!(state.get("t").snapshot_id, None);
    }

    /// **`needs_full_reload` refuses rather than reloading.** The sink is never
    /// called, and the cursor does not move — so the next round sees the same
    /// window and refuses again, loudly, until a human intervenes.
    ///
    /// RED-when-broken: make `deliver_window` fall through and an overwrite
    /// commit silently rewrites the whole destination table.
    #[tokio::test]
    async fn a_full_reload_window_is_refused_and_never_loaded() {
        let dir = tempfile::tempdir().unwrap();
        let mut state = SyncState::open(dir.path().join("state.json")).unwrap();
        let (mut sink, log) = spy(dir.path(), false);

        let ups = [batch(vec![1, 2])];
        let w = TableWindow {
            table: "bench.doris2g",
            snapshots: SnapshotWindow::new(Some(1042), 1043),
            upserts: &ups,
            deletes: &[],
            needs_full_reload: true,
            sequence: None,
            key_columns: &[],
        };
        let err = deliver_window(&mut sink, &mut state, &w)
            .await
            .expect_err("a full-reload window must be refused");
        assert!(err.to_string().contains("FULL RELOAD"), "{err}");
        assert!(
            log.lock().unwrap().is_empty(),
            "the sink must not be called at all"
        );
        assert_eq!(
            state.get("bench.doris2g").snapshot_id,
            None,
            "cursor must not move on a refused window"
        );

        // The standalone pre-check, which is what a read path should call.
        assert!(refuse_full_reload("t", false).is_ok());
        assert!(refuse_full_reload("t", true).is_err());
    }

    /// Same window ⇒ same label, every time, with no state kept anywhere. This
    /// is the entire replay-safety mechanism.
    #[test]
    fn label_is_derived_and_stable() {
        let a = stream_load_label("fake", "personer2g", Some(1042), 1043, 0);
        let b = stream_load_label("fake", "personer2g", Some(1042), 1043, 0);
        assert_eq!(a, b);
        assert_eq!(a, "skade-fake:personer2g-1042-1043-0");
        assert_eq!(
            stream_load_label("fake", "personer2g", None, 1043, 0),
            "skade-fake:personer2g-init-1043-0"
        );
    }

    /// **Collision-free across snapshot pairs and names.** `-` is the field
    /// separator and is therefore banned inside the name, which is what stops
    /// `("x", "y-1", 2, 3, 0)` from rendering the same string as
    /// `("x", "y", 1, 2, 3)`.
    ///
    /// RED-when-broken: let `-` through `sanitize_name` and this fails.
    #[test]
    fn labels_do_not_collide() {
        let mut seen = HashSet::new();
        let names = [
            ("fake", "personer2g"),
            ("fake", "personer2g-1"),
            ("fake-1", "personer2g"),
            ("fake", "personer.2g"),
            ("fake", "personer_2g"),
            ("f", "a"),
        ];
        let mut n = 0;
        for (db, t) in names {
            for from in [None, Some(0), Some(1), Some(2), Some(1042)] {
                for to in [1_i64, 2, 3, 1043] {
                    for chunk in 0..4 {
                        let l = stream_load_label(db, t, from, to, chunk);
                        assert!(
                            l.len() <= MAX_LABEL_LEN,
                            "label over {MAX_LABEL_LEN} chars: {l}"
                        );
                        assert!(
                            seen.insert(l.clone()),
                            "label collision on ({db}, {t}, {from:?}, {to}, {chunk}): {l}"
                        );
                        n += 1;
                    }
                }
            }
        }
        crate::testmatrix::emit(
            crate::testmatrix::COMPONENT,
            "table_sink::labels_collision_free",
            seen.len() == n,
            &format!("{n} distinct windows ⇒ {} distinct labels", seen.len()),
        );
    }

    /// **Every generated label must satisfy Doris's own regex.** Measured
    /// against the live cluster: a `.` in the label is rejected outright with
    /// `[ANALYSIS_ERROR] ... Label format error. regex:
    /// ^[\-_A-Za-z0-9:\p{L}]{1,128}$`, which is why database and table are
    /// joined with `:` and every other character is folded to `_`.
    ///
    /// RED-when-broken: put the `.` back and this fails on the first name that
    /// contains one.
    #[test]
    fn every_label_satisfies_the_doris_regex() {
        let nasty = [
            ("fake", "personer2g"),
            ("fake", "personer.2g"),
            ("my db", "tab/le"),
            ("db", "t\u{e4}bell"),
            ("db", "\u{4eba}"),
            ("db", &*"x".repeat(400)),
            ("", ""),
        ];
        let mut n = 0;
        for (db, t) in nasty {
            for from in [None, Some(1_042)] {
                for chunk in [0usize, 17] {
                    let l = stream_load_label(db, t, from, 1_043, chunk);
                    assert!(
                        is_valid_doris_label(&l),
                        "label rejected by Doris's regex: {l:?} (from {db:?}/{t:?})"
                    );
                    assert!(!l.contains('.'), "a dot is invalid in a Doris label: {l}");
                    assert!(l.len() <= MAX_LABEL_LEN, "over 128 chars: {l}");
                    n += 1;
                }
            }
        }
        crate::testmatrix::emit(
            crate::testmatrix::COMPONENT,
            "table_sink::label_matches_doris_regex",
            true,
            &format!("{n} labels, all matching ^[-_A-Za-z0-9:\\p{{L}}]{{1,128}}$"),
        );
    }

    /// A name long enough to need truncation must stay unique — the hash
    /// suffix, not the prefix, is what guarantees that.
    #[test]
    fn truncated_labels_stay_unique_and_in_bounds() {
        let long_a = "a".repeat(400);
        let long_b = format!("{}b", "a".repeat(399));
        let la = stream_load_label("db", &long_a, Some(1), 2, 0);
        let lb = stream_load_label("db", &long_b, Some(1), 2, 0);
        assert!(la.len() <= MAX_LABEL_LEN && lb.len() <= MAX_LABEL_LEN);
        assert_ne!(la, lb, "truncation must not reintroduce a collision");
        // The snapshot fields survive truncation intact.
        assert!(la.ends_with("-1-2-0"), "{la}");
    }
}