fix(duckdb): normalize a timestamp operand only when its type needs it (fixes spiceai/spiceai#12574) - #50
Open
claudespice wants to merge 4 commits into
Conversation
The DuckDB comparison rewrite renders a bare operand as `TO_TIMESTAMP(EPOCH_MS(<operand>) / 1000)` so that both sides of the comparison are TIMESTAMPTZ. It decided that without knowing the operand's type, because no schema reached the unparser, so it fired on columns that compare exactly without it and truncated them to whole milliseconds -- silently changing which rows a comparison inside that millisecond selects. Thread the schema the columns belong to into the rendering and normalize only a type that needs it. Measured against DuckDB v1.5.5: TIMESTAMP_S, TIMESTAMP_MS and TIMESTAMP_NS cannot be compared with a TIMESTAMPTZ at all, and a naive microsecond TIMESTAMP would compare in the session's TimeZone rather than in UTC, so all four naive shapes keep the rewrite. A timezone-aware column is already the type and the reference frame the literal renders as, so it no longer gets one. Without a schema, or for a column the schema does not carry, the rendering is unchanged. Render the literal on the other side of that comparison at full precision too, for DuckDB only. Integer division dropped every digit below the second, so an exact column was still compared against a different instant than the caller named. A whole-second literal renders byte-identically. Fixes spiceai/spiceai#12574
Subtraction and the non-temporal operand are the two shapes the type test takes the rewrite off besides the reported comparison. Measured against DuckDB v1.5.5: two TIMESTAMPTZ values subtract to the same interval the normalized form returns, and a non-temporal operand still refuses to bind - now naming the types it could not compare instead of the division the rewrite introduced.
…harness The predicate's unit arms all agreed, so the whole thing is `Timestamp(_, None)` - the timezone is what decides it, and spelling the units out read as though they discriminated something. `column_with_name` replaces `field_with_name`, which builds a Vec of every field name and formats it into an error this discards on a path the doc comment explicitly supports. Bind the scaled seconds once per literal arm rather than writing the same division up to four times, bind the schema once in `update` so both clauses visibly render against the same one, and give the DuckDB tests a shared two-row fixture and one query helper. Two comments were left asserting things this change made false: `is_duckdb_timestamp_operand` said an operand's type is not visible here, and `handle_cast` said its rendering must match the literal arms - which now keep microseconds where the cast keeps whole seconds. Both restated, and the column-vs-column gap the first one hides is spiceai/spiceai#13145.
Author
|
@copilot review |
There was a problem hiding this comment.
Pull request overview
Adds schema-aware DuckDB timestamp rendering to avoid unnecessary precision loss during DML operations.
Changes:
- Threads table schemas through expression rendering.
- Restricts timestamp operand normalization by resolved type.
- Preserves sub-second DuckDB timestamp literals and adds regression tests.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
core/src/util/dml.rs |
Adds schema-aware DML rendering and DuckDB tests. |
core/src/sql/sql_provider_datafusion/expr.rs |
Implements type-aware normalization and sub-second rendering. |
core/src/duckdb/write.rs |
Passes table schemas into DELETE and UPDATE rendering. |
Suppressed comments (2)
core/src/sql/sql_provider_datafusion/expr.rs:319
- Converting the microsecond count to
f64makes the newly added DuckDB rendering inexact for valid timestamps after roughly 2255 (for example,2^53 + 1microseconds is rounded before division), so a filter or assignment can still target a neighboring instant. Build the decimal seconds from the integer and scale instead;BigDecimalis already used in this renderer.
let seconds = *value as f64 / 1_000_000.0;
core/src/sql/sql_provider_datafusion/expr.rs:341
- The millisecond path has the same precision boundary: valid large
i64millisecond values exceed2^53, so the cast can round away a millisecond before the DuckDB SQL is produced. Render exact scaled decimal seconds rather than passing throughf64.
let seconds = *value as f64 / 1000.0;
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // `div_euclid` floors, so the truncation goes the same way either side of the | ||
| // epoch. | ||
| Some(Engine::DuckDB) => { | ||
| let seconds = value.div_euclid(1_000) as f64 / 1_000_000.0; |
| return true; | ||
| }; | ||
|
|
||
| matches!(field.data_type(), DataType::Timestamp(_, None)) |
Comment on lines
+512
to
+516
| /// A **naive** timestamp needs it, at every unit, and loses nothing it holds. DuckDB v1.5.5 refuses | ||
| /// to compare `TIMESTAMP_S`, `TIMESTAMP_MS` or `TIMESTAMP_NS` with a `TIMESTAMPTZ` at all — | ||
| /// *"Cannot compare values of type `TIMESTAMP_S` and type `TIMESTAMP WITH TIME ZONE`"* — and a | ||
| /// microsecond `TIMESTAMP`, which does compare, would be read in the session's `TimeZone` where the | ||
| /// rendered literal is a UTC instant. |
…tness Copilot found a real regression: a resolved `Date32`/`Date64` column was being taken out of the normalization set alongside the timezone-aware timestamps, but a date needs it for the reference frame rather than to bind. Measured on DuckDB v1.5.5, a bare `DATE` promotes to a TIMESTAMPTZ at midnight in the SESSION's TimeZone while the rendered literal is midnight UTC, so `"dt" = TO_TIMESTAMP(..)` answers false under America/Los_Angeles and true under UTC. Dates stay normalized, with a DELETE run under both session timezones pinning it. Two doc claims went further than the code delivers. The rewrite is not free for every naive type - EPOCH_MS truncates a naive TIMESTAMP or TIMESTAMP_NS to the millisecond, which is spiceai/spiceai#13146 - and the literal rendering keeps microseconds only to about the year 2255, because TO_TIMESTAMP takes a DOUBLE. Rendering the fraction from integer arithmetic does not raise that ceiling: at 2260 both forms land on the same wrong microsecond, and the naive decimal form is outright wrong for pre-epoch values.
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The DuckDB comparison rewrite renders a bare operand as
TO_TIMESTAMP(EPOCH_MS(<operand>) / 1000)so DuckDB sees two
TIMESTAMPTZvalues. It decided that without knowing the operand's type,because no schema reached the unparser — so it also fired on columns that compare exactly without
it, truncating them to whole milliseconds. A
</>/=against a literal inside that millisecondthen selected different rows, silently.
That is not a corner case.
Timestamp(_, Some(tz))is what every timezone-aware column in aDuckDB-accelerated dataset lands on, and it maps to
TIMESTAMPTZ, the one type the rewrite couldonly harm.
Measured against DuckDB v1.5.5 rather than reasoned about:
TIMESTAMPTZbare?Timestamp(Second, None)TIMESTAMP_STIMESTAMP_Sand typeTIMESTAMP WITH TIME ZONE"Timestamp(Millisecond, None)TIMESTAMP_MSTimestamp(Nanosecond, None)TIMESTAMP_NSTimestamp(Microsecond, None)TIMESTAMPTimeZonewhile the literal is a UTC instantTimestamp(_, Some(tz))TIMESTAMPTZSo the timezone decides it, not the unit, and only the last row changes behaviour.
The literal on the other side of that comparison was truncated too, by integer division:
2026-01-01 00:00:00.000999Zrendered asTO_TIMESTAMP(1767225600). An exact column comparedagainst a whole-second literal is still the wrong answer, so both halves are fixed here. A
whole-second literal renders byte-identically, which is why no existing expectation moved.
Changes
expr.rs:to_sql_with_engine_and_schematakes the schema the expression's columns come from andthreads it to the one site that needs it.
to_sql_with_enginedelegates withNone, so a callerwith no schema to offer renders exactly what it rendered before.
expr.rs:duckdb_normalizes_timestamp_operandresolves the operand against that schema.Un-resolvable — no schema, not a
Column, or a column the schema does not carry — normalizes, asbefore; only a resolved type declines. A non-temporal operand also stops being normalized: it
never bound either way, but DuckDB now names the types it could not compare instead of failing on
the division the rewrite introduced.
expr.rs: the DuckDB timestamp literal arms keep their sub-second digits. The nanosecond armdrops to microseconds in integer arithmetic — 1.7e18 exceeds the 2^53 an
f64holds exactly, anda
TIMESTAMPTZcannot carry nanoseconds anyway.util/dml.rs:filters_to_sql_with_schema/assignments_to_sql_with_schema, with the existingtwo delegating to them.
duckdb/write.rs:delete_fromandupdatepass the table's schema.Performance impact
None on any per-row or per-batch path.
filters_to_sql*is reached only fromTableProvider::delete_from/update, which DataFusion calls once at plan time; the renderedStringis stored on the sink and theDELETE/UPDATEthen executes entirely inside DuckDB. Theschema lookup is a linear scan over the field list, once per comparison, and
Option<&Schema>is aregister-sized
Copythrough the recursion.Test plan
make lintmake testNew tests, all executed against a real in-memory DuckDB rather than asserting on rendered text
alone:
a_microsecond_timestamptz_deletes_the_row_inside_the_millisecond— the reported defect. Two rowsa microsecond apart, a filter naming the instant between them; asserts the right row goes, and
that with the column-side truncation left in place it does not.
a_naive_millisecond_column_is_still_normalized_and_still_binds— the rewrite still covers what itwas written for.
subtracting_a_timestamp_from_a_timezone_aware_column_still_bindsandtest_duckdb_does_not_normalize_a_non_temporal_column— the two arms the type test newly leavesbare.
an_update_stores_the_sub_second_instant_it_was_assigned— aSETvalue carries the same literalrendering as a filter.
TimeUnits with and without a timezone, plus the no-schema,absent-schema and column-not-in-schema fallbacks.
Both production changes were neutered independently and re-neutered after the review pass: forcing
the type test to always normalize fails 4 tests, restoring integer division on the literal fails 4.
Review gate
codexexited 1 with "Your workspace is out of credits. Askyour workspace owner to refill in order to continue.";
grokis not installed, so the receiptrecords
engine=none exit=1. Its angles were hand-run instead, and two changed the diff: whethera non-temporal operand could now bind rather than error (measured — it still refuses, with a
better message), and whether
TIMESTAMPTZ - TIMESTAMPTZbinds once subtraction is no longernormalized (measured — it does, returning the same interval). Both are now tests.
/security-review: skipped — it scoped itself to the invoking session's working directory andwas handed an empty diff, which would have minted a clean pass over nothing. Hand-audited instead.
The only category that applies is SQL injection, and the one new rendering path formats an
i64through
f64Display, which cannot emit a quote, whitespace, exponent,inforNaN; the newschema lookup uses the column name as a lookup key only, and identifiers still render through
quoted_identifier. The gate cannot widen aWHEREclause into a tautology — it decides whetheran operand is wrapped, never the predicate's structure.
/simplify: ran, 4 agents. Applied: collapsed the type test toTimestamp(_, None)(the unitarms all agreed);
column_with_nameforfield_with_name, which built and discarded aVecofevery field name on a supported path; bound the scaled seconds once per literal arm and the schema
once in
update; a shared fixture and one query helper for the DuckDB tests. It also caught twocomments this change had made false —
is_duckdb_timestamp_operandclaiming an operand's type isnot visible, and
handle_castclaiming its rendering must match the literal arms, which nowdiffer in precision. Skipped, with reasons: changing the three existing signatures instead of
adding
*_with_schema(this crate is published upstream asdatafusion-contrib/datafusion-table-providers,so additive is the merge-friendly shape, and
to_sql→to_sql_with_engineis the same patternalready in this file); merging the new test module into
duckdb_execution_tests(its helper ishardcoded to a different fixture, so it would mean rewriting tests outside this diff); a
RenderCtxstruct for the two context parameters (churns twelve call sites for no behaviourchange).
Review round 2 — Copilot
Three findings, all valid, all acted on:
Date32/Date64column was being taken out of the normalization set. A realregression, and the one case where the rewrite is load-bearing for the reference frame rather
than for binding: DuckDB promotes a bare
DATEto aTIMESTAMPTZat midnight in the session'sTimeZone, while the rendered literal is midnight UTC. Measured on v1.5.5,"dt" = TO_TIMESTAMP(1767225600)answersfalseunderAmerica/Los_AngelesandtrueunderUTC. Dates stay normalized; aDELETErun under both session timezones pins it.EPOCH_MStruncates anaive
TIMESTAMPorTIMESTAMP_NSto the millisecond, measured. Corrected, with the remainderfiled as A naive microsecond or nanosecond timestamp column is still truncated to the millisecond by the DuckDB normalization spiceai#13146 and the reason it is kept anyway (dropping it would trade a bounded
truncation for a session-timezone-dependent result).
TO_TIMESTAMPtakes aDOUBLE, somicrosecond resolution holds only to about the year 2255 — Arrow's nanosecond range runs to 2262.
Its suggested remedy, rendering the fraction from integer arithmetic, does not fix it: at 2260
both forms land on the same wrong microsecond, because DuckDB's own parse is what rounds, and the
naive
{sec}.{frac:06}form is outright wrong for pre-epoch values (-1µsrenders as-1.999999,which round-trips to
-1999999µs). The rendering is unchanged and the comment now states the realbound.
Follow-ups filed
which share the catch-all arm. Left out because it needs measuring against each engine.
spiceai/spiceairenderswithout a schema and has one in scope; blocked on the rev bump for this PR.
DuckDB refuses it. Fixing that widens the rewrite where this change narrows it.
millisecond by the rewrite it keeps. Pinning UTC losslessly needs
AT TIME ZONE, an ICU-extensiondependency this rendering does not take.
Fixes spiceai/spiceai#12574