11//! Incremental CRC state updates via commit deltas.
22//!
3- //! A [`CrcDelta`] captures CRC-relevant changes from a single commit (produced by reading a
4- //! `.json` commit file during log replay, or from in-memory transaction state during writes).
5- //! [`Crc::apply`] advances a CRC forward one commit at a time by applying a delta .
3+ //! The universal invariant: `Crc[X] + CrcDelta = Crc[Y]`. Two producers build deltas:
4+ //! the single- commit forward producer (`Transaction::build_crc_delta`) and the multi-commit
5+ //! reverse producer (`LogSegment::build_crc_delta_from_stale`). [`Crc::apply`] is the consumer .
66//!
77//! A CRC tracks two categories of fields, updated differently:
88//! - **Metadata fields** (protocol, metadata, domain metadata, set transactions, in-commit
9- //! timestamp): always kept up-to-date -- every `apply` unconditionally merges these from the
10- //! delta.
11- //! - **File stats** ([`FileStatsState`]): only updated when the state is `Complete` and the
12- //! commit's operation is incremental-safe. Once the state degrades (e.g. a non-incremental
13- //! operation like ANALYZE STATS, or a missing file size), file stats stop updating for the
14- //! lifetime of that CRC.
9+ //! timestamp): merged from the delta. Protocol/metadata replace when the delta carries them;
10+ //! DM/txn upsert by key; ICT unconditionally replaces the base (whether `Some` or `None`).
11+ //! - **File stats** ([`FileStatsState`]): only updated when the state is `Complete` and
12+ //! `delta.is_incremental_safe`. Once the state degrades (e.g. a non-incremental operation like
13+ //! ANALYZE STATS, or a missing file size), file stats stop updating for the lifetime of that CRC.
14+
15+ use std:: collections:: HashMap ;
1516
1617use tracing:: warn;
1718
@@ -21,28 +22,41 @@ use super::{
2122} ;
2223use crate :: actions:: { DomainMetadata , Metadata , Protocol , SetTransaction } ;
2324
24- /// The CRC-relevant changes ("delta") from a single commit. Produced either by reading a
25- /// `.json` commit file during log replay, or from in-memory transaction state during writes.
25+ // PREFACTOR-PR: this struct's switch from `Vec<DomainMetadata>` /
26+ // `Vec<SetTransaction>` (named `*_changes`) to `HashMap<String, _>` keyed by
27+ // domain / app_id should land as a pre-factor PR before the
28+ // `LogSegment::build_crc_delta_from_stale` work. The HashMap shape:
29+ // 1. enforces "at most one entry per key" structurally (Vec only hopes),
30+ // 2. matches how both producers naturally accumulate (forward txn + reverse replay),
31+ // 3. lets `Crc::apply` consume directly without per-key bookkeeping.
32+ // The pre-factor PR also touches `Transaction::build_crc_delta` (boundary conversion
33+ // at the call site goes away once `generate_domain_metadata_actions` and the
34+ // committer chain produce HashMap-shaped data) and `Crc::apply` iteration.
35+
36+ /// Delta between two CRC versions: `Crc[X] + CrcDelta = Crc[Y]`. See the [module docs](self)
37+ /// for producers and consumer.
2638#[ derive( Debug , Clone , Default ) ]
2739pub ( crate ) struct CrcDelta {
2840 /// Net file count, size changes and histograms.
2941 pub ( crate ) file_stats : FileStatsDelta ,
30- /// New protocol action, if this commit changed it .
42+ /// Newest protocol observed in the covered range, if any .
3143 pub ( crate ) protocol : Option < Protocol > ,
32- /// New metadata action, if this commit changed it .
44+ /// Newest metadata observed in the covered range, if any .
3345 pub ( crate ) metadata : Option < Metadata > ,
34- /// All DM actions in this commit, including tombstones (`removed=true`).
35- pub ( crate ) domain_metadata_changes : Vec < DomainMetadata > ,
36- /// All [`SetTransaction`] actions in this commit.
37- pub ( crate ) set_transaction_changes : Vec < SetTransaction > ,
38- /// In-commit timestamp, if present in this commit.
46+ /// DomainMetadata actions keyed by domain, including tombstones (`removed=true`).
47+ /// At most one entry per domain (latest action wins). Tombstones are required:
48+ /// [`Crc::apply`] reads `is_removed()` to decide between upsert and removal.
49+ pub ( crate ) domain_metadata : HashMap < String , DomainMetadata > ,
50+ /// SetTransaction actions keyed by app_id. At most one entry per app_id (latest wins).
51+ pub ( crate ) set_transactions : HashMap < String , SetTransaction > ,
52+ /// In-commit timestamp at the end version. Replaces the base's ICT unconditionally
53+ /// (whether `Some` or `None`).
3954 pub ( crate ) in_commit_timestamp : Option < i64 > ,
40- /// Must be `Some` with an incremental-safe value for file stats to update. `None` or
41- /// unrecognized values transition the [`FileStatsState`] to `Indeterminate`.
42- pub ( crate ) operation : Option < String > ,
43- /// A file action in this commit had a missing `size` field, making byte-level file stats
44- /// impossible to compute.
45- pub ( crate ) has_missing_file_size : bool ,
55+ /// Whether the file-stats portion of this delta can be applied incrementally. False
56+ /// when the covered range contains a non-incremental operation (e.g. ANALYZE STATS),
57+ /// a commit with file actions but no commitInfo, or a remove with a missing `size`
58+ /// field. Triggers a transition to `Indeterminate` via [`Crc::apply`].
59+ pub ( crate ) is_incremental_safe : bool ,
4660}
4761
4862impl CrcDelta {
@@ -54,21 +68,16 @@ impl CrcDelta {
5468 let protocol = self . protocol ?;
5569 let metadata = self . metadata ?;
5670 // For CREATE TABLE we know the full domain metadata state: the transaction either
57- // included domain metadata actions or it didn't. Always Complete.
71+ // included domain metadata actions or it didn't. Always Complete. Drop tombstones
72+ // (a fresh table has no domains to remove).
5873 let domain_metadata_state = DomainMetadataState :: Complete (
59- self . domain_metadata_changes
74+ self . domain_metadata
6075 . into_iter ( )
61- . filter ( |dm| !dm. is_removed ( ) )
62- . map ( |dm| ( dm. domain ( ) . to_string ( ) , dm) )
76+ . filter ( |( _, dm) | !dm. is_removed ( ) )
6377 . collect ( ) ,
6478 ) ;
6579 // CREATE TABLE starts with a known-complete set of transactions (possibly empty).
66- let set_transaction_state = SetTransactionState :: Complete (
67- self . set_transaction_changes
68- . into_iter ( )
69- . map ( |txn| ( txn. app_id . clone ( ) , txn) )
70- . collect ( ) ,
71- ) ;
80+ let set_transaction_state = SetTransactionState :: Complete ( self . set_transactions ) ;
7281 // For version zero the delta IS the full table histogram. Validate that all bins
7382 // are non-negative (a real table can't have negative file counts). If validation
7483 // fails, drop the histogram.
@@ -100,7 +109,7 @@ impl CrcDelta {
100109impl Crc {
101110 /// Apply a commit delta.
102111 /// - Protocol / metadata: replaced when present in the delta, kept otherwise.
103- /// - ICT: unconditional replace (None correctly clears a previously-enabled value ).
112+ /// - ICT: unconditional replace (whether `Some` or `None` ).
104113 /// - Domain metadata / set transactions: upserted by key into the existing map; the
105114 /// `Complete`/`Partial` variant is preserved.
106115 /// - File stats: governed by the [`FileStatsState`] state machine.
@@ -119,11 +128,11 @@ impl Crc {
119128 let map = match & mut self . domain_metadata_state {
120129 DomainMetadataState :: Complete ( m) | DomainMetadataState :: Partial ( m) => m,
121130 } ;
122- for dm in delta. domain_metadata_changes {
131+ for ( domain , dm ) in delta. domain_metadata {
123132 if dm. is_removed ( ) {
124- map. remove ( dm . domain ( ) ) ;
133+ map. remove ( & domain) ;
125134 } else {
126- map. insert ( dm . domain ( ) . to_string ( ) , dm) ;
135+ map. insert ( domain, dm) ;
127136 }
128137 }
129138
@@ -133,27 +142,15 @@ impl Crc {
133142 let map = match & mut self . set_transaction_state {
134143 SetTransactionState :: Complete ( m) | SetTransactionState :: Partial ( m) => m,
135144 } ;
136- map. extend (
137- delta
138- . set_transaction_changes
139- . into_iter ( )
140- . map ( |txn| ( txn. app_id . clone ( ) , txn) ) ,
141- ) ;
145+ map. extend ( delta. set_transactions ) ;
142146
143- // In-commit timestamp: unconditional replace (not guarded by `if let Some`).
144- // If ICT was disabled after being enabled, the delta carries None, which correctly
145- // clears the previous value.
147+ // In-commit timestamp: unconditional replace. `None` is a legal value (ICT disabled).
146148 self . in_commit_timestamp_opt = delta. in_commit_timestamp ;
147149
148- let is_incremental_safe = delta
149- . operation
150- . as_deref ( )
151- . is_some_and ( FileStatsDelta :: is_incremental_safe) ;
152150 self . file_stats_state = transition_file_stats (
153151 & self . file_stats_state ,
154152 & delta. file_stats ,
155- is_incremental_safe,
156- delta. has_missing_file_size ,
153+ delta. is_incremental_safe ,
157154 ) ;
158155 }
159156}
@@ -165,15 +162,14 @@ fn transition_file_stats(
165162 current : & FileStatsState ,
166163 delta : & FileStatsDelta ,
167164 is_incremental_safe : bool ,
168- has_missing_file_size : bool ,
169165) -> FileStatsState {
170166 match current {
171167 // Indeterminate is terminal in incremental replay. A future full add/remove dedup
172168 // pass could recover Complete.
173169 FileStatsState :: Indeterminate => FileStatsState :: Indeterminate ,
174- // Either a non-incremental op (e.g. ANALYZE STATS) or a missing remove.size makes
175- // incremental tracking impossible: a full add/remove reconciliation can recover.
176- _ if !is_incremental_safe || has_missing_file_size => FileStatsState :: Indeterminate ,
170+ // A non-incremental signal (unsafe op, missing commitInfo, or missing remove.size)
171+ // makes incremental tracking impossible; a full reconciliation can recover.
172+ _ if !is_incremental_safe => FileStatsState :: Indeterminate ,
177173 FileStatsState :: Complete ( stats) => FileStatsState :: Complete ( FileStats {
178174 // Counts and bytes have no non-negative check.
179175 num_files : stats. num_files + delta. net_files ,
@@ -247,7 +243,7 @@ mod tests {
247243 net_bytes,
248244 ..Default :: default ( )
249245 } ,
250- operation : Some ( "WRITE" . to_string ( ) ) ,
246+ is_incremental_safe : true ,
251247 ..Default :: default ( )
252248 }
253249 }
@@ -317,32 +313,21 @@ mod tests {
317313 }
318314
319315 #[ test]
320- fn test_apply_unsafe_op_transitions_to_indeterminate ( ) {
316+ fn test_apply_not_incremental_safe_transitions_to_indeterminate ( ) {
321317 let mut crc = base_crc ( ) ;
322318 let unsafe_change = CrcDelta {
323- operation : Some ( "ANALYZE STATS" . to_string ( ) ) ,
319+ is_incremental_safe : false ,
324320 ..write_delta ( 1 , 100 )
325321 } ;
326322 crc. apply ( unsafe_change) ;
327323 assert ! ( crc. file_stats_state. is_indeterminate( ) ) ;
328324 }
329325
330- #[ test]
331- fn test_apply_none_op_transitions_to_indeterminate ( ) {
332- let mut crc = base_crc ( ) ;
333- let unknown_delta = CrcDelta {
334- operation : None ,
335- ..write_delta ( 1 , 100 )
336- } ;
337- crc. apply ( unknown_delta) ;
338- assert ! ( crc. file_stats_state. is_indeterminate( ) ) ;
339- }
340-
341326 #[ test]
342327 fn test_indeterminate_stays_indeterminate ( ) {
343328 let mut crc = base_crc ( ) ;
344329 let unsafe_change = CrcDelta {
345- operation : Some ( "ANALYZE STATS" . to_string ( ) ) ,
330+ is_incremental_safe : false ,
346331 ..write_delta ( 1 , 100 )
347332 } ;
348333 crc. apply ( unsafe_change) ;
@@ -353,19 +338,6 @@ mod tests {
353338 assert ! ( crc. file_stats_state. is_indeterminate( ) ) ;
354339 }
355340
356- // ===== apply: missing-file-size tests =====
357-
358- #[ test]
359- fn test_missing_file_size_transitions_to_indeterminate ( ) {
360- let mut crc = base_crc ( ) ;
361- let delta = CrcDelta {
362- has_missing_file_size : true ,
363- ..write_delta ( 1 , 100 )
364- } ;
365- crc. apply ( delta) ;
366- assert ! ( crc. file_stats_state. is_indeterminate( ) ) ;
367- }
368-
369341 // ===== apply: non-file-stats field updates =====
370342
371343 #[ test]
@@ -398,11 +370,20 @@ mod tests {
398370 let mut crc = base_crc ( ) ;
399371 crc. domain_metadata_state = base;
400372 let delta = CrcDelta {
401- domain_metadata_changes : vec ! [
402- DomainMetadata :: new( "keep" . to_string( ) , "new" . to_string( ) ) ,
403- DomainMetadata :: new( "add" . to_string( ) , "y" . to_string( ) ) ,
404- DomainMetadata :: remove( "drop" . to_string( ) , "x" . to_string( ) ) ,
405- ] ,
373+ domain_metadata : HashMap :: from ( [
374+ (
375+ "keep" . to_string ( ) ,
376+ DomainMetadata :: new ( "keep" . to_string ( ) , "new" . to_string ( ) ) ,
377+ ) ,
378+ (
379+ "add" . to_string ( ) ,
380+ DomainMetadata :: new ( "add" . to_string ( ) , "y" . to_string ( ) ) ,
381+ ) ,
382+ (
383+ "drop" . to_string ( ) ,
384+ DomainMetadata :: remove ( "drop" . to_string ( ) , "x" . to_string ( ) ) ,
385+ ) ,
386+ ] ) ,
406387 ..write_delta ( 0 , 0 )
407388 } ;
408389 crc. apply ( delta) ;
@@ -431,11 +412,9 @@ mod tests {
431412 }
432413
433414 #[ test]
434- fn test_apply_clears_in_commit_timestamp_when_ict_disabled ( ) {
415+ fn test_apply_clears_in_commit_timestamp_when_delta_is_none ( ) {
435416 let mut crc = base_crc ( ) ;
436417 crc. in_commit_timestamp_opt = Some ( 1000 ) ;
437-
438- // Delta without ICT (e.g. ICT was disabled) clears the previous value.
439418 let delta = CrcDelta {
440419 in_commit_timestamp : None ,
441420 ..write_delta ( 0 , 0 )
@@ -507,7 +486,7 @@ mod tests {
507486 let delta = CrcDelta {
508487 protocol : Some ( test_protocol ( ) ) ,
509488 metadata : Some ( Metadata :: default ( ) ) ,
510- domain_metadata_changes : vec ! [ dm ] ,
489+ domain_metadata : HashMap :: from ( [ ( "my.domain" . to_string ( ) , dm ) ] ) ,
511490 ..write_delta ( 0 , 0 )
512491 } ;
513492 let crc = delta. into_crc_for_version_zero ( ) . unwrap ( ) ;
@@ -548,10 +527,16 @@ mod tests {
548527 let mut crc = base_crc ( ) ;
549528 crc. set_transaction_state = base;
550529 let delta = CrcDelta {
551- set_transaction_changes : vec ! [
552- SetTransaction :: new( "existing" . to_string( ) , 2 , Some ( 2000 ) ) ,
553- SetTransaction :: new( "new" . to_string( ) , 1 , Some ( 1500 ) ) ,
554- ] ,
530+ set_transactions : HashMap :: from ( [
531+ (
532+ "existing" . to_string ( ) ,
533+ SetTransaction :: new ( "existing" . to_string ( ) , 2 , Some ( 2000 ) ) ,
534+ ) ,
535+ (
536+ "new" . to_string ( ) ,
537+ SetTransaction :: new ( "new" . to_string ( ) , 1 , Some ( 1500 ) ) ,
538+ ) ,
539+ ] ) ,
555540 ..write_delta ( 0 , 0 )
556541 } ;
557542 crc. apply ( delta) ;
@@ -575,7 +560,7 @@ mod tests {
575560 let delta = CrcDelta {
576561 protocol : Some ( test_protocol ( ) ) ,
577562 metadata : Some ( Metadata :: default ( ) ) ,
578- set_transaction_changes : vec ! [ txn] ,
563+ set_transactions : HashMap :: from ( [ ( "my-app" . to_string ( ) , txn) ] ) ,
579564 ..write_delta ( 0 , 0 )
580565 } ;
581566 let crc = delta. into_crc_for_version_zero ( ) . unwrap ( ) ;
@@ -626,7 +611,7 @@ mod tests {
626611 net_bytes,
627612 net_histogram : Some ( hist) ,
628613 } ,
629- operation : Some ( "WRITE" . to_string ( ) ) ,
614+ is_incremental_safe : true ,
630615 ..Default :: default ( )
631616 }
632617 }
@@ -685,7 +670,7 @@ mod tests {
685670 net_bytes : 100 ,
686671 net_histogram : None ,
687672 } ,
688- operation : Some ( "WRITE" . to_string ( ) ) ,
673+ is_incremental_safe : true ,
689674 ..Default :: default ( )
690675 } ;
691676 crc. apply ( delta) ;
@@ -700,7 +685,7 @@ mod tests {
700685 fn apply_drops_histogram_on_indeterminate ( ) {
701686 let mut crc = base_crc_with_histogram ( & [ 100 , 200 ] ) ;
702687 let unsafe_delta = CrcDelta {
703- operation : Some ( "ANALYZE STATS" . to_string ( ) ) ,
688+ is_incremental_safe : false ,
704689 ..write_delta ( 1 , 100 )
705690 } ;
706691 crc. apply ( unsafe_delta) ;
@@ -709,20 +694,6 @@ mod tests {
709694 assert ! ( crc. file_stats( ) . is_none( ) ) ;
710695 }
711696
712- #[ test]
713- fn apply_remove_without_size_transitions_to_indeterminate ( ) {
714- // A remove action with missing size makes incremental tracking impossible;
715- // file_stats returns None because Indeterminate carries no data.
716- let mut crc = base_crc_with_histogram ( & [ 100 , 200 ] ) ;
717- let delta = CrcDelta {
718- has_missing_file_size : true ,
719- ..write_delta ( 1 , 100 )
720- } ;
721- crc. apply ( delta) ;
722- assert ! ( crc. file_stats_state. is_indeterminate( ) ) ;
723- assert ! ( crc. file_stats( ) . is_none( ) ) ;
724- }
725-
726697 #[ test]
727698 fn into_crc_for_version_zero_includes_histogram ( ) {
728699 let delta_hist = histogram_from_sizes ( & [ 500 , 1000 ] ) ;
@@ -734,7 +705,7 @@ mod tests {
734705 net_bytes : 1500 ,
735706 net_histogram : Some ( delta_hist) ,
736707 } ,
737- operation : Some ( "WRITE" . to_string ( ) ) ,
708+ is_incremental_safe : true ,
738709 ..Default :: default ( )
739710 } ;
740711 let crc = delta. into_crc_for_version_zero ( ) . unwrap ( ) ;
@@ -789,7 +760,7 @@ mod tests {
789760 net_bytes : 1450 , // (100 + 1500) - 150
790761 net_histogram : Some ( delta_hist) ,
791762 } ,
792- operation : Some ( "WRITE" . to_string ( ) ) ,
763+ is_incremental_safe : true ,
793764 ..Default :: default ( )
794765 } ;
795766
0 commit comments