Skip to content

Commit 9f49c8d

Browse files
g-talbotclaude
andauthored
fix: SS-2 invariant — nulls always sort last, regardless of direction (#6343)
* 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> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3ebae79 commit 9f49c8d

6 files changed

Lines changed: 93 additions & 62 deletions

File tree

docs/internals/adr/002-sort-schema-parquet-splits.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ These invariants must hold across all code paths (ingestion, compaction, query).
119119
| ID | Invariant | Rationale |
120120
|----|-----------|-----------|
121121
| **SS-1** | All rows within a split are sorted according to the sort schema recorded in that split's metadata | Foundation for page-level pruning and sorted merge. Violated data produces incorrect merge results |
122-
| **SS-2** | Nulls sort after non-null values for ascending columns and before non-null values for descending columns | Consistent null ordering across ingestion and merge. Matches Husky convention |
122+
| **SS-2** | Nulls always sort after non-null values, regardless of sort direction (nulls last) | Consistent null ordering across ingestion and merge. Enables nulls to be implicit in sorted_series key encoding |
123123
| **SS-3** | If a sort column is missing from a split, all rows in that split are treated as null for that column. This is not an error | Enables schema evolution — columns can be added to the sort schema without rewriting existing splits |
124124
| **SS-4** | The sort schema stored in a split's metadata is the schema that was in effect when that split was written. Already-written splits are never re-sorted | Changes propagate forward only. Old splits age out via retention |
125125
| **SS-5** | The sort schema string is the same in the metastore (per-split metadata), the Parquet `key_value_metadata`, and the Parquet `sorting_columns` field for a given split | Three representations of the same truth. Inconsistency between them would cause incorrect merge or pruning behavior |

docs/internals/locality-compaction/phase-1-sorted-splits.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ Each column in the schema has:
8585
- **String/binary:** Sorted lexicographically by byte value.
8686
- **Integer types** (i8, i16, i32, i64, u8, u16, u32, u64): Sorted numerically.
8787
- **Float types** (f32, f64): Sorted numerically, with NaN handling matching IEEE 754 total order (NaN sorts after all other values).
88-
- **Null handling:** Null values sort **after** all non-null values for ascending columns, and **before** all non-null values for descending columns. This matches Husky's behavior and ensures nulls cluster at the end of each column's range.
88+
- **Null handling:** Null values always sort **after** all non-null values, regardless of sort direction (`nulls_first: false`). This enables nulls to be implicit in the sorted_series key encoding — absent columns are simply omitted from the key, and their keys naturally sort after keys that include those columns.
8989

9090
### Timeseries ID (Optional Locality Tiebreaker)
9191

@@ -286,7 +286,7 @@ The Parquet reader always reads the file footer first \-- the footer contains th
286286

287287
Input splits may not have identical column sets. Schema evolution (adding or removing columns over time) means that splits from different time periods may have different columns. The merge handles this as follows:
288288

289-
- **Sort columns.** If a sort column is missing from an input split, all rows from that split are treated as having null values for that column. Nulls sort after non-null values (ascending) or before non-null values (descending), as specified in the sort schema. The k-way merge handles this naturally.
289+
- **Sort columns.** If a sort column is missing from an input split, all rows from that split are treated as having null values for that column. Nulls always sort after non-null values (`nulls_first: false`), regardless of direction. The k-way merge handles this naturally.
290290

291291
- **Non-sort columns.** The merge computes the **union** of all column names across all input splits. The output split contains every column that appears in at least one input. When streaming a column through the merge, rows originating from inputs that lack that column are filled with nulls. The output Parquet schema uses the type from whichever input(s) contain the column; if multiple inputs have the same column name with different types, the merge fails with an error (this indicates a schema evolution conflict that must be resolved at the index configuration level).
292292

docs/internals/specs/tla/SortSchema.tla

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
\*
1212
\* Key Invariants (from ADR-002):
1313
\* - SS-1: All rows within a split are sorted according to its recorded sort schema
14-
\* - SS-2: Nulls sort after non-null for ascending, before non-null for descending
14+
\* - SS-2: Nulls always sort after non-null values (nulls last, regardless of direction)
1515
\* - SS-3: Missing sort columns in a split are treated as null (not an error)
1616
\* - SS-4: A split's sort schema never changes after it is written
1717
\* - SS-5: The three copies of sort schema (metastore, KV metadata, sorting_columns)
@@ -37,7 +37,7 @@
3737
\* | ChangeSchema action | Metastore update_index() changing sort_schema |
3838
\* | CompactSplits action | Parquet merge executor (sorted k-way merge, ADR-003) |
3939
\* | RowsSorted property | debug_assert! after lexsort_to_indices in sort_batch() |
40-
\* | NullOrdering property | SortColumn nulls_first field per direction |
40+
\* | NullOrdering property | SortColumn nulls_first=false (nulls always last) |
4141
\* | Columns | ParquetField enum variants in schema/fields.rs |
4242

4343
EXTENDS Integers, Sequences, FiniteSets, TLC
@@ -132,16 +132,13 @@ GetValue(row, col, columns_present) ==
132132
\* Compare two values for a single column with null ordering rules (SS-2)
133133
\* Returns: -1 (less), 0 (equal), 1 (greater)
134134
\*
135-
\* For ascending: nulls sort AFTER non-null (nulls are "greater")
136-
\* For descending: nulls sort BEFORE non-null (nulls are "lesser")
135+
\* Nulls always sort AFTER non-null values, regardless of direction.
136+
\* This matches the writer's nulls_first=false and enables nulls to be
137+
\* implicit in the sorted_series key.
137138
CompareValues(v1, v2, direction) ==
138139
CASE v1 = NULL /\ v2 = NULL -> 0
139-
[] v1 = NULL /\ v2 /= NULL ->
140-
IF direction = "asc" THEN 1 \* null after non-null for ascending
141-
ELSE -1 \* null before non-null for descending
142-
[] v1 /= NULL /\ v2 = NULL ->
143-
IF direction = "asc" THEN -1 \* non-null before null for ascending
144-
ELSE 1 \* non-null after null for descending
140+
[] v1 = NULL /\ v2 /= NULL -> 1 \* null always after non-null
141+
[] v1 /= NULL /\ v2 = NULL -> -1 \* non-null always before null
145142
[] v1 /= NULL /\ v2 /= NULL ->
146143
IF direction = "asc"
147144
THEN (CASE v1 < v2 -> -1 [] v1 = v2 -> 0 [] v1 > v2 -> 1)
@@ -218,13 +215,12 @@ SS1_RowsSorted ==
218215
\A s \in splits :
219216
IsSorted(s.rows, s.sort_schema, s.columns_present)
220217

221-
\* SS-2: Null values are ordered correctly per column direction
218+
\* SS-2: Null values always sort after non-null (nulls last, regardless of direction)
222219
\* This is enforced by the CompareValues function used in IsSorted.
223220
\* We verify it explicitly: for every adjacent pair of rows, when all
224221
\* earlier sort columns are equal, if one value is NULL and the other
225-
\* is not, the NULL must be in the correct position:
226-
\* - Ascending: NULL comes AFTER non-null (nulls last)
227-
\* - Descending: NULL comes BEFORE non-null (nulls first)
222+
\* is not, the NULL must come after the non-null value.
223+
\* This matches the writer's nulls_first=false.
228224
SS2_NullOrdering ==
229225
\A s \in splits :
230226
\A i \in 1..(Len(s.rows) - 1) :
@@ -241,10 +237,8 @@ SS2_NullOrdering ==
241237
IN ev1 = ev2
242238
IN
243239
earlier_equal =>
244-
\* Ascending: null must NOT appear before non-null
245-
/\ ~(dir = "asc" /\ v_curr = NULL /\ v_next /= NULL)
246-
\* Descending: non-null must NOT appear before null
247-
/\ ~(dir = "desc" /\ v_curr /= NULL /\ v_next = NULL)
240+
\* Null must NOT appear before non-null (regardless of direction)
241+
~(v_curr = NULL /\ v_next /= NULL)
248242

249243
\* SS-3: If a sort column is missing from a split's data, all rows in that
250244
\* split are treated as null for that column. This is not an error.

quickwit/quickwit-dst/src/invariants/registry.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ use std::fmt;
3232
pub enum InvariantId {
3333
/// SS-1: all rows within a split are sorted according to the split's schema
3434
SS1,
35-
/// SS-2: null values sort correctly per direction (nulls last asc, first desc)
35+
/// SS-2: null values always sort after non-null (nulls last, regardless of direction)
3636
SS2,
3737
/// SS-3: missing sort columns are treated as NULL
3838
SS3,
@@ -113,7 +113,7 @@ impl InvariantId {
113113
pub fn description(self) -> &'static str {
114114
match self {
115115
Self::SS1 => "rows sorted by split schema",
116-
Self::SS2 => "null ordering correct per direction",
116+
Self::SS2 => "nulls always sort after non-null",
117117
Self::SS3 => "missing sort columns treated as NULL",
118118
Self::SS4 => "sort schema immutable after write",
119119
Self::SS5 => "three copies of sort schema identical",

quickwit/quickwit-dst/src/invariants/sort.rs

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ use std::cmp::Ordering;
2121

2222
/// Compare two optional values with null ordering per SS-2.
2323
///
24-
/// - Ascending: nulls sort AFTER non-null (nulls last).
25-
/// - Descending: nulls sort BEFORE non-null (nulls first).
24+
/// Nulls always sort AFTER non-null values, regardless of sort direction.
25+
/// This matches the writer's `nulls_first: false` and enables nulls to be
26+
/// implicit in the sorted_series key (absent columns are simply omitted).
2627
///
2728
/// For two non-null values, the natural ordering is used (reversed for
2829
/// descending). Two nulls compare as equal.
@@ -33,20 +34,8 @@ pub fn compare_with_null_ordering<T: Ord>(
3334
) -> Ordering {
3435
match (a, b) {
3536
(None, None) => Ordering::Equal,
36-
(None, Some(_)) => {
37-
if ascending {
38-
Ordering::Greater // null after non-null
39-
} else {
40-
Ordering::Less // null before non-null
41-
}
42-
}
43-
(Some(_), None) => {
44-
if ascending {
45-
Ordering::Less // non-null before null
46-
} else {
47-
Ordering::Greater // non-null after null
48-
}
49-
}
37+
(None, Some(_)) => Ordering::Greater, // null always after non-null
38+
(Some(_), None) => Ordering::Less, // non-null always before null
5039
(Some(va), Some(vb)) => {
5140
if ascending {
5241
va.cmp(vb)
@@ -86,14 +75,14 @@ mod tests {
8675

8776
#[test]
8877
fn descending_null_ordering() {
89-
// null < non-null in descending
78+
// null always after non-null, even in descending
9079
assert_eq!(
9180
compare_with_null_ordering(None::<&i32>, Some(&1), false),
92-
Ordering::Less
81+
Ordering::Greater
9382
);
9483
assert_eq!(
9584
compare_with_null_ordering(Some(&1), None::<&i32>, false),
96-
Ordering::Greater
85+
Ordering::Less
9786
);
9887
// non-null comparison reversed
9988
assert_eq!(

quickwit/quickwit-dst/src/models/sort_schema.rs

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,7 @@ impl SortSchemaModel {
145145

146146
/// Compare two values with null ordering (SS-2).
147147
/// Returns `Ordering`: Less, Equal, Greater.
148-
/// Ascending: nulls sort AFTER non-null.
149-
/// Descending: nulls sort BEFORE non-null.
148+
/// Nulls always sort AFTER non-null, regardless of direction.
150149
///
151150
/// Delegates to the shared [`crate::invariants::sort::compare_with_null_ordering`]
152151
/// for the null ordering logic, converting model-specific `Value` to `Option`.
@@ -502,9 +501,9 @@ impl Model for SortSchemaModel {
502501
},
503502
),
504503
// SS-2: Null values ordered correctly per direction.
505-
// Ascending: null must NOT appear before non-null.
506-
// Descending: non-null must NOT appear before null.
507-
// Mirrors SortSchema.tla lines 228-247
504+
// Nulls must never appear before non-null values, regardless of
505+
// sort direction. This matches the writer's nulls_first=false.
506+
// Mirrors SortSchema.tla SS-2.
508507
Property::always(
509508
"SS-2: null ordering",
510509
|_model: &SortSchemaModel, state: &SortSchemaState| {
@@ -522,18 +521,9 @@ impl Model for SortSchemaModel {
522521
});
523522

524523
if earlier_equal {
525-
// Ascending: null must not appear before non-null.
526-
if sc.direction == Direction::Asc
527-
&& v_curr.is_null()
528-
&& !v_next.is_null()
529-
{
530-
return false;
531-
}
532-
// Descending: non-null must not appear before null.
533-
if sc.direction == Direction::Desc
534-
&& !v_curr.is_null()
535-
&& v_next.is_null()
536-
{
524+
// Null must not appear before non-null,
525+
// regardless of direction (nulls always last).
526+
if v_curr.is_null() && !v_next.is_null() {
537527
return false;
538528
}
539529
}
@@ -615,14 +605,72 @@ mod tests {
615605
std::cmp::Ordering::Less
616606
);
617607

618-
// Descending: null < non-null
608+
// Descending: null still after non-null (nulls always last)
619609
assert_eq!(
620610
compare_values(Value::Null, Value::Val(1), Direction::Desc),
621-
std::cmp::Ordering::Less
611+
std::cmp::Ordering::Greater
622612
);
623613
assert_eq!(
624614
compare_values(Value::Val(1), Value::Null, Direction::Desc),
625-
std::cmp::Ordering::Greater
615+
std::cmp::Ordering::Less
616+
);
617+
}
618+
619+
/// Verify that the model detects null ordering violations.
620+
/// Construct rows where a null appears before a non-null value in a
621+
/// descending column. With nulls-always-last, this is wrong — the
622+
/// model's `is_sorted` and `row_leq` must reject it.
623+
#[test]
624+
fn ss2_detects_null_before_non_null_in_descending() {
625+
let schema = vec![SortColumn {
626+
column: Column::C1,
627+
direction: Direction::Desc,
628+
}];
629+
630+
// WRONG order: null before non-null
631+
let bad_rows = vec![
632+
Row {
633+
cells: BTreeMap::new(), // C1 missing = null
634+
},
635+
Row {
636+
cells: [(Column::C1, Value::Val(5))].into(),
637+
},
638+
];
639+
640+
// row_leq(null, non-null) must be false (null sorts after non-null).
641+
assert!(
642+
!row_leq(&bad_rows[0], &bad_rows[1], &schema),
643+
"null row must not be <= non-null row (nulls always last)"
644+
);
645+
646+
// is_sorted must reject this ordering.
647+
assert!(
648+
!is_sorted(&bad_rows, &schema),
649+
"rows with null before non-null must not be considered sorted"
650+
);
651+
}
652+
653+
/// Verify the model accepts correct null ordering: non-null before null.
654+
#[test]
655+
fn ss2_accepts_non_null_before_null_in_descending() {
656+
let schema = vec![SortColumn {
657+
column: Column::C1,
658+
direction: Direction::Desc,
659+
}];
660+
661+
// CORRECT order: value before null (nulls last)
662+
let good_rows = vec![
663+
Row {
664+
cells: [(Column::C1, Value::Val(5))].into(),
665+
},
666+
Row {
667+
cells: BTreeMap::new(), // null
668+
},
669+
];
670+
671+
assert!(
672+
is_sorted(&good_rows, &schema),
673+
"non-null before null must be accepted (nulls always last)"
626674
);
627675
}
628676

0 commit comments

Comments
 (0)