Skip to content

fix: fall back to string-only when the VARIANT write fails - #118

Merged
fuziontech merged 3 commits into
mainfrom
fix/variant-write-fallback
Aug 13, 2026
Merged

fix: fall back to string-only when the VARIANT write fails#118
fuziontech merged 3 commits into
mainfrom
fix/variant-write-fallback

Conversation

@fuziontech

Copy link
Copy Markdown
Member

Root cause of the 2026-08-12 incident

Enabling VARIANT dual-write on prod-us events-nrt crash-looped all 16 consumers within minutes (PostHog/charts#14189, reverted in PostHog/charts#14232).

One tenant's payload contained a JSON integer above INT64_MAX (9223372036854775999). try_cast(try_cast(properties AS JSON) AS VARIANT) accepts it — it's a legitimate UINT64 variant, and sub-field access returns it fine. But DuckDB shreds VARIANT into typed Parquet columns on write, and there the value overflows converting UINT64 → INT64:

Invalid Input Error: Type UINT64 with value 9223372036854775999 can't be cast
because the value is out of range for the destination type INT64

The INSERT fails, the flush fails, offsets never advance, and the same batch fails forever. try_cast cannot guard this: the cast isn't what breaks, the physical write is.

Fix

Retry the failed flush once with the VARIANT projection dropped. Records land string-only with a NULL companion — the same degradation ensure_variant_columns already applies for a wrong-typed companion — instead of wedging the partition. If the string-only retry also fails, the error propagates unchanged, since the projection wasn't the cause.

New signals: millpond_variant_write_fallback_total (per flush) and errors_total{type="variant_write"}.

Rejected alternative: normalizing huge integers to strings in the JSON text before the cast. A regex over raw JSON corrupts any string value containing a 19+ digit run ({"session": "12345678901234567890"} → invalid JSON), and the string column is authoritative anyway. Values are never rewritten.

Why the tests didn't catch it

The existing conn fixture is plain in-memory DuckDB — it never writes Parquet, so it cannot exercise shredding at all. This PR adds a ducklake_conn fixture backed by a real local DuckLake catalog with a Parquet data path, which reproduces the production failure exactly. Shredding only trips above DuckLake's data-inlining threshold, so these tests use a few hundred rows rather than one.

Three new tests: the poison batch lands string-only (all rows present, companion NULL); a later clean batch still dual-writes (the fallback is per-flush, not sticky); an unrelated INSERT failure still raises.

Upstream

try_cast succeeding while the write overflows looks like a DuckDB bug worth reporting separately — this fix is the operational guard either way.

Test plan

  • 715 passed, 1 xfailed; ruff + format clean
  • New tests fail without the fix (verified: the poison batch raises InvalidInputException)

🤖 Generated with Claude Code

2026-08-12 incident: enabling dual-write on prod-us events-nrt
crash-looped all 16 consumers. One tenant's payload carried a JSON
integer above INT64_MAX; try_cast(... AS VARIANT) accepts it (it is a
valid UINT64 variant), but DuckDB's shredded Parquet write then
overflows converting UINT64 -> INT64 and fails the whole INSERT. Offsets
never advanced, so the same batch failed forever.

Retry the failed flush once with the VARIANT projection dropped: the
records land string-only with a NULL companion, matching how
ensure_variant_columns already degrades a wrong-typed companion. If the
retry also fails the error propagates — the projection wasn't the cause.
Values are never rewritten; regex-normalizing big ints in the JSON text
corrupts any string containing a long digit run.

Adds variant_write_fallback_total + errors_total{type="variant_write"}.

Tests use a new ducklake_conn fixture — a REAL local DuckLake catalog
writing Parquet. The existing in-memory `conn` fixture cannot reproduce
this class of bug at all (no Parquet, no shredding), which is exactly
why the pre-incident test suite passed. Shredding needs a few hundred
rows to trip data inlining.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@fuziontech
fuziontech requested a review from a team August 12, 2026 17:27
fuziontech and others added 2 commits August 12, 2026 17:51
Review found the batch-level retry was the wrong layer. It absorbed
retryable failures (commit contention permanently NULLed companions and
blinded the contention alert), dropped every source's companion rather
than the offender's, skipped the reset_caches() the outer retry drives,
leaked an abandoned Parquet file per flush, and could not help at all
when a small write is INLINED into the catalog — verified: that path
succeeds, then detonates later in ducklake_flush_inlined_data, where no
retry can reach it.

Prevent instead: _variant_projection nulls the companion per row for
JSON carrying a 19+ digit integer in value position. Anchoring on the
delimiters keeps long digit runs inside string values (session ids) from
false-positiving — verified against a real catalog. Only offending rows
lose VARIANT; neighbours, other sources, and every string column are
untouched, and nothing unshreddable ever reaches the column, inlined or
not.

The retry survives as a narrow backstop for value shapes the pattern
misses: only the out-of-range conversion signature is absorbed, counters
increment after the string-only write actually succeeds (they inflated
3x per flush during outages), and the log says a nonzero counter means a
guard bug, not routine degradation.

Tests: promote the local-DuckLake fixture to tests/integration/conftest.py
with its pytest.skip guard (the copy here would hard-error CI offline),
add an inlining-enabled variant, and cover per-row precision, the inlined
path, second-source isolation, contention NOT being absorbed, and the
backstop still firing. The old re-raise test failed both INSERTs so it
passed against unfixed main; it now fails only the projecting INSERT.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…JSON

Re-review of the reworked guard found it would crash-loop prod again,
for a new reason, and was wrong in both directions on top of that.
Verified each claim against a real DuckLake catalog:

- regexp_matches() only binds VARCHAR. arrow_converter types each key
  from its first non-null sample, so an all-numeric batch for the
  configured source yields int64 — BinderException, backstop doesn't
  match it, partition wedges. main handled this fine.
- The pattern required a leading :/,/[ delimiter, so a bare top-level
  number never matched. Under default data inlining that commits poison
  into catalog state and breaks ducklake_flush_inlined_data forever,
  where no write-time retry can reach it.
- "19+ digits" is not the hazard. Measured: only (INT64_MAX, UINT64_MAX]
  fails to shred. Nanosecond timestamps and snowflake ids — 19 digits,
  ubiquitous — shred fine but were having their whole row's companion
  nulled, silently, with no metric.

Replace the SQL regex with a two-stage Arrow pass: a vectorized
prefilter shaped to the window's decimal form (19 digits leading 9, or
20 leading 1 — so ns timestamps don't trip it), then orjson round-trip
on just the flagged rows, rewriting only ints in the dangerous window as
strings. Precision comes from the parser, never from a pattern. Result
lands in a hidden per-source column read only inside the VARIANT cast,
so the source column still writes byte for byte, and the SQL projection
goes back to main's simple form — no type fragility.

Degradation is now "one value is typed as a string" rather than "the row
loses its whole companion", and it is visible: variant_values_coerced_total.
~10ms per 8k-row flush when nothing matches, ~22ms when something does.

Tests pin the boundary in both directions (ns timestamps, snowflakes,
INT64_MAX, >UINT64_MAX, negatives all keep numeric typing), cover the
bare-number/inlined path and non-string sources, and assert the fallback
counter fires. The old tests encoded a false premise — that a 30-digit
INT128 value explodes — which would have blocked any correct narrowing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@fuziontech
fuziontech merged commit 0edd03c into main Aug 13, 2026
17 checks passed
@fuziontech
fuziontech deleted the fix/variant-write-fallback branch August 13, 2026 01:10
jghoman added a commit that referenced this pull request Aug 13, 2026
…drift-gated (#119)

The 1.5.2 extension channel is frozen at ducklake build 415a9ebd, which
leaks per-connection native memory proportional to catalog metadata
churn (A/B-verified in viaduck: ~1MiB per snapshot-advancing bind).
The 1.5.5 channel serves d8a1881e, which is flat — millpond's long-lived
writer connections against a busy shared catalog have the same exposure.
pyducklake (ours) rides along as a deliberate lockstep canary: it
hard-pins duckdb, so a one-sided bump of either package fails uv lock
instead of silently splitting the two DuckLake stacks. Published
yesterday, so it gets a fixed-date exclude-newer-package carve-out from
the 7-day cooldown.

Because the 1.5.5 channel is LIVE (the frozen channel was accidental
drift protection), the Dockerfile now asserts the ducklake build SHA in
the extension-install layer — an unexpected ducklake build cannot
produce an image at all. Verified both directions: correct SHA builds,
wrong SHA fails the layer. The release workflow runs in parallel with
CI, so the test canary alone could not stop a drifted build from
reaching the fleet via the mutable tag.

Behavior changes under d8a1881e, all verified against real catalogs:
- The (INT64_MAX, UINT64_MAX] shred-rejection window and its write-time
  error signature are UNCHANGED; the #118 guard and backstop hold. The
  inlined-path deferred failure now reports INT128 (was UINT64); the
  width-agnostic matcher still catches it.
- JSON numbers outside [INT64_MIN, UINT64_MAX] in a VARIANT become
  lossy DOUBLE (were digit-preserving VARCHAR). Deliberate policy:
  follow native DuckLake semantics; the string column stays
  authoritative (now asserted). Rows written across the version
  boundary mix both representations in the companion permanently.
- The ducklake attach no longer lists its metadata db as a separate
  duckdb_databases() row (test-fixture-only impact).
- No catalog metadata migration: old and new builds read/write the same
  catalogs interchangeably, so mixed fleets and co-readers (viaduck,
  duckgres) are unaffected.

New tests/integration/test_ducklake_engine_contract.py pins the engine
behaviors the variant guard assumes — the containment invariant
(engine-rejected values must be guard-rewritten, probed through the real
sanitize_variant_sources chain with the guard bypassed at the engine),
its converse (shreddable probes round-trip untouched), boundary values,
nested/array/sci-notation shapes, error-signature recognition, and the
inlined commit-then-detonate path — so the next bump that shifts any of
this fails at the PR instead of on the fleet.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants