Skip to content

fix(sqlite): shift a timestamp by an interval through datetime(), choosing date() by the operand's type - #88

Merged
lukekim merged 3 commits into
spiceai:spiceai-55from
grokspice:fix/sqlite-interval-datetime
Oct 18, 2026
Merged

lukekim merged 3 commits into
spiceai:spiceai-55from
grokspice:fix/sqlite-interval-datetime

Conversation

@grokspice

@grokspice grokspice commented Oct 9, 2026 •

Copy link
Copy Markdown

Summary

SQLiteIntervalVisitor rewrites expr +/- INTERVAL into SQLite's date() when the interval has no positive intraday part, and into datetime() otherwise. SQLite's date() returns the calendar day alone, so ts + INTERVAL '1 day' and every subtracted intraday interval (ts - INTERVAL '1 hour', whose negated hours fail the > 0 test) discarded the time of day of a timestamp column: a projection answered midnight, and a filter over the shifted timestamp compared the day alone and kept rows the predicate excludes (spiceai/spiceai#14714; Turso in Spice shares this visitor).

The function is now chosen by the operand's type, never by the interval. The type is only known on the logical plan, so the federated scan's SQLTable (SQLiteRemoteTable) carries a logical optimizer, mark_date_operands, that wraps every DATE operand of interval arithmetic in an identity marker function before the plan is unparsed; the visitor renders a marked operand through date() and anything else through datetime(), and consumes the marker. A DATE stays date-only text, so it still compares equal to a date literal (rendered date-only too); a timestamp, or a date cast to one, keeps its time of day; a chain of intervals is rewritten from the outside in, one call per interval; every rewritten expression keeps its name (aliased back where the marker changed it), since the plan's schema may not change under a logical optimizer and the unparser finds a window expression by its name; and subqueries are traversed, since a federated statement keeps its same-source EXISTS.

Refs spiceai/spiceai#14714 (closed by the pin bump in spiceai/spiceai that picks this up and wires Turso to SQLiteRemoteTable).

Approaches that were tried and rejected

Each was reproduced against SQLite before being dropped (the adversarial review below found the first three):

  • datetime() unconditionally: d + INTERVAL '1 day' = DATE '2026-10-03' stops matching, because the date literal is rendered date-only while the shifted value carries 00:00:00.
  • SQLite's 'subsec' modifier: puts .000 on every whole-second result, so ts + INTERVAL '1 hour' = TIMESTAMP '2026-10-02 01:30:00' and <= at that boundary stop matching.
  • CASE WHEN length(x) <= 10 THEN date(...) ELSE datetime(...) END in SQL: a date cast to a timestamp renders as CAST(d AS TEXT), still ten characters, so its hour is discarded; and cloning the operand into the branches grows a chain of intervals exponentially.

Changes

  • core/src/sqlite/sqlite_interval.rs: mark_date_operands (the logical optimizer, with its identity marker UDF sqlite_date_operand), create_shifted_expr replaces create_datetime_function/SQLiteIntervalType/intraday, and the visitor consumes the marker. Unit tests pin the rendering for whole-day, intraday, negated and marked operands, the full SQL rewrite for the report's shapes plus a chain and a leftover marker, and the plan marking (only a DATE operand, a cast to timestamp left alone, names preserved).
  • core/src/sqlite/federation.rs: SQLiteRemoteTable, the SQLTable the federated scan reads, whose logical_optimizer is mark_date_operands.
  • Federated tests through the real provider over an in-memory SQLite table, each asserting the rows and the SQL sent to SQLite: the report's timestamp projections and filter, whole-second boundaries (=, <=, >), dates (projection, 25-hour and chained intervals, =/<=/> against a date literal), a date cast to a timestamp, an unaliased projection, an ORDER BY, a window ORDER BY, and an EXISTS subquery.

Reproduction

A probe crate drove the federated SQLiteTable over an in-memory table (ts timestamps 2026-10-02T12:34:56, 2026-10-02T00:30:00, 2026-01-31T23:59:59; d the same days), the way spiced does. On the base of this branch (b4a86350d5), every timestamp case was wrong and the filter kept the excluded row:

$ tp-probe interval
WRONG SELECT id, ts - INTERVAL '1 hour' AS shifted FROM rows ORDER BY id
expected 2026-10-02T11:34:56 / 2026-10-01T23:30:00 / 2026-01-31T22:59:59
actual   2026-10-02T00:00:00 / 2026-10-01T00:00:00 / 2026-01-31T00:00:00
WRONG SELECT id, ts + INTERVAL '1 day' AS shifted FROM rows ORDER BY id      (actual: all midnight)
WRONG SELECT id, ts - INTERVAL '1 day' AS shifted FROM rows ORDER BY id      (actual: all midnight)
WRONG SELECT id FROM rows WHERE ts - INTERVAL '1 hour' < TIMESTAMP '2026-10-02 10:00:00' ORDER BY id
expected ids 2, 3 — actual ids 1, 2, 3
OK    SELECT id, d + INTERVAL '1 day' AS next_day FROM rows ORDER BY id
OK    SELECT id, d - INTERVAL '1 day' AS prev_day FROM rows ORDER BY id
interval exit=1

On this branch, with the SQL sent to SQLite now reading datetime(rows.ts, '-1 hours') and date(rows.d, '+1 days'):

$ tp-probe interval
OK    SELECT id, ts - INTERVAL '1 hour' AS shifted FROM rows ORDER BY id
OK    SELECT id, ts + INTERVAL '1 day' AS shifted FROM rows ORDER BY id
OK    SELECT id, ts - INTERVAL '1 day' AS shifted FROM rows ORDER BY id
OK    SELECT id FROM rows WHERE ts - INTERVAL '1 hour' < TIMESTAMP '2026-10-02 10:00:00' ORDER BY id
OK    SELECT id, d + INTERVAL '1 day' AS next_day FROM rows ORDER BY id
OK    SELECT id, d - INTERVAL '1 day' AS prev_day FROM rows ORDER BY id
interval exit=0

The federated tests, run against the base implementation (its production code with these tests) and then with the fix:

(base) test federated::interval_arithmetic_on_a_timestamp_keeps_the_time_of_day ... FAILED
(base) test federated::interval_arithmetic_on_a_date_stays_a_date ... FAILED   (the 25-hour case shifts through datetime())
(fix)  test result: ok. 19 passed; 0 failed

The guards for the rejected approaches were watched failing against each one: the boundary and date-literal tests against unconditional datetime() + subsec; the cast-to-timestamp test against the CASE rewrite; the window and name tests against the marker without name preservation; the EXISTS test against the marker without subquery traversal.

Test plan

  • cargo test -p datafusion-table-providers --lib --no-default-features --features sqlite-federation,sqlite-bundled -- sqlite::sqlite_interval — 19 passed, 0 failed
  • cargo clippy -p datafusion-table-providers --no-default-features --features sqlite-federation,sqlite-bundled --tests -- -D warnings — no finding in the two changed files (the pre-existing dead-code findings it reports in core/tests/ and sql_provider_datafusion/mod.rs come from test helpers only used under other features; cargo clippy --all-features, which CI runs, does not report them)
  • CI (make test)

Known limitation, pre-existing

datetime() renders whole seconds, so a stored timestamp's fractional seconds are truncated by the shift, as they already were on the intraday path; keeping them needs a representation the dialect's timestamp literals and comparisons agree on, and is tracked in spiceai/spiceai#14903.

Review gate

Attests the two fix commits pushed onto this PR (591ca13..28178f9), not the author's original change.

  • Adversarial review: codex — scoped to origin/spiceai-55...HEAD, run twice (the approach changed between them).
    • Round 1 on 12b07eb — verdict needs-attention, 1 medium: a scan's pushed-down filter was resolved against the projected schema, so a filtered DATE column the projection drops went unmarked and rendered through datetime(). Reproduced at the logical-plan level (the filter rendered d + <interval> = … where the projected case renders sqlite_date_operand(t.d) + …) and fixed in 28178f9, with a_scan_filter_marks_a_date_column_its_projection_drops failing before it and passing after.
    • Round 2 on 28178f9 — the round-1 finding is gone; verdict needs-attention on a new high: datetime() formats to whole seconds, so sub-second precision is still lost. Measured on SQLite 3.50.4 (datetime('2026-10-02 12:34:56.789123','+1 days') → 2026-10-03 12:34:56). Out of scope and filed as SQLite federation: interval arithmetic through datetime() truncates sub-second precision spiceai#14950 — before this PR the same expression rendered date() and lost the entire time of day, so this PR narrows the loss rather than introducing it.
  • Security review: scoped to 591ca13..28178f9 — no findings. The commits add two imports, one ScalarUDFImpl method and two tests: no user-input parsing, no SQL string construction, no I/O, no authn/authz surface, no secrets, no logging.
  • Checks run here: cargo fmt --all -- --check clean; cargo clippy -p datafusion-table-providers --lib --features sqlite-bundled,sqlite-federation -- -D warnings clean; cargo test … -- sqlite_interval → 22 passed; 0 failed (rustc 1.98.1). The host cannot link the default sqlite3.lib, so these use sqlite-bundled; the repo's full make test/make lint need system libs this host lacks and were left to CI.

…osing date() by the operand's type

`SQLiteIntervalVisitor` rewrites `expr +/- INTERVAL` into SQLite's `date()` when the
interval has no positive intraday part, and into `datetime()` otherwise. SQLite's
`date()` returns the calendar day alone, so `ts + INTERVAL '1 day'` and every
subtracted intraday interval (`ts - INTERVAL '1 hour'`, whose negated hours fail the
`> 0` test) discarded the time of day of a timestamp column: a projection answered
midnight, and a filter over the shifted timestamp compared the day alone and kept
rows the predicate excludes (spiceai/spiceai#14714).

Choose the function by the operand's type rather than by the interval. The type is
only known on the logical plan, so the federated scan's `SQLTable` now carries a
logical optimizer, `mark_date_operands`, that wraps every `DATE` operand of interval
arithmetic in an identity marker function before the plan is unparsed; the visitor
then renders a marked operand through `date()` and anything else through
`datetime()`, and consumes the marker. A `DATE` stays date-only text, so it still
compares equal to a date literal, which is rendered date-only too; a timestamp, or a
date cast to one, keeps its time of day. A chain of intervals is rewritten from the
outside in, one call per interval. Every rewritten expression keeps its name, aliased
back where the marker changed it: the plan's schema may not change under a logical
optimizer, and the unparser finds a window expression by its name.

Rendering `datetime()` unconditionally would have broken the date comparison;
dispatching on the text's length in SQL would have misread a date cast to a
timestamp (its text is still ten characters) and, by cloning the operand into the
branches, grown a chain of intervals exponentially; and SQLite's `'subsec'` modifier
would have put `.000` on every whole-second result and broken `=`/`<=` against a
whole-second timestamp literal. None is used.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The identity marker changes non-null date expressions to nullable, violating logical optimizer schema preservation.

1 open finding
What changed in this PR

Fixes SQLite interval arithmetic to select date() or datetime() based on operand type.

Changes:

  • Marks typed DATE operands before SQL unparsing.
  • Preserves timestamp time components and date-only comparisons.
  • Adds comprehensive federated regression tests.
File Description
core/​src/​sqlite/​sqlite_interval.rs Adds operand marking, type-aware rewriting, and tests.
core/​src/​sqlite/​federation.rs Installs logical optimizers for SQLite federation.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread core/src/sqlite/sqlite_interval.rs
@spiceemma

Copy link
Copy Markdown

Status: blocked on two things this bot can't clear from here.

1. Red checks on 591ca13c19 don't come from this diff (it only touches core/src/sqlite/). From run 37871863462:

  • Build (adbc): failed to fetch submodule crates/libduckdb-sys/duckdb-sources ... SSL error: unknown error; class=Ssl (16). The runner's network failed before compiling.
  • DuckDB Tests (release): the DuckDB process aborted with IO Error: Corrupt database file: computed checksum 5381 does not match stored checksum 0.
  • Python Tests: test_clickhouse.py setup fails with ClickHouse connection failed. decompression error: incorrect magic number. fix(sqlite): return the error SQLite raises mid-query instead of ending the rows early #87 (a different SQLite change) shows the same error in run 37870489732, and its DuckDB Tests (release) fails too.
  • Build (duckdb) was cancelled.

Re-running the failed jobs on the same commit needs admin here: gh run rerun --failed returned Must have admin rights to Repository. A maintainer re-run would confirm whether these failures are infra.

2. One open Copilot thread on sqlite_interval.rs:59. It says DateOperand uses the default return_field_from_args, so a non-null DATE operand's result becomes nullable and the plan schema changes. This looks valid but hasn't been reproduced (unverified, code inspection only). The branch is on the author's fork, so it needs a push from the author.

…marker

`DateOperand` is an identity — `invoke_with_args` returns its argument and
`return_type` returns the argument's type — but it left
`ScalarUDFImpl::return_field_from_args` at the default, which builds
`Field::new(self.name(), self.return_type(..)?, true)`. The `true` is
unconditional, so wrapping a `NOT NULL` `DATE` column reported it nullable:

    Field { name: "sqlite_date_operand(d)", data_type: Date32, nullable: true }

and every expression whose field is computed from it widened with it, changing
the optimized plan's schema against the one the unparsed plan is validated on.

Return the argument's field instead, so type, nullability and metadata all
survive the marker.

Raised by Copilot on spiceai#88.
…ot the projection

`mark_date_operands` resolves a node's expressions against its inputs, falling
back to the node's own schema when it has none — which for a `TableScan` is the
*projected* schema. A scan's pushed-down filters are not evaluated against the
projection, though: they read the table. So

    SELECT id FROM t WHERE d + INTERVAL '1 day' = DATE '2026-10-04'

pushes the filter into the scan and projects `id` alone, `d` is absent from the
schema the marker is handed, `operand.get_type` fails, and the operand is left
unmarked. Since this PR chooses the SQLite function by the marker rather than by
the interval, an unmarked operand now renders through `datetime()`:

    datetime(d, '+1 days')  ->  '2026-10-04 00:00:00'

which equals no `DATE` literal, so SQLite drops rows the predicate matches.
Before this PR the same filter rendered `date(d, '+1 days')` and kept them, so
this is a regression the rest of the change introduces rather than a pre-existing
gap.

Merge the scan's qualified source schema in before the projected one, so a column
the projection drops is still resolvable for the filters that name it.

Found by the pre-push adversarial review (codex) on spiceai#88.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The cross-stage logical-plan and SQL AST rewrite warrants final human review despite strong test coverage.

0 open findings

1 resolved since last review

🧠 Review effort: Balanced

@grokspice

Copy link
Copy Markdown
Author

Status update, not a new escalation. The standing hold listed two blockers; one of them is cleared, and the work that cleared it turned up a third thing worth knowing about.

Cleared: the Copilot nullability thread

It was recorded here as "looks valid but hasn't been reproduced (unverified, code inspection only)". It is now reproduced and fixed in 12b07eb — the operand came back nullable: true through the marker, and the test asserting otherwise failed before the change and passes after. Thread reply, thread resolved.

New, and the reason this push was worth making: a silent wrong-results regression in this PR

The pre-push adversarial review caught it, and I reproduced it before changing anything. mark_date_operands resolved a node's expressions against the node's own schema when it has no inputs — which for a TableScan is the projected schema. A scan's pushed-down filters read the table, not the projection, so for

SELECT id FROM t WHERE d + INTERVAL '1 day' = DATE '2026-10-04'

d is absent from the schema the marker is handed, get_type fails, and the operand is silently left unmarked:

before:  ["d + <interval> = Date32(\"2024-10-04\")"]
after:   ["sqlite_date_operand(d) + <interval> = Date32(\"2024-10-04\")"]

Because this PR moves the choice of SQLite function from the interval to the marker, an unmarked operand now renders datetime(d, '+1 days') → '2026-10-04 00:00:00', which equals no DATE literal — so SQLite drops rows the predicate matches. Before this PR the same filter rendered date(d, '+1 days') and kept them, so this is a regression the rest of the change introduces, not a pre-existing gap.

Fixed in 28178f9 by merging the scan's qualified source schema before the projected one. Regression test a_scan_filter_marks_a_date_column_its_projection_drops fails on the old code, passes on the new; module total 22 passed; 0 failed.

Split out rather than widened

The review's second round raised sub-second precision loss: datetime() formats to whole seconds. Measured on SQLite 3.50.4, datetime('2026-10-02 12:34:56.789123','+1 days') → 2026-10-03 12:34:56. Real, but not from this PR — before it the expression rendered date() and lost the whole time of day. Filed as spiceai/spiceai#14950.

Still blocking, unchanged

  1. A human review. reviewDecision=REVIEW_REQUIRED; the only review on this PR is Copilot's.
  2. Python Tests, which is red repo-wide and not this diff's doing — last success 2026-09-20, failing on every branch since 2026-09-26 with ClickHouse connection failed. decompression error: incorrect magic number.

One correction to the earlier note here, now that there is a measurement behind it: DuckDB Tests (release) is flaky, not broken. Over 2026-09-01→10-09 it failed 46 of 71 concluded runs, 45 of them in one unrelated soak test, across 23 branches and 7 authors — and the same commit passed upstream while failing on the fork. Details and artifacts in spiceai/spiceai#14183. A red there is not evidence against this PR.

@grokspice

Copy link
Copy Markdown
Author

Status update, not a new escalation. The DuckDB Tests (release) red here is now better characterized than the standing hold says.

Across the five DuckDB Tests (release) runs on the three open PRs in this repo, the check fails on 3/3 runs of branches without #89's change (this PR ×2, #87 ×1) and passes on 2/2 runs of #89's branch — same test and case every time, upsert_retention_memory::upsert_with_retention_under_query_load_does_not_grow_memory::case_1_single_column. This PR's diff (core/src/sqlite/federation.rs, core/src/sqlite/sqlite_interval.rs) has no DuckDB file in it.

One honest wrinkle specific to this PR: the error text differs between its two runs. 37871863462 aborted with IO Error: Corrupt database file: computed checksum 5381 does not match stored checksum 0, where #87's run reports Duplicate key … violates primary key constraint. Same test, same case, different symptom — consistent with a write path that corrupts state when a statement repeats a key, but I am not claiming more than the correlation.

Table, ruled-out confounders and calibration: #89's comment. Recommended action is merge #89, then re-run this check rather than waiting it out. Python Tests is still the repo-wide ClickHouse break, and I still have pull only here.

@grokspice

Copy link
Copy Markdown
Author

Retracting my previous comment (6095047018). The correlation it reported does not hold, and the recommendation it gave was wrong.

The refuting artifact is a same-commit pass/fail pair: 37917557707 here (success) and 37917555233 on the fork (failure), both at ecf886a0d6, the fork run aborting with IO Error: Corrupt database file: computed checksum 5381 does not match stored checksum 0 — the same symptom this PR's run showed, on the branch I had claimed was immune.

The "honest wrinkle" I flagged — that your two runs failed with different error text — was in fact the answer, not a wrinkle. spiceai/spiceai#14183 (open since 2026-09-18) covers exactly that spread: "engine state corruption (checksum-0 blocks, upsert PK violation, allocator bitmask) across unrelated branches". One flaky soak test with several faces, not one deterministic defect.

Ignore "merge #89, then re-run this check". The standing hold stands as written: both red checks are pre-existing repo-wide instability (DuckDB Tests (release) → #14183, Python Tests → the ClickHouse break), this diff touches core/src/sqlite/ only, and I have pull only here.

@lukekim lukekim added the bug Something isn't working label Oct 18, 2026
@lukekim
lukekim merged commit f103fe8 into spiceai:spiceai-55 Oct 18, 2026
12 of 14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants