Conversation
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>
There was a problem hiding this comment.
💡 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; |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 👍 / 👎.
Four findings were fixed upstream within days; seven were filed as duckdb/duckdb issues duckdb#25107-duckdb#25113; one is intended behavior per duckdb#16489. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Note on the two red CI jobs (Linux Release, Windows 64-bit): both fail while compiling the out-of-tree 🤖 Generated with Claude Code |
Ports peterxcli/duckdb#4 from a standalone Catch2 binary into the `fuzzing` extension, so the properties run inside DuckDB and are driven from SQL: LOAD fuzzing; SELECT * FROM fuzzing_check('strings', max_success => 1000); The suite is 49 properties across 7 suites (36 generative + 13 deterministic probes for the bugs in docs/FINDINGS.md). `fuzzing_properties()` lists them without running anything; `fuzzing_check()` runs them and returns the status, the shrunk counterexample and a `reproduce` string that replays the exact run. Probes for still-open upstream bugs are registered with FUZZING_KNOWN_FAIL, so they report `known_fail` while the bug is alive and flip to `fixed` once upstream lands the fix - the run reports when a guard can be retired, not just when something breaks. Notable details: - The duckdb submodule moves to upstream main. It was 12,340 commits behind and the properties use internal APIs (Identifier, FlatVector::GetDataMutable, count_t, the vector<Identifier> bind signature) that did not exist at the old pin. For the same reason the stock _extension_distribution.yml job (pinned to v1.5.4) cannot build this repo, and is replaced by a build-and-test job. - Property translation units only self-register, so nothing references them and the linker drops them from the static archive - the suite silently disappeared from the statically linked shell while still working in the loadable extension. FUZZING_PROPERTY_FILE anchors each unit. The anchors have to be *called*: taking their address is foldable at -O3, and only the first survived. - The integer and hugeint division properties asserted that division by zero yields NULL. Upstream now errors by default and returns NULL only under null_on_division_by_zero; both properties now assert the current contract and cover the setting. Verified against duckdb/duckdb@f94f62df7a: 42 pass, 7 known_fail (matching the 7 open upstream issues), no findings; test/sql/fuzzing.test passes 16 assertions on a FORCE_ASSERT + ASan/UBSan build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
<details>
<summary>WARNING: ThreadSanitizer: data race; Read of size 8</summary>
```c++
Filters: test/sql/show_select/summarize_subquery.test
[0/1] (0%): test/sql/show_select/summarize_subquery.test==================
WARNING: ThreadSanitizer: data race (pid=80064)
Read of size 8 at 0x00010708e758 by thread T10:
#0 duckdb::ArenaAllocator::AlignNext() <null> (libduckdb.dylib:arm64+0x2a37af0)
#1 std::__1::vector<double, duckdb::arena_stl_allocator<double>>::reserve(unsigned long) vector.h:1100 (libduckdb.dylib:arm64+0x3184418)
#2 duckdb_tdigest::TDigest::updateCumulative() t_digest.hpp:538 (libduckdb.dylib:arm64+0x31819cc)
#3 duckdb_tdigest::TDigest::process() t_digest.hpp:583 (libduckdb.dylib:arm64+0x31816d8)
#4 void duckdb::(anonymous namespace)::ApproxQuantileScalarOperation::Finalize<long long, duckdb::(anonymous namespace)::ApproxQuantileState>(duckdb::(ano nymous namespace)::ApproxQuantileState&, long long&, duckdb::AggregateFinalizeData&) approximate_quantile.cpp:162 (libduckdb.dylib:arm64+0x318d540)
duckdb#5 void duckdb::AggregateFunction::StateFinalize<duckdb::(anonymous namespace)::ApproxQuantileState, long long, duckdb::(anonymous namespace)::ApproxQuant ileScalarOperation>(duckdb::Vector&, duckdb::AggregateFinalizeInputData&, duckdb::Vector&, unsigned long long, unsigned long long) aggregate_function.hpp:822 (libduckdb.dylib:arm64+0x318ca64)
duckdb#6 duckdb::RowOperations::FinalizeStates(duckdb::RowOperationsState&, duckdb::TupleDataLayout&, duckdb::Vector&, duckdb::DataChunk&, unsigned long long) r ow_aggregate.cpp:182 (libduckdb.dylib:arm64+0x166f848)
duckdb#7 duckdb::RadixHTLocalSourceState::Scan(duckdb::RadixHTGlobalSinkState&, duckdb::RadixHTGlobalSourceState&, duckdb::DataChunk&) <null> (libduckdb.dylib:a rm64+0x2458dac)
duckdb#8 duckdb::RadixHTLocalSourceState::ExecuteTask(duckdb::RadixHTGlobalSinkState&, duckdb::RadixHTGlobalSourceState&, duckdb::DataChunk&) <null> (libduckdb. dylib:arm64+0x2457ea0)
duckdb#9 duckdb::RadixPartitionedHashTable::GetData(duckdb::ExecutionContext&, duckdb::DataChunk&, duckdb::GlobalSinkState&, duckdb::OperatorSourceInput&) const <null> (libduckdb.dylib:arm64+0x2459624)
duckdb#10 duckdb::PhysicalHashAggregate::GetDataInternal(duckdb::ExecutionContext&, duckdb::DataChunk&, duckdb::OperatorSourceInput&) const physical_hash_aggreg ate.cpp:978 (libduckdb.dylib:arm64+0x21305cc)
duckdb#11 duckdb::PhysicalOperator::GetData(duckdb::ExecutionContext&, duckdb::DataChunk&, duckdb::OperatorSourceInput&) const <null> (libduckdb.dylib:arm64+0x2 448dac)
...
Previous write of size 8 at 0x00010708e758 by thread T7:
#0 std::__1::vector<double, duckdb::arena_stl_allocator<double>>::reserve(unsigned long) vector.h:1100 (libduckdb.dylib:arm64+0x31844a0)
#1 duckdb_tdigest::TDigest::updateCumulative() t_digest.hpp:538 (libduckdb.dylib:arm64+0x31819cc)
#2 duckdb_tdigest::TDigest::process() t_digest.hpp:583 (libduckdb.dylib:arm64+0x31816d8)
#3 void duckdb::(anonymous namespace)::ApproxQuantileScalarOperation::Finalize<long long, duckdb::(anonymous namespace)::ApproxQuantileState>(duckdb::(ano nymous namespace)::ApproxQuantileState&, long long&, duckdb::AggregateFinalizeData&) approximate_quantile.cpp:162 (libduckdb.dylib:arm64+0x318d540)
#4 void duckdb::AggregateFunction::StateFinalize<duckdb::(anonymous namespace)::ApproxQuantileState, long long, duckdb::(anonymous namespace)::ApproxQuant ileScalarOperation>(duckdb::Vector&, duckdb::AggregateFinalizeInputData&, duckdb::Vector&, unsigned long long, unsigned long long) aggregate_function.hpp:822 (libduckdb.dylib:arm64+0x318ca64)
duckdb#5 duckdb::RowOperations::FinalizeStates(duckdb::RowOperationsState&, duckdb::TupleDataLayout&, duckdb::Vector&, duckdb::DataChunk&, unsigned long long) r ow_aggregate.cpp:182 (libduckdb.dylib:arm64+0x166f848)
duckdb#6 duckdb::RadixHTLocalSourceState::Scan(duckdb::RadixHTGlobalSinkState&, duckdb::RadixHTGlobalSourceState&, duckdb::DataChunk&) <null> (libduckdb.dylib:a rm64+0x2458dac)
duckdb#7 duckdb::RadixHTLocalSourceState::ExecuteTask(duckdb::RadixHTGlobalSinkState&, duckdb::RadixHTGlobalSourceState&, duckdb::DataChunk&) <null> (libduckdb. dylib:arm64+0x2457ea0)
duckdb#8 duckdb::RadixPartitionedHashTable::GetData(duckdb::ExecutionContext&, duckdb::DataChunk&, duckdb::GlobalSinkState&, duckdb::OperatorSourceInput&) const <null> (libduckdb.dylib:arm64+0x2459624)
duckdb#9 duckdb::PhysicalHashAggregate::GetDataInternal(duckdb::ExecutionContext&, duckdb::DataChunk&, duckdb::OperatorSourceInput&) const physical_hash_aggrega te.cpp:978 (libduckdb.dylib:arm64+0x21305cc)
duckdb#10 duckdb::PhysicalOperator::GetData(duckdb::ExecutionContext&, duckdb::DataChunk&, duckdb::OperatorSourceInput&) const <null> (libduckdb.dylib:arm64+0x2 448dac)
...
Location is heap block of size 56 at 0x00010708e740 allocated by main thread:
#0 operator new(unsigned long) <null> (libclang_rt.tsan_osx_dynamic.dylib:arm64e+0x91650)
#1 duckdb::ArenaAllocator::AllocateNewBlock(unsigned long long) <null> (libduckdb.dylib:arm64+0x2a37830)
#2 std::__1::vector<duckdb_tdigest::Centroid, duckdb::arena_stl_allocator<duckdb_tdigest::Centroid>>::reserve(unsigned long) vector.h:1100 (libduckdb.dyli b:arm64+0x3180da0)
#3 void duckdb::(anonymous namespace)::ApproxQuantileOperation::Operation<long long, duckdb::(anonymous namespace)::ApproxQuantileState, duckdb::(anonymou s namespace)::ApproxQuantileScalarOperation>(duckdb::(anonymous namespace)::ApproxQuantileState&, long long const&, duckdb::AggregateUnaryInput&) approximate_ quantile.cpp:122 (libduckdb.dylib:arm64+0x318ccd4)
#4 void duckdb::AggregateFunction::UnaryScatterUpdate<duckdb::(anonymous namespace)::ApproxQuantileState, long long, duckdb::(anonymous namespace)::Approx QuantileScalarOperation>(duckdb::Vector*, duckdb::AggregateInputData&, unsigned long long, duckdb::Vector&, unsigned long long) aggregate_function.hpp:782 (li bduckdb.dylib:arm64+0x318c454)
duckdb#5 duckdb::RowOperations::UpdateStates(duckdb::RowOperationsState&, duckdb::AggregateObject&, duckdb::Vector&, duckdb::DataChunk&, unsigned long long, duc kdb::optional_ptr<duckdb::ClusteredAggr const, true>) aggregate_function.hpp (libduckdb.dylib:arm64+0x166ed24)
duckdb#6 duckdb::GroupedAggregateHashTable::UpdateAggregates(duckdb::DataChunk&, duckdb::vector<unsigned long long, false, std::__1::allocator<unsigned long lon g>> const&, unsigned long long, bool) <null> (libduckdb.dylib:arm64+0x240d234)
duckdb#7 duckdb::GroupedAggregateHashTable::AddChunk(duckdb::DataChunk&, duckdb::Vector&, duckdb::DataChunk&, duckdb::vector<unsigned long long, false, std::__1 ::allocator<unsigned long long>> const&) <null> (libduckdb.dylib:arm64+0x240f000)
duckdb#8 duckdb::GroupedAggregateHashTable::AddChunk(duckdb::DataChunk&, duckdb::DataChunk&, duckdb::vector<unsigned long long, false, std::__1::allocator<unsig ned long long>> const&) <null> (libduckdb.dylib:arm64+0x240cb30)
duckdb#9 duckdb::RadixPartitionedHashTable::Sink(duckdb::ExecutionContext&, duckdb::DataChunk&, duckdb::OperatorSinkInput&, duckdb::DataChunk&, duckdb::vector<u nsigned long long, false, std::__1::allocator<unsigned long long>> const&) const <null> (libduckdb.dylib:arm64+0x2455190)
duckdb#10 duckdb::PhysicalHashAggregate::Sink(duckdb::ExecutionContext&, duckdb::DataChunk&, duckdb::OperatorSinkInput&) const physical_hash_aggregate.cpp:466 ( libduckdb.dylib:arm64+0x212bafc)
SUMMARY: ThreadSanitizer: data race (libduckdb.dylib:arm64+0x2a37af0) in duckdb::ArenaAllocator::AlignNext()+0x2c
==================
```
</details>
`SUMMARIZE` calculates three `approx_quantile` aggregates (`q25`, `q50`,
`q75`). Each aggregate owns a TDigest whose vectors use the hash table’s
`ArenaAllocator`.
Two hash-aggregate worker threads concurrently finalized separate
TDigest states:
- Both called `TDigest::process()` → `updateCumulative()` →
`vector::reserve()`.
- The states shared the same non-thread-safe `ArenaAllocator`.
- One thread read allocator position metadata in
`ArenaAllocator::AlignNext()` while another wrote it.
- The shared arena had originally been allocated during
`approx_quantile` aggregation on the main thread.
Result: a race in arena allocation during parallel `approx_quantile`
finalization, reported at `ArenaAllocator::AlignNext()`.
It was intermittent because `reserve()` only occurs when vector capacity
must grow, and the worker calls had to overlap closely enough.
The fix reserves the cumulative buffer and sufficient centroid merge
capacity when the TDigest is constructed and its allocator is still used
by only one thread. Parallel finalization then operates entirely within
already allocated buffers and no longer mutates the shared arena.
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:
CAST(v AS VARCHAR) -> CAST(back AS T),Value::ToSQLString()-> re-parse,to_json-> cast back, INSERT -> SELECT, and write -> CHECKPOINT -> reopen for everyforce_compressionsettingLIKE/ILIKEvs a 40-line reference matcher; substring/split/pad/trim/translate/levenshtein vs reference implementations; integer/HUGEINT/DECIMAL arithmetic must error exactly when the__int128result is out of range; date parts vs independent civil-calendar algorithmslist_sortvsORDER BY,list_containsvslist_positionThe core piece is
test/property/generators.cpp: randomLogicalTypes (including nested STRUCT/LIST/ARRAY/MAP/UNION/ENUM) and randomValues 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
How to run / how to find bugs
When a property fails you get the shrunk counterexample plus the exact value/SQL/error, e.g.:
…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 onv1.6.0-dev13151:SELECT last_day(DATE '5881580-07-10')throws an INTERNAL error that invalidates the whole database —last_daycomputes first-of-next-month (out of range) and is not markedSetFallible()date_trunc('week'/'isoyear', DATE '5877642-06-25 (BC)')— signed integer overflow (UB) indate_t::operator-(date.hpp:58); aborts under UBSan'12:00:00-05:00:59'::TIMETZprints12:00:00-05:59(a different offset)list_sort([INTERVAL '31 days', INTERVAL '1 month'])returns[31 days, 1 month]although31 days > 1 monthistrueandORDER BYsorts the other way —create_sort_keyskips interval normalizationValue::ToSQLString()doesn't escape quotes in STRUCT keys, renders BIT values bare (re-parse as INTEGER), and loses UNION member typesto_microseconds(9223372036854775807)::VARCHARprints 10-digit hours that the interval parser (9-digit limit) cannot read backlist_contains([false,true,NULL], NULL)returns NULL butlist_position(...)returns 3 — the twin functions disagree on NULL semanticsweekofyear/isoyear/date_truncfail with "Date out of range" on minimum-year dates whose result is representableSort::Sort->DecodeSortKeyBind), which breaks for API-created types with unparseable string forms (e.g. ENUM values containing NUL, possible via Arrow dictionaries)'883406386745030.3'::JSON::DECIMAL(16,1)->883406386745030.2(JSON -> DECIMAL goes through a double)(-32768)::SMALLINT % (-1)::SMALLINTerrors ("Overflow in division") where PostgreSQL returns 0levenshtein/damerau_levenshtein/hammingcount bytes, not characters, for multi-byte UTF-8Each known bug is fenced off in the tests with an
RC_PREguard markedKNOWN 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
test/property/README.mdtest/property/FINDINGS.mdtest/property/CMakeLists.txt-DDUCKDB_BUILD_DIR=...test/property/include/property_test.hppPropDBhelpers, value comparison, assertion macrostest/property/generators.cpptest/property/test_*.cpp🤖 Generated with Claude Code