Repository navigation
fix(duckdb): three DuckDB 2.0 drift defects — cursor row loss, false schema drift, invalid discovered contracts - #749
Merged
Conversation
DuckDB 2.0 infers HUGEINT where 1.5 infers DOUBLE for the same integer literal, and TIMESTAMP WITH TIME ZONE where 1.5 infers TIMESTAMP for the same JSON timestamp. The drift gate compared type NAMES by string equality, so upgrading DuckDB -- no contract edit, no new data -- raised SchemaDriftError on a working pipeline. A false positive: the inferrer drifted, the data did not. Both versions satisfy `duckdb>=1.5.0`, so Python 3.11+ already resolves to 2.0. Those pairs are now one physical type under two names. The family check runs AFTER the widening/narrowing ladder, not before: a check placed first would collapse every ordered pair inside _NUMERIC_ORDER and type_widened / type_narrowed would never fire again. With the narrow explicit table that ordering is defensive rather than load-bearing, since only `double` of those eight names belongs to a family at all -- but the obvious future refactor is to reuse the broad `normalize_type` helper, under which all eight collapse, and the 56-ordered-pair test fails loudly at that point. The test docstring says so rather than claiming a discrimination it does not have. `family_rename_note` keeps it honest. DOUBLE and HUGEINT are NOT value- identical: for the same CSV literal 1.5 landed 1.7014118346046924e+35 (17 significant digits, lossy) where 2.0 lands the exact 128-bit integer. The pair is collapsed so the pipeline keeps running, and the note states that the physical type moved, so the precision change is reported instead of hidden. Founder decision, 2026-10-26. Verified: the 56 ordered numeric pairs are all still classified (0 swallowed), widening and narrowing still report themselves, real drift still reports type_changed, and the version renames are silent. 62 tests green on duckdb 1.5.6 and 2.0.0.
…dator rejected
The emitter wrote the source's type name straight through, so a discover on
DuckDB 2.0 produced `type: hugeint` and `type: '"null"'` and `fluid validate`
then exited 1 on the file the tool had just written. Reproduced through the real
CLI: on 2.0.0 both emitted contracts fail validation
("'hugeint' is not valid under any of the given schemas"), and after this change
both exit 0; on 1.5.6 the emitted types and exit codes are unchanged.
Scope is wider than reported. Validating every name in DuckDB's own
`duckdb_types()` against `$defs.column.properties.type` of the newest bundled
schema shows 35 rejected names, not two -- and `uinteger` and `integer[]` are
rejected on 1.5 as well, so this was never only a 2.0 problem. The schema's
pattern branch already accepts parameterised and spaced forms
(`decimal(18,2)`, `varchar(255)`, `struct(...)`, `timestamp with time zone`),
so only `<type>[]` and the quoted `"NULL"` need shape handling.
Canonicalised at the one chokepoint, `emitter.py`, so every discover backend
inherits it. An unsigned or 128-bit type maps to a width that HOLDS its range
rather than the similar-sounding name: `uinteger` (0..2^32-1) does not fit
`integer`, and `hugeint` does not fit `bigint`, so they become `bigint` and
`numeric`. That is a deliberate departure from the `hugeint -> bigint` sketch in
the decision: `bigint` would under-represent, and both land in the same family
for drift purposes either way.
A name the table does not list passes through unchanged, which is deliberate
rather than a gap -- the schema accepts a cross-dialect superset, so the JDBC
backends' own dialect names must not be squashed. The docstring says that
instead of claiming a `string` fallback the code does not implement.
Contracts already emitted with an invalid type stay invalid (the fix is
emit-time), but those already failed validation, so nothing working changes.
`tests/test_ci_duckdb_floor.py` correctly demanded the new version-sensitive
test files be registered with the duckdb-floor CI job; all three are added.
…change
Live in shipped 0.22.0. The cursor is persisted as text and pasted into
`WHERE <field> > '<text>'` as an UNTYPED literal, and the two DuckDB versions
disagree about the column type it is coerced against: `read_json_auto` over a
timestamp carrying `Z` resolves TIMESTAMP on 1.5 and TIMESTAMP WITH TIME ZONE on
2.0, where a naive literal is read in the session time zone -- the host's, since
nothing pinned it. On a host with a non-zero UTC offset the `>` boundary
therefore moved by that offset.
Measured end to end, 6 rows in 3 batches at TZ=America/New_York: writing the
cursor on 1.5.6 and continuing on 2.0.0 landed 5 of 6 rows -- id=3 was never
landed and the cursor advanced past it -- at exit code 0 with the stream
recorded `succeeded`. Nothing warned. Both versions satisfy `duckdb>=1.5.0`, so
one install can write state another reads. CSV sources were never affected:
`read_csv_auto` already sniffed TIMESTAMPTZ on 1.5.
A timestamp cursor now compares INSTANTS, `epoch_us(field) > epoch_us(TIMESTAMP
'<naive-utc>')`, which no session time zone can shift, and the stored text is
normalised to a naive UTC instant. All five scenarios now land the same rows as
the all-one-version control: cross-version in both directions, under UTC-4 and
UTC+2, and a legacy cursor already on disk.
No state file needs rewriting. A cursor a shipped 2.0 install wrote carries an
explicit offset (`2026-05-01 10:00:00-04:00`); 1.5 drops that offset when it
coerces to a naive TIMESTAMP, which re-emitted rows. Normalising on READ repairs
those in place. Measured: normalising the text alone still lost id=3, because
what moves is the coercion, not the text -- both halves are needed.
Pinning the session zone to UTC was tried first and REVERTED. `TimeZone` is an
ICU-backed setting; the sandbox disables extension autoloading and external
access, so where ICU is not already loaded DuckDB does not even recognise the
option ("The following options were not recognized: TimeZone") and the
connection fails. It passed locally only because this machine has ICU
statically loaded; it broke 3 in-tree tests, including one whose DuckDB had no
ICU reachable. A pin would therefore fail exactly the hardened environments the
sandbox exists for. `epoch_us` needs no extension -- verified with autoload
disabled and an empty extension directory, on both versions and both zones.
Scalar cursors keep the plain comparison, including date-as-integer ones:
Python 3.11+ parses basic ISO, so a bare `20260501` would otherwise take the
instant path and `epoch_us` would fail on its INTEGER column.
Accepted cost: wrapping the column in a function can stop the predicate being
pushed into the Parquet or JSON reader, so a large source scans more. Correct
rows beat a faster wrong answer.
The output-determinism half of Trello 1024 is NOT here. Its only known fix is
the ICU-dependent pin, and it is not a 2.0 regression at all -- 1.5.6 writes
byte-identical host-local output for the same zone -- so it is being filed on
its own merits. Founder decision, 2026-10-26.
…nt family rename Both found by the scoped review of this branch, both confirmed, both mine. 1. HIGH. `epoch_us(VARCHAR)` is a BinderException on duckdb 1.5.6 and 2.0.0, so a VARCHAR cursor column holding timestamp-looking values would have failed EVERY incremental run on it. `_cursor_comparison` chose the instant path from the cursor TEXT, and a VARCHAR column of `'2026-05-01 10:00:00'` yields a cursor indistinguishable from a real timestamp's. That was my shortcut: the approved option said a column-type probe, and I inferred from the value instead. `_cursor_column_type` now asks the source (`typeof(...) ... LIMIT 1`, with `DESCRIBE` as the fallback for an empty source) and only a type in `_EPOCH_US_TYPES` takes the instant path. That set was measured, not assumed: TIMESTAMP, TIMESTAMPTZ, TIMESTAMP_S/_MS/_NS, DATE and TIME are accepted on both versions; VARCHAR, INTEGER, DECIMAL and the `"NULL"` of an all-null column all raise. With no connection the predicate is exactly what the previous release emitted, so nothing regresses where the probe cannot run. 2. MEDIUM. The family collapse let a contract that declared `schemaPolicy: strict` pass a `timestamp` -> `timestamptz` or `double` -> `hugeint` change with no event, hence no decision, hence no error — and `family_rename_note` had NO production caller, so the comment claiming the precision change is "reported rather than hidden" was false as shipped. It is also reachable without any DuckDB upgrade: an offset-bearing CSV infers TIMESTAMPTZ on 1.5.6 as well, so a declared `timestamp` against an upstream that genuinely gains a zone offset hit it too. `_log_family_renames` now runs in `enforce_schema_policy_or_raise` BEFORE its early return, so the operator is told the physical type moved whatever the policy decides. Suppressing the failure stays the intent; suppressing the notice was not. Writing the test for (2) surfaced a third bug of my own: `family_rename_note` answered for an unchanged column too, because `double` is same-family as itself — so the gate would have logged a spurious rename on every run. It now returns None when the two names are equal. Mutation-checked all three: deciding the instant path from the value fails 9 tests, dropping the probe call fails the call-site test, and removing `_log_family_renames` from the gate fails the gate-level test. That last one needed a test through `enforce_schema_policy_or_raise` rather than the helper — a direct call passes either way, which is how the dead note got shipped in the first place. 2477 tests green on duckdb 1.5.6 and 2.0.0.
…moving it Third must-fix from the review, and the one that made the discover fix incomplete rather than wrong. `fluid init --discover` writes the CANONICAL type into the contract (`numeric` for a HUGEINT column) while DuckDB goes on inferring the SOURCE type (`hugeint`), and `numeric` was not in any family — so the drift gate failed at apply exactly what the emitter had just stopped failing at validate. Measured before the fix: all eight remapped numeric names plus `list` and `null` came back `type_changed`. The family table now also holds the names the emitter canonicalises TO, so the round trip is closed. Verified over the installed DuckDB's entire `duckdb_types()` list: every name discover can emit now survives the gate, on 1.5.6 and 2.0.0. The unsigned and 128-bit groups are kept apart by width (`uinteger`/`uint32`/`oid` with `bigint`; `uint16`/`usmallint` with `integer`; `uint8`/`utinyint` with `smallint`), which matches how the emitter chooses a width that holds the range. Two tests stop the tables drifting apart again: one asserts every alias in the emitter's table is same-family as its target, the other sweeps `duckdb_types()` through emit-then-diff. Neither existed, which is how this got through the first time. Quotes are stripped in `_same_type_family` because `typeof(NULL)` is the quoted string `"NULL"` — that is how an all-null column arrives. `string`/`null` is deliberately its own group with no real type in it, so `string` -> `integer` once data actually arrives is still drift. Also from the review, all confirmed and all mine: - `_canonical_cursor_text` raised an unhandled OverflowError for a year-1 or year-9999 cursor across a non-zero offset, turning a nonsense value into a failed run. Guarded; the text is returned unchanged, as the previous release compared it. - A docstring still cited the reverted time-zone pin and `_duckdb_compat.SESSION_TIME_ZONE`, a symbol this branch deleted. Corrected; no references remain. - The schema tests validated against the NEWEST bundled schema (0.7.6) while the emitter declares `fluidVersion: 0.7.3`, which is what `fluid validate` actually gates on. They now gate on the declared version AND on every bundled version, so a future bump cannot quietly break it. Checked: the mappings are valid on 0.7.3, 0.7.5 and 0.7.6 alike, so this was test fidelity, not a bug. - The emitter's wiring was pinned by nothing — 827 discover tests passed with the call site reverted. Now mutation-caught. 2486 tests green on duckdb 1.5.6 and 2.0.0.
📄 Documentation ReminderThis PR appears to be missing a documentation reference. Our docs live in a separate repo. Please update the PR description with one of:
See the Contributing Guide for details. |
CodeQL flagged `fluid_build.cli.discover.emitter` as imported with both `import` and `import from` in this file: a top-level `from ... import canonical_column_type` plus two function-local `import ... as emitter` statements. Taken as suggested, because here it genuinely applies: the tests only call `emit_contract`, so the module object was never needed. It is now in the top-level `from` import and both local aliases are gone. (Not the same situation as the sandbox spill tests on #743, where the module object IS required — those tests replace a private helper that production code resolves as an attribute of its own module, so a bare imported name would patch nothing. Nothing here monkeypatches.) 2486 tests green on duckdb 1.5.6 and 2.0.0, and reverting the emitter's call site still fails test_the_emitter_actually_applies_the_canonicalisation, so the wiring is still pinned.
This was referenced Oct 26, 2026
Merged
fas89
added a commit
that referenced
this pull request
Oct 27, 2026
Cuts 0.23.0 from the unreleased changes. A minor bump, not a patch: it adds seven optional 0.7.6 preview fields, and it changes the shape of exported ODCS and ODPS artifacts — the per-port contract name, the input-port contractId, the Kafka server format and column physicalType, quality rule ids, and the import pass-through bucket's address. Two of its commits are marked breaking, since a contract whose pinned contract id collides with a sibling's now fails validate and every export path; that export used to succeed and silently drop one output port's entire schema. The release also carries three commits that were on main in no release yet: the .tmp symlink sandbox fix (#743), the DuckDB 2.0 drift fixes (#749), and the acquisition ambient-credential fix (#750).
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.
Closes three of the four items on the roadmap board's "[P1][forge-cli] DuckDB 2.0 type and time-zone drift" (Trello 1024). All three are live in shipped 0.22.0. The fourth is deliberately split out — see the end.
Both DuckDB versions satisfy
duckdb>=1.5.0, so Python 3.10 stays on 1.5.x while 3.11+ resolves to 2.0: one install can write state another reads. Everything below was reproduced on duckdb 1.5.6 and 2.0.0 in two full project environments.1. An incremental cursor lost rows across a version change
The cursor is persisted as text and pasted into
WHERE <field> > '<text>'as an untyped literal.read_json_autoover a timestamp carryingZresolvesTIMESTAMPon 1.5 andTIMESTAMP WITH TIME ZONEon 2.0, where a naive literal is read in the session time zone — the host's, since nothing pinned it. The>boundary moved by the host's UTC offset.Measured end to end, 6 rows in 3 batches at
TZ=America/New_York: writing the cursor on 1.5.6 and continuing on 2.0.0 landed 5 of 6 rows —id=3never landed and the cursor advanced past it — at exit code 0 with the stream recordedsucceeded. Nothing warned.A timestamp cursor now compares instants,
epoch_us(field) > epoch_us(TIMESTAMP '<naive-utc>'), which no session zone can shift, and the stored text is normalised to a naive UTC instant. Five scenarios now land the same rows as the all-one-version control: cross-version both directions, UTC-4 and UTC+2, and a legacy cursor already on disk.No state file needs rewriting. A cursor a shipped 2.0 install wrote carries an explicit offset (
2026-05-01 10:00:00-04:00); 1.5 drops that offset coercing to naiveTIMESTAMP, which re-emitted rows. Normalising on read repairs those in place. Measured: normalising the text alone still lostid=3— what moves is the coercion, not the text, so both halves are needed.CSV sources were never affected:
read_csv_autoalready sniffedTIMESTAMPTZon 1.5.Why there is no
TimeZone=UTCpinA pin was implemented, approved, and reverted.
TimeZoneis an ICU-backed setting; the sandbox disables extension autoloading and external access, so where ICU is not already loaded DuckDB reportsThe following options were not recognized: TimeZoneand the connection fails. It passed locally only because that machine has ICU statically linked — it broke 3 in-tree tests, one of whose DuckDB had no ICU reachable. A pin would fail exactly the hardened environments the sandbox exists for.epoch_usneeds no extension, verified with autoload disabled and an empty extension directory.Accepted cost: wrapping the column in a function can stop the predicate being pushed into the Parquet or JSON reader, so a large source scans more. Correct rows beat a faster wrong answer.
2. Upgrading DuckDB raised a false
SchemaDriftErrorThe drift gate compared type names, and 2.0 infers
HUGEINTwhere 1.5 infersDOUBLEfor the same integer literal, andTIMESTAMPTZwhere 1.5 infersTIMESTAMP. A DuckDB upgrade alone — no contract edit, no new data — broke a working pipeline.Those pairs are now one physical type under two names. The widening/narrowing ladder is untouched: the family check runs after it, and all 56 ordered
_NUMERIC_ORDERpairs are still classified (0 swallowed). Real drift —varchar→integer,string→integer,timestamp→date— still reportstype_changed.DOUBLEandHUGEINTare not value-identical: for the same CSV literal 1.5 landed1.7014118346046924e+35(17 significant digits, lossy) where 2.0 lands the exact 128-bit integer. The acquisition gate therefore logs that the physical type moved, before its early return, so a contract declaringschemaPolicy: strictstill hears about it.3.
fluid init --discoveremitted contracts its own validator rejectedThe emitter wrote the source's type name through, so a discover on DuckDB 2.0 produced
type: hugeintandtype: '"null"'andfluid validateexited 1 on the file the tool had just written. Through the real CLI: before → both contracts exit 1 on 2.0.0; after → both exit 0; 1.5.6 unchanged in both directions.Scope is wider than reported. Validating every name in DuckDB's own
duckdb_types()against the schema showed 35 rejected names, not two — anduintegerandinteger[]were rejected on 1.5 too, so this was never only a 2.0 problem. An unsigned or 128-bit type maps to a width that holds its range (uinteger→bigint,hugeint→numeric), not to the similar-sounding name.What the review caught in my own work
The scoped review returned three must-fix findings, all real, all mine:
epoch_us(VARCHAR)is aBinderExceptionon both versions, so a VARCHAR cursor column holding timestamp-looking text would have failed every incremental run. The approved design said a column-type probe; I had inferred from the cursor value instead. Now_cursor_column_typeasks the source and only measuredepoch_us-capable types take the instant path.numericwhile DuckDB keeps inferringhugeint, so the gate failed atapplywhat the emitter stopped failing atvalidate; all eight remapped numerics pluslistandnulltripped it. The family table now holds the emitter's canonical targets, verified across the wholeduckdb_types()list, and two new tests stop the tables drifting apart again.family_rename_notehad no production caller, so an earlier commit message of mine claiming the precision change was "reported rather than hidden" was false. Writing the test for it exposed a further bug: it fired for unchanged columns too, which would have logged a spurious rename every run.Plus four confirmed lows: an unhandled
OverflowErrorfor a year-1/year-9999 cursor across an offset; a docstring citing the reverted pin and a deleted symbol; tests validating against schema 0.7.6 while the emitter declares 0.7.3 (mappings are valid on 0.7.3/0.7.5/0.7.6, so fidelity not a bug); and the emitter's wiring, which 827 discover tests passed with reverted.Validation
main(a macOS/varquirk inbroad_root_reason×2, a dotenv-reader test, a live coding-agent test). 0 caused by this change._log_family_renamesfrom the gate fails the gate-level test; reverting the emitter call site fails the wiring test. Two of my earlier tests turned out not to discriminate — which is how the dead note shipped in the first place — so each guard is now verified by reverting it.ruffand CI-pinnedblack==24.10.0clean whole-tree. Startup budget 6/6.ripwire --quality-deltagating 16, exactly the base — no quality regressions.tests/test_ci_duckdb_floor.pycorrectly demanded the new version-sensitive files be registered with theduckdb-floorjob; all three are.Not here
The fourth item — landed CSV/Parquet timestamps follow the host time zone — is split out deliberately. It is not a 2.0 regression at all (1.5.6 writes byte-identical host-local output for the same zone), and its only known fix is the ICU-dependent pin above, so it deserves a card where that constraint is the headline rather than riding along on a fix that no longer carries it.
Documentation
No new flag, config key, environment variable or CLI surface. The three fixes are internal: a SQL predicate, a type-family comparison, and a type mapping at the discover emitter. What changes for a user is that a build which silently lost rows now keeps them, a pipeline that failed a DuckDB upgrade now runs, and a discovered contract now validates — all of which are recorded in
CHANGELOG.mdunder## [Unreleased].