Created
August 17, 2026 07:09
-
-
Save driskell/c37d730bff4e1ecf740e0807184eac4f to your computer and use it in GitHub Desktop.
Doris parse number fail, string: '-----2628' — root cause analysis & fix
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Doris parse number fail, string: '-----2628' — root cause analysis & fix | |
| Context | |
| Inserting CloudFront logs from a Glue/Hive JSON external table into an OLAP unique-key table: | |
| INSERT INTO internal.cloudfront.test | |
| SELECT ..., IF(`sc-content-len` = "-", NULL, `sc-content-len`) AS `sc-content-len` | |
| FROM aws_glue.`cloudfront-logs`.cloudfront WHERE ...; | |
| fails with [INVALID_ARGUMENT]parse number fail, string: '-----2628', while the same statement with | |
| IF(... , 0, ...) succeeds. sc-content-len is string in Glue, BIGINT in the target table. | |
| Checkout under investigation: 4.0.1-rc02 (791725594de). | |
| The reported string is not in the data. It is five NULL rows' worth of - bytes glued onto the next | |
| real value 2628 — a stale-cursor bug in the strict-mode string→number cast. This is a genuine | |
| upstream Doris defect, still present on master, and in its milder form it corrupts data silently. | |
| --- | |
| Root cause (confirmed) | |
| be/src/vec/data_types/serde/data_type_number_serde.cpp:726-756 | |
| size_t current_offset = 0; | |
| ... | |
| for (size_t i = 0; i < size; ++i) { | |
| if (null_map && null_map[i]) { | |
| continue; // <-- skips the cursor update below | |
| } | |
| size_t next_offset = (*offsets)[i]; | |
| size_t string_size = next_offset - current_offset; | |
| StringRef str_ref(&(*chars)[current_offset], string_size); | |
| if (!try_parse_impl<T>(vec_to[i], str_ref, params)) { | |
| return Status::InvalidArgument("parse number fail, string: '{}'", | |
| std::string((char*)&(*chars)[current_offset], string_size)); | |
| } | |
| current_offset = next_offset; // <-- never reached for NULL rows | |
| } | |
| ColumnString stores row i as chars[offsets[i-1] .. offsets[i]). This loop re-derives the row | |
| start from a running current_offset cursor instead of offsets[i-1], and the continue for | |
| rows bypasses the cursor advance. After the first NULL row the cursor is stale, so every subsequent | |
| non-NULL row is read as the concatenation of all skipped NULL rows' bytes plus its own va | |
| The StringRef handed to try_parse_impl is the same wrong slice, so this is not merely a b | |
| message — the parse itself operates on garbage. | |
| Sibling serdes that index per-row via col_str.get_data_at(i) — data_type_datev2_serde.cpp, | |
| data_type_datetimev2_serde.cpp, data_type_date_or_datetime_serde.cpp, data_type_ipv4_serd | |
| data_type_ipv6_serde.cpp, data_type_time_serde.cpp — are all immune. Only the two that hand-roll | |
| the cursor are affected: | |
| ┌────────────────────────────────────────────────────────────┬───────────────────────────────────────────┐ | |
| │ File │ Function │ Status │ | |
| ├────────────────────────────────────────────────────────────┼───────────────────────────────────────────┤ | |
| │ be/src/vec/data_types/serde/data_type_number_serde.cpp:726 │ from_string_strict_mode_batch │ buggy (all int/float/bool targets) │ | |
| ├────────────────────────────────────────────────────────────┼───────────────────────────────────────────┤ | |
| │ be/src/vec/data_types/serde/data_type_decimal_serde.cpp:74 │ from_string_strict_mode_batch │ buggy (identical shape) │ | |
| ├────────────────────────────────────────────────────────────┼───────────────────────────────────────────┤ | |
| │ be/src/vec/data_types/serde/data_type_number_serde.cpp:699 │ from_string_batch (non-strict) │ correct — advances unconditionally │ | |
| └────────────────────────────────────────────────────────────┴───────────────────────────────────────────┘ | |
| Why NULL rows still carry - bytes | |
| A NULL row in Nullable(String) normally has an empty nested slice, because every null-pro | |
| entry point (insert_default, insert_many_defaults, insert_data(nullptr, ·), insert(Field(Null))) | |
| routes to ColumnStr::insert_default(), which pushes offsets.push_back(chars.size()). When | |
| nested slice is empty, offsets[i] == offsets[i-1], the stale cursor happens to stay correct, and the | |
| bug is invisible. That is why this has survived unnoticed. | |
| IF(cond, NULL, nullable_col) breaks that assumption. be/src/vec/functions/if.cpp:249-256: | |
| if (is_column_nullable(*arg_else.column)) { // if(cond, null, nullable) | |
| auto result_column = (*std::move(arg_else_column)).mutate(); | |
| assert_cast<ColumnNullable&>(*result_column) | |
| .apply_null_map(assert_cast<const ColumnUInt8&>(*arg_cond.column)); | |
| ColumnNullable::apply_null_map_impl (column_nullable.cpp:541-554) only ORs the null map — | |
| arr1[i] |= arr2[i] — and never touches the nested column. The rows that just became NULL | |
| their original - bytes in chars, with non-empty offset deltas. | |
| Full chain for the failing query | |
| 1. FE: findCommonPrimitiveTypeForCaseWhen (TypeCoercionUtils.java:1949-1958) — "string-like vs all | |
| other type" → common type StringType. So the plan is cast(if(col = '-', cast(null as stri | |
| 2. FE: SessionVariable.enableStrictCast() (SessionVariable.java:5603-5614) returns | |
| enableInsertStrict when statementContext.isInsert(). enable_insert_strict defaults to tru | |
| so the cast runs in CastModeType::StrictMode regardless of enable_strict_cast (default false). | |
| 3. BE if(): execute_for_null_condition → recurse → execute_for_null_then_else → apply_nul | |
| Result is Nullable(String) whose nested ColumnString still holds - under the new NULLs. | |
| 4. BE cast: function_cast.cpp:176-189 prepare_remove_nullable unwraps the nullable and pa | |
| raw null map down. need_replace_null_data_to_default (function_cast.cpp:107-111) returns | |
| false when the source is DataTypeString, so the nested chars are handed over unscrubbed. | |
| 5. cast_to_basic_number_common.h:455-463 → from_string_strict_mode_batch(*col_from, *column_to, opts, null_map). | |
| 6. Five consecutive - rows are skipped without advancing the cursor; row six is read as - | |
| Status::InvalidArgument → (host)[INVALID_ARGUMENT]… (status.cpp:44) → FE | |
| AbstractInsertExecutor → UserException.getMessage() prefixes errCode = 2, detailMessage = | |
| Why IF(col = "-", 0, col) works | |
| Same common type (StringType), so the plan becomes if(col = '-', '0', col). then is a non | |
| constant, so execute_for_null_then_else does not fire; execute_for_nullable_then_else builds the | |
| nested result from scratch via execute_generic → insert_from per row, producing a compact | |
| correctly-offset ColumnString with 0 where the dashes were. No new NULLs are introduced, the null | |
| map is all-zero, the cursor never desyncs. It is not that 0 is safer than NULL — it is th | |
| row with non-empty bytes ever reaches the cast. | |
| --- | |
| ⚠️ Silent data corruption (the more serious half) | |
| The error you hit is the lucky outcome. With a single NULL row before a value, the glued | |
| is still parseable: | |
| ┌───────────────────────────┬──────────────┬───────────────────────────────────────────────┐ | |
| │ NULL run before the value │ Glued string │ Result | |
| ├───────────────────────────┼──────────────┼───────────────────────────────────────────────┤ | |
| │ - ×1, then 2628 │ -2628 │ parses as -2628 — silently inserted, no erro | |
| ├───────────────────────────┼──────────────┼───────────────────────────────────────────────┤ | |
| │ - ×2+, then 2628 │ --2628 … │ parse fails → visible error | |
| └───────────────────────────┴──────────────┴───────────────────────────────────────────────┘ | |
| So a load that "succeeds" on this code path can write negated content lengths. Any table already | |
| loaded with CAST(<expr producing NULLs over non-empty strings> AS <number/decimal>) under | |
| insert should be treated as suspect. Worth a sanity check on any existing data: | |
| SELECT count(*) FROM internal.cloudfront.test WHERE `sc-content-len` < 0; | |
| The same applies to any DECIMAL target via data_type_decimal_serde.cpp. | |
| --- | |
| Minimal reproduction (shareable with the Doris team) | |
| No Hive, Glue, S3 or external catalog needed — a plain OLAP table reproduces it. One buck | |
| duplicate key on id keep row order deterministic within the block. | |
| CREATE TABLE test_strict_cast_null_offset ( | |
| id INT, | |
| s VARCHAR(16) | |
| ) | |
| DUPLICATE KEY(id) | |
| DISTRIBUTED BY HASH(id) BUCKETS 1 | |
| PROPERTIES ("replication_num" = "1"); | |
| INSERT INTO test_strict_cast_null_offset VALUES (1, '-'), (2, '-'), (3, '2628'); | |
| SET enable_strict_cast = true; | |
| SELECT id, CAST(IF(s = '-', NULL, s) AS BIGINT) AS v | |
| FROM test_strict_cast_null_offset ORDER BY id; | |
| Expected: 1 → NULL, 2 → NULL, 3 → 2628. | |
| Actual: ERROR 1105 (HY000): … [INVALID_ARGUMENT]parse number fail, string: '--2628' | |
| — the two skipped NULL rows' - bytes are prepended to row 3's value. | |
| Silent wrong-result variant (the more important one for the report) — drop to a single NU | |
| the glued string still parses: | |
| TRUNCATE TABLE test_strict_cast_null_offset; | |
| INSERT INTO test_strict_cast_null_offset VALUES (1, '-'), (2, '2628'); | |
| SELECT id, CAST(IF(s = '-', NULL, s) AS BIGINT) AS v | |
| FROM test_strict_cast_null_offset ORDER BY id; | |
| Expected: 1 → NULL, 2 → 2628. Actual: 2 → -2628, no error. | |
| Notes for the report: | |
| - SET enable_strict_cast = true is only needed for a bare SELECT. An INSERT … SELECT take | |
| strict path automatically because SessionVariable.enableStrictCast() returns enable_insert_strict | |
| (default true) for insert statements — which is how this shows up in real loads. | |
| - CAST(… AS DECIMAL(10,2)) reproduces identically via data_type_decimal_serde.cpp. | |
| - CAST(… AS DATE/DATETIME/IPV4) does not reproduce — those serdes index with get_data_at( | |
| - Any expression that marks rows NULL without clearing the nested string works, e.g. | |
| NULLIF(s, '-') in place of the IF. A plain WHERE-filtered column does not, because filter | |
| rebuilds the ColumnString compactly. | |
| --- | |
| Security assessment | |
| Not a security vulnerability — a data-integrity bug. | |
| - No memory-safety impact. current_offset is always a previously-read entry of a monotoni | |
| non-decreasing offsets array, so string_size = offsets[i] - current_offset cannot underflow and | |
| the StringRef never leaves the chars buffer. Over-wide read, in-bounds. | |
| - No information disclosure across a trust boundary. The extra bytes belong to adjacent rows of the | |
| same column in the same query — data the caller can already SELECT. | |
| - No privilege escalation, no DoS, no crash, no remote trigger. The party who writes the SQL is the | |
| party whose data is corrupted. | |
| If forced onto the CVSS 3.1 scale: AV:L/AC:H/PR:L/UI:R/S:U/C:N/I:L/A:N ≈ 2.5 (Low), drive | |
| solely by the silent integrity impact. Report as a normal GitHub issue, not to security@apache.org. | |
| --- | |
| Version status | |
| Verified from_string_strict_mode_batch in data_type_number_serde.cpp (moved to | |
| be/src/core/data_type_serde/ on master) across the tree: | |
| ┌─────────────────────────────────────────────────────┬────────┐ | |
| │ Ref │ Status │ | |
| ├─────────────────────────────────────────────────────┼────────┤ | |
| │ 4.0.1-rc02 (this checkout) │ buggy │ | |
| ├─────────────────────────────────────────────────────┼────────┤ | |
| │ 4.0.5-rc01, 4.0.8 │ buggy │ | |
| ├─────────────────────────────────────────────────────┼────────┤ | |
| │ 4.1.0, 4.1.3 │ buggy │ | |
| ├─────────────────────────────────────────────────────┼────────┤ | |
| │ origin/branch-4.0, origin/branch-4.1, origin/master │ buggy │ | |
| └─────────────────────────────────────────────────────┴────────┘ | |
| Nothing in 4.0.1-rc02..origin/master touches these two functions except the file move and a | |
| try_parse_impl<T, true> template-arg change. gh searches for existing issues/PRs on | |
| from_string_strict_mode_batch and "parse number fail strict cast null" found nothing. | |
| No upstream fix exists — this needs reporting. | |
| --- | |
| Immediate workarounds | |
| Recommended (keeps strict insert, no NULL ever enters the cast — sc-content-len is never -1): | |
| NULLIF(CAST(IF(`sc-content-len` = "-", "-1", `sc-content-len`) AS BIGINT), -1) AS `sc-content-len` | |
| Alternative, if the extra CPU is acceptable — relaxing the cast makes - become NULL naturally and | |
| uses the correct from_string_batch path, but it also weakens error checking for the whole | |
| SET enable_insert_strict = false; | |
| -- then simply: CAST(`sc-content-len` AS BIGINT) | |
| Do not just swap the branches (IF(col <> '-', col, NULL)) — that takes the symmetric | |
| apply_negated_null_map path at if.cpp:282-288 and hits exactly the same bug. | |
| --- | |
| Implementation plan — test-first | |
| Scope rule: nothing gets fixed unless a test covers it. Anything in this document without a test in | |
| Phase 1 stays untouched; the out-of-scope list at the end says what that excludes and why | |
| defects qualify — the two serde cursor bugs and the reproducible half of the if() shared-null-map | |
| defect, which T4 pins directly. | |
| Work top to bottom. Do not touch data_type_number_serde.cpp, data_type_decimal_serde.cpp | |
| if.cpp until Phase 2 has shown every new test failing for the right reason. | |
| Phase 0 — get onto master | |
| This checkout is a detached HEAD at 4.0.1-rc02. All work happens on the upstream default branch, | |
| which is master (this repo has no main): | |
| git fetch origin | |
| git switch master | |
| No branch is created and nothing is staged or committed — all changes stay in the working tree until | |
| you decide otherwise. | |
| Verified present on master before starting: both serde cursor bugs, and if.cpp:251-253 / | |
| :282-284 unchanged, with still no ColumnNullable::mutate() override. | |
| Paths differ on master — the file-and-line references in the analysis above are from the 4.0.1 | |
| checkout. Translate as: | |
| ┌─────────────────────────────────────────────────────────┬───────────────────────────────┐ | |
| │ 4.0.1 │ master │ | |
| ├─────────────────────────────────────────────────────────┼───────────────────────────────┤ | |
| │ be/src/vec/data_types/serde/data_type_number_serde.cpp │ be/src/core/data_type_serde/data_type_number_serde.cpp │ | |
| ├─────────────────────────────────────────────────────────┼───────────────────────────────┤ | |
| │ be/src/vec/data_types/serde/data_type_decimal_serde.cpp │ be/src/core/data_type_serde/data_type_decimal_serde.cpp │ | |
| ├─────────────────────────────────────────────────────────┼───────────────────────────────┤ | |
| │ be/src/vec/functions/if.cpp │ be/src/exprs/function/if.cpp │ | |
| ├─────────────────────────────────────────────────────────┼───────────────────────────────┤ | |
| │ be/src/vec/columns/column_nullable.{h,cpp} │ be/src/core/column/column_nullable.{h,cpp} │ | |
| ├─────────────────────────────────────────────────────────┼───────────────────────────────┤ | |
| │ be/src/vec/columns/column_string.h │ be/src/core/column/column_string.h │ | |
| ├─────────────────────────────────────────────────────────┼───────────────────────────────┤ | |
| │ be/test/vec/function/cast/ │ be/test/exprs/function/cast/ │ | |
| └─────────────────────────────────────────────────────────┴───────────────────────────────┘ | |
| Once the upstream PR lands, cherry-pick to branch-4.1 and branch-4.0 — the latter is the | |
| actually running in production here. | |
| Phase 1 — write the tests (no production changes) | |
| Paths below are the master layout (see the Phase 0 table). | |
| T1 — BE unit test, integer strict cast. New file | |
| be/test/exprs/function/cast/cast_to_int_from_string_with_nulls_test.cpp, following the ha | |
| be/test/exprs/function/cast/cast_test.h and the existing cast_to_boolean_test.cpp, which already | |
| drives from_string_strict_mode_batch. | |
| Behaviour to assert: a strict-mode batch cast reads each row's own bytes, regardless of p | |
| NULL rows. Build a ColumnString that holds non-empty text on rows whose null-map bit is set — this | |
| is the state if() produces and the state no existing test constructs: | |
| ┌──────┬──────────────────────────────┬───────────────┬──────────────────────────────────────────────┐ | |
| │ Case │ Input strings │ Null map │ Expected result │ | |
| ├──────┼──────────────────────────────┼───────────────┼──────────────────────────────────────────────┤ | |
| │ T1a │ ["-", "2628"] │ [1, 0] │ OK; [·, 2628] — pins the silent -2628 corruption │ | |
| ├──────┼──────────────────────────────┼───────────────┼──────────────────────────────────────────────┤ | |
| │ T1b │ ["-","-","-","-","-","2628"] │ [1,1,1,1,1,0] │ OK; [·,·,·,·,·, 2628] │ | |
| ├──────┼──────────────────────────────┼───────────────┼──────────────────────────────────────────────┤ | |
| │ T1c │ ["-", "2628"] │ [0, 0] │ error, message quotes exactly '-' │ | |
| ├──────┼──────────────────────────────┼───────────────┼──────────────────────────────────────────────┤ | |
| │ T1d │ ["", "2628"] │ [1, 0] │ OK; [·, 2628] — the empty-slice case that passes today, guards the fix │ | |
| └──────┴──────────────────────────────┴───────────────┴──────────────────────────────────────────────┘ | |
| · = value under a set null-map bit; assert only the status and the non-null rows. | |
| T2 — BE unit test, decimal strict cast. Same four cases against | |
| DataTypeDecimalSerDe<T>::from_string_strict_mode_batch, in | |
| be/test/exprs/function/cast/cast_to_decimal_from_string_with_nulls_test.cpp. | |
| T3 — SQL regression test. New | |
| regression-test/suites/function_p0/cast/test_cast_string_to_number_with_nulls.groovy, alongside | |
| test_cast_to_complex_types_strict.groovy. Use the table and queries from Minimal reproduc | |
| above: both the '--2628' error case and the silent -2628 case, for BIGINT and DECIMAL, under | |
| enable_strict_cast = true and again via INSERT … SELECT with the default enable_insert_st | |
| T4 — BE unit test for the secondary defect. be/test/vec/columns/column_nullable_test.cpp, | |
| as written in the Secondary defect section below. This is the only test that covers if.cpp, so it | |
| alone authorises the if.cpp change. | |
| Phase 2 — confirm red | |
| Build BE and run T1, T2, T4; start a cluster and run T3. Every new case must fail, and th | |
| match the predicted mechanism — T1a/T1b reporting -2628 / '--2628', T4 showing src has acquired | |
| nulls. T1c and T1d must pass already; if they don't, the diagnosis is wrong and this plan | |
| revisiting before any fix. | |
| Phase 3 — fix | |
| F1 → covered by T1, T3. be/src/core/data_type_serde/data_type_number_serde.cpp, | |
| from_string_strict_mode_batch. Delete the current_offset / chars / offsets locals and ind | |
| row: | |
| const auto str_ref = str.get_data_at(i); | |
| if (!try_parse_impl<T>(vec_to[i], str_ref, params)) { | |
| return Status::InvalidArgument("parse number fail, string: '{}'", str_ref.to_string()); | |
| } | |
| ColumnStr::get_data_at (column_string.h:140-144) is | |
| StringRef(&chars[offsets[i-1]], offsets[i] - offsets[i-1]), and offsets[-1] == 0 is guaranteed by | |
| PaddedPODArray's zeroed left pad, so row 0 needs no special case. This matches the alread | |
| date, time and IP serdes. | |
| F2 → covered by T2, T3. be/src/core/data_type_serde/data_type_decimal_serde.cpp, | |
| from_string_strict_mode_batch. Identical change around the CastToDecimal::from_string(... | |
| F3 → covered by T4. be/src/exprs/function/if.cpp:251-253 and the symmetric :282-284. Buil | |
| freshly allocated OR-ed null map instead of mutating through a shallow clone. Prefer this over a deep | |
| ColumnNullable::mutate() override — narrower, and safe to backport. | |
| Both F1 and F2 are pure simplifications: no new helpers, and no behaviour change for the | |
| case that T1d locks down. | |
| Phase 4 — confirm green | |
| Re-run T1–T4; all pass. Then re-run the two existing suites that assert on the parse number fail | |
| text, regression-test/suites/load_p0/tvf/test_tvf_strict_mode_and_filter_ratio.groovy and | |
| test_tvf_error_column.groovy, plus be/test/exprs/function/cast/cast_to_boolean_test.cpp and | |
| be/test/core/column/column_nullable_test.cpp. Finally re-run the real Glue INSERT with th | |
| IF(..., NULL, ...) expression. | |
| Phases 2 and 4 are cheaper on a debug BE: ColumnStr::sanity_check_simple is #ifndef NDEBUG only, so | |
| a debug build makes any offset divergence loud rather than silent. | |
| Out of scope (no test, so no fix) | |
| - ColumnVector::resize not zero-filling (column_vector.h:263), leaving uninitialised valu | |
| under null rows in from_string_strict_mode_batch. Logically masked by the null map; no observable | |
| behaviour to assert against. | |
| - The in-place condition mutation at if.cpp:432-438 (nested_bool_data[i] &= !null_map[i]). Same | |
| const-cast class of defect as F3, but it only touches nested bool values under NULL rows, | |
| unobservable by design — so no test can pin it. Leave it. | |
| - A deep ColumnNullable::mutate() override. The correct long-term fix per cow.h:358-360, | |
| changes every ColumnNullable consumer and T4 does not cover that blast radius. Raise separately. | |
| Upstream | |
| Worth an apache/doris issue + PR against master with cherry-picks to branch-4.1 and branch-4.0. | |
| Include the silent -2628 case in the issue — it reframes this from "confusing error messa | |
| "silent wrong results", which should get it picked up quickly. F3 is a separate defect in a separate | |
| file; raise it as its own issue and PR rather than bundling. | |
| --- | |
| Secondary defect — if() writes through a shallow clone (in scope, via T4) | |
| if.cpp:251-256 uses (*std::move(arg_else_column)).mutate(). Because arg_else.column is still | |
| live in the block, COW::shallow_mutate (cow.h:305-311) takes the use_count() > 1 branch a | |
| calls clone() — but ColumnNullable(const ColumnNullable&) = default copies WrappedPtr members, | |
| so the clone shares the original's null-map object. chameleon_ptr::operator*() non-const | |
| bare const_cast (cow.h:342), so apply_null_map writes through into the input column's null map. | |
| cow.h:358-360 explicitly says a class with WrappedPtr members must reimplement mutate() t | |
| mutate; ColumnNullable does not. | |
| Harmless for the failing INSERT (the source column is not otherwise projected), but it means | |
| IF(col = '-', NULL, col) can retroactively NULL out col itself for any other consumer of | |
| block column. | |
| Verification cases | |
| Reuse the same table, and keep the target a string so the primary cast bug stays out of the way: | |
| CREATE TABLE test_if_shared_nullmap ( | |
| id INT, | |
| s VARCHAR(16) -- must be NULLABLE; a NOT NULL column takes a different if() branch | |
| ) | |
| DUPLICATE KEY(id) | |
| DISTRIBUTED BY HASH(id) BUCKETS 1 | |
| PROPERTIES ("replication_num" = "1"); | |
| INSERT INTO test_if_shared_nullmap VALUES (1, '-'), (2, '-'), (3, '2628'); | |
| S1 — sibling projection (visual). s must be unaffected by what IF does. | |
| SELECT id, s, IF(s = '-', NULL, s) AS v FROM test_if_shared_nullmap ORDER BY id; | |
| Correct: s = -, -, 2628 and v = NULL, NULL, 2628. | |
| Defect present: s also comes back NULL, NULL, 2628. | |
| S2 — same thing as a scalar assertion, easier to put in a regression test: | |
| SELECT count(s) AS cnt_s, count(IF(s = '-', NULL, s)) AS cnt_v FROM test_if_shared_nullma | |
| Correct: cnt_s = 3, cnt_v = 1. Defect present: cnt_s = 1. | |
| S3 — the symmetric branch (if.cpp:282-288, apply_negated_null_map), which must be fixed t | |
| SELECT count(s) AS cnt_s, count(IF(s <> '-', s, NULL)) AS cnt_v FROM test_if_shared_nullm | |
| Correct: cnt_s = 3, cnt_v = 1. Defect present: cnt_s = 1. | |
| S4 — BE unit test (the definitive check). S1–S3 depend on the planner handing if() the sa | |
| ColumnPtr the projection later reads, so a clean result there does not clear the code defect. This | |
| does, and belongs in be/test/core/column/column_nullable_test.cpp. Behaviour to assert: t | |
| mutable handle to a shared nullable column and applying a null map leaves the original column | |
| unchanged. | |
| // build src = Nullable(String) ["-", "-", "2628"], all non-null | |
| ColumnPtr src = ...; | |
| auto cond = ColumnUInt8::create(); // {1, 1, 0} | |
| auto held = src; // second reference, as if(...) does | |
| auto mutated = (*std::move(held)).mutate(); | |
| assert_cast<ColumnNullable&>(*mutated).apply_null_map(*cond); | |
| EXPECT_FALSE(assert_cast<const ColumnNullable&>(*src).has_null()); // fails today | |
| EXPECT_TRUE(assert_cast<const ColumnNullable&>(*mutated).has_null()); | |
| S4 is T4 in the phased plan and is what authorises the F3 change. S1–S3 are diagnostics o | |
| them for the upstream issue, but do not gate the fix on them, since a clean result there reflects plan | |
| shape rather than the absence of the defect. | |
| The in-place condition mutation at if.cpp:432-438 and a deep ColumnNullable::mutate() ove | |
| both listed under Out of scope above — no test can pin the former, and T4 does not cover the blast | |
| radius of the latter. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment