Repository navigation
fix(duckdb): compare a naive µs/ns timestamp with a literal at full precision, not the millisecond (refs spiceai/spiceai#13146) - #93
Conversation
…ning it to UTC A naive TIMESTAMP compared with a TIMESTAMPTZ was rendered through TO_TIMESTAMP(EPOCH_MS(col) / 1000), which truncates it to the millisecond, so a comparison inside that millisecond selected the wrong rows and a DELETE built from one removed rows its predicate excluded. A naive microsecond column now renders as (col AT TIME ZONE 'UTC'), exact and independent of the session TimeZone; a naive nanosecond column is compared on epoch_ns(col) against the other side's instant in nanoseconds. Seconds, milliseconds and dates keep the lossless EPOCH_MS form. Refs spiceai/spiceai#13146
…nd past 2262 epoch_ns of a TIMESTAMPTZ outside the nanosecond range is a DuckDB conversion error, so scale the bound's microseconds in a HUGEINT instead. Adds execution tests for that bound and for a naive column's subtraction under non-UTC sessions. Refs spiceai/spiceai#13146
…on calendar AT TIME ZONE 'UTC' converts through DuckDB's session Calendar, so under Calendar='islamic' a 2026 row compared above 2027. make_timestamptz(epoch_us(col)) reads the column's own microsecond count as a UTC instant, independent of both the session TimeZone and Calendar. Refs spiceai/spiceai#13146
…und's precision A microsecond column compared with a nanosecond literal matched the bound truncated to the column's unit, and a coarse literal past 2262 overflowed when scaled to nanoseconds. Literal bounds are now compared in nanoseconds in a HUGEINT, with the literal's own count; the column is read through epoch_us/epoch_ns, independent of the session TimeZone and Calendar. Refs spiceai/spiceai#13146
…ve column A cast to a naive nanosecond timestamp rendered through a microsecond TIMESTAMP, so a column compared at full precision was compared against a truncated bound: a row equal to the bound read as greater than it. Such a bound is now cast to TIMESTAMP_NS and read through epoch_ns; a cast to a timezone-aware nanosecond timestamp, which has no exact rendering, is refused. Refs spiceai/spiceai#13146
A bound that is not a literal is rendered at a precision of its own (handle_cast reads a cast through EPOCH), so reading the column exactly against it traded one wrong answer for another; such comparisons keep the EPOCH_MS rendering, tracked in spiceai/spiceai#15222. Date literals join timestamp literals on the exact path. Refs spiceai/spiceai#13146
There was a problem hiding this comment.
🟢 Approved
The literal-only rewrite preserves compatibility fallbacks, has focused regression coverage, and has no unresolved findings.
0 open findings
What changed in this PR
Fixes DuckDB filter rendering so naive microsecond and nanosecond timestamps compare precisely with literals, preventing incorrect row selection in DELETE and UPDATE operations.
Changes:
- Compares timestamp and date literals using integer nanosecond values.
- Preserves existing rendering for non-literal bounds.
- Adds precision, session-setting, and DELETE regression tests.
| File | Description |
|---|---|
| core/src/sql/sql_provider_datafusion/expr.rs | Implements precision-preserving comparisons and rendering/execution tests. |
| core/src/duckdb/write.rs | Refactors test setup and adds DELETE precision regression coverage. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
(run 38870908625 on So the PR is waiting on a maintainer to merge it. This account can't rerun, push, or set assignees in this repo, so it can't do anything more here. |
…atafusion-table-providers The datafusion-table-providers pin now includes spiceai/datafusion-table-providers#93. A naive microsecond column compared with a literal renders as an integer comparison in nanoseconds, not through EPOCH_MS, so the sub-millisecond part is kept (#13146).
…spiceai#15580) * check_fork_patches: require the Brotli decoder change, not only the header * build: pin datafusion-table-providers and duckdb-rs at their merged commits The fork pull requests are merged. Pin the commits on spiceai-55 and spiceai-1.4.4 instead of the pull request branches. * test(search): expect the full-precision naive timestamp filter from datafusion-table-providers The datafusion-table-providers pin now includes spiceai/datafusion-table-providers#93. A naive microsecond column compared with a literal renders as an integer comparison in nanoseconds, not through EPOCH_MS, so the sub-millisecond part is kept (spiceai#13146). --------- Co-authored-by: jeadie <jack@spice.ai>
Summary
The DuckDB filter rendering pins a naive timestamp column to UTC before comparing it with the
TIMESTAMPTZa literal renders as. It did that withTO_TIMESTAMP(EPOCH_MS(<col>) / 1000), andEPOCH_MSyields whole milliseconds, so a naive microsecond (TIMESTAMP) or nanosecond (TIMESTAMP_NS) column was read as.000when it held.000999. These filters are theWHEREclause ofDELETEandUPDATEon a DuckDB-accelerated dataset, so a predicate bounding the column inside one millisecond deleted rows it excluded. spiceai/spiceai#13146 tracks this.Against a timestamp or date literal, a naive µs/ns column is now compared in integers, at the literal's own precision:
TIMESTAMP(µs)(epoch_us("ts")::HUGEINT * 1000) <op> <literal ns>TIMESTAMP_NSepoch_ns("ts") <op> <literal ns>-make_timestamptz(epoch_us("ts")) - <literal>TO_TIMESTAMP(EPOCH_MS("ts") / 1000) <op> …TIMESTAMP_S,TIMESTAMP_MS, datesEPOCH_MSloses nothing for them)The literal's nanosecond count is an
i128. A µs or second literal past 2262 therefore scales without overflow, and DuckDB reads it as aHUGEINT.Why not the fix the issue proposed. The issue proposed
<col> AT TIME ZONE 'UTC'. It is exact, but it converts through DuckDB's sessionCalendar. On DuckDB 1.4.4 withSET Calendar='islamic',(us AT TIME ZONE 'UTC') > make_timestamptz(<2027-01-01>)returnstruefor a row in 2026.make_timestamptz(epoch_us(us))returnsfalsefor the same row. The integer forms depend on neitherTimeZonenorCalendar.Why non-literal bounds keep the old rendering.
handle_castrenders a timestamp cast asTO_TIMESTAMP(EPOCH(CAST(<x> AS TIMESTAMP))), which drops nanoseconds and goes through aDOUBLE. Reading the column exactly against a bound truncated that way trades one wrong answer for another. Adversarial review produced DELETEs that removed rows equal to a nanosecond-cast bound, plus aTIMESTAMPTZ -> TIMESTAMP_NScast DuckDB refuses. Those shapes stay byte-for-byte on the current rendering, and their precision is tracked in spiceai/spiceai#15222. Constant bounds are unaffected: DataFusion folds them to literals beforedelete_from/update. The plan for the reported query shows the filter reaching the provider asts_us < TimestampMicrosecond(1767225600000500, None).Changes
core/src/sql/sql_provider_datafusion/expr.rsduckdb_normalizes_timestamp_operand(abool) is nowduckdb_timestamp_normalization. It returnsNone,EpochMillis,EpochMicrosorEpochNanosfrom the column's resolved type.duckdb_timestamp_literal_nanosreturns a timestamp or date literal's instant in nanoseconds as ani128.render_exprrenders each case as in the table above.core/src/duckdb/write.rssetup_writer_with_batchtest helper;setup_writer_with_datanow delegates to it.test_delete_from_naive_timestamp_keeps_sub_millisecond_rows, which runs the realDuckDBTableWriter::delete_fromagainst naive µs and ns columns.Performance impact
The rendering happens once, at plan time. Per row, DuckDB evaluates the new predicate instead of the old one. Measured on DuckDB 1.4.4 over 20M rows:
count(*)with each predicate, 30 interleaved repetitions, 4 threads:Test plan
New tests fail on
spiceai-55before the fix, on their assertions:cargo test -p datafusion-table-providers --features duckdb --lib -- naive_timestamp sub_millisecondate68b0c8(tests only) →test result: FAILED. 1 passed; 6 failed. Examples:rows left by DELETE WHERE ts_us < TimestampMicrosecond(1767225600000500, None):left: [3],right: [2, 3]ts < .000500 under TimeZone=UTC:left: ["a_midnight", "b_999us", "c_999us_123ns"],right: ["a_midnight"]ts > .000999122:left: ["d_2ms"],right: ["c_999us_123ns", "d_2ms"]Each test added during review was watched red against the rendering it guards against, then green:
a_naive_column_does_not_depend_on_the_session_calendarunderAT TIME ZONE:left: 1, right: 0a_naive_microsecond_column_compares_against_a_finer_literalwith the bound truncated to the column's unit:left: ["b_999us", "c_999us_123ns"], right: []a_naive_column_compares_against_a_date_literalunderEPOCH_MS:left: ["d_2ms"],right: ["b_999us", "c_999us_123ns", "d_2ms"]cargo test -p datafusion-table-providers --features duckdb --libat the PR head →test result: ok. 361 passed; 0 failed; 2 ignored.cargo test -p datafusion-table-providers --features duckdb,duckdb-federation --test integration duckdb→test result: ok. 21 passed; 0 failed; 5 ignored(at3a3e976).cargo clippy --all-features(make lint) →Finished, no warnings or errors in this crate (one future-incompat note on theproc-macro-error2dependency).End to end,
spicedat spiceai/spiceai trunk0d9f9ba002, built with this branch patched in (--config 'patch."https://github.com/spiceai/datafusion-table-providers.git".datafusion-table-providers.path=…'). The dataset is a parquet file with naivetimestamp[us]andtimestamp[ns]columns, accelerated by DuckDB withaccess: read_writeandwrite_mode: acceleration. Rows: id 1 =.000999/.000999123, id 2 = midnight, id 3 =.002.On trunk the first
DELETEremoves id 1 (.000999), which its predicate excludes.Review gate
review-mvan13np-acvfwo(head3a3e976):AT TIME ZONEdepends on the sessionCalendar. Reproduced; fixed by theepoch_usrendering, guarded bya_naive_column_does_not_depend_on_the_session_calendar.review-mvan9zan-meljn7andreview-mvanabp6-0b2aky(headb5e5bf8): a µs column against a finer ns literal matched the truncated bound, andchecked_muloverflowed for a 2300 µs literal. Both fixed by thei128literal path and guarded by tests.review-mvankbuo-a64e1kandreview-mvansbwh-pseedw(heads2e3525b,3618189): bounds that are not literals (casts of columns, timestamp arithmetic,TIMESTAMPTZ-source casts) are rendered lossily byhandle_cast, so an exact column against them still gives wrong answers. Resolved by leaving those shapes on the current rendering, with DuckDB DELETE/UPDATE: a naive timestamp compared with a non-literal bound is still read at millisecond precision spiceai#15222 filed for the bound precision.e3f12bd: the codex run exited 1 because its workspace is out of credits, and grok is not installed. This is a declared skip for the engine pass. An independent subagent review ofe3f12bdran DuckDB 1.4.4 probes and found no regression in the new forms (NULL /IS [NOT] DISTINCT FROM,HUGEINTliterals, precedence underAND/=, sentinels). It noted two gaps this diff leaves exactly as they were. First, a literal on the left (lit < col) is not normalized. Whether DataFusion produces that order for DML is unverified. Second, DuckDBinfinityrows in a µs column compare as NULL, as they did before./code-review high: four findings. Fixed: anepoch_ns(<non-literal>)error past 2262, a missing subtraction execution test, and a lossy cast in a test. One (the ns literal path skipping the µs sentinel refusal) needed no change, because it is an integer comparison./security-review: not run as the skill, which reviews the spiceai/spiceai checkout. A manual pass over this diff found nothing. The only new interpolations are integers computed in Rust and the already-quoted column identifier./simplify: not run as the skill, for the same reason. Simplified by hand: the normalizationmatchcollapsed to one fallback arm, and the cast-precision helper that review rounds had introduced was deleted.Refs spiceai/spiceai#13146 — a Spice PR bumps the
datafusion-table-providerspin to this fix and adds a guard test there; that PR closes the issue.