chore(core): add randomized query fuzzer and fix multiple SQL correctness and resource-leak bugs - #7021
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
SymbolFunction.getStrLen() threw UnsupportedOperationException, which
made StrFunction wrappers like upper(), lower(), trim(), and the
varchar->string cast blow up whenever their argument was a symbol
column. The compiler accepts a symbol function where a string is
required because getStrA()/getStrB() transparently fall back to
getSymbol(); getStrLen() was the one method that broke that
transparency.
Queries like `SELECT avg(length(lower(sym))) FROM t` surfaced a
CairoException("unexpected reduce error") in the parallel group-by
path. The same failure could happen in any str-function chain that
ultimately delegated getStrLen() to a symbol argument (length, trim,
count(DISTINCT str), str/str equality with null check, etc.).
SymbolFunction.getStrLen() now returns the length of the symbol value,
mirroring how getStrA/getStrB already delegate to getSymbol. This is
consistent with the str/symbol transparency the rest of the class
already provides.
AddIntFunc.isConstant() returned true when one operand was a constant null and the other was any function, including a column reference. FunctionParser.functionToConstant() then evaluated the expression with a null record, which NPEd on the column's getInt(null). The query fuzzer surfaced this via filter and projection shapes like `WHERE c >= ((null + c) + length(sym))` and `SELECT (null + i) FROM t`. The null-propagation heuristic is correct semantically (null + x always evaluates to null) but violated the contract that isConstant() implies getX(null) is safe. Move the fold from isConstant() to AddIntFunctionFactory.newInstance(): when one operand is a constant null, free the other operand and return IntConstant.NULL. The non-null operand is never read, so a column reference is safe. Apply the same pattern to all Int/Long/Float/Double variants of Add/Sub/Mul/Div -- 15 factories in total. Only AddIntFunc actually exhibited the NPE, since it was the only one with the null- propagating isConstant(). Folding at construction time in the sibling factories is a modest runtime win: a null-arithmetic subexpression collapses to a typed null constant instead of reading the other operand per row. AddIntFunc.isConstant() override is deleted; the BinaryFunction default (both-constant) now covers it correctly, since the null-propagation case never reaches the function instance. SqlCompilerImplTest#testEvaluateNullArithmeticColumnExpression covers 21 (operator, type, null-position) combinations across the 15 factories, plus the original fuzzer-reported filter shape.
…expressions with wider type
… long multiplication ignoring nulls in AVX2 JIT
The fuzzer surfaced JIT-vs-Java divergences on filters like WHERE null >= v AND d <= 0.7 (v VARCHAR) The IR serializer's ensureOnlyVarSizeHeaderChecks() rejected only the column-vs-column case where both operands were var-size. But serializeNull() emits the var-size NULL sentinel as an I8/I4 IMM, so <varsize_col> >= null slipped past the check, the native kernel ran an arbitrary integer comparison against the var-size header, and the result diverged from the Java filter. The IR serializer now also rejects any binary operator other than EQ / NE when at least one operand is var-size, forcing JIT compilation to fall back to the Java filter for these predicates. IS NULL / IS NOT NULL keeps JIT-ing because they lower to EQ / NE against the NULL header IMM, which is the only legitimate var-size operand the JIT runtime supports.
For predicates like (s SHORT * 78941) > l LONG the JIT was dispatching int32_mul with the wrapped low 32 bits, while the Java filter computed the same product at long width via MulInt.getLong's ((long) l) * r promotion. The two paths diverged for any input where the int32 result overflowed -- e.g. SHORT 32767 with the multiplier above wraps to a negative int while the long product is +2_587_012_787L. The root cause sits in serializeUntypedNumber, which is invoked when a predicate has mixed-size operands. It tried Numbers.parseInt first and, on success, emitted the constant as an I4 IMM regardless of the wider operands present in the same predicate. read_imm preserved the I4 dtype, load_registers normalised both arithmetic operands to the column's narrow dtype, and the kernel dispatched int32_mul. The fix tracks whether the predicate observed any LONG / FLOAT / DOUBLE operand and, when so, skips the parseInt fallback in serializeUntypedNumber. Numbers.parseLong then emits the IMM at I8, the JIT widens the narrow column side via convert(), and mul dispatches to int64_mul -- matching MulInt.getLong's behaviour. Predicates without a wider operand keep the previous I4 path and continue to JIT in scalar mode without divergence. Found by the differential JIT-on/off mode of the query fuzzer.
For predicates like (a INT + b INT) > l LONG the JIT was dispatching int32_add / int32_mul, wrapping the result for inputs that overflowed int32, while the Java filter's AddInt.getLong / MulInt.getLong / etc. promote via ((long) l) OP r and compute at long width. The two paths diverged for any input that crossed INT_MAX -- e.g. (46341 * 46341) wraps to a negative int but is +2_147_488_281L at long width. This is the column-only sibling of the literal-side bug fixed earlier in serializeUntypedNumber. The literal fix only widens an IMM operand; column-column arithmetic has no IMM to widen and stayed at int32. Add a new IR opcode SX_I64 (sign-extend top of stack to i64) and a per-predicate NarrowI64WidenDetector that decides whether to emit it. The detector pre-walks the predicate subtree once on descent and sets needsNarrowI64Widening when it sees arithmetic AND a LONG operand AND an INT operand (BYTE / SHORT alone never overflow int32 with the operators QuestDB supports, so they do not need widening on their own). When the flag is set, serializeColumn / serializeBindVariable emit SX_I64 after each narrow column / bind variable. The native scalar emit_code dispatches SX_I64 to int32_to_int64 (with null-check, so INT_NULL maps to LONG_NULL on the way through). The arithmetic op then dispatches to int64_* and matches the Java filter's getLong path. Predicates without a LONG operand keep their I4 IR -- in particular (i * i) > d in DOUBLE context stays at int32 width, matching IntFunction.getDouble's intToDouble(getInt) at int width. The AVX2 emit_code lists SX_I64 alongside the short-circuit opcodes as unsupported and aborts compilation in SIMD; mixed-size predicates already force scalar mode, so SX_I64 only ever reaches the scalar path. Found by the differential JIT-on/off mode of the query fuzzer while auditing bug 10's hasNarrowInt() trigger for I4 coverage gaps.
Tighten the fuzzer harness and a couple of scattered cast/hashmap
hygiene items flagged in the level-3 review.
QueryRunner.runOnce now catches Exception instead of Throwable so OOME,
StackOverflow, AssertionError and other Errors propagate promptly
rather than letting the fuzzer keep iterating in a degraded JVM.
QueryFuzzTest.testQueryFuzz wraps pool.halt() in its own try/catch so
a halt-time failure cannot mask the original test exception.
QueryFuzzTest.failures switches from java.util.ArrayList to ObjList per
the project's collection convention. The bvs.clear() pair in
QueryRunner.runBindVariant is now annotated explaining why both calls
are intentional, and the literal/bind rnd state determinism invariant
in QueryFuzzTest is documented at the call site.
Schema log now spells out the exact partition list that went into
CONVERT PARTITION TO PARQUET LIST for ParquetMode.PARTIAL tables, so a
post-mortem reader can reproduce the storage shape from the log alone
instead of having to replay the seed. FuzzTable carries the partition
list explicitly; FuzzTableFactory.applyParquetConversion returns a
small ParquetConversion record that captures both axes.
Old-style switch statements in SampleByClause, GroupByClause and
QueryRunner.hasIntOverflowingConstantArithmetic move to enhanced switch
expressions per CLAUDE.md.
The cast abstract bases drop the trivial field-level Javadoc the PR
had converted to line comments; only the substantive comments on
AbstractCastToDecimal64Function.decimal (threading constraint) and
AbstractCastToSymbolFunction.symbolTableShortcut (INT_NULL sentinel)
remain. IntIntHashMap.noEntryValue gains a comment explaining why it
stays hardcoded to -1 even though noEntryKeyValue is configurable.
Two items from the section are intentionally skipped (false positives
documented in followups.md): the shadow-table rewrite regex collision
is astronomically unlikely and tokenising would be invasive churn;
AbstractCastToDecimal64Function getDecimal{8,16,32} truncation only
matters for hypothetical future subclasses that violate the cast()
contract.
A query-fuzzer divergence on a SPLICE JOIN whose master had an indexed SYMBOL column, an interval WHERE on ts, and an outer ORDER BY on the indexed symbol surfaced a planner bug: generateTableQuery picked SortedSymbolIndexRecordCursorFactory for the master because the order-by advice matched the indexed key, the interval intrinsic hit a single partition, and there was no residual filter. That factory emits rows in symbol order and zeroes the timestamp index, after which the parent SPLICE join either threw the misleading "left side of time series join has no timestamp" or, with the index restored, would have silently fed sym-ordered input into a merge that assumes ts order. The optimizer already has the right signal: generateJoins pushes isTimestampRequired before generating the master, precisely so descendants know "your parent needs ts metadata and ts order". The two strip-ts paths in generateTableQuery -- the SortedSymbolIndexRecordCursorFactory branch and the order-by-key-column shortcut inside the indexed-sym WHERE branch -- now consult that flag and fall through to a normal PageFrameRecordCursorFactory when set. The outer ORDER BY is then honoured by a sort above the join, as in the non-indexed case. Standalone (non-join) queries still take the SortedSymbolIndex path, since isTimestampRequired is false outside a time-series join.
QueryRunner.hasIntOverflowingConstantArithmetic matched only flat parenthesised arithmetic of two integer literals. A nested shape like ((-17::BYTE + 125931) * -816546) places a parenthesised subexpression on the outer multiplication's left side, so the regex never fires and the bind-vs-literal int-overflow asymmetry escapes the skip path: the literal form folds to LONG (-102813787044) and rejects the WHERE while the bind form keeps INT arithmetic, wraps to 265428060 and accepts it, surfacing as a row-count divergence. Iterate the scan: each pass folds non-overflowing matches into their numeric value and rescans, exposing the next outer level. The folded value drops the parentheses so the outer subtree's own parens become the parens the regex needs at the next level. Operands outside long range leave the match in place so the loop still terminates.
compileWorkerFiltersConditionally built per-worker copies of a non-thread-safe boolean filter in a loop without a try/catch. If compileBooleanFilter threw on iteration N>0, the N already-constructed filters were stranded in a local ObjList and never freed, leaking whatever native resources they held. The sibling method compileWorkerFunctionsConditionally already had the right guard; this change brings the boolean variant in line by wrapping the loop in a try/catch that frees the partial list before rethrowing. Add a test SQL function test_throwing_filter() in the test-only functions package. It is a BooleanFunction with isThreadSafe()=false that allocates a small native buffer per successful newInstance() call and throws SqlException on a configurable Nth call. The new test TopKFilterCompilationLeakTest drives the per-worker compilation path through the parallel top-K gate by running a WHERE/ORDER BY/LIMIT query with sharedQueryWorkerCount=4 and dev mode enabled. It checks that exactly two constructed instances are closed - the original filter via the outer-catch cascade and the partial worker copy via the new try/catch - and assertMemoryLeak catches the leaked native allocation if the guard regresses.
|
@puzpuzpuz — review findings from a deep pass over the PR. Critical and moderate items below; minor/nits omitted. CriticalC1.
|
…uzzer # Conflicts: # java-questdb-client
Three follow-ups from the PR #7021 review. CastFloatToSymbol keyed its shortcut map by Float.floatToIntBits(), and floatToIntBits(-0.0f) equals Numbers.INT_NULL, the map's empty-slot sentinel. Every -0.0f row collided with the empty slot, producing a fresh symbol id: the symbols list grew unbounded and approx_count_distinct over-counted (7 instead of 3). The FLOAT cast now passes a NaN bit pattern floatToIntBits can never produce as its sentinel, keeping -0.0f distinct from 0.0f to match the constant path and the DOUBLE cast. DivIntFunctionFactory.getLong() inherited intToLong(getInt()), so a nested INT product under a long-context division wrapped at int32 before the outer cast widened it, diverging from the JIT which widens narrow operands up front. getLong() now recurses via getLong(), matching Add/Sub/Mul/Neg from the same review's bug 53. PageFrameMemoryPool.remapRemainingColumns could overwrite a filter column's full-buffer address with the compacted buffer when a remaining column shared its parquet column. It now skips filter columns. The path is currently unreachable -- the optimizer keeps filters below duplicating projections -- so the change is a defensive guard. Regression tests cover the cast and division fixes.
Three more follow-ups from the PR #7021 review (M2, M4, M6). PageFrameReduceTask and UnorderedPageFrameSequence rebuilt every CAIRO worker error as CairoException.nonCritical(), dropping the original errno and, with it, isCritical(). A critical worker exception was silently downgraded. Both now capture errno in setErrorMsg and rebuild via critical(errno); errno == NON_CRITICAL reduces to the prior behaviour. PageFrameReduceTaskTest gains a critical-errno round-trip test and a non-critical guard. ParallelGroupByFuzzTest gains coverage for the supportsParallelism delegation of twap, mode(BOOLEAN), sparkline and last(ARRAY): each runs over a cast-to-symbol argument and must drop from Async Group By to a serial GroupBy while returning the same result as the plain form. Only corr was covered before. WhereClauseParser's OR-timestamp rollback comment now notes it only covers the graceful non-extractable return; a thrown SqlException aborts the compile and clear() resets the parser, so the partially-stamped model is never reused.
enableParallelGroupBy, enableJitCompiler and convertToParquet were each a 50/50 coin flip. They now read true in 80%/66% of runs so the parallel group-by, JIT and parquet code paths - which carry most of the risk - are exercised far more often than their serial / interpreted / native fallbacks across CI runs.
|
@bluestreak01 thanks for the deep pass - this was very useful. Summary of how each item was handled; the three contested ones (C2, M3, M5) have evidence below. CriticalC1 — One deviation from your suggested fix: I went with the special-sentinel option (a NaN bit pattern C2 —
So there's no failing regression test — the guard is purely defensive, with a comment saying so. If you have a query shape that keeps a duplicating projection below a filtered async op, I'll happily turn it into a real test. ModerateM1 — M2 — agreed, comment softened ( M3 — could not reproduce a divergence; left as-is. Two parts:
Verified empirically — instrumented M4 — agreed, addressed ( M5 — left as-is. The M6 — agreed, added ( M7 — done (PR title/scope, labels, and the M8 — noted. Leaving the branch history as-is since it's squash-merged. |
[PR Coverage check]😍 pass : 810 / 896 (90.40%) file detail
|
Four tests failed on CI run 235374 across mac-griffin, mac-other,
windows-griffin-sub, and the SelfHosted Linux Griffin/Other-(B) jobs:
- SampleByFillTest.testFillValueRejectedForArrayAggregate
- SampleByFillTest.testFillValueWithSum{Minus,Plus,Times}ConstantOverFill
All four were added on master in #7021 (1843d9b) and merged into
this branch. The sum-rewrite trio asserts that rewriteAggregate splits
sum(c +/- K) into sum(c) +/- count(*) * K and sum(c * K) into sum(c)
* K, then verifies that SAMPLE BY FILL drives the resulting plan
correctly under FILL(0). On this branch, none of those rewrites were
firing: the inner Async Group By kept the original sum(c +/- 1000)
or sum(c * 1000) shape, and the wrapping VirtualRecord disappeared.
The cardinality-violation test changed error message because a new
validation site began firing earlier than the old type-coercion path.
Root cause: the earlier "Cover per-column FILL across duplicate
aggregates" commit re-exposed baseModel.fillValues onto groupByModel.
sampleByFill before the column-processing loop in rewriteSelect-
Clause0. The re-expose was meant to give assembleGroupByFunctions
access to the fill list so it could validate each aggregate's
getSampleByFlags() against the chosen fill mode. But the two gates
that already keyed off groupByModel.getSampleByFill().size() picked
up the re-exposed value too, suppressing both detectDuplicate-
Aggregates (the deliberate fix) and rewriteAggregate (collateral
damage). Pre-existing dedup-related branch tests in
SampleByFillNullValueTest required that suppression to stay on, so
the two gates needed independent decisions.
Three coordinated edits:
- Add hasNonNoneRewrittenFill(IQueryModel), a static helper that
reads baseModel.fillStride and baseModel.fillValues directly. It
returns true exactly when rewriteSampleBy moved a non-NONE FILL
list onto the base model, which is the form the re-expose was
designed for. Both gates can now consult this independently of the
groupByModel.sampleByFill field.
- Move the re-expose to AFTER the column-processing loop. By the
time the re-expose runs, both the dedup-detection gate (line 9347)
and the rewriteAggregate gate (line 9384) have already evaluated.
The re-expose then sets sampleByFill on the group-by model just
for assembleGroupByFunctions' getSampleByFlags() validation later
in generateSelectGroupBy.
- Update the two gates to check both forms of FILL: an explicit
sampleByFill that arrived via moveSampleByFrom (sampleBy still on
base), or a rewritten fillStride+fillValues from rewriteSampleBy.
The dedup gate now skips dedup whenever either form is present;
the rewriteAggregate gate suppresses sum(c+/-K) and sum(c*K)
rewrites whenever either form is present, because the rewrite is
unsafe under FILL(VALUE != 0). With FILL(v != 0), v lands on each
inner aggregate slot and the outer arithmetic yields v +/- v*K or
v*K in empty buckets, not the user-visible v.
Update the three master plan assertions for sum-rewrite under FILL.
The data assertions stay correct under either rewrite or no-rewrite
because the queries use FILL(0), and 0 propagates through the
arithmetic identically. The plan assertions now match the no-rewrite
plan, which is what the branch produces and what the branch test
testFillValueAppliesAfterAggregateArithmetic in SampleByFillNull-
ValueTest already required for FILL(42) to return 42 instead of
42 * 10.
Update testFillValueRejectedForArrayAggregate to expect the flag-
based rejection message "support for VALUE fill is not yet
implemented [function=first(a), class=FirstArrayGroupByFunction]"
rather than the older type-coercion message. Both paths reject the
same query; the flag-based path now fires first because the array
branch deliberately added that validation to surface unsupported
fill modes up front.
The QwpEgressBootstrapTest.testConcurrentQueryRejectedInPhaseOne
failure in the same CI run is pre-existing on master: it reproduces
identically on origin/master with no branch changes applied. Not
addressed here.
Test plan:
- All four failing SampleByFillTest cases now pass locally.
- SampleByTest (312), SampleByFillTest (81), SampleByFillNullValue-
Test (73), SampleByLinearFillTest, SampleByFromToFillTest, Sample-
ByDecimalTest, SampleByArrayTest, GroupByTest (112), GroupBy-
RewriteTest (33), GroupByFunctionTest (49), SqlOptimiserTest (174),
SqlParserTest (1079), ArrayAggDoubleGroupByFunctionFactoryTest (40),
ArrayAggDoubleArrayGroupByFunctionFactoryTest (43) - 2002 tests
total, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ness and resource-leak bugs (#7021)
An INT expression now carries exactly one value: the value its four bytes hold. INT arithmetic wraps modulo 2^32 in every context, exactly as LONG arithmetic wraps modulo 2^64. To compute at 64 bits, widen an operand: secs * 1_000_000L, or i::long * j. This reverts PR #4824 and PR #7021, and reopens GitHub issue #4752 in its reported form: to_utc(1_720_468_802 * 1_000_000, tz) returns a 1970 date again. That cost was weighed and accepted rather than overlooked. #4824 fixed #4752 by giving the INT operators a getLong() that recomputes at 64 bits. That makes one INT-typed function answer two different numbers for one row, with nothing in the type to say which a consumer gets. This branch set out to make that rule total, and the attempt is what showed it could not be. The property is infectious in both directions: the bitwise operators and the conditionals are dual-valued only because an operand might be, so the producer set is the transitive closure over every INT-returning function; and on the consumer side the alias reference, filter compile, projection, three store copiers, the memoizer, the conditionals, the IN key, every set-operation leg, each join side, sort-key materialisation, the window chain, the distinct map, bind variables, runtime constants, the JIT frontend and the parquet pushdown each have to ask which width applies. A boundary that does not ask is silently wrong. Deleted with the regime: Function#isIntWidthStable / #isRowStable and every override, the RecordCursorFactory / ColumnTypes isColumn* pair and all 24 factory overrides, LongWidthIntFunction and the long-width getLong() on 14 subclass factories, IntWideColumn, FactoryColumnTypes, FactoryMetadata, PriorityMetadata's width answers, widensIntSource, LoopingRecordToRowCopier's intWidthUnstableColumns, and InLongFunctionFactory's split key. Both row copiers are byte-identical to the merge base again: the store path is purely type-directed. IntFunctionMemoizer reverts to a single memo, functionToConstant0's INT arm folds to an IntConstant holding the wrap, and IntFunction's temporal getters and CastIntToLongFunctionFactory all spell Numbers.intToLong(getInt()). json_extract is the one function that had the two-values problem for a reason other than arithmetic: it derives getInt() and getLong() from two independent native parses, so an out-of-range number reads as null under one and as its value under the other. The new JsonExtractIntFunction sign-extends getInt() for every 64-bit read, selected by target type in the factory so there is no per-row branch. The JIT reproduces the rule by hand. arithExprType now types a node by operand promotion alone, which collapses genuineArithType into it; needsNarrowI64Widening, i64WrapLeaves, i64WidenFoldRoots, isFoldableOverflowConst and the per-element IN key width are deleted, and WidthCtx drops from seven booleans to two. Bringing it into step surfaced four genuine JIT-vs-Java divergences, all fixed here: - WHERE -i < 2147483649 returned zero rows under the vectorized backend where Java returns every row. avx2::convert() harmonises an i32-with- i64 pairing only in wide-lane mode, and got this one wrong there too, so forceScalarOnUnharmonisedNarrowArith routes a narrow arithmetic subtree at a 64-bit boundary to the scalar backend. - WHERE i32 IN (m * 3, nl) returned nothing where Java matched. An IN key is one node with one emitted width, so a single 64-bit pairing lifts the whole list; and narrowKeptConstants stops a co-present LONG from promoting m * 3 to int64_mul. - An out-of-INT-range constant lost its width in an all-INT-column predicate, so parseInt rejected it and the filter left the JIT. - An IN NULL element did not follow its list's width, leaving INT_NULL at I4 against the key's i64 lanes. Leaf promotion survives and is now stated positively: a narrow operand of a genuinely 64-bit arithmetic node sign-extends, because Java resolves the (LL) factory there and reads it through IntColumn#getLong. The node is 64 bits because an operand is, never because a narrower one overflowed. WHERE i * j > long_col now runs scalar. SX_I64 is emitted per leaf, so the frontend cannot sign-extend a subtree's result, and neither vectorized mode reproduces the Java answer for the unharmonised pairing. Emitting SX_I64 after an operator would recover it, and is the obvious follow-up. Inserting an overflowing INT expression into a designated timestamp now fails with "designated timestamp before 1970-01-01 is not allowed" where it previously stored a valid timestamp: the wrapped product is negative. This applies to INSERT ... VALUES and to an explicit ::TIMESTAMP in INSERT ... SELECT alike. The query fuzzer's oracle gets stronger, not just shorter. The literal and bind spellings of a constant arithmetic subtree must now agree exactly, so the whole int-overflow tolerance family is removed. Budget for triage on the first full fuzz run, and do not re-add the carve-out to make a failure go away without proving it is the same asymmetry. IntWidthWrapTest is the new spelling matrix, IntWidthContextTest keeps the three contexts that reach 64 bits through overload resolution rather than syntax, and IntArithmeticOverflowFoldingTest is pruned to the seven reassociation and agreement tests that are not about width. IntWidthAnswerPairingTest and DistinctTimeSeriesIntWidthTest are deleted: they existed only to police the regime. Where a parity assertion became empty it is paired with its complement so no site turns vacuous. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Adds
QueryFuzzTestundercore/src/test/java/io/questdb/test/griffin/fuzz/- a seeded randomised JUnit test that generates 1-3 WAL tables with a wide range of column types, inserts rows spanning multiple DAY partitions, and then runs a configurable budget of randomly generated queries against them. The oracle is crash-only: the test fails on any exception outside a short allowlist, and materialises every result row so per-column accessors are exercised end to end.The same branch also ships fixes for every bug class the fuzzer surfaced during development. After the fixes, repeated runs of 5k-10k queries each are clean.
Behavior changes
Bug 27 below changes the result type of
INT - FLOATfrom DOUBLE to FLOAT. The newSubFloatFunctionFactoryresolves-(FF)directly, mirroring+(FF),*(FF), and/(FF)which already returned FLOAT for the same operand pair. Queries that previously relied on the intermediate result of anINT - FLOATsubtree being kept at f64 precision will see it truncated to f32. Downstream casts, comparisons near an f32 ulp boundary, or chained arithmetic that further widens to f64 can produce different values than before. To preserve the old behavior, cast one operand to DOUBLE explicitly:c0::DOUBLE - c5orc0 - c5::DOUBLE.Bug 41's follow-up changes how STRING / VARCHAR
<=and>=against a constant NULL behave when the table side is also null. Previously the literal-form short-circuit returned FALSE for every row, soc <= nullproduced 0 rows even whenc IS NULL. The bind-variable form already returned TRUE through the runtimeChars.lessThan/Utf8s.lessThanpath under QuestDB'sNULL = NULL -> trueconvention, so the literal and bind forms disagreed on the both-null edge. The fix now honoursnegated && argIsNull(rec), soc <= nullandc >= nullreturn the matching NULL rows -- aligning the literal form with the bind form, the runtime path, and the in-PR DECIMAL fix for Bug 26. Queries that relied on the previous "always 0 rows" behaviour forc <= null/c >= nullneed to filter explicitly withc IS NOT NULL, or usec < null/c > nullwhich still return 0 rows in every case.Bug 53 below changes how the Java filter evaluates nested INT arithmetic when one operand of the comparison is LONG.
MulInt,AddInt,SubInt, andNegInt'sgetLong()overrides now recurse through their subtrees via.getLong()instead of.getInt(). Previously only the outermost level widened, so an innerINT * INTproduct wrapped mod 2^32 before the outer cast promoted the sum, while the JIT pre-pass widened every narrow operand to i64 up front. Queries that relied on the intermediate int wrap will now see the long-precision answer. To preserve the old wrap, force the inner result back to INT explicitly, e.g.(a * b)::INT + c. Single-level shapes such as(a * b) > lkeep their existing result because leafIntColumn/IntConstantreturn the same value viagetInt()andgetLong().EmptyTableRecordCursorFactorynow reports random access support. The factory substitutes the master side of a plan wheneverSqlCodeGeneratorfolds the WHERE clause to constant FALSE (e.g.((c0 + null))::TIMESTAMP < timestamp_const--AddIntFunctionFactoryshort-circuitsc0 + nulltoIntConstant.NULLand the timestamp comparator foldsnull < tsto FALSE). Operators that require random access on an input -- chiefly SPLICE JOIN -- previously rejected such queries withleft side of splice join doesn't support random access; they now compile and produce the correct empty-master output (one slave-leads pair per slave entry). Random access on an empty cursor is trivially correct sincerecordAtis never invoked. No query that compiled before stops compiling.Coverage
Column types: BOOLEAN, BYTE, SHORT, CHAR, INT, LONG, FLOAT, DOUBLE, DATE, TIMESTAMP, STRING, VARCHAR, SYMBOL, LONG256, UUID, IPv4, DECIMAL(p,s), DOUBLE[1D/2D]. BINARY and GEOHASH are intentionally excluded.
Top-level shapes: plain SELECT, GROUP BY (implicit or explicit), SAMPLE BY (with FILL modes NULL/PREV/LINEAR/0 and ALIGN TO CALENDAR / FIRST OBSERVATION), ASOF/LT/SPLICE JOIN, each with random table and column aliases.
Expressions (in projection, WHERE and aggregate arguments): column refs, typed constants, arithmetic,
expr::TYPEcasts, single-arg functions (abs, length, upper, lower). Predicates additionally use AND/OR/NOT, IS NULL/IS NOT NULL, IN lists, and comparisons in both orderable and equality-only forms depending on operand kind.ORDER BY across all shapes, over projection aliases, 1-based positional indices, or (for SimpleClause without aliases) the designated timestamp.
Sources: queries usually target a direct real table. Roughly 10% of the time the source is a
long_sequence(N)virtual table; the rest are wrapped in an inline subquery or a CTE with a randomised inner shape -SELECT *,SELECT * WHERE ... LIMIT N, a renaming projection (SELECT c AS r0, ts AS r_ts FROM base, outer sees the renamed columns including a renamed ts), or an aggregated inner (SELECT key AS k, count() AS cnt FROM base). The logical schema propagates so outer SAMPLE BY / GROUP BY / ORDER BY remain well-typed.Identifier garbling: each table and column emission has a 1% chance of being replaced with a non-existent name (e.g.
bork_col_4251). QuestDB responds withSqlException("Invalid column"/"table does not exist"), which the oracle treats as a legitimate user-facing error and skips.Storage shape: each generated table picks a parquet mode uniformly at random -
NONEkeeps every partition native,ALLconverts all non-active partitions,PARTIALflips an independent coin per non-active partition so the table holds a mix of native and parquet partitions. EachSYMBOLcolumn independently has a 20% chance of being created withINDEX. Mode and indexed-column choices print on the per-table schema log line so a failure can be reproduced from the seed alone.Differential JIT mode: when enabled (default on),
QueryRunnerruns every successfully compiled query twice on the primary table - once with JIT enabled, once with JIT disabled - and compares the two outcomes. Each generated query carries adeterministicflag that the clause generator sets based on whether it could pick a different valid row subset across runs (LIMIT over a parallel GROUP BY / hash join without a fully-disambiguating ORDER BY is the typical non-deterministic case). Deterministic queries must match row-for-row as a multiset (line-sorted to absorb parallel iteration-order non-determinism); non-deterministic queries must match row counts only - a count divergence is still a real JIT bug regardless of which rows survive the LIMIT. Exception classes and messages must match in both modes. Several JIT bugs in the list below were found this way.Differential storage mode: when enabled (default on), each fuzz table is created with a shadow sibling (
fuzz_t0_shadow, etc.) that holds byte-identical row content but draws its own random parquet mode and per-column index flags. Every query runs once against the primary and once against the shadow at JIT-off, and the two outcomes are compared with the same multiset / row-count / exception rules as the JIT diff. Any divergence is a silent storage bug - typically a parquet decoder mismatch, a parallel-reduce path that handles native and parquet partitions inconsistently, or an indexed-vs-unindexed symbol scan returning a different row set. With both diffs on, three runs are required per query (primary @ JIT-on, primary @ JIT-off, shadow @ JIT-off); JIT-off on the primary is the shared pivot. The query rewrite uses word-boundary regex on the primary table names so column references and aliases are untouched.Serial vs parallel toggle: queries normally run through a 4-thread shared worker pool. With a 5% per-query coin flip, all four parallel-execution flags (parallel filter, GROUP BY, top-K, parquet read) are turned off for the duration of that query so the serial code paths are exercised alongside the parallel ones. The coin flip pulls from the seeded rnd, so replaying with the same seed pair reproduces the same on/off pattern. Each serial-mode run logs a
fuzz serial: <sql>line and the end-of-run summary reports the serial count.Differential bind variable mode: for ~20% of queries the runner generates a bind-variable variant: a fraction of bindable typed constants (BOOLEAN, BYTE, SHORT, INT, LONG, FLOAT, DOUBLE, CHAR, STRING, VARCHAR, SYMBOL, DATE, TIMESTAMP) are rewritten as named
:bN::TYPEplaceholders, with their string values supplied throughBindVariableService.setStr(name, value). The variant runs at JIT-off and (when diff-JIT is enabled) at JIT-on, since the JIT compiler has its own bind-variable handling, and each is compared against the literal form's pivot at the same JIT mode using the existing rowset / row-count / exception rules. The two SQL forms are produced by running the clause generator twice with snapshot-and-restore on the seeded rnd, so the trees are identical and only the leaves differ. Five known compile-time-vs-bind-time divergences are skipped as planner-controlled choices: "column must appear in GROUP BY clause" (literal-form constant fold equates two occurrences that the bind form keeps distinct), "constant expected" (GROUP BY / SAMPLE BY positions that require a foldable expression), "there is no matching function" (literal types its constant at compile time, bind picks the STRING overload viasetStr), "there is no matching operator" (same shape as the function case, in either direction: either the literal narrows to a type with no overload while the bind path picks STRING viasetStr, or the literal folds an entire subtree to a constant - e.g. atrue != truecontradiction collapsing the WHERE - and never type-checks an unsupported sibling likeINover BOOLEAN that the bind form keeps opaque), and "decimal places but scale is limited to" (literalDOUBLE/FLOAT::DECIMAL(p,s)runs throughDecimalUtil.parseDecimalConstantwithlossy=falseso over-scaled constants are rejected at compile time, while the bind path runs throughCastDoubleToDecimalFunctionFactorywithlossy=trueand silently truncates excess decimal places). Non-bindable types (DECIMAL, IPv4, LONG256, UUID, DOUBLE arrays) stay as literals in both forms.Oracle
QueryRunnercatches the following as skips (legitimate user-facing errors):SqlException- parser / compiler / type-systemImplicitCastException- runtime cast failure on an out-of-range literalNumericException- numeric parse failureAnything else, including any
CairoException, fails the test. A generated SELECT should never leak aCairoException- if it does, the engine is surfacing an internal error via the query API. Failures are collected across the whole run and reported together at the end so a single invocation lists every bug found rather than aborting on the first one.Config knobs
-Dquestdb.fuzz.queries=N- queries per run (default 100)-Dquestdb.fuzz.diff.jit=true|false- differential JIT-on/off mode (default true)-Dquestdb.fuzz.diff.shadow=true|false- differential storage mode (default true)-Dquestdb.fuzz.dump=path- append every generated query to a file; useful for analysing the corpus distribution across many runs-Dquestdb.fuzz.workers=N- shared query worker pool size (default 4); useful for bisecting parallelism-dependent races against worker count-Dquestdb.fuzz.s0=L -Dquestdb.fuzz.s1=L- replay a specific seed pair, as printed in the run'srandom seeds: ...line, to reproduce a failure deterministicallySeeds from
TestUtils.generateRandom(LOG)print on each run so any failure is reproducible deterministically.Bugs found and fixed in this branch
Each item below was first seen as a fuzzer failure and is fixed by a commit on this branch. A targeted regression test accompanies each fix.
CairoException("unexpected reduce error: ..."). Worker threads convertedImplicitCastExceptionandNumericExceptioninto a genericCairoExceptionon the way back to the collector, hiding the original class from callers and from the fuzzer oracle. Fix:PageFrameReduceTaskclassifies the error kind andUnorderedPageFrameSequence.buildError()rebuilds the original type (CAIRO/IMPLICIT_CAST/NUMERIC) on the collector side. The same wrapping pattern in the parallel filter path is fixed by the same change.SymbolFunction.getStrLen()threwUnsupportedOperationException.StrFunctionwrappers likeupper(),lower(),trim(), and the varchar->string cast blew up whenever their argument was a symbol column, even thoughgetStrA()/getStrB()already delegate transparently togetSymbol(). Fix:getStrLen()mirrors that delegation viaTableUtils.lengthOf(getSymbol(rec)).SAMPLE BY ... FILL(value)on incompatible fill/aggregate type combinations threwUnsupportedOperationException. The compiler accepted any literal as a fill value regardless of the aggregate's runtime type, so e.g.FILL(0)over a DECIMAL / UUID / STRING / IPv4 / LONG256 / GEOHASH / ARRAY aggregate hit a missing per-kind accessor (IntFunction.getDecimal64,getLong256, etc.) at row time. The fix landed in two waves. (a) Upstream's newSampleByFillRecordCursorFactory(PR feat(sql): add cross-column FILL(PREV) and parallelise SAMPLE BY FILL #6946, merged into this branch) added aColumnType.isConvertibleFrom-based rejection inSqlCodeGenerator.generateFill()that catches most of these (UUID / STRING / IPv4 / LONG256 / GEOHASH / ARRAY against an INT fill) with a typedSqlException. (b) That check accepts INT -> DECIMAL becauseisConvertibleFromis true viaCastIntToDecimalFunctionFactory, but the cursor read path never wrapped the fill function, soIntFunction.getDecimal64()still threw at row time. This branch closes the gap:generateFill()now wraps the fill function withfunctionParser.createImplicitCast(...)wheneverisConvertibleFromis true andisBuiltInWideningCastis false, so the wrap factory's typed accessor handles the read.SampleByFillTest.testFillValueRejectedFor*covers the rejection paths, andtestFillValue{Int,String}CastsToDecimalAggregateplustestFillValueIntWidensToLongAggregatecover the wrap and the no-wrap-needed widening sanity check.SAMPLE BY ... FILL(NULL) ORDER BYwith a LONG256 key threwUnsupportedOperationException.SampleByFillRecord(the gap-row record shared by the FILL(NULL) / FILL(PREV) / FILL(VALUE) cursors) was missinggetLong256/getLong256A/getLong256Boverloads, so any consumer that goes throughRecordSinkFactory's LONG256 path -- including a downstream ORDER BY -- tripped at sort time. Fix: add the three accessors delegating to the underlying function'sgetLong256*at the gap-row'sbaseindex.SampleByTest.testFillNullOrderBySampleByLong256Keycovers it.GROUP BYon a barenullliteral threwIllegalArgumentExceptionfromRecordSinkFactory's ASM generator. Fix:RecordSinkFactorytreatsColumnType.NULLas a zero-width slot in the relevant switch, andOrderedMapFixedSizeRecordallows zero-size NULL columns through its size guard. No memory is reserved for the NULL key column.null + colconstant-folded into an NPE at compile time.AddIntFunc.isConstant()returnedtruewhen one operand was a constant null and the other was a column reference;FunctionParser.functionToConstant()then called the column'sgetInt(null)and NPEd. The fold itself is correct (null + x = null) but violated theisConstant() implies getX(null) is safecontract. Fix: move the fold fromisConstant()tonewInstance()- when one operand is a constant null, free the other and return the typed null constant. Applied to all 15 Int/Long/Float/Double x Add/Sub/Mul/Div factories; onlyAddInthad the NPE, but folding at construction is a small runtime win for the rest.-1collided with the cast-to-symbol shortcut maps' empty-slot sentinel.AbstractCastToSymbolFunction.symbolTableShortcutis anIntIntHashMapwhose default empty-slot sentinel is-1, soINT::SYMBOLoverlength()'s null-marker-1confused a real-1key with an empty slot and tripped anAssertionErrorwhen used as a SAMPLE BY / GROUP BY key. The same-1collision applies to theLongIntHashMap-backed sister factoriesCastLongToSymbol,CastDateToSymbol,CastTimestampToSymbol: they filter onlyNumbers.LONG_NULLupstream, so a real-1L(or a Date / Timestamp of-1ms /-1us before the epoch) walks the never-found path --next++, a fresh entry onsymbols, an inserted-but-invisible map entry, and on rehash the migration loop drops every sentinel-equal key while the bookkeeping countersize = capacity - freekeeps reporting the table as full. NoAssertionErrorlike the INT case -- silent unbounded native-memory growth and an inflated distinct-symbol count for any query that casts a column containing-1to SYMBOL. Fix: newIntIntHashMapand publicLongIntHashMap(int, double, long)constructors take a custom no-key sentinel; the four cast factories passNumbers.INT_NULL/Numbers.LONG_NULLso-1can be stored as a regular key.CastTest.test{Long,Date,Timestamp}ToSymbolHandlesNegativeOneAsKeycover the three long-keyed paths.Overflow in scale adjustmentand DECIMAL(76,1) subquery+GROUP BY+ORDER BY NPE. Both surfaced asCairoException("unexpected reduce error: ...")and were resolved by the parallel-reduce typed-exception fix above; no separate change to the DECIMAL code path was needed.s != 100000over a SHORT column wrapped tos != -31072and matched the row that happened to equal-31072instead of all rows.CompiledFilterIRSerializer.serializeNumber()parsed the literal as a long, narrowed via cast, and emitted the truncated value. Fix: range-check before narrowing forI1_TYPE/I2_TYPEand throwSqlException("byte/short literal out of range"), which makesSqlCodeGeneratorfall back to the Java filter where implicit widening to INT preserves the literal exactly.WHERE d = (s * s) + iover (SHORT s, INT i, DOUBLE d): the JIT emittedi16 mul i16 -> i16and overflowed; the Java filter promoted to INT first and returned the right answer. Fix: extendCompiledFilterIRSerializer'sforceScalarModetrigger to fire whenever an arithmetic predicate touches narrow ints (I1_TYPE/I2_TYPE), via a newTypesObserver.hasNarrowInt()helper. The scalar JIT path already widens narrow operands correctly.PageFrameSequence(NATIVE_CB2).(-1234567L)::DECIMAL(4,2) FROM t WHERE s > 5constant-folds the cast at compile time and throwsImplicitCastException, which extendsRuntimeException- notCairoException. Twocatch (SqlException | CairoException e)blocks inSqlCodeGenerator(generateQuery0Inner,generateSelectVirtualWithSubQuery) didn't catch it, leaking the partially built factory tree including the async filter's circuit-breaker buffer. Fix: broaden both catches tocatch (Throwable e)with the originalclose()/freeObjList()cleanup preserved.FunctionParserwith NPE.WhereClauseParser.tryExtractOrTimestampIntrinsicswas non-transactional. For(timestamp = 'value' OR cast(-339289)::DATE = timestamp), the recursion processed the lhs first (stampingintrinsicValue=TRUE), then the rhs bailed when the cast turned out to be DATE not TIMESTAMP. The lhs was already half-extracted;collapseIntrinsicNodeslater dropped it, leaving the OR node withlhs == nullwhileparamCount == 2, andFunctionParser's post-order traversal pushed one fewer Function than the OR's pop count expected. Fix: maketryExtractOrTimestampIntrinsicstransactional - track every node it stamps and on failure revert the marks, restoremodel.intrinsicValue, and clear the interval builder via a newIntrinsicModel.clearIntervalFilters().count_distinct(ARRAY[...])projection cleanup mishandled native memory when the rewrite was rejected.SqlOptimiser.rewriteCountDistinctrewritescount_distinct(arr) FROM tintocount(*) FROM (SELECT arr FROM t WHERE arr IS NOT NULL GROUP BY arr). With ARRAY as the GROUP BY key,GroupByUtils.validateGroupByColumnsthrowsunsupported type of expression. InsideassembleGroupByFunctionsthe parsed ARRAY Function ends up referenced from both outer and inner projection lists; the failure path needs to free each unique reference exactly once. A naive index-aligned dedup double-frees past the designated timestamp slot: the first column loop appends anullplaceholder to outer but skips inner for the timestamp column, so subsequent non-timestamp entries sit atouter[i]andinner[i-1]. The misalignment makesouter[i]after the timestamp slot the same Function reference asinner[i-1], which the previous iteration already freed -- closing the same Function twice underflows native allocator counters and trips assertions under-ea, reachable via any GROUP BY / SAMPLE BY whose first-loop parse fails on a column past the timestamp slot. Fix: walk outer first (Misc.freeis null-safe), keep the list populated as a reference-identity index, then walk inner and free only references not already in outer. No extra allocation.GroupByTest.testGroupByBrokenColumnAfterTimestampClosesFunctionsOncecovers it.WHERE l = (l * 939722L)over a nullable LONG: under QuestDB'sNULL = NULL -> trueconvention the Java filter returned the NULL rows, the JIT returned zero rows. The AVX2 i64 mul kernel wrote the product back into the input ymm viac.vpmuludq(lhs, lhs, rhs), then the outermul()handed that clobberedlhstoblend_with_nulls(); withLong.MIN_VALUEon a lane and an even multiplier, the low-32-bit product wrapped to zero and the blend no longer recognised the lane as null. Only triggered on the AVX2 main loop (>=4 lanes); the scalar tail used a fresh destination and was correct. Fix: writec.vpmuludqinto a fresh ymm temp, preservinglhsfor the null blend.NATIVE_TREE_CHAIN. Found at the 50k budget onSELECT null AS e0, c2, (c1)::INT AS e2 FROM t ORDER BY 3, 1(~2 KB per occurrence).SqlCodeGenerator.generateOrderBy()builds aSortKeyMaterializingRecordCursorFactory(whose cursor allocatesNATIVE_TREE_CHAINbuffers), then evaluatesrecordComparatorCompiler.newInstanceas the next argument toSortedLightRecordCursorFactory. When the comparator threwcolumn type is not supported for order by: NULL, the outer catch closed the original factory but the wrapper that now held it as a base was orphaned. Fix: reassignrecordCursorFactoryto the wrapper after construction so the existing catch closes the correct top-level factory.WHERE null >= c1 AND c0 <= 0.673144(c1 VARCHAR, c0 DOUBLE) inside aSAMPLE BY ... FILL(0) ORDER BY ts DESCquery: the Java filter returned 0 rows, the JIT returned 32 440.CompiledFilterIRSerializer.ensureOnlyVarSizeHeaderChecks()rejected only the column-vs-column case, butserializeNull()emits the var-size NULL sentinel as anI8/I4 IMM, so<varsize_col> >= nullslipped past and the kernel ran an arbitrary integer comparison against the var-size header. Fix: reject any binary operator other thanEQ/NEwhen at least one operand is var-size, forcing fallback to the Java filter.IS NULL/IS NOT NULLkeep JIT-ing because they lower toEQ/NEagainst the NULL header IMM, the only legitimate var-size operand the JIT runtime supports.getLong. Found in differential JIT mode on((c.s SHORT * 78941) - c.l LONG) > c.f FLOATinside an LT JOIN: forc.s = 32767the JIT returned the row, the Java filter did not.CompiledFilterIRSerializer.serializeUntypedNumber()triedNumbers.parseIntfirst and emitted78941as anI4 IMMregardless of any wider operand in the same predicate;int32_multhen wrapped32767 * 78941to a negative int. The Java filter evaluatesMulInt.getLong()as((long) l) * rwhenever the consumer reaches in viagetLong, computing at long width. Fix: when the predicate has a LONG operand, skip the parseInt fallback so the IMM is emitted at I8; the JIT widens the narrow column side viaconvert()and dispatchesint64_mul. F4 / F8 operands intentionally do not trigger this -IntFunction.getDoubledoesintToDouble(getInt)and keeps the int math at int width.hasNarrowInt()trigger for I4 coverage gaps. Bug 17's fix only widens an IMM operand; predicates like(a INT + b INT) > l LONGand(a INT * a INT) > l LONGhave no IMM to widen and the JIT kept dispatchingint32_add/int32_mul, wrapping any input that crossed INT_MAX (e.g.46341 * 46341at long width is+2_147_488_281L). Fix: a new IR opcodeSX_I64(sign-extend top of stack to i64) and a per-predicateNarrowI64WidenDetectorthat pre-walks the subtree on descent. When the detector sees arithmetic + LONG + INT operands, the IR emitter insertsSX_I64after each narrow column / bind variable; the native scalaremit_codedispatches toint32_to_int64(with null-check, soINT_NULLmaps toLONG_NULL) and the arithmetic op then dispatches toint64_*. Predicates without a LONG operand keep the I4 IR. Bug 54 below extends the trigger to DOUBLE context too, with FLOAT (F4) as the only suppressing source.NOT ((0.348512 * c.b BYTE) <= (c.b * c.i INT))wherec.i = INT_NULL: the Java filter excluded those rows, the JIT counted them. The scalarmul/add/sub/divfori8/i16/i32dispatched toint32_*correctly but tagged the result withlhs.dtype()(e.g.i8forBYTE * INT). The downstream f32 / f64 conversion then went throughcvt_null_check(i8) = falseand skipped the NaN substitution, so INT_NULL flowed in as-2_147_483_648.0. The same gap applied to division by zero on narrow operands. Fix: tag the result asi32whennull_checkis on so the f32 / f64 conversion picks upcvt_null_check(i32) = trueand applies NaN substitution.SubIntFunc.getLong()returnedINT_NULLon NULL inputs, silently widening to long-2_147_483_648instead ofLONG_NULL. Found in differential JIT mode onWHERE c6 = (562945 - c4)over(c4 INT, c6 LONG)withc4NULL: the JIT returned the rows wherec6 IS NULL, the Java filter returned the rows wherec6 = -2_147_483_648.EqLongFunctionFactory.Func.getBooleanevaluatesleft.getLong(rec) == right.getLong(rec), andSubIntFuncoverrodegetLongto returnNumbers.INT_NULL- which sign-extends to a regular long, accidentally matching that magic value and missing the genuinely-null rows. Sister factories (AddInt,MulInt,NegInt) andIntFunction.getLongcorrectly returnNumbers.LONG_NULLviaNumbers.intToLong. Fix:SubIntFunc.getLongreturnsNumbers.LONG_NULLon null inputs;QueryRunner.reconcileis also tightened to skip cases where both JIT modes throw the same allowlisted exception class with different messages on a non-deterministic query.unexpected reduce error: index N out of bounds for list size N. Found at the 20k budget on shapes likeSELECT min(rv) FROM (SELECT k AS rk, v AS rv FROM x) WHERE NOT ('z' IS NULL AND 0.5::FLOAT < (rk - rk)).SelectedRecordCursorFactory.getPageFrameCursor()only wrapped the cursor whenisCrossedIndex(columnCrossIndex)reported a reorder, missing the drop-only case. The consumer then saw projected metadata (1 column) alongside the underlying scan's column mapping (2 columns);PageFrameMemoryPool.openParquetiterated bycolumnMapping.getColumnCount()and indexedcolumnTypes(sized to metadata), so a worker thread asserted out of bounds. Fix: also detect drops viacolumnCrossIndex.size() != base.getMetadata().getColumnCount()and wrap whenever any projection is needed;SelectedPageFrameCursor.wrap()projects the base column mapping so the exposed mapping stays parallel with the projected metadata.SELECT (abs(t0.c2) * t0.c2) AS k, approx_count_distinct(t0.sym) ... ORDER BY 2 DESC, 2: shadow's parquet partition produced 49 NULL group keys vs primary's 4. The qualified references go through aSelectedRecordprojection whose column mapping lists the parquet column twice.PageFrameMemoryPool.openParquetkeyed its decode-buffer-to-query-column wiring (fromParquetColumnIndexes) by parquet column index, so the second iteration'ssetQuickoverwrote the first; both decoded buffers landed in the same query slot and the other slot stayed at zero, which the engine reads as "column missing - all NULL." Fix: dedupeparquetColumnsso each unique column appears once per pass, key aparquetIdxToDecodeSlotmap by parquet index, and havedecode()/decodeRemainingColumns()fan a single decoded buffer out to every query slot resolving to that parquet column.SELECT c0, count(), ts FROM fuzz_t1 WHERE c1 IS NOT NULL SAMPLE BY 5m ORDER BY 1, 1 DESCover ac0 LONG256schema. With GROUP BY over parquet,AsyncGroupByRecordCursorFactoryswitches to late materialization, andPageFrameFilteredMemoryRecordresolves each access viagetRowIndex(columnIndex)so late-materialized columns read at the compacted index. Most accessors routed through that helper, butgetLong256A/getLong256B(the variantsRecordSinkFactory-generated GROUP BY key code calls) fell through toPageFrameMemoryRecord's privategetLong256(int, Long256Acceptor)which used the absoluterowIndexdirectly. Every late-materialized LONG256 read fetched the wrong row's bytes; aggregates landed in the wrong groups. Fix: promote the parent helper from private to protected and add a filtered-record override usinggetRowIndex(columnIndex). Storage-divergence failures dropped from 7-9 per 20k queries to 0.CompareDecimal*Functionslow paths matched NULL inputs. Found by the storage-divergence oracle onWHERE c1 <= 26945.4149::DECIMAL(18, 4)over ac1 DECIMAL(18, 5)column with NULL rows, where native returned a non-zero count and parquet returned 0. Two independent bugs: (a)ParquetRowGroupFilter.prepareFilterListpushed the literal at its own scale and tag, so a smaller-scale literal was 10x undersized vs the row group min/max statistics and the pruner skipped every group - fix:PushdownFilterExtractorrebuilds the literal at the column's tag and scale via a newrescaleDecimalForPushdownhelper, and drops the condition if rescaling would lose precision; (b)CompareDecimal{64,128,256}Function.getBoolfedDecimal*.compareTowith NULL operands, soc1 < valueandc1 <= valuematched every NULL row - fix: the base classes short-circuit NULL on either side to false;EqDecimalFunctionFactory's slow-path subclasses overridegetBoolso=keepsNULL = NULL = true. The same investigation also relaxes the fuzz storage-diff comparator to skip two known-benign divergence classes.WhereClauseParserself-comparison clobbered a FALSE intrinsic value set by an earlier conjunct. Found at the 15k budget onWHERE (sym <= sym AND sym IS NOT NULL) AND sym IS NULLover asym SYMBOL INDEXcolumn. The walker correctly setmodel.intrinsicValue = FALSEfor theIS NULL AND IS NOT NULLpair, then visitedsym <= symand ran throughanalyzeLess'snodesEqual(lhs, rhs)shortcut, which unconditionally wrotemodel.intrinsicValue = TRUEand overwrote the FALSE. Downstream code only reads the FALSE sentinel, so the planner reached the indexed-symbol filter path where the assertionnKeyValues > 0 || nKeyExcludedValues > 0fired. Fix: guard the tautology branch in bothanalyzeLessandanalyzeGreaterso it only writes TRUE when the model is not already FALSE; the contradiction branch (x < x,x > x) still writes FALSE unconditionally.<=/>=returned false on two NULL operands while every other type returned true. Refines 24(b). The slow-path NULL guard added for 24(b) returned false unconditionally regardless of thenegatedflag, so<=and>=(the negated forms of>and<) also returned false on two NULLs - while STRING / LONG<=/>=factories return true under QuestDB'sNULL = NULL -> trueconvention. SoWHERE d <= dover a single-NULL row returned 0 rows for DECIMAL but 1 for STRING and LONG. Fix: followNumbers.lessThan/Chars.lessThan, which fold both-NULL throughnegated(true for>=/<=, false for</>). The DECIMAL fast paths and the rescaling base classes now applynegated && isNull() == isNull(). Equality factories'getBooloverrides skip the guard so=and!=are unaffected.(c0 - (c0 - c5)) <= c5over(c0 INT, c5 FLOAT). The math package shipped+(FF),*(FF),/(FF)but no-(FF), soINT - FLOATfell through to-(DD)and ran at f64 in the interpreter while the JIT computed at f32 like its arithmetic siblings. Fix: addSubFloatFunctionFactoryso subtraction resolves through-(FF)and matches the f32 path used by the other three operators. The result type forINT - FLOATchanges from DOUBLE to FLOAT, so a downstream cast around an f32 ulp boundary may round differently than before.QueryFuzzTestwas wired to a 4-thread shared worker pool, onWHERE length((c0)::SYMBOL) < 4.AbstractCastToSymbolFunctionand the six standaloneCast{Long,Double,Date,Timestamp,Str,Varchar}ToSymbolfactories each carry a mutable hash-map symbol cache plus anextcounter, but none overrodeFunction.isThreadSafe(). The inheritedUnaryFunctiondefault returnsgetArg().isThreadSafe()(true for a column reference), socompileWorkerFunctionsConditionallyskipped the per-worker clone and all four workers mutated one cache; a concurrent rehash droveAbstractIntHashSet.probe()into an infinite loop. Fix: every cast-to-symbol function overridesisThreadSafe()to return false.ParallelFilterTest.testCastToSymbolInParallelFiltercovers it.to_long256(LLLL)andmin/max(decimal128)shared scratch buffers across parallel workers. Same family as 28.AbstractCastToLong256Function(13 concrete subclasses),LongsToLong256Function, andMinMaxDecimal128Funceach hold mutableLong256ImplorDecimal128scratch fields and missed theisThreadSafe()=falseoverride (the siblingMinMaxDecimal256Funcalready had it). Parallel filter and parallel GROUP BY shared one instance across workers; scratch races produced wrong filter outcomes, wrong key bucketing, and wrong min/max. Fix: add the override on each.ParallelFilterTest.testLong256FunctionsInParallelFilterandParallelGroupByFuzzTest.testParallelDecimal128MinMaxcover the regressions.QueryRunneralso gains an "unsatisfiable-shortcircuit" skip when one side returns 0 rows from a folded contradiction and the other throwsImplicitCastExceptionon an out-of-range cast.approx_count_distinctparallel HLL undercounted overcast(... as SYMBOL). Found by the storage-divergence oracle: primary returned 47, shadow 59 onapprox_count_distinct((c)::SYMBOL)over identical data. The call routes toApproxCountDistinctIntGroupByFunction, which hashesarg.getInt(record). For cast-to-SYMBOL the id is a per-instance counter onAbstractCastToSymbolFunction, so each worker's clone hashed{0..k_w-1}into the same HLL buckets and the parallel merge collapsed the union. The function hadsupportsParallelism()returning true unconditionally; fix is to delegate toUnaryFunction.super.supportsParallelism(), which forces serial GROUP BY when the arg is non-parallel. Long and IPv4 variants got the same delegation.ParallelGroupByFuzzTest.testParallelApproxCountDistinctOverCastToSymbolcovers it.supportsParallelism()=trueunconditionally, ignoring their args. Audit triggered by 30. Eight more aggregates had the same anti-pattern:corr,covar_samp,covar_pop,regr_slope,regr_intercept,twap,last(ARRAY),mode(BOOLEAN),sparkline. Fix: each delegates toUnaryFunction.super.supportsParallelism()orBinaryFunction.super.supportsParallelism()(the latter ANDs left and right). For the covariance subtree the delegation is consolidated onAbstractCovarGroupByFunction.count(<long_constant>)is deliberately kept astruebecause its arg value is never read. No new bug attributable here, but the override was structurally wrong and would have surfaced like 30 for any future arg whosesupportsParallelism()is false.Decimal64buffer across parallel-GROUP BY workers. Found in differential JIT mode onSELECT sym, (c2)::DECIMAL(18, 3), count(ts) ... GROUP BY 1, 2: row groups paired the right sym values with cast results from other workers' rows.AbstractCastToDecimal64Functionwrites into an inheriteddecimalfield and reads it back viagetDecimalNN, but didn't overrideisThreadSafe(), so the planner shared one instance across workers. The sibling DECIMAL128/256 base already declaredisThreadSafe()=false. Fix: add the override; while here, fold the only-subclass parentToDecimal64FunctionintoAbstractCastToDecimal64Functionso the buffer and accessors live next to thecast()they belong to.ParallelGroupByFuzzTest.testParallelGroupByCastDoubleToDecimal64covers it.FilteredRecordCursorFactoryconstructor tripped on nested filter wrappers. Found by the bind-variable differential onSELECT * FROM (SELECT sym AS k, count() AS cnt FROM t) WHERE (cnt IS NOT NULL) AND (:b0::VARCHAR != 'UX').SqlOptimisersplits the WHERE: the constant-only conjunct gets pushed past the GROUP BY into the inner model, the aggregate-column conjunct stays at the outer level. Codegen then wraps each model's filter independently, producingFilter(Filter(GroupBy(scan)))and tripping the outer constructor'sassert !(base instanceof FilteredRecordCursorFactory). The literal form folded'KNWL'::VARCHAR != 'UX'to TRUE before the optimiser ran so only one model had a WHERE; the bind form keeps the value opaque so the split fires. Fix: when the base is already aFilteredRecordCursorFactory, AND-combine the two filters with a short-circuitChainedAndFilterand reroute the wrapper to the inner base. Same row set, one cursor wrapper instead of two.GroupByTest.testNestedFilterAcrossGroupByWithBindVariablecovers it.GROUP BYfailed compilation with "group by column does not match any key column is select statement". Found by the bind-variable differential on shapes likeSELECT true AS e0, 'J' AS e1, max(cnt) AS a0 FROM (...) GROUP BY e0, 2.SqlOptimiserskips effectively-constant GROUP BY entries when another key remains, but left them ingroupByModel.getGroupBy()sovalidateGroupByColumnsthen looked them up in analiasToColumnMapthat no longer carried them. Bind variables hid the bug because:bN::TYPEis a FUNCTION, not effectively-constant, so the cast survived the drop. Fix: also remove the dropped entries fromgroupByso the validator and downstreamtempBoolList/nonAggSelectCountaccounting only see entries the inner model actually keys on; the last remaining constant in an all-constant GROUP BY survives, preserving the empty-table fallback.GroupByTest.testGroupByConstantsOnlyMultiKeycovers it.INlist compilation tripped on bind variables wrapped in::CHAR/::SYMBOL/::NULL. Found by the bind-variable differential onWHERE c IN ('Y', 'I', :b0::CHAR)andWHERE s IN ('A', :b0::SYMBOL).InCharFunctionFactory.newInstancereads every list element at compile time viagetChar(null)/getStrA(null);InSymbolFunctionFactory.newInstancedefers STRING / VARCHAR / UNDEFINED runtime-constants toinit()but the SYMBOL / NULL / CHAR branches still read eagerly. A bind variable wrapped in those casts isn't bound yet, soNamedParameterLinkFunction.getBase()'s assertion fired. Fix: apply the deferred-list pattern already used inInStrFunctionFactory/InVarcharFunctionFactory.InCharFunctionFactorygrows the deferred path on top of the existing constant fold;InSymbolFunctionFactory's deferred branch now covers SYMBOL / CHAR / NULL too, and the deferred-set rebuild routes CHAR-typed values throughgetChar.InCharTestandInSymbolTest.testBindVarTypedCastInListcover both.ORDER BYmis-resolved against an inner GROUP BY model. Found by the bind-variable differential onSELECT count_distinct(:b0::CHAR) AS a0 FROM t ORDER BY 1, but reproduces without binds:SELECT count_distinct('M'::CHAR) AS a0 FROM t ORDER BY 1also throws "Invalid column: cast".SqlOptimiser.rewriteCountDistinctlifts thecount_distinctargument into a synthetic inner GROUP BY model whose alias is drawn from the AST token; for an explicit cast the token is literallycast.rewriteOrderByPositionthen resolvedORDER BY 1against the deepest baseGroupBy / baseOuter rather than the user's outermost SELECT projection, so1rewrote tocastand the outer alias check failed. Fix: resolve positional ORDER BY refs againstmodel.getColumns()directly.OrderByExpressionTest.testOrderByPositionAfterCountDistinctRewritecovers it.PushdownFilterExtractorevaluated a bind-variable-cast literal at compile time and tripped its assertion. Found at the 1k budget onWHERE NOT ((:b0::BYTE)::DECIMAL(38, 1) >= c0)over ac0 DECIMAL(38, 1)column. When the literal's storage tag or scale doesn't match the column's,rescaleDecimalForPushdownreads the literal's raw value throughDecimalUtil.load(... null)to rebuild it. The read walks down the cast chain toCastStrToByteFunctionFactory.Func.getBytewhich callsarg.getStrA(null)on the bind variable - whosebasefield isn't set untilinit()- andNamedParameterLinkFunction.getBase()asserts. Runtime-constants can't drive parquet row group statistics anyway. Fix: gate the rescale onf.isConstant(); runtime-constants fall through to the existing "rescale failed" branch which adds the function to the condition's value list.ParquetTest.testDecimalFilterWithBindVariableSkipsPushdowncovers it.SELECT 'R', approx_count_distinct('X') FROM t WHERE NOT (... always-true ...): literal returned the single default-aggregate row, bind returned no rows. The shape reproduces without binds:SELECT 'X'::CHAR AS e0, count() FROM t WHERE 1=0also returns no rows.isEffectivelyConstantExpressiononly accepted bare CONSTANT and FUNCTION nodes registered as runtime-constant, so casts and binds fell through to the keyed GROUP BY route which produces 0 rows on empty input rather than the no-explicit-GROUP-BY default row. Fix: the predicate accepts BIND_VARIABLE leaves, the "cast" FUNCTION token, and walksnode.args; the SELECT-list switch lifts a bare bind to the outer projection when an aggregate forces a GROUP BY model implicitly;GroupByUtils.validateGroupByColumnsaccepts BIND_VARIABLE alias roots.GroupByTest.testNonKeyedAggOverConstantCastProjectionand four siblings cover the rule and exceptions.c > (-286452 * (-952151 * -382988))over BYTEc: native returned every row, parquet returned 0.FunctionParserfolds the subtree toLongConstant(-1.04e17);ParquetRowGroupFilter.prepareFilterListdid(int) f.getLong(null)for BYTE / SHORT / INT columns and flipped the sign back to+1.7e9, pruning every row group. Fix: aclampLongToInthelper saturates non-null longs into[INT.MIN_VALUE+1, INT.MAX_VALUE]while preserving theLONG_NULL -> INT_NULLsentinel mapping; the BYTE / SHORT / INT branches route both INT- and LONG-typed value functions through it.ParquetTest.testIntOverflowConstantFilterOn{Byte,Short,Int}Columncover the three paths.LongConstant(-1.04e17)via<(LL); the JIT walked the AST directly, computed the multiplications at int32 width, wrapped to+1.7e9, and matched a fraction of rows by accident. Fix: mirror the fold at IR emission.CompiledFilterIRSerializer.descenddetects a pure-constant integer arithmetic subtree viatryFoldConstantArith, and when(int) longResult != longResultemits a singleIMM I8in place of the subtree, withmarkFoldedI8Imm()onPredicateContextrecording the I8 in bothlocalTypesObserverandglobalTypesObserversogetExecHint()reportsMIXED_SIZE_TYPEand the backend picks the scalar path. Without the type observation, a predicate likeNOT (c5 < ((K*K) - (L - c5)))withc5 FLOATlooked single-size (only F4 from the column), the AVX2convert()table had no entry for a 4-element i64 YMM vs. 8-element f32 YMM, and the I8 - F4 sub fell through unchanged intovpsubq-- producing garbage and dropping NULLc5rows at any frame >= 8 rows (below that the scalar tail covers it).NarrowI64WidenDetectorobserves I8 on the fold root so the existinghasI4 && hasI8widening fires for INT columns; the scalar-mode forcer also fires whenneedsNarrowI64Wideningis set becauseSX_I64is not implemented on the SIMD path.CompiledFilterIRSerializerTest.testConstantArith*andCompiledFilterRegressionTest.testConstantOverflowFold*cover it.WHERE upper(c4) IS NULL OR NOT (upper(upper('BGZT')) > null): literal returned the rows whereupper(c4) IS NULL, bind admitted every row.Lt{Str,Varchar,StrVarchar,VarcharStr}FunctionFactoryshort-circuited toBooleanConstant.FALSEwhen one operand was a NULL constant.SqlOptimiser.optimiseBooleanNotrewritesNOT (X > Y)intoX <= Y, and<=is built viaNegatingFunctionFactory(>)which naively invertsBooleanConstant.FALSEto TRUE. The literal form's all-constant path fell through to the runtimeFuncinstead, which honors the negated flag throughChars.lessThan/Utf8s.lessThan, so only the bind path inverted. A first attempt replaced the short-circuit with an "always false"NegatableBooleanFunctionthat ignored the negated flag, but that was wrong for the both-null case:Chars.lessThan(null, null, true)andUtf8s.lessThan(null, null, true)return TRUE under QuestDB'sNULL = NULL -> trueconvention (matching the in-PR DECIMAL fix in Bug 26), soc <= nullover a NULL row produced 0 rows under the literal short-circuit and 1 row through the runtimeFuncreached by the bind form -- the same literal-vs-bind divergence the original fix was meant to close, just shifted into the both-null edge. Fix: split the null-side function by argument type intoLtStrFunctionFactory.StrNullSideFunc(checksarg.getStrA(rec) == null) andLtVarcharFunctionFactory.VarcharNullSideFunc(checksarg.getVarcharA(rec) == null); both returnnegated && argIsNull(rec). The mixed factories pick the variant matching the non-null operand's type at construction. Behavior change documented above.LtStrNullReproTest.testNullComparisonAgainstNullValueHonoursEqualityConventioncovers all eight (op x null-side) directions for both VARCHAR and STRING with a bind-variable parity check.InSymbolFunctionFactory$Funcfalsely reportedisConstant() = truewhen the IN's LHS was constant and the RHS held a deferred bind variable, NPEing at parse time. Found by the bind-variable differential onWHERE (abs(0.023278::FLOAT))::SYMBOL IN (((0.065706 + :b0::SHORT))::VARCHAR, :b1::STRING).FuncextendsBooleanFunction implements UnaryFunction, whose defaultisConstant()only inspectsarg. The all-constant fast path innewInstanceshort-circuits toBooleanConstantonly when botharg.isConstant()anddeferredValues == null, so a constant LHS paired with a bind-variable RHS still constructs aFunc- yet the inherited default still answered true based solely onarg.FunctionParser.parseFunctionthen folded the IN call viafunctionToConstant -> getBool(null)beforeinit()had settestFunc, NPEing. Fix: overrideFunc.isConstant()to requirearg.isConstant()and every deferred entry'sisConstant()to be true.InSymbolTest.testBindVarRhsWithConstantLhscovers it.ColumnValueCachereturned a register that was never loaded on the OR_SC fast path. Found in differential JIT mode onNOT (c0 IS NULL) AND c1 IN (c0, c1)over(c0 FLOAT, c1 DOUBLE). Predicates sort by priority (IN beforec0 != null) and the mixed F4/F8 widths force scalar mode and short-circuit AND serialization.serializeInemitsBEGIN_SC, c1==c1, OR_SC(2), c0==c1, AND_SC(0), END_SC(2); the innerc0==c1reads c0 viaread_mem, which both emitsc.movss(reg, mem)and pushes(column, type, reg)intoColumnValueCache. At runtime,c1==c1is true for any non-NULL c1, so OR_SC takes its forward jump to END_SC and the movss is skipped. The next predicate'sMEM c0then hits the cache and returns the samereg-- never written on the jumped-to path -- so the F4 NE against the IMM NaN compares stale bits. Fix:ColumnValueCachegainssize()/truncate();emit_codeon both x86 and aarch64 snapshotsvalue_cache.size()at BEGIN_SC and truncates back at END_SC so entries added inside the block do not leak past it.CompiledFilterTest.testInShortCircuitDoesNotLeakColumnCacheAfterEndSccovers it.MinCharGroupByFunction.merge()stored the surviving char viaputInt, overrunning the 2-byte CHAR slot inFlyweightPackedMapValue. The trailing 2 zero bytes landed on the next OrderedMap entry's keySize prefix when entries were 8-byte tight (var-size string keys of 1-3 chars), zeroing the next keySize and crashingOrderedMapVarSizeCursor.hasNext()with SIGSEGV on a fraction of runs; the rest were silent. Fix: useputCharto match every other Min/Max group-by function.ParallelGroupByFuzzTest.testParallelStringKeyGroupByWithMinCharFunctioncovers it.IsIPv4OrderedGroupByFunctionwrote the running IPv4 value viaputLong, overrunning its 4-byte map slot.computeFirst/computeNextpromoted the IPv4 to long viaNumbers.ipv4ToLongand stored it withputLong, corrupting the next map value column. Fix: store the raw IPv4 int viaputInt, matching the IPv4 column width.IPv4Test.testIPv4IsOrderedKeyedcovers it.InStrFunctionFactoryreturned the wrong result forINover CHAR(0). Found by the bind-variable differential onWHERE (0.664321)::CHAR IN ((0.492662)::CHAR, 'Z'): literal returned 0 rows, bind every row. With all-CHAR constants the dispatcher picksin(Sv)overin(Av)(constant CHAR matches STRING viasupportImplicitCastCharToStr, andInStrFunctionFactoryis registered first); the LHS is then wrapped inCastCharToStrFunctionFactory, which maps CHAR(0) to a NULL string, butparseToStringadded CHAR(0) list elements as the 1-char NUL string, so the set never matched the null LHS. Fix: mirror the cast inparseToStringso CHAR(0) is added as null, restoring parity with=over CHAR and withInCharFunctionFactory.InCharTest.testBindVarLiteralDivergenceForNulCharcovers it.InVarcharFunctionFactoryandInSymbolFunctionFactoryhad the same CHAR(0) IN-list bug as 46, reachable on plain VARCHAR / SYMBOL columns. Found while auditing 46: a NULL VARCHAR / SYMBOL row tested against a CHAR(0) IN entry was dropped while it correctly matched an explicit NULL entry. With VARCHAR / SYMBOL column LHS the dispatcher picks the type-native factory exactly, so the asymmetry is reachable without the InStr overload swap.InVarcharFunctionFactory.parseToVarcharadded a CHAR(0) list element as a 1-byte NULUtf8StringandInSymbolFunctionFactory's eager and deferred CHAR branches added it as a 1-char NUL string, whileCastCharToVarcharFunctionFactory/CastCharToSymbolFunctionFactorymap CHAR(0) to a typed NULL constant. Fix: route CHAR(0) list elements (andFunc.deferredValueToStringfor InSymbol) throughset.add(null).InVarcharTest.testCharNulInListMatchesNullVarcharRowandInSymbolTest.testCharNulInListMatchesNullSymbolRowcover the all-const fold, the runtime-constant init() / deferred path, and CHAR(0) alone, alongside an explicit NULL, and against a non-zero CHAR.(195683)::IPv4 = LONG256_col.ArgSwappingpicksEqLong256StrFunctionFactory's=(HS)since IPv4'sOVERLOAD_PRIORITYlists{IPv4, STRING, VARCHAR}, butFunctionParser.createFunctionnever inserted a cast andIPv4Function.getStrA/getVarcharAare final-and-unsupported, so the factory crashed withUnsupportedOperationException. The literal form failed at compile time; the bind form's runtime-const branch deferred decoding toinit()and the optimizer elided the entire subtree (it sat behindOR (NOT (c != c))) beforeinit()ran, so it legitimately succeeded. Fix: add an IPv4 -> STRING wrap increateFunctionnext to the existing UUID -> STRING wrap; the factory then receives aCastIPv4ToStrwhosegetStrAreturns dotted-quad form andEqLong256StrFunctionFactorythrows a cleanImplicitCastExceptionon non-hex. A VARCHAR wrap is unreachable -- every VARCHAR-slot factory also has a STRING slot, and the IPv4 priority order picks STRING first (UUID and INTERVAL handle their promotions the same way).QueryRunneralso gainsisFoldOrderAsymmetry, scoped to the bind axis, so the elidable-subtree fold-order asymmetry is a benign skip rather than a divergence.EqLong256StrFunctionFactoryTestand three newIPv4Testcases cover it.min(DECIMAL128/256)returned a per-frame last-seen value instead of the minimum when the input set scale on its sink (e.g.FLOAT -> DECIMALviaDecimal.ofString).MapValue.getDecimalNNonly restores the raw bytes, so the running aggregate loaded back from the map carried its scratch's stale scale (initially 0);Decimal*.compareToaligns scales by10^delta, making the running value look ~10^scale times larger than every newcomer sominreplaced on every row, with the parallel merge picking by race.maxwas unaffected because it routes through the staticDecimal*.comparethat ignores scale. Fix:MinMaxDecimal{128,256}Func.computeNext/mergesetScale(arg.getScale())on the loaded scratch before the comparison; both sides come from the same arg and must share scale.ParallelGroupByFuzzTest.testParallelDecimal{128,256}MinMaxOverCastcover it.avg(DECIMAL128, target_scale)corrupted the running sum on factory reuse when per-shard sums overflowed into the 256-bit accumulator andtarget_scale != arg_scale.AvgDecimal128Rescale256GroupByFunction.merge's "both shards overflowed" branch called scale-awareDecimal256.add; the map only persists raw bytes, so on a second cursor opendecimal256A.scalecarried over from the prior drain'scalc()(which doessetScale(arg-scale)thendivide(target-scale)) whiledecimal256B.scalestayed at default 0, and the add rescaled by10^deltaand corrupted the running sum. Audit follow-up to 50; non-rescaleAvg*and allSum*were already safe (pinned scale in ctor or scale-blinduncheckedAddthroughout). Fix: switch the suspect line toDecimal256.uncheckedAdd, matching the surrounding branches.ParallelGroupByFuzzTest.testParallelAvgDecimal128RescaleOverflowFactoryReusecovers it -- drains the same factory twice and catches one group at...99.9instead of...99.0.EqStrCharFunctionFactory's constant-string branch short-circuited CHAR(0) to "always false". Same shape as 46/47 but for=/!=instead ofIN. Found by the bind-variable differential on(0.645116::FLOAT)::CHAR != ((0.713754::FLOAT)::INT)::CHAR: literal folded the whole comparison to FALSE, bind folded to TRUE. Both sides evaluate to CHAR(0); the dispatcher ranks the swapped=(SA)overload as exact match (constant CHAR matches STRING viasupportImplicitCastCharToStr), andnewInstancethen readstrFunc.getStrA(null), got null becauseCharFunction.getStrAmaps CHAR(0) to null, and short-circuited toNegatedAwareBooleanConstantFunc-- ignoring that the runtime path'sChars.equalsNc(null, '\0')returns true. The literal form folded the whole comparison at compile time and never visited this factory. Fix: split thestr == nullcheck from thestr.length() != 1check and, in the null branch, fall back toConstStrFunc(charFunc, (char) 0)so the non-constant CHAR side is probed against CHAR(0) per row.EqStrCharFunctionTest.testCharNulConstantMatchesCharNulRowcovers it.SqlOptimiser.pushDownLimitAdvicepropagated the outer LIMIT onto baseModel through aggregating rewrites, undercounting post-aggregation rows. Found by the storage-divergence oracle onSELECT (t0.c2 + t0.c2) AS e0, -768785.71056::DECIMAL(38, 5), first(true) FROM fuzz_t1 t0 WHERE t0.sym IS NOT NULL AND ... LIMIT 31: primary returned 28 rows, shadow 31 over identical data. The push-down only blocked on DISTINCT, so it stamped baseModel'slimitAdviceLo=31even when a GROUP BY, WINDOW, or HORIZON JOIN was about to wrap above; the inner Async Filter then truncated to 31 raw rows and the GROUP BY collapsed those into 28 unique e0 keys. Fix: a newLIMIT_PUSH_DOWN_ROW_COUNT_BLOCKERSmask covers DISTINCT, GROUP BY (and therefore SAMPLE BY), WINDOW, and HORIZON JOIN; both push-down call sites consult it. WINDOW JOIN is intentionally excluded -- it preserves the master's row count and the rewrite assertstranslationIsRedundantfor it, so it never reaches the push-down.LimitTest.testLimitNotPushedBelow{Distinct,GroupBy,Window,HorizonJoin,SampleBy}cover the rule.QueryFuzzTest(s0=56701203663320, s1=1778240196323) onc0 <= ((-732674 * a.c5) + -238927)overc0 LONG, c5 INT, c5 = 10000: the JIT returned the row, the Java filter did not. The JIT'sNarrowI64WidenDetectorpre-walks the predicate, sees a LONG operand and widens every narrow leaf to i64 viaSX_I64, so the inner-732674 * 10000lifts to long before the addition. The Java filter'sAddInt.getLongrecursed viaright.getInt(rec)/left.getInt(rec), so the innerMulIntreturned its int32-wrapped product (-7_326_740_000truncates to1_262_956_096); the outer+(-238927)widened too late and the comparison saw a different value. Fix:MulInt,AddInt,SubInt, andNegInt'sgetLong()overrides now call.getLong()on subtree operands, mirroring the JIT pre-pass. Single-level shapes such as(a * b) > lare unaffected because leafIntColumn/IntConstantreturn identical values viagetInt()andgetLong(). Behavior change documented above.CompiledFilterTest.testNestedIntArithmeticWidenedToLongInLongContextcovers it.SqlCodeGeneratorpicked a sym-ordered master factory for a time-series join, stripping the timestamp metadata. Found by the storage-divergence oracle onSELECT b.val, b.sym, a.sym FROM t a SPLICE JOIN u b WHERE a.ts IN '...'::DATE ORDER BY 3wherea.symis indexed: primary threw "left side of time series join has no timestamp", shadow (no index on sym) ran fine.generateTableQuerysaw the order-by advice on the indexed key, the single-partition interval intrinsic, and no residual filter, and builtSortedSymbolIndexRecordCursorFactoryfor the master -- which emits rows in symbol order and zeroes the timestamp index. The parent SPLICE then either threw the misleading "no timestamp" or, with the index restored, would have silently fed sym-ordered input into a merge that assumes ts order. Fix: the two strip-ts paths (theSortedSymbolIndexRecordCursorFactorybranch and the order-by-key-column shortcut inside the indexed-sym WHERE branch) now consultexecutionContext.isTimestampRequired()-- already pushed bygenerateJoinsfor exactly this signal -- and fall through to a normalPageFrameRecordCursorFactorywhen set. The outer ORDER BY is honoured by a sort above the join, as in the non-indexed case. Standalone (non-join) queries still take the SortedSymbolIndex path.JoinTest.testSpliceJoinIndexedSymbolMasterWithOrderByPreservesTimestampcovers it.Adjacent fix from code review
Not surfaced by the fuzzer; found during code review of PR #6993 (parallel top-K through SELECT projections). The fix lives on this branch because the leak affects every parallel filter path the fuzzer already exercises.
SqlCodeGenerator.compileWorkerFiltersConditionallyleaked partial worker filters when per-worker compilation failed mid-loop. The helper built per-worker copies of a non-thread-safe boolean filter in aforloop without atry/catch, while its siblingcompileWorkerFunctionsConditionallyalready had the right guard. IfcompileBooleanFilterthrew on iteration N>0 (parser error, JIT error, allocation failure), the N already-constructed filters were stranded in a localObjListand never freed -- leaking whatever native resources they held. Reachable from every parallel path that calls the helper: async filter, async GROUP BY, parallel top-K, and WINDOW JOIN compilation. Fix: wrap the loop intry { ... } catch (Throwable th) { Misc.freeObjList(workerFilters); throw th; }matching the sibling method's pattern.TopKFilterCompilationLeakTest.testPerWorkerFilterLeakOnPartialCompileFailurecovers it via a new test-onlytest_throwing_filter()SQL function (in theengine.functions.testpackage, dev-mode gated likenpe()andalloc()) that allocates native memory pernewInstanceand throws on a configurable Nth call; bothAssert.assertEquals(2, CLOSE_COUNT.get())andassertMemoryLeakcatch the regression independently.Test config tuning
The fuzzer's earlier SAMPLE BY clause occasionally produced
LimitOverflowExceptionfrom the ORDER BY sort, not because of a bug but because the test config capscairo.sql.sort.key.max.pages=128(16 MB). 30s/1m buckets over the fuzzer's 30-75h table span, multiplied by typical key cardinality, exceeded that cap.SampleByClause.INTERVALSnow starts at 5m, which keeps the worst-case row count comfortably inside 128 × 128 KB while still exercising every SAMPLE BY shape.Intentional limitations
long_sequence()does not carry; mixing wrapped sources into joins was kept out of scope.FuzzTable.Test plan
mvn -Dtest=QueryFuzzTest test -pl corepasses at the default 100-query budget.mvn -Dtest=QueryFuzzTest test -pl core -Dquestdb.fuzz.queries=10000runs without failures across multiple seeds (verified on 5k, 10k, 20k, and 30k budgets, both single-mode and differential JIT mode).mvn -Dtest=QueryFuzzTest test -pl core -Dquestdb.fuzz.queries=N -Dquestdb.fuzz.dump=/tmp/fuzz.sqlproduces a corpus file suitable for offline analysis.SampleByTest,GroupByTest,SqlCompilerImplTest,LengthFunctionFactoryTest,CompiledFilterTest,WhereClauseParserTest,ParquetTest, etc.).TODOs
To be done in future PRs