Skip to content

Add RapidCheck property-based tests that find real DuckDB bugs - #4

Open
peterxcli wants to merge 2 commits into
mainfrom
property-tests-rapidcheck
Open

Add RapidCheck property-based tests that find real DuckDB bugs#4
peterxcli wants to merge 2 commits into
mainfrom
property-tests-rapidcheck

Conversation

@peterxcli

Copy link
Copy Markdown
Owner

What is this?

A property-based test harness for DuckDB using RapidCheck (the C++ equivalent of Rust's proptest), inspired by apache/arrow-rs#10352.

Instead of fixed inputs, each test states a property and RapidCheck runs it against hundreds of randomly generated inputs, shrinking any failure to a minimal counterexample:

  • round trips — any value must survive CAST(v AS VARCHAR) -> CAST(back AS T), Value::ToSQLString() -> re-parse, to_json -> cast back, INSERT -> SELECT, and write -> CHECKPOINT -> reopen for every force_compression setting
  • oraclesLIKE/ILIKE vs a 40-line reference matcher; substring/split/pad/trim/translate/levenshtein vs reference implementations; integer/HUGEINT/DECIMAL arithmetic must error exactly when the __int128 result is out of range; date parts vs independent civil-calendar algorithms
  • consistency — constant vs parameterized paths, list_sort vs ORDER BY, list_contains vs list_position

The core piece is test/property/generators.cpp: random LogicalTypes (including nested STRUCT/LIST/ARRAY/MAP/UNION/ENUM) and random Values of any type, seeded with adversarial special cases (NaN/-0.0/denormals, NUL bytes, combining marks, quotes/braces in strings, extreme dates/timestamps/intervals, decimal width/scale limits).

How to build

# 1. build DuckDB with assertions + ASAN/UBSAN (best build for bug hunting)
GEN=ninja CORE_EXTENSIONS='json' make relassert

# 2. build the property tests (standalone project; links against the build above,
#    so iterating on tests never rebuilds DuckDB; RapidCheck is fetched via FetchContent)
cmake -S test/property -B build/property -G Ninja -DDUCKDB_BUILD_DIR=$PWD/build/relassert
cmake --build build/property

How to run / how to find bugs

# run everything (100 cases per property by default)
build/property/property_test "[property]"

# crank up iterations for a hunting session; noshrink=1 surveys failures fast
# (shrinking large nested counterexamples re-runs a query per shrink step)
RC_PARAMS="max_success=1000 max_size=45 noshrink=1" build/property/property_test "[property]"

# one suite: [roundtrip] [strings] [lists] [arithmetic] [datetime] [storage]
build/property/property_test "[strings]"

# reproduce a failure deterministically (RapidCheck prints the seed of every run)
RC_PARAMS="seed=4570269180443172881" build/property/property_test "VARCHAR cast round trip"

When a property fails you get the shrunk counterexample plus the exact value/SQL/error, e.g.:

Falsifiable after 981 tests and 7 shrinks
Round trip through VARCHAR failed
  original: TIME WITH TIME ZONE[][]: [['00:00:00-05:59']] timetz_bits=75658
  back:     TIME WITH TIME ZONE[][]: [['00:00:00-05:59']] timetz_bits=79139

…which is how the TIMETZ formatting bug below was found (two different offsets printing identically).

Bugs found so far

Full write-up with minimal reproductions in test/property/FINDINGS.md. Verified on v1.6.0-dev13151:

  1. SELECT last_day(DATE '5881580-07-10') throws an INTERNAL error that invalidates the whole databaselast_day computes first-of-next-month (out of range) and is not marked SetFallible()
  2. date_trunc('week'/'isoyear', DATE '5877642-06-25 (BC)') — signed integer overflow (UB) in date_t::operator- (date.hpp:58); aborts under UBSan
  3. TIMETZ offsets with zero minutes but nonzero seconds print incorrectly: '12:00:00-05:00:59'::TIMETZ prints 12:00:00-05:59 (a different offset)
  4. list_sort([INTERVAL '31 days', INTERVAL '1 month']) returns [31 days, 1 month] although 31 days > 1 month is true and ORDER BY sorts the other way — create_sort_key skips interval normalization
  5. Value::ToSQLString() doesn't escape quotes in STRUCT keys, renders BIT values bare (re-parse as INTEGER), and loses UNION member types
  6. INTERVAL text asymmetry: to_microseconds(9223372036854775807)::VARCHAR prints 10-digit hours that the interval parser (9-digit limit) cannot read back
  7. list_contains([false,true,NULL], NULL) returns NULL but list_position(...) returns 3 — the twin functions disagree on NULL semantics
  8. weekofyear/isoyear/date_trunc fail with "Date out of range" on minimum-year dates whose result is representable
  9. Sort internally round-trips column types through SQL strings (Sort::Sort -> DecodeSortKeyBind), which breaks for API-created types with unparseable string forms (e.g. ENUM values containing NUL, possible via Arrow dictionaries)
  10. '883406386745030.3'::JSON::DECIMAL(16,1) -> 883406386745030.2 (JSON -> DECIMAL goes through a double)
  11. (-32768)::SMALLINT % (-1)::SMALLINT errors ("Overflow in division") where PostgreSQL returns 0
  12. levenshtein/damerau_levenshtein/hamming count bytes, not characters, for multi-byte UTF-8

Each known bug is fenced off in the tests with an RC_PRE guard marked KNOWN ISSUE, so the suite is fully green (12 test cases / 35 properties at 1000 iterations each) and keeps hunting for new bugs rather than rediscovering these.

Files

file contents
test/property/README.md build/run/extend docs
test/property/FINDINGS.md the bug list with repros
test/property/CMakeLists.txt standalone project, -DDUCKDB_BUILD_DIR=...
test/property/include/property_test.hpp PropDB helpers, value comparison, assertion macros
test/property/generators.cpp random types + values for any DuckDB type
test/property/test_*.cpp the six property suites

🤖 Generated with Claude Code

peterxcli and others added 2 commits August 24, 2026 12:18
Standalone property-based test harness using RapidCheck, in the spirit of
proptest harnesses (compare apache/arrow-rs#10352). Generates random
LogicalTypes (incl. nested STRUCT/LIST/ARRAY/MAP/UNION/ENUM) and Values
with adversarial special cases, and checks properties against independent
oracles: text/SQL/JSON round trips, LIKE vs a reference matcher, string
and list functions, integer/HUGEINT/DECIMAL arithmetic vs __int128,
date parts vs civil-calendar algorithms, and persistent storage round
trips across force_compression settings.

Bugs found by these tests are documented with minimal reproductions in
test/property/FINDINGS.md; the corresponding properties skip them via
RC_PRE guards marked KNOWN ISSUE so they keep hunting for new ones.

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

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ba6c729d14

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

PROP_ASSERT_VALUES_EQUAL(mat.GetValue(0, 0), Value::BIGINT(int64_t(range_count)));
if (range_count > 0) {
auto expected_min = step > 0 ? start : start + (int64_t(range_count) - 1) * step;
auto expected_max = step > 0 ? start + (int64_t(range_count) - 1) * step : start;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Compute generated range endpoints in int128

When large steps cross zero, the mathematical endpoint fits in BIGINT even though the intermediate multiplication does not. For example, start = INT64_MIN, stop = INT64_MAX, and step = INT64_MAX produce range_count == 3, but this line evaluates 2 * INT64_MAX in int64_t; the default UBSan build therefore aborts inside the oracle, while an unsanitized build can report a false endpoint mismatch. Keep the endpoint calculation in __int128 until after the addition and checked conversion.

Useful? React with 👍 / 👎.

include(FetchContent)
FetchContent_Declare(rapidcheck
GIT_REPOSITORY https://github.com/emil-e/rapidcheck.git
GIT_TAG master

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Pin RapidCheck to an immutable revision

When RAPIDCHECK_SOURCE_DIR is not supplied, every clean configuration fetches whatever commit master references at that moment. An upstream API change, force-push, or regression can consequently make the same DuckDB commit stop building or change its property-generation behavior without any repository change. Use a specific commit or release tag so the test harness remains reproducible.

Useful? React with 👍 / 👎.

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.

1 participant