Skip to content

Commit 0d36eb5

Browse files
committed
feat(log_segment): add CrcReplayAccumulator for stale-CRC reverse replay
Adds LogSegment::build_crc_delta_from_stale, which reverse-replays plain commit files in (base_version, end_version] and produces a CrcDelta that Crc::apply consumes to advance the base CRC to the snapshot version. Foundation for PR5's Snapshot wiring. Includes prefactor-marked changes to CrcDelta (HashMap-keyed DM/txn, single is_incremental_safe flag, flat Option<i64> ICT) that will be split into a separate pre-factor PR before merge; see PREFACTOR-PR: markers.
1 parent c30a677 commit 0d36eb5

4 files changed

Lines changed: 955 additions & 122 deletions

File tree

kernel/src/crc/delta.rs

Lines changed: 89 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
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

1617
use tracing::warn;
1718

@@ -21,28 +22,41 @@ use super::{
2122
};
2223
use 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)]
2739
pub(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

4862
impl 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 {
100109
impl 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

Comments
 (0)