fix(cubesql): Fail instead of returning NULL for out-of-range timestamps - #11408
fix(cubesql): Fail instead of returning NULL for out-of-range timestamps#11408marciodfg wants to merge 2 commits into
Conversation
A time dimension value outside the range of `Timestamp(Nanosecond)` — anything before 1677-09-21 or after 2262-04-11, all of it legal in the source — was logged server-side and appended as NULL. `MIN(d)` then came back NULL over a column holding no NULLs, with the row count and schema the caller expected, so nothing downstream could tell the answer apart from a real one. A value that fails to parse one line above already aborts the query. Do the same for a value that parses but cannot be represented, and name the representable range so the message says what to do about it. This also matches how `pg-srv` already rejects out-of-range timestamps on the wire. `test_df_cube_scan_execute` asserted the old behaviour, feeding 9999-12-31 to the timestamp column and expecting NULL back; its fixture now uses an in-range value and the out-of-range cases get a test of their own. Fixes cube-js#11407
|
/bot-deep-review |
|
Claude finished @ovr's task in 7m 48s —— View job Deep review complete — no correctness bugs found; 1 medium, 2 low, plus 2 items needing a maintainer decision (compat flag, docs). 3 inline comments posted.Full review — click to expand
VerdictThe diagnosis is right and the fix is in the right place. A silent Mechanics check out:
Findings
Two things that need your call, @ovr — not the author's1. The failure is all-or-nothing, and one path fails mid-stream. The PR description reasons about the batch path only. On the native streaming path the same error is raised per chunk in Blast radius, concretely: any query projecting an affected dimension now fails, including a BI tool's opening One data point that argues for shipping it: users are not cornered. The same My read: ship it as a hard error rather than behind a flag — a config flag that defaults to "silently return wrong answers" mostly preserves the bug for the people who most need to know about it, and the 2. Docs / changelog. The docs checkbox is unchecked and this is user-visible: a query that returned rows yesterday errors today. Worth a short note in Non-blocking observationThe Security / performanceNothing to flag. No new allocation on the hot path (the What I could and couldn't verifyStatic review plus targeted reads; I did not compile or run the suite locally (the DataFusion fork build is long-running on this runner), so I'm taking the author's |
| return Err(CubeError::user(format!( | ||
| "Timestamp out of range: {}. Timestamps are \ | ||
| represented with nanosecond precision and must be \ | ||
| between {} and {}", | ||
| timestamp, MIN_NANOSECOND_TIMESTAMP, MAX_NANOSECOND_TIMESTAMP | ||
| ))); |
There was a problem hiding this comment.
medium — the error doesn't say which column, and reformats the value the source sent
Now that this aborts the whole query, the message is the only diagnostic the user gets. Two things it drops:
- No column name. A scan over a cube with several time dimensions produces
Timestamp out of range: 9999-12-31 00:00:00and nothing else — the user has to guess which dimension to fix.schema_fieldis in scope here: theDecimalarm below already reachesprecision/scalebound bytransform_response_body's match arm through the same two macro layers, so identifiers from that context resolve inside$builder_block. timestampis the re-DisplayedNaiveDateTime, not the input."9999-12-31 00:00:00.000"prints back as9999-12-31 00:00:00, so the string in the error doesn't match what's in the source data / agrepof the response.sis still live (parse_date_str(s.as_ref())only borrows), and printing it also matchespg-srv's wording, which quotes the raw text:Timestamp out of range: '{}'(pg-srv/src/values/timestamp.rs:187).
| return Err(CubeError::user(format!( | |
| "Timestamp out of range: {}. Timestamps are \ | |
| represented with nanosecond precision and must be \ | |
| between {} and {}", | |
| timestamp, MIN_NANOSECOND_TIMESTAMP, MAX_NANOSECOND_TIMESTAMP | |
| ))); | |
| return Err(CubeError::user(format!( | |
| "Timestamp out of range in column {:?}: '{}'. \ | |
| Timestamps are represented with nanosecond \ | |
| precision and must be between {} and {}", | |
| schema_field.name(), | |
| s, | |
| MIN_NANOSECOND_TIMESTAMP, | |
| MAX_NANOSECOND_TIMESTAMP | |
| ))); |
Worth considering a closing hint too, since the error is now terminal — e.g. "cast the dimension to date, or clamp the sentinel in the data model" — so the message says what to do, not just what the range is. This matters most on the streaming path, where the failure lands mid-stream with no row/chunk context at all.
| // NULL for a column that holds no NULLs. Fail the | ||
| // query instead, the way an unparseable value | ||
| // already does just above. | ||
| return Err(CubeError::user(format!( |
There was a problem hiding this comment.
low — CubeError::user maps to SQLSTATE 26000; post_processing (22000) fits a bad data value better
cube_to_error_response (sql/postgres/error.rs:69) maps CubeErrorCauseType::User → ErrorCode::InvalidSqlStatement → 26000 invalid_sql_statement_name (pg-srv/src/protocol.rs:1040). The SQL statement is fine here; a value in the source data can't be represented. Postgres itself raises 22008 datetime_field_overflow for this, and the closest cause available is CubeErrorCauseType::PostProcessing → DataException → 22000 — so CubeError::post_processing(...) is the more honest classification for a client that branches on SQLSTATE class.
Two caveats that make this cosmetic rather than urgent, both worth knowing before you decide:
- On the transport path the cause is overwritten anyway:
load_data'smap_errsetserr.cause = CubeErrorCauseType::DatabaseExecution(...)(scan.rs:824), so the client actually sees58000 system_errorregardless of what's chosen here. - On the native streaming path only the message survives —
js_stream_push_chunkdoescx.throw_error(e.message)(packages/cubejs-backend-native/src/stream.rs:275), dropping the cause entirely.
So either choice works today; post_processing just won't be wrong if the DatabaseExecution override is ever narrowed. Also note the neighbouring parse_date_str failure this is modelled on ends up as DataFusionError::Internal → an internal error, so "behaves the same way" holds for control flow but not for classification.
| let error = convert_transport_response(response, schema, member_fields) | ||
| .expect_err(&format!("{value} should not be silently nulled")); | ||
|
|
||
| assert!( | ||
| error.message.contains("Timestamp out of range"), | ||
| "unexpected error for {value}: {}", | ||
| error.message | ||
| ); | ||
| } |
There was a problem hiding this comment.
low — assertion is loose, and nothing pins the two boundary constants
contains("Timestamp out of range") passes even if the message names the wrong value or the bounds go stale. Two cheap additions:
- Assert the offending value (and, if you take the suggestion above, the column name) actually appears — that's the part a user needs and the part most likely to regress.
- Add the inclusive boundaries as success cases, so
MIN_NANOSECOND_TIMESTAMP/MAX_NANOSECOND_TIMESTAMPare covered by a test rather than by a comment:1677-09-21 00:12:43.145224192→Some(i64::MIN)2262-04-11 23:47:16.854775807→Some(i64::MAX)- one nanosecond outside either end → error
I checked both constants against timestamp_nanos_opt's arithmetic and they are exactly i64::MIN/i64::MAX nanoseconds (the negative end is representable because chrono rebalances timestamp + 1 / subsec - 1_000_000_000 before the multiply, so the bound is inclusive, unlike pandas which reserves i64::MIN for NaT). Note the sub-second precision only survives the %.f chrono fallback, not parse_fast — which needs the T separator — so these cases exercise a different parse path than the existing fixtures, which is a bonus.
The updated test_df_cube_scan_execute fixture (2262-04-11 00:00:00.000 → 9223286400000000000) is correct — 23h47m under the ceiling — and keeping 9999-12-31 in the Date32 column so both behaviours sit side by side in one fixture is a nice touch.
|
@marciodfg, thank you for submitting the PR, i've triggered our review bot. Could you review the comments from it? Thanks |
Addresses the review on cube-js#11408. The error is now the only diagnostic a user gets, so it names the column and quotes the value as it arrived rather than as chrono re-renders it, and it says what to do about it. A scan can select several time dimensions; without the name the user has to guess which one to fix. Classified as `post_processing` rather than `user`: a value in the source data that cannot be represented is a data exception (`22000`), not an invalid SQL statement (`26000`). Cosmetic today, since `load_data` overwrites the cause with `DatabaseExecution` at scan.rs:824, but it stops being wrong if that is ever narrowed. The tests now assert the column name and the offending value appear in the message, not just that "Timestamp out of range" does, and they cover one nanosecond outside each end. `convert_transport_response_accepts_boundary_timestamps` pins both constants from the other side: the inclusive bounds must map to `i64::MIN` and `i64::MAX`. Move either bound by a nanosecond and one of the two tests fails. Both boundary values parse through the chrono `%.f` fallback rather than `parse_fast`, so they also cover a second parse path. Documents the limit under a new `## Limitations` heading in the SQL API reference, with the two workarounds. Nothing under docs-mintlify/ mentioned the representable range, and this reverses an earlier decision recorded in rust/cubesql/CHANGELOG.md ("Ignore timestamps which can't be represented as nanoseconds instead of failing"), so it is worth stating rather than only changing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vtep3D786RDmrR8fBAXFyD
|
Thanks for running the bot — all three comments are addressed in
Also added the docs note (SQL API → Limitations → Timestamp range) and updated the description with the streaming-path failure shape. On the red checks: I reproduced |
Check List
Issue Reference this PR resolves
#11407
Description of Changes Made
transform_responsematerialises a Cube response into Arrow. Time dimensions land inTimestamp(Nanosecond), whose i64 nanosecond count only spans1677-09-21to2262-04-11. A source value outside that window — legal in every database that stores dates asdate/datetime2/timestamp— was logged server-side and appended as NULL:The client sees no error.
SELECT MIN(d) FROM tcomes back NULL over a column that holds no NULLs, the row count and the schema are both what the caller expects, and nothing downstream can tell the answer apart from a real one. That is the half of #11407 worth fixing — the other symptom in that issue already fails loudly.A value that fails to parse one line above already aborts the query (
parse_date_str(...)?). This makes a value that parses but cannot be represented behave the same way, and quotes the representable range so the error says what to do about it.pg-srvalready rejects out-of-range timestamps on the wire withTimestamp out of range: '{}', so the scan path is now consistent with it.Compatibility
This turns a silent NULL into a hard error, and
9999-12-31end-of-time sentinels in SCD-2 dimension tables are common enough that some users will notice. That is the intent — today they read NULL and have no way to tell — but if you would rather ship it behind a config flag, or hold it for theTimestamp(Microsecond)migration that the// TODO switch parsing to microsecondsabove this branch points at, say the word and I will rework it.Worth flagging:
test_df_cube_scan_executeasserted the old behaviour. Its fixture fed9999-12-31 00:00:00.000to the timestamp column and expectedNoneback, while theDate32column in the same fixture round-tripped the same date fine. That fixture value now uses an in-range timestamp near the upper bound, and the out-of-range cases (below and above the range) get a dedicated test.Failure shape
The reasoning above covers the batch path. On the native streaming path the same error is raised per chunk and thrown at
packages/cubejs-backend-native/src/stream.rs:275, so withCUBESQL_STREAM_MODE=truea client can receive several chunks and then the error — a partial result followed by a failure. Still better than silently wrong data, but a distinct shape that the tests do not cover.Any query projecting an affected dimension now fails, including a BI tool's opening
SELECT * … LIMIT 100, and it fails after the source database has already done the work.COUNT(*)over the same cube is unaffected. Users are not cornered, which is the main reason I think the hard error is shippable: the same value still succeeds asDate32and asTimestamp(Millisecond)(scan.rs:1114), soSELECT d::datekeeps working whereSELECT dnow errors, and clamping the sentinel in the data model is a second way out. Both are in the docs note.Verification