Skip to content

Commit 243960d

Browse files
g-talbotclaude
andauthored
feat: wire shared invariant functions into all production check_invariant! calls (#6344)
* fix: SS-2 invariant — nulls always sort last, regardless of direction The SS-2 invariant incorrectly specified that nulls sort before non-null for descending columns. The actual production code uses nulls_first=false everywhere — nulls always sort after non-null values, regardless of direction. This is required for sorted_series key correctness: null columns are omitted from the key, and their keys naturally sort after keys that include those columns. Fixed in all locations: - TLA+ SortSchema.tla: CompareValues and SS2_NullOrdering - Rust invariants/sort.rs: compare_with_null_ordering - Rust models/sort_schema.rs: SS-2 property check and compare_values test - Rust invariants/registry.rs: SS-2 description - ADR-002: SS-2 invariant table - Phase 1 design doc: null handling descriptions Stateright exhaustive_sort_schema BFS passes with corrected model. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: verify model detects null ordering violations Added two tests to the Stateright sort_schema model: - ss2_detects_null_before_non_null_in_descending: constructs rows with null before non-null in a descending column, verifies both row_leq and is_sorted reject this ordering (nulls always last). - ss2_accepts_non_null_before_null_in_descending: verifies the correct ordering (non-null then null) is accepted. These confirm the model can catch the exact class of bug that the SS-2 invariant fix addresses. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add SS-2 production invariant check using shared model function Adds verify_ss2_null_ordering to the writer's sort verification path. This calls quickwit_dst::invariants::sort::compare_with_null_ordering — the same function the Stateright model uses for exhaustive BFS verification — to confirm that: 1. The shared invariant function returns Greater for (null, non-null) regardless of sort direction (debug_assert at function entry) 2. No adjacent row pair in the sorted output has null before non-null in any sort column (when earlier columns are equal) This bridges the gap where SS-2 was verified by the model but never checked at runtime in production code. The check runs in debug builds (via check_invariant! / debug_assert!) and reports to the invariant recorder in all builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: use shared invariant functions for all TW-2 checks All TW-2 (window_duration divides 3600) checks now call the shared quickwit_dst::invariants::window::is_valid_window_duration instead of inlining the `3600 % duration_secs == 0` logic. This ensures production and model execute identical validation: - sort_fields/window.rs: validate_window_duration + window_start - storage/writer.rs: build_compaction_key_value_metadata - split/metadata.rs: ParquetSplitMetadataBuilder::build The shared functions used by production check_invariant! calls are now the same code the Stateright models use: - SS-2: compare_with_null_ordering (sort.rs) - TW-2: is_valid_window_duration (window.rs) - TW-1: window_start_secs (window.rs) — already shared Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9f49c8d commit 243960d

3 files changed

Lines changed: 117 additions & 4 deletions

File tree

quickwit/quickwit-parquet-engine/src/sort_fields/window.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ pub fn validate_window_duration(duration_secs: u32) -> Result<(), SortFieldsErro
4848
reason: "must be positive",
4949
});
5050
}
51-
if 3600 % duration_secs != 0 {
51+
if !quickwit_dst::invariants::window::is_valid_window_duration(duration_secs) {
5252
return Err(SortFieldsError::InvalidWindowDuration {
5353
duration_secs,
5454
reason: "must evenly divide 3600 (one hour)",
@@ -87,7 +87,8 @@ pub fn window_start(
8787
);
8888
check_invariant!(
8989
InvariantId::TW2,
90-
3600 % duration_secs == 0,
90+
duration_secs > 0
91+
&& quickwit_dst::invariants::window::is_valid_window_duration(duration_secs as u32),
9192
": duration_secs={} does not divide 3600",
9293
duration_secs
9394
);

quickwit/quickwit-parquet-engine/src/split/metadata.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -532,7 +532,10 @@ impl ParquetSplitMetadataBuilder {
532532
// Enforced at build time so no invalid metadata propagates to storage.
533533
quickwit_dst::check_invariant!(
534534
quickwit_dst::invariants::InvariantId::TW2,
535-
self.window_duration_secs == 0 || 3600 % self.window_duration_secs == 0,
535+
self.window_duration_secs == 0
536+
|| quickwit_dst::invariants::window::is_valid_window_duration(
537+
self.window_duration_secs
538+
),
536539
": window_duration_secs={} does not divide 3600",
537540
self.window_duration_secs
538541
);

quickwit/quickwit-parquet-engine/src/storage/writer.rs

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,10 @@ pub(crate) fn build_compaction_key_value_metadata(
6969
// but belt-and-suspenders at the serialization boundary).
7070
quickwit_dst::check_invariant!(
7171
quickwit_dst::invariants::InvariantId::TW2,
72-
metadata.window_duration_secs() == 0 || 3600 % metadata.window_duration_secs() == 0,
72+
metadata.window_duration_secs() == 0
73+
|| quickwit_dst::invariants::window::is_valid_window_duration(
74+
metadata.window_duration_secs()
75+
),
7376
" at Parquet write: window_duration_secs={} does not divide 3600",
7477
metadata.window_duration_secs()
7578
);
@@ -308,6 +311,17 @@ impl ParquetWriter {
308311
i
309312
);
310313
}
314+
315+
// SS-2: verify nulls always sort after non-null values.
316+
// Uses the shared compare_with_null_ordering from
317+
// quickwit_dst::invariants::sort — the same function the
318+
// Stateright model uses for exhaustive verification.
319+
// For each adjacent row pair and each sort column, when
320+
// earlier columns are equal, the comparison must not
321+
// yield Greater (which would mean null came before non-null).
322+
if sorted_batch.num_rows() > 1 {
323+
verify_ss2_null_ordering(&sorted_batch, &self.resolved_sort_fields);
324+
}
311325
}
312326
}
313327

@@ -552,6 +566,101 @@ fn resolve_sort_fields(sort_fields_str: &str) -> Result<Vec<ResolvedSortField>,
552566
.collect())
553567
}
554568

569+
/// SS-2: Verify that nulls always sort after non-null values in the sorted batch.
570+
///
571+
/// Uses [`quickwit_dst::invariants::sort::compare_with_null_ordering`] — the
572+
/// same function the Stateright model (`models::sort_schema`) uses for
573+
/// exhaustive verification. This ensures production and model execute
574+
/// identical null-ordering logic.
575+
///
576+
/// For each pair of adjacent rows and each sort column, when all earlier
577+
/// sort columns have equal null/non-null status at both rows, we call
578+
/// `compare_with_null_ordering(None, Some(&()), ascending)` to ask the
579+
/// shared invariant: "does null come before non-null?" If the answer is
580+
/// Greater (null should sort after), but we see null at row i and non-null
581+
/// at row i+1, that's a violation.
582+
fn verify_ss2_null_ordering(batch: &RecordBatch, sort_fields: &[ResolvedSortField]) {
583+
let schema = batch.schema();
584+
let num_rows = batch.num_rows();
585+
if num_rows <= 1 {
586+
return;
587+
}
588+
589+
// Resolve sort column indices.
590+
let sort_cols: Vec<(usize, &str, bool)> = sort_fields
591+
.iter()
592+
.filter_map(|sf| {
593+
schema
594+
.index_of(sf.name.as_str())
595+
.ok()
596+
.map(|idx| (idx, sf.name.as_str(), !sf.descending))
597+
})
598+
.collect();
599+
600+
// Ask the shared invariant function: does null sort after non-null?
601+
// This must return Greater (null > non-null) for SS-2 to hold.
602+
// We check once and use the result — if the shared function is wrong,
603+
// this test and the Stateright model will both fail.
604+
let null_vs_nonnull = quickwit_dst::invariants::sort::compare_with_null_ordering(
605+
None::<&u8>,
606+
Some(&0u8),
607+
true, // ascending — but SS-2 says direction doesn't matter
608+
);
609+
debug_assert_eq!(
610+
null_vs_nonnull,
611+
std::cmp::Ordering::Greater,
612+
"SS-2: compare_with_null_ordering must return Greater for (null, non-null)"
613+
);
614+
615+
// Also verify for descending — the shared function must give the same answer.
616+
let null_vs_nonnull_desc = quickwit_dst::invariants::sort::compare_with_null_ordering(
617+
None::<&u8>,
618+
Some(&0u8),
619+
false, // descending
620+
);
621+
debug_assert_eq!(
622+
null_vs_nonnull_desc,
623+
std::cmp::Ordering::Greater,
624+
"SS-2: compare_with_null_ordering must return Greater for (null, non-null) in descending \
625+
too"
626+
);
627+
628+
// Now check the actual data: for each adjacent row pair, null must not
629+
// appear before non-null when earlier columns are equal.
630+
for i in 0..num_rows - 1 {
631+
for (k, &(col_idx, col_name, _ascending)) in sort_cols.iter().enumerate() {
632+
let col = batch.column(col_idx);
633+
634+
// Only check when earlier columns are equal at rows i and i+1.
635+
// Two values are equal if both null, or both non-null with the
636+
// same scalar value (checked via single-element slice comparison).
637+
let earlier_equal = sort_cols[..k].iter().all(|&(earlier_idx, _, _)| {
638+
let c = batch.column(earlier_idx);
639+
let a_null = c.is_null(i);
640+
let b_null = c.is_null(i + 1);
641+
if a_null != b_null {
642+
return false;
643+
}
644+
if a_null {
645+
return true; // both null
646+
}
647+
// Both non-null: compare single-element slices.
648+
c.slice(i, 1) == c.slice(i + 1, 1)
649+
});
650+
651+
if earlier_equal && col.is_null(i) && !col.is_null(i + 1) {
652+
quickwit_dst::check_invariant!(
653+
quickwit_dst::invariants::InvariantId::SS2,
654+
false,
655+
": null before non-null in column '{}' at row {}",
656+
col_name,
657+
i
658+
);
659+
}
660+
}
661+
}
662+
}
663+
555664
#[cfg(test)]
556665
mod tests {
557666
use std::sync::Arc;

0 commit comments

Comments
 (0)