Repository navigation
fix(duckdb): an upsert keeps the last copy of a key a write repeats - #89
Conversation
Regression tests for spiceai/spiceai#14643: under `on_conflict: upsert`, a write that repeats a key in a later record batch, within a batch, or under another spelling of the same float key (`-0.0`, a second NaN) must keep the copy that arrived last, on every run. They fail on this base: the write is one `INSERT ... ON CONFLICT DO UPDATE` statement, and DuckDB keeps whichever copy its scan processes first.
DuckDB resolves a conflict between two rows of the same INSERT statement by keeping whichever row it processes first, and its parallel scan of the statement's input decides that order. A write under `on_conflict: upsert` was one `INSERT ... ON CONFLICT DO UPDATE` over the whole stream, so a key the write repeated across record batches kept a copy chosen by scheduling (spiceai/spiceai#14643: the first copy in 9 of 10 runs on one host, in 1 of 10 on another). A conflict with a row an earlier statement of the same transaction inserted is resolved as DO UPDATE says. So the write is now cut into statements at the first row whose key an earlier row of the same statement carried: no key repeats within a statement, and across statements the last copy wins, in arrival order. A write that repeats no key is still one statement. Keys are held as hashes (a collision only ends a statement early), floats are compared as DuckDB keys them (-0.0 as 0.0, every NaN as one), a NULL key never repeats, and a statement holds at most 2^20 distinct keys so the set stays bounded.
… one place The statement loop lives in `upsert_groups::write_statements`, so the sink only supplies the insert of one statement; the groups hold the receiver directly instead of a source trait; a batch's keys are hashed once, and a batch a statement ends inside is held with its hashes and the row the next statement starts at, so a repeat-heavy batch is not re-encoded per statement. Tests drive the production loop, cover `f32` keys, a NULL beside a float key, an ambiguous case-insensitive target, and an insert error.
…luded DuckDB imports a Date64 as a DATE, a nanosecond Time64 as a microsecond TIME, a nanosecond timestamp with a time zone as a microsecond TIMESTAMP WITH TIME ZONE, a nanosecond duration as a microsecond INTERVAL (each truncated toward zero), and a dictionary as its values, so two arrow values that differ only below that precision are one key to it. The key hash now compares them at that precision, or a repeat went unnoticed and the statement kept whichever copy its scan reached first. A NULL key is hashed like any value: within one statement DuckDB resolves two NULL unique keys as one conflict and drops the later row, where across statements it keeps both, so a repeated NULL key starts a new statement and every row is kept.
… drop the duration arm DuckDB refuses an INTERVAL as an index key, so a duration is never a conflict target and needs no canonical form. The zoned-timestamp test puts the negative value in a batch of its own, so a floor division fails it; the sink tests read a key's names in a fixed order.
… column position `INSERT INTO table SELECT * FROM view` is positional, so the written data may name its columns differently from the table. The conflict target is resolved against the table's schema and read from the written data at that position, and a run-end-encoded key column is decoded before its values are compared.
…le's type The insert casts the written data to the table's column types, so "01" and "1" written to a BIGINT key are one key to DuckDB. A key column of another type than the table's is cast to it before it is compared; a value the cast cannot convert compares as NULL, which only ends a statement early.
…y other Arrow's cast of a written key to the table's type agrees with the insert's only for exact conversions (a widened integer or float, a string or binary spelling, a dictionary or run-end encoding, a timestamp given a time zone, a widened Date32); a float written to an integer key is truncated by arrow and rounded by DuckDB, and text is parsed on the insert's own terms. A key written as any other type puts every row in a statement of its own, which is slow and right.
…zone is not an exact conversion When every row is in a statement of its own no key is compared, so the written key is left to the insert's own conversion rather than arrow's, which has no cast for some of them. A timestamp given a time zone is read by DuckDB in its session's zone and by arrow in the field's, so it is no longer a trusted conversion.
There was a problem hiding this comment.
🟡 Changes recommended
Key grouping uses the configured schema rather than the live positional DuckDB schema, allowing incorrect grouping on accepted append schemas.
2 open findings
What changed in this PR
Fixes nondeterministic DuckDB upserts so repeated keys retain their last-arriving row.
Changes:
- Splits upserts into bounded statements without duplicate keys.
- Canonicalizes DuckDB key representations.
- Adds regression and edge-case coverage.
| File | Description |
|---|---|
core/src/duckdb.rs |
Registers the grouping module. |
core/src/duckdb/upsert_groups.rs |
Implements grouping, canonicalization, and unit tests. |
core/src/duckdb/write.rs |
Integrates grouped inserts and sink tests. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
The red
|
| run | branch | result |
|---|---|---|
37897037853 |
fix/duckdb-upsert-keep-last (this PR) |
17 passed, 2 errors |
37874989216 |
lukim/federation-correlated-binding |
17 passed, 2 errors |
37871863462 |
fix/sqlite-interval-datetime |
17 passed, 2 errors |
37870489732 |
fix/sqlite-rows-error |
17 passed, 2 errors |
ERROR test_clickhouse.py::TestClickHouseParameterized::test_get_tables - Exception: ClickHouse connection failed. decompression error: incorrect magic number
ERROR test_clickhouse.py::TestClickHouseParameterized::test_parameterized_view - Exception: ClickHouse connection failed. decompression error: incorrect magic number
============ 17 passed, 16 warnings, 2 errors in 146.89s (0:02:26) =============
Only the two ClickHouse tests fail, on all four. Everything else in the suite passes.
Nothing in this repository changed when it broke
$ gh api "repos/spiceai/datafusion-table-providers/commits?sha=main&since=2026-09-19T00:00:00Z&until=2026-09-27T00:00:00Z"
[]
No commit landed on main between the last green run and the first red one.
The probable cause, labelled as a hypothesis
python/python/tests/test_clickhouse.py:12 starts the server from an unpinned tag:
["docker", "run", "--name", "clickhouse", ..., "clickhouse/clickhouse-server:latest"]clickhouse/clickhouse-server:latest today resolves to 26.9.13.15, republished 2026-10-08 (Docker Hub tags API), while the client is pinned at clickhouse = "0.13.3" (core/Cargo.toml:31). A moving server image against a pinned client is consistent with both the timing and a decompression failure during the HTTP handshake.
This is not verified. I could not reproduce it: this host has no Docker available, so I have not run the two tests against any specific server version and have not identified which release first breaks the 0.13.3 client. The correlation above is the whole of the evidence; the causal claim is a hypothesis. What is established is the first list: the failure predates this branch, is identical across four unrelated branches, and no repository change coincides with it.
What this needs
Pinning the image to a known-good tag is the obvious shape of a fix, but choosing the tag requires a run that I cannot perform, and I have read-only access here (gh api repos/spiceai/datafusion-table-providers → permissions.push=false), so I can neither push such a fix nor re-run the job. A maintainer with write access is required — both for that and for this PR's review.
|
Status update, not a new escalation. While re-verifying the red checks on #87 and #88, I measured something that makes this PR more urgent than its own review queue suggests: the The measurementOne test fails, and it is the same one every time: Job conclusions for
3/3 fail without it, 2/2 pass with it. Why the obvious confounders don't explain it
The failure mode matches what this PR describes#87's run panics at A duplicate key surviving an Calibration — what this is and is notThis is a strong correlation, not a proof. n is small (5 runs), and this PR's own description establishes the underlying ordering is scheduling-dependent ("the first copy in 9 of 10 runs on the reporter's host, in 1 of 10 runs on mine"), so 2 green runs cannot rule out luck. The error text also varies between the failing runs — #88's I have What this means for the queueIf the correlation holds, merging this PR clears the |
|
Retracting my previous comment ( The artifact that refutes itI sampled only the runs in this repository and missed a sixth run on the fork. The same push produced two runs two seconds apart, on the same commit:
Identical SHA, opposite outcomes. The fork run died in the same test with: A same-commit pass/fail pair is the artifact for flaky, and it is the one piece of evidence my correlation table could not survive. 2 passes out of 3 runs on this commit is not a fix. It is already tracked, and this PR already said so
What the record should say
The process failure was mine and is specific: I built a causal claim from a 5-run sample without first searching for an existing issue covering the failure signature, and without looking for a same-commit rerun — the one check that would have refuted it in a single query. Both were available before I posted. |
…error and interval fixes, and read Turso's shifted timestamps (fixes spiceai#14661) (spiceai#15444) * test(accelerators): DuckDB upsert keeps a repeated key's last copy; SQLite fails a query it aborts; SQLite and Turso interval shifts keep the time of day Regression tests for spiceai#14643, spiceai#14661 and spiceai#14714, written against trunk before the datafusion-table-providers pin moves to the fork fixes they guard. * fix(accelerators): move datafusion-table-providers to the DuckDB upsert, SQLite row-error and SQLite interval fixes, and mark Turso's date operands Pins datafusion-table-providers at 87c2cba (spiceai/datafusion-table-providers#87, spiceai#88, spiceai#89). Turso builds its own federated table, so it now installs SQLiteRemoteTable and mark_federated_date_operands, which the shared interval rewrite reads to shift a DATE through date() and everything else through datetime(). * test(turso): read the timestamp SQLite's datetime() answers; check interval shifts reach the engine through federated projections * fix(turso): read the YYYY-MM-DD HH:MM:SS text SQLite's datetime() answers as a UTC timestamp, not NULL A shifted timestamp reaches Turso as datetime(ts, '-1 hours'), whose answer is not RFC3339, so the reader returned NULL for every row. * refactor(tests): share the CSV dataset and federated-SQL helpers, fold the upsert tests onto one helper, and pin rows inside the interval cases * test(sqlite): require the overflowing call in the SQL sent to SQLite, not anywhere in the plan * fix(accelerators): pin datafusion-table-providers at the SQLite fixes, before the DuckDB upsert statements that invalidate a DuckDB database spiceai/datafusion-table-providers#89 splits a DuckDB upsert into several statements in one transaction, and on DuckDB 1.4.4 a reused table then fails with an internal error that invalidates the database (604 of 1000 overwrites in a release A/B; 0 of 1000 before it). Pin spiceai#88's merge commit instead, and drop the spiceai#89 guard tests and ledger row. --------- Co-authored-by: t <a@b.c>


Summary
Under
on_conflict: upsert, the DuckDB sink wrote a whole stream as oneINSERT INTO t SELECT * FROM <arrow scan view> ON CONFLICT (k) DO UPDATE SET …. DuckDB resolves a conflict between two rows of the same statement by keeping whichever row it processes first, and its parallel scan of the statement's input decides that order. So a key the write repeated across record batches kept a copy chosen by scheduling: the first copy in 9 of 10 runs on the reporter's host, in 1 of 10 runs on mine (spiceai/spiceai#14643).A conflict between a row and a row an earlier statement of the same transaction inserted is resolved as
DO UPDATEsays. This change cuts an upsert write into statements at the first row whose key an earlier row of the same statement carried (core/src/duckdb/upsert_groups.rs): no key repeats within a statement, and across statements the last copy wins, in arrival order. A write that repeats no key is still one statement.u64hashes of their arrow row encoding; a collision only ends a statement early (one more statement, same rows).CREATE TABLE … AS SELECT * FROM <arrow view>, which is how the sink creates its tables):-0.0as0.0and every NaN as one NaN (INSERT … VALUES (-0.0, …) ON CONFLICT (id) DO UPDATEupdates a stored0.0); aDate64as aDATE(ms / 86_400_000, truncated toward zero:-1 msis 1970-01-01), a nanosecondTime64as a microsecondTIME, a nanosecond timestamp with a time zone as a microsecondTIMESTAMP WITH TIME ZONE(-1 nsis epoch 0 µs), and a dictionary as its values; anINTERVAL(a duration or interval column) and aFloat16cannot be index keys in DuckDB at all (Invalid type for index key/Unsupported Internal Arrow Type e), so they are never conflict targets. A nanosecond timestamp without a time zone is aTIMESTAMP_NSand keeps every nanosecond. The rows written still carry the values as given.INSERT (1,NULL,'x'),(1,NULL,'y') … ON CONFLICT (a,b) DO UPDATEstores one row), where across statements, as for a plain insert, it keeps both; so a repeated NULL key starts a new statement and every row is kept. Through the sink, repeated NULL keys within one batch are refused by the per-batch constraint check (itsDISTINCTcounts NULLs as one), so the one-statement-per-NULL cost applies only towrite_to_tablewithout constraints; across batches it is one statement per batch, as for any key.INSERT INTO table SELECT * FROM <view>), so the conflict target is resolved against the table's schema and read from the written data at that column position; the written data may name its columns differently, and a key column written as another type is compared as the table's type when arrow's cast is an exact conversion the insert performs the same way (a widened integer or float, a string or binary spelling, a dictionary or run-end encoding, a widenedDate32); a key written as any other type (a float into an integer key, which arrow truncates and DuckDB rounds; text into a number or date; a timestamp given a time zone, which DuckDB reads in its session's zone) puts every row in a statement of its own, which is slow and right.write_to_tableis the one entry point for the overwrite, append, and file-swap overwrite paths, so all three get the behavior.Refs spiceai/spiceai#14643 (the spiceai pin bump, with its accelerator regression tests and the
docs/dev/fork_patches.mdrow, closes it).Reproduction — spiceai/spiceai#14643
Shipped runtime, before. Spice v2.4.0-rc.1 (
spiced_linux_x86_64, whose datafusion-table-providers pin465926a3carries the samecore/src/duckdb/write.rsasspiceai-55's head;git diff 465926a3 ef2e590 -- core/src/duckdb/write.rsis empty), the issue's Spicepod (engine: duckdb,mode: file,refresh_mode: full,primary_key: id,on_conflict: { id: upsert },target_partitions: 1) overacross.csv(ids0..8191withv = first, then one row0,last), ten fresh starts, each asked:Engine, before (why one statement cannot be fixed by ordering). DuckDB 1.4.4 via Python, a
RecordBatchReaderyielding the same two batches, oneINSERT … SELECT * FROM src ON CONFLICT (id) DO UPDATE SET v = excluded.v, 20 runs each:With one thread the rows are processed in arrival order and DuckDB still keeps the first copy: within a statement the first-processed copy wins, and only a later statement's
DO UPDATEreplaces a stored row.Fork tests, before and after, on a CI sandbox (grokspice's fork;
make testruns theduckdb-federationlib tests in theTestsjob):the final regression tests alone on the unchanged
spiceai-55base (CI sandbox (red run): upsert regression tests alone on the unchanged base (spiceai/spiceai#14643) grokspice/datafusion-table-providers#2, commita1dd93e): https://github.com/grokspice/datafusion-table-providers/actions/runs/37894009849 (Testsjob 113701090024,make test):the same tests with the fix (CI sandbox: DuckDB upsert keeps the last copy of a repeated key (spiceai/spiceai#14643) grokspice/datafusion-table-providers#1, commit
fed05b3): https://github.com/grokspice/datafusion-table-providers/actions/runs/37893972430 (Testsjob 113700969462): the 9duckdb::write::test::upsert_*tests and the 27duckdb::upsert_groups::tests::*tests allok;test result: ok. 519 passed; 0 failed; 2 ignored; finished in 64.15s. (Clippygreen on both runs;DuckDB Tests (release)runs onlymake test-integration-duckdb, which dies on the unrelatedupsert_retention_memorysoak crash, DuckDB: upsert_retention_memory fails with engine state corruption (checksum-0 blocks, upsert PK violation, allocator bitmask) across unrelated branches spiceai#14183, on every branch of this fork today;Python Testshangs on every branch, so both sandbox runs were cancelled after theirTestsjob finished.)(The first tests-only push,
be156d8, did not compile on CI: its tests calledconnect_syncon a borrowed pool. That is why the red run is a separate branch carrying the final tests.)Changes
core/src/duckdb/upsert_groups.rs(new):write_statementsdrives one insert per statement overUpsertGroups, which hands out a write's rows statement by statement, andStatementReader, the arrow stream DuckDB scans for one statement.core/src/duckdb/write.rs:write_to_tableroutes anOnConflict::Upsertwrite throughwrite_statements;insert_from_readeris the one-statement body it and every other write use.upsert_groups::testsdrive the production loop (statement boundaries across batches and within a batch, every copy in its own statement, a repeated NULL key, empty write and empty batches, the key cap, composite keys,-0.0/NaN forf32andf64,Date64, nanosecondTime64, zoned and plain nanosecond timestamps, dictionary keys, case-insensitive target, missing and ambiguous target, an insert error);write::testthrough the real sink and DuckDB (upsert_overwrite_keeps_the_last_copy_of_a_key_repeated_across_batches,upsert_append_keeps_the_last_copy_over_stored_and_earlier_rows,upsert_overwrite_keeps_the_last_of_many_copies_under_a_parallel_scan,upsert_keeps_the_last_copy_of_a_key_repeated_within_a_batch,upsert_keeps_the_last_copy_of_a_float_key_duckdb_holds_as_one,upsert_keeps_the_last_copy_of_a_date64_key_duckdb_stores_as_a_date,upsert_keeps_every_row_whose_unique_key_is_null,upsert_resolves_the_conflict_target_against_the_table_not_the_written_names,upsert_keeps_the_last_copy_of_a_key_written_as_text_into_a_bigint). The module's tests also cover the conflict target read by the table's column position from differently named written data, written data too narrow for that position (an error), and a run-end-encoded key. The sink tests setSET threads = 1on the test database so that, before the fix, one statement keeps the first copy on every run rather than at random.Performance impact
Int64keys in 123 batches of 8,192 costs 82.3 ms (release build, best of 5, arrow-only probe of the module on this host, final revision), against 1,156 ms for the single-statementINSERT … ON CONFLICT DO UPDATEof the same rows into an empty table on DuckDB 1.4.4 (Python, best of 3), i.e. about 6%.primary_keyand every unique index into a constraint, andupsert_dedup_by_row_idresolves the batch to one row per key above the sink), so the ceiling there is one statement per record batch: 7,539 ms vs 1,156 ms for 1,000,000 rows in 123 batches into an empty table when every batch repeats a key of the previous one, 1,082 ms vs 172 ms into a table already holding them (same Python probe).write_to_tablecalled without constraints can reach one statement per repeated row; measured in Python at about 4.7–7.7 ms P99 per upsert statement (6,000 sequential statements, per 1,000-statement block), so one 8,192-row batch over 2 distinct keys is 4,096 statements and about 18 s where one statement took 0.14 s. That is the cost of the answer being right: before, those writes were one statement that kept a random copy, and on a NULL unique key dropped rows.(1,a),(2,b),(1,c)reports 3); as one statement DuckDB reported 2, not counting the copy it discarded.on_data_writtenreceives that count.Test plan
make test(cargo test --features …duckdb-federation… -p datafusion-table-providers --lib) — fork CITestsjob: 9 failed on the tests-only branch, 519 passed with the fix (runs and output above).cargo clippy --all-features -- -D warningsandcargo fmt --all -- --check— fork CIClippyjob green on run 37893972430 (fed05b3); the module also passes-D clippy::pedantic -D clippy::unwrap_usedin an arrow-only probe on this host.first_repeatforced toNone(no grouping) in the probe, 9 of the then 13 tests fail (a_key_repeated_in_a_later_batch_starts_a_new_statement_at_that_batch,every_copy_of_a_key_lands_in_its_own_statement_in_arrival_order, …FAILED;test result: FAILED. 4 passed; 9 failed).Review gate
/simplify: applied before the reviews (c691366): the statement loop moved intoupsert_groups::write_statements, the source trait and generics dropped, each batch's keys hashed once and a held batch kept with its hashes and offset, test fixtures deduplicated; the module's tests and the verify script re-run afterwards (20 passed;batch_verify.pybase run exit 1 at8a1e2a4c7).codex, two completed jobs.review-mv0k13lr-1vsvlnond8045a6: needs-attention, one medium finding — the sink's per-batch constraint check (UpsertOptions::default()) rejects a key repeated within one batch before the grouping runs, so[(0,a),(1,b),(0,c)]throughwrite_allfails rather than keepingc. Refuted as out of scope: that is the documentedupsertcontract (constraints.md: "a key repeated within one record batch fails the write unlessupsert_dedup_by_row_idis set"), unchanged by this PR; #14643 is the across-batch case, and relaxing the check is a surface change for its own Enhancement. The within-batch test is deliberately at thewrite_to_tablelevel and says so.review-mv0k6u3d-ixapo5on182d6e6: needs-attention, two medium findings, both fixed in1bdb838: the conflict target was resolved against the written data's column names although the insert is positional (now resolved against the table's schema and read by position; teststhe_conflict_target_is_read_from_the_written_data_by_the_tables_position,written_data_with_fewer_columns_than_the_tables_key_position_is_an_error, andupsert_resolves_the_conflict_target_against_the_table_not_the_written_namesthrough DuckDB), and a run-end-encoded key column skipped canonicalisation (now decoded like a dictionary; testa_run_end_encoded_key_repeats_by_its_decoded_value).review-mv0kgjoz-b81weson1bdb838: needs-attention, one medium finding, fixed in8721463: a key written as another type than the table's ("01"/"1"intoBIGINT) collides after DuckDB's cast but hashed apart; the key column is now cast to the table's type first.review-mv0kow3p-yrwb6pon8721463: needs-attention: arrow's cast disagrees with DuckDB's for a float written to an integer key (truncate vs round) → fixed in19c9716: only exact conversions are trusted (converts_exactly, testonly_exact_conversions_are_trusted; the time-zone arm was later removed, next round), any other type change isolates every row in its own statement (testsa_key_written_as_a_type_the_insert_converts_inexactly_isolates_every_row,a_key_written_as_a_narrower_integer_is_compared_as_the_tables_integer, andupsert_keeps_the_last_copy_of_a_key_written_as_text_into_a_bigintthrough DuckDB); the within-batch finding repeated and stands refuted as above.review-mv0kx53s-sex12pon19c9716: needs-attention, two medium findings, both fixed infed05b3: the isolate path still ran arrow's cast (a struct written into aVARCHARkey has no arrow cast but DuckDB's insert converts it) → an isolated write hashes nothing (testan_isolated_write_converts_nothing_itself); attaching a time zone is not exact, since DuckDB reads a zone-less timestamp in its session's zone → removed from the trusted conversions (only_exact_conversions_are_trusted). Final HEADfed05b3:review-mv0l8gzv-0cc65u, needs-attention with that same within-batch finding only (refuted as above); receiptengine=codex exit=0.Before codex had credits (
Your workspace is out of creditsonc691366, receiptattempt=codex result=exit-1 engine=none), a same-family stand-in (a fresh-context Claude agent told to run probes, not cross-model) reviewedc691366and found, with Python DuckDB 1.4.4 artifacts: (1) a later NULL unique key within one statement is dropped by DuckDB → fixed ind8045a6(NULL keys hashed like values; testupsert_keeps_every_row_whose_unique_key_is_null); (2)Date64/Time64(ns)/zoned ns timestamps compared above DuckDB's stored precision → fixed ind8045a6(canonical_type/canonical_key_column; unit tests per type andupsert_keeps_the_last_copy_of_a_date64_key_duckdb_stores_as_a_date); (3) one statement per repeated key within a batch costs ~4.7–7.7 ms P99 each → accepted with numbers above (unreachable through the sink, which refuses within-batch repeats); (4) the reported row count now counts updates → noted above./code-review high(sub-agent on the branch diff; the skill diffs the main checkout): no confirmed defect; raised the precision gap (fixed, above), the dictionary interning hypothesis (arrow 59's row format no longer interns; the dictionary is cast to its values before encoding), the statement-count cost (accepted, above), and the new key-resolution error wording (kept: DuckDB previously raised a binder error for the same input). Round 2 on thec691366..d8045a6delta: one vacuous test (the zoned-timestamp case could not tell truncation from floor) → fixed in182d6e6, watched failing underdiv_euclid(left: [2, 3] right: [2, 2, 1]); the INTERVAL gap → moot, DuckDB refusesINTERVALas an index key, arm removed and documented; unorderedstring_agg→ORDER BY name./security-review(sub-agent, same diff): no critical or high finding; no new SQL path (the conflict-target names only reachschema.index_of), the per-statement view name is dropped before the next registration andduckdb_arrow_scanreplaces on a clash, memory bounded by the key cap, no panic reachable from data, an error rolls the transaction back. One pre-existing identifier-quoting observation inon_conflict.rs, unchanged by this PR, is being reported privately rather than in this public thread.Follow-ups
datafusion-table-providerspin, add thedocs/dev/fork_patches.mdrow with theaccelerator-duckdbguard tests, and close DuckDB acceleration: on_conflict: upsert keeps the first copy of a key a refresh repeats across record batches, varying run to run spiceai#14643.website/docs/features/data-acceleration/constraints.mdline 98 ("which copy is kept can vary from run to run") describes the behavior this change removes; it changes with the pin bump.