Skip to content

chore(core): add randomized query fuzzer and fix multiple SQL correctness and resource-leak bugs - #7021

Merged
bluestreak01 merged 160 commits into
masterfrom
puzpuzpuz_query_fuzzer
May 22, 2026
Merged

chore(core): add randomized query fuzzer and fix multiple SQL correctness and resource-leak bugs#7021
bluestreak01 merged 160 commits into
masterfrom
puzpuzpuz_query_fuzzer

Conversation

@puzpuzpuz

@puzpuzpuz puzpuzpuz commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds QueryFuzzTest under core/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 - FLOAT from DOUBLE to FLOAT. The new SubFloatFunctionFactory resolves -(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 an INT - FLOAT subtree 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 - c5 or c0 - 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, so c <= null produced 0 rows even when c IS NULL. The bind-variable form already returned TRUE through the runtime Chars.lessThan / Utf8s.lessThan path under QuestDB's NULL = NULL -> true convention, so the literal and bind forms disagreed on the both-null edge. The fix now honours negated && argIsNull(rec), so c <= null and c >= null return 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 for c <= null / c >= null need to filter explicitly with c IS NOT NULL, or use c < null / c > null which 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, and NegInt's getLong() overrides now recurse through their subtrees via .getLong() instead of .getInt(). Previously only the outermost level widened, so an inner INT * INT product 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) > l keep their existing result because leaf IntColumn / IntConstant return the same value via getInt() and getLong().

EmptyTableRecordCursorFactory now reports random access support. The factory substitutes the master side of a plan whenever SqlCodeGenerator folds the WHERE clause to constant FALSE (e.g. ((c0 + null))::TIMESTAMP < timestamp_const -- AddIntFunctionFactory short-circuits c0 + null to IntConstant.NULL and the timestamp comparator folds null < ts to FALSE). Operators that require random access on an input -- chiefly SPLICE JOIN -- previously rejected such queries with left 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 since recordAt is 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::TYPE casts, 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 with SqlException("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 - NONE keeps every partition native, ALL converts all non-active partitions, PARTIAL flips an independent coin per non-active partition so the table holds a mix of native and parquet partitions. Each SYMBOL column independently has a 20% chance of being created with INDEX. 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), QueryRunner runs 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 a deterministic flag 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::TYPE placeholders, with their string values supplied through BindVariableService.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 via setStr), "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 via setStr, or the literal folds an entire subtree to a constant - e.g. a true != true contradiction collapsing the WHERE - and never type-checks an unsupported sibling like IN over BOOLEAN that the bind form keeps opaque), and "decimal places but scale is limited to" (literal DOUBLE/FLOAT::DECIMAL(p,s) runs through DecimalUtil.parseDecimalConstant with lossy=false so over-scaled constants are rejected at compile time, while the bind path runs through CastDoubleToDecimalFunctionFactory with lossy=true and silently truncates excess decimal places). Non-bindable types (DECIMAL, IPv4, LONG256, UUID, DOUBLE arrays) stay as literals in both forms.

Oracle

QueryRunner catches the following as skips (legitimate user-facing errors):

  • SqlException - parser / compiler / type-system
  • ImplicitCastException - runtime cast failure on an out-of-range literal
  • NumericException - numeric parse failure

Anything else, including any CairoException, fails the test. A generated SELECT should never leak a CairoException - 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's random seeds: ... line, to reproduce a failure deterministically

Seeds 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.

  1. Parallel reduce/filter wrapped typed exceptions as CairoException("unexpected reduce error: ..."). Worker threads converted ImplicitCastException and NumericException into a generic CairoException on the way back to the collector, hiding the original class from callers and from the fuzzer oracle. Fix: PageFrameReduceTask classifies the error kind and UnorderedPageFrameSequence.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.
  2. SymbolFunction.getStrLen() threw UnsupportedOperationException. StrFunction wrappers like upper(), lower(), trim(), and the varchar->string cast blew up whenever their argument was a symbol column, even though getStrA()/getStrB() already delegate transparently to getSymbol(). Fix: getStrLen() mirrors that delegation via TableUtils.lengthOf(getSymbol(rec)).
  3. SAMPLE BY ... FILL(value) on incompatible fill/aggregate type combinations threw UnsupportedOperationException. 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 new SampleByFillRecordCursorFactory (PR feat(sql): add cross-column FILL(PREV) and parallelise SAMPLE BY FILL #6946, merged into this branch) added a ColumnType.isConvertibleFrom-based rejection in SqlCodeGenerator.generateFill() that catches most of these (UUID / STRING / IPv4 / LONG256 / GEOHASH / ARRAY against an INT fill) with a typed SqlException. (b) That check accepts INT -> DECIMAL because isConvertibleFrom is true via CastIntToDecimalFunctionFactory, but the cursor read path never wrapped the fill function, so IntFunction.getDecimal64() still threw at row time. This branch closes the gap: generateFill() now wraps the fill function with functionParser.createImplicitCast(...) whenever isConvertibleFrom is true and isBuiltInWideningCast is false, so the wrap factory's typed accessor handles the read. SampleByFillTest.testFillValueRejectedFor* covers the rejection paths, and testFillValue{Int,String}CastsToDecimalAggregate plus testFillValueIntWidensToLongAggregate cover the wrap and the no-wrap-needed widening sanity check.
  4. SAMPLE BY ... FILL(NULL) ORDER BY with a LONG256 key threw UnsupportedOperationException. SampleByFillRecord (the gap-row record shared by the FILL(NULL) / FILL(PREV) / FILL(VALUE) cursors) was missing getLong256 / getLong256A / getLong256B overloads, so any consumer that goes through RecordSinkFactory's LONG256 path -- including a downstream ORDER BY -- tripped at sort time. Fix: add the three accessors delegating to the underlying function's getLong256* at the gap-row's base index. SampleByTest.testFillNullOrderBySampleByLong256Key covers it.
  5. GROUP BY on a bare null literal threw IllegalArgumentException from RecordSinkFactory's ASM generator. Fix: RecordSinkFactory treats ColumnType.NULL as a zero-width slot in the relevant switch, and OrderedMapFixedSizeRecord allows zero-size NULL columns through its size guard. No memory is reserved for the NULL key column.
  6. null + col constant-folded into an NPE at compile time. AddIntFunc.isConstant() returned true when one operand was a constant null and the other was a column reference; FunctionParser.functionToConstant() then called the column's getInt(null) and NPEd. The fold itself is correct (null + x = null) but violated the isConstant() implies getX(null) is safe contract. Fix: move the fold from isConstant() to newInstance() - 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; only AddInt had the NPE, but folding at construction is a small runtime win for the rest.
  7. -1 collided with the cast-to-symbol shortcut maps' empty-slot sentinel. AbstractCastToSymbolFunction.symbolTableShortcut is an IntIntHashMap whose default empty-slot sentinel is -1, so INT::SYMBOL over length()'s null-marker -1 confused a real -1 key with an empty slot and tripped an AssertionError when used as a SAMPLE BY / GROUP BY key. The same -1 collision applies to the LongIntHashMap-backed sister factories CastLongToSymbol, CastDateToSymbol, CastTimestampToSymbol: they filter only Numbers.LONG_NULL upstream, so a real -1L (or a Date / Timestamp of -1 ms / -1 us before the epoch) walks the never-found path -- next++, a fresh entry on symbols, an inserted-but-invisible map entry, and on rehash the migration loop drops every sentinel-equal key while the bookkeeping counter size = capacity - free keeps reporting the table as full. No AssertionError like the INT case -- silent unbounded native-memory growth and an inflated distinct-symbol count for any query that casts a column containing -1 to SYMBOL. Fix: new IntIntHashMap and public LongIntHashMap(int, double, long) constructors take a custom no-key sentinel; the four cast factories pass Numbers.INT_NULL / Numbers.LONG_NULL so -1 can be stored as a regular key. CastTest.test{Long,Date,Timestamp}ToSymbolHandlesNegativeOneAsKey cover the three long-keyed paths.
  8. DECIMAL Overflow in scale adjustment and DECIMAL(76,1) subquery+GROUP BY+ORDER BY NPE. Both surfaced as CairoException("unexpected reduce error: ...") and were resolved by the parallel-reduce typed-exception fix above; no separate change to the DECIMAL code path was needed.
  9. JIT silently truncated out-of-range BYTE / SHORT literals. Found in differential JIT mode: s != 100000 over a SHORT column wrapped to s != -31072 and matched the row that happened to equal -31072 instead of all rows. CompiledFilterIRSerializer.serializeNumber() parsed the literal as a long, narrowed via cast, and emitted the truncated value. Fix: range-check before narrowing for I1_TYPE / I2_TYPE and throw SqlException("byte/short literal out of range"), which makes SqlCodeGenerator fall back to the Java filter where implicit widening to INT preserves the literal exactly.
  10. JIT computed narrow arithmetic at the narrow width inside wider expressions. Found in differential JIT mode on WHERE d = (s * s) + i over (SHORT s, INT i, DOUBLE d): the JIT emitted i16 mul i16 -> i16 and overflowed; the Java filter promoted to INT first and returned the right answer. Fix: extend CompiledFilterIRSerializer's forceScalarMode trigger to fire whenever an arithmetic predicate touches narrow ints (I1_TYPE / I2_TYPE), via a new TypesObserver.hasNarrowInt() helper. The scalar JIT path already widens narrow operands correctly.
  11. Projection constant DECIMAL cast overflow leaked the inner factory + its PageFrameSequence (NATIVE_CB2). (-1234567L)::DECIMAL(4,2) FROM t WHERE s > 5 constant-folds the cast at compile time and throws ImplicitCastException, which extends RuntimeException - not CairoException. Two catch (SqlException | CairoException e) blocks in SqlCodeGenerator (generateQuery0Inner, generateSelectVirtualWithSubQuery) didn't catch it, leaking the partially built factory tree including the async filter's circuit-breaker buffer. Fix: broaden both catches to catch (Throwable e) with the original close() / freeObjList() cleanup preserved.
  12. Half-collapsed OR in WHERE crashed FunctionParser with NPE. WhereClauseParser.tryExtractOrTimestampIntrinsics was non-transactional. For (timestamp = 'value' OR cast(-339289)::DATE = timestamp), the recursion processed the lhs first (stamping intrinsicValue=TRUE), then the rhs bailed when the cast turned out to be DATE not TIMESTAMP. The lhs was already half-extracted; collapseIntrinsicNodes later dropped it, leaving the OR node with lhs == null while paramCount == 2, and FunctionParser's post-order traversal pushed one fewer Function than the OR's pop count expected. Fix: make tryExtractOrTimestampIntrinsics transactional - track every node it stamps and on failure revert the marks, restore model.intrinsicValue, and clear the interval builder via a new IntrinsicModel.clearIntervalFilters().
  13. count_distinct(ARRAY[...]) projection cleanup mishandled native memory when the rewrite was rejected. SqlOptimiser.rewriteCountDistinct rewrites count_distinct(arr) FROM t into count(*) FROM (SELECT arr FROM t WHERE arr IS NOT NULL GROUP BY arr). With ARRAY as the GROUP BY key, GroupByUtils.validateGroupByColumns throws unsupported type of expression. Inside assembleGroupByFunctions the 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 a null placeholder to outer but skips inner for the timestamp column, so subsequent non-timestamp entries sit at outer[i] and inner[i-1]. The misalignment makes outer[i] after the timestamp slot the same Function reference as inner[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.free is 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.testGroupByBrokenColumnAfterTimestampClosesFunctionsOnce covers it.
  14. AVX2 i64 multiplication lost NULL propagation through register clobber. Found in differential JIT mode on WHERE l = (l * 939722L) over a nullable LONG: under QuestDB's NULL = NULL -> true convention 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 via c.vpmuludq(lhs, lhs, rhs), then the outer mul() handed that clobbered lhs to blend_with_nulls(); with Long.MIN_VALUE on 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: write c.vpmuludq into a fresh ymm temp, preserving lhs for the null blend.
  15. ORDER BY against an unsupported NULL-typed key leaked NATIVE_TREE_CHAIN. Found at the 50k budget on SELECT null AS e0, c2, (c1)::INT AS e2 FROM t ORDER BY 3, 1 (~2 KB per occurrence). SqlCodeGenerator.generateOrderBy() builds a SortKeyMaterializingRecordCursorFactory (whose cursor allocates NATIVE_TREE_CHAIN buffers), then evaluates recordComparatorCompiler.newInstance as the next argument to SortedLightRecordCursorFactory. When the comparator threw column 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: reassign recordCursorFactory to the wrapper after construction so the existing catch closes the correct top-level factory.
  16. JIT compiled non-equality NULL comparisons against var-size columns and produced wrong results. Found in differential JIT mode on WHERE null >= c1 AND c0 <= 0.673144 (c1 VARCHAR, c0 DOUBLE) inside a SAMPLE BY ... FILL(0) ORDER BY ts DESC query: the Java filter returned 0 rows, the JIT returned 32 440. CompiledFilterIRSerializer.ensureOnlyVarSizeHeaderChecks() rejected only the column-vs-column case, but serializeNull() emits the var-size NULL sentinel as an I8/I4 IMM, so <varsize_col> >= null slipped past and the kernel ran an arbitrary integer comparison against the var-size header. Fix: reject any binary operator other than EQ / NE when at least one operand is var-size, forcing fallback to the Java filter. IS NULL / IS NOT NULL keep JIT-ing because they lower to EQ / NE against the NULL header IMM, the only legitimate var-size operand the JIT runtime supports.
  17. JIT computed narrow-int arithmetic at int32 width while the Java filter promoted to long via getLong. Found in differential JIT mode on ((c.s SHORT * 78941) - c.l LONG) > c.f FLOAT inside an LT JOIN: for c.s = 32767 the JIT returned the row, the Java filter did not. CompiledFilterIRSerializer.serializeUntypedNumber() tried Numbers.parseInt first and emitted 78941 as an I4 IMM regardless of any wider operand in the same predicate; int32_mul then wrapped 32767 * 78941 to a negative int. The Java filter evaluates MulInt.getLong() as ((long) l) * r whenever the consumer reaches in via getLong, 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 via convert() and dispatches int64_mul. F4 / F8 operands intentionally do not trigger this - IntFunction.getDouble does intToDouble(getInt) and keeps the int math at int width.
  18. JIT column-only INT arithmetic also overflowed at int32 width in LONG context. Found while auditing bug 10's hasNarrowInt() trigger for I4 coverage gaps. Bug 17's fix only widens an IMM operand; predicates like (a INT + b INT) > l LONG and (a INT * a INT) > l LONG have no IMM to widen and the JIT kept dispatching int32_add / int32_mul, wrapping any input that crossed INT_MAX (e.g. 46341 * 46341 at long width is +2_147_488_281L). Fix: a new IR opcode SX_I64 (sign-extend top of stack to i64) and a per-predicate NarrowI64WidenDetector that pre-walks the subtree on descent. When the detector sees arithmetic + LONG + INT operands, the IR emitter inserts SX_I64 after each narrow column / bind variable; the native scalar emit_code dispatches to int32_to_int64 (with null-check, so INT_NULL maps to LONG_NULL) and the arithmetic op then dispatches to int64_*. 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.
  19. JIT narrow-int arithmetic mistagged its result dtype, skipping NaN substitution for INT_NULL in float context. Found in differential JIT mode on NOT ((0.348512 * c.b BYTE) <= (c.b * c.i INT)) where c.i = INT_NULL: the Java filter excluded those rows, the JIT counted them. The scalar mul / add / sub / div for i8 / i16 / i32 dispatched to int32_* correctly but tagged the result with lhs.dtype() (e.g. i8 for BYTE * INT). The downstream f32 / f64 conversion then went through cvt_null_check(i8) = false and 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 as i32 when null_check is on so the f32 / f64 conversion picks up cvt_null_check(i32) = true and applies NaN substitution.
  20. SubIntFunc.getLong() returned INT_NULL on NULL inputs, silently widening to long -2_147_483_648 instead of LONG_NULL. Found in differential JIT mode on WHERE c6 = (562945 - c4) over (c4 INT, c6 LONG) with c4 NULL: the JIT returned the rows where c6 IS NULL, the Java filter returned the rows where c6 = -2_147_483_648. EqLongFunctionFactory.Func.getBoolean evaluates left.getLong(rec) == right.getLong(rec), and SubIntFunc overrode getLong to return Numbers.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) and IntFunction.getLong correctly return Numbers.LONG_NULL via Numbers.intToLong. Fix: SubIntFunc.getLong returns Numbers.LONG_NULL on null inputs; QueryRunner.reconcile is also tightened to skip cases where both JIT modes throw the same allowlisted exception class with different messages on a non-deterministic query.
  21. Async aggregate over partial-parquet tables crashed with unexpected reduce error: index N out of bounds for list size N. Found at the 20k budget on shapes like SELECT 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 when isCrossedIndex(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.openParquet iterated by columnMapping.getColumnCount() and indexed columnTypes (sized to metadata), so a worker thread asserted out of bounds. Fix: also detect drops via columnCrossIndex.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.
  22. Parquet read silently returned NULL for one of two query columns that referenced the same parquet column. Found by the storage-divergence oracle on 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 a SelectedRecord projection whose column mapping lists the parquet column twice. PageFrameMemoryPool.openParquet keyed its decode-buffer-to-query-column wiring (fromParquetColumnIndexes) by parquet column index, so the second iteration's setQuick overwrote 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: dedupe parquetColumns so each unique column appears once per pass, key a parquetIdxToDecodeSlot map by parquet index, and have decode() / decodeRemainingColumns() fan a single decoded buffer out to every query slot resolving to that parquet column.
  23. Late-materialized LONG256 read came from the wrong row over parquet partitions. Found by the storage-divergence oracle on SELECT c0, count(), ts FROM fuzz_t1 WHERE c1 IS NOT NULL SAMPLE BY 5m ORDER BY 1, 1 DESC over a c0 LONG256 schema. With GROUP BY over parquet, AsyncGroupByRecordCursorFactory switches to late materialization, and PageFrameFilteredMemoryRecord resolves each access via getRowIndex(columnIndex) so late-materialized columns read at the compacted index. Most accessors routed through that helper, but getLong256A / getLong256B (the variants RecordSinkFactory-generated GROUP BY key code calls) fell through to PageFrameMemoryRecord's private getLong256(int, Long256Acceptor) which used the absolute rowIndex directly. 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 using getRowIndex(columnIndex). Storage-divergence failures dropped from 7-9 per 20k queries to 0.
  24. Parquet pushdown for DECIMAL columns silently pruned valid row groups when the literal scale differed from the column's, and the CompareDecimal*Function slow paths matched NULL inputs. Found by the storage-divergence oracle on WHERE c1 <= 26945.4149::DECIMAL(18, 4) over a c1 DECIMAL(18, 5) column with NULL rows, where native returned a non-zero count and parquet returned 0. Two independent bugs: (a) ParquetRowGroupFilter.prepareFilterList pushed 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: PushdownFilterExtractor rebuilds the literal at the column's tag and scale via a new rescaleDecimalForPushdown helper, and drops the condition if rescaling would lose precision; (b) CompareDecimal{64,128,256}Function.getBool fed Decimal*.compareTo with NULL operands, so c1 < value and c1 <= value matched every NULL row - fix: the base classes short-circuit NULL on either side to false; EqDecimalFunctionFactory's slow-path subclasses override getBool so = keeps NULL = NULL = true. The same investigation also relaxes the fuzz storage-diff comparator to skip two known-benign divergence classes.
  25. WhereClauseParser self-comparison clobbered a FALSE intrinsic value set by an earlier conjunct. Found at the 15k budget on WHERE (sym <= sym AND sym IS NOT NULL) AND sym IS NULL over a sym SYMBOL INDEX column. The walker correctly set model.intrinsicValue = FALSE for the IS NULL AND IS NOT NULL pair, then visited sym <= sym and ran through analyzeLess's nodesEqual(lhs, rhs) shortcut, which unconditionally wrote model.intrinsicValue = TRUE and overwrote the FALSE. Downstream code only reads the FALSE sentinel, so the planner reached the indexed-symbol filter path where the assertion nKeyValues > 0 || nKeyExcludedValues > 0 fired. Fix: guard the tautology branch in both analyzeLess and analyzeGreater so it only writes TRUE when the model is not already FALSE; the contradiction branch (x < x, x > x) still writes FALSE unconditionally.
  26. DECIMAL <= / >= 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 the negated flag, so <= and >= (the negated forms of > and <) also returned false on two NULLs - while STRING / LONG <= / >= factories return true under QuestDB's NULL = NULL -> true convention. So WHERE d <= d over a single-NULL row returned 0 rows for DECIMAL but 1 for STRING and LONG. Fix: follow Numbers.lessThan / Chars.lessThan, which fold both-NULL through negated (true for >= / <=, false for < / >). The DECIMAL fast paths and the rescaling base classes now apply negated && isNull() == isNull(). Equality factories' getBool overrides skip the guard so = and != are unaffected.
  27. JIT vs interpreter divergence on INT minus FLOAT subtraction. Found in differential JIT mode on (c0 - (c0 - c5)) <= c5 over (c0 INT, c5 FLOAT). The math package shipped +(FF), *(FF), /(FF) but no -(FF), so INT - FLOAT fell through to -(DD) and ran at f64 in the interpreter while the JIT computed at f32 like its arithmetic siblings. Fix: add SubFloatFunctionFactory so subtraction resolves through -(FF) and matches the f32 path used by the other three operators. The result type for INT - FLOAT changes from DOUBLE to FLOAT, so a downstream cast around an f32 ulp boundary may round differently than before.
  28. Cast-to-symbol functions shared a mutable symbol cache across parallel-filter workers, hanging or returning wrong results. Found once QueryFuzzTest was wired to a 4-thread shared worker pool, on WHERE length((c0)::SYMBOL) < 4. AbstractCastToSymbolFunction and the six standalone Cast{Long,Double,Date,Timestamp,Str,Varchar}ToSymbol factories each carry a mutable hash-map symbol cache plus a next counter, but none overrode Function.isThreadSafe(). The inherited UnaryFunction default returns getArg().isThreadSafe() (true for a column reference), so compileWorkerFunctionsConditionally skipped the per-worker clone and all four workers mutated one cache; a concurrent rehash drove AbstractIntHashSet.probe() into an infinite loop. Fix: every cast-to-symbol function overrides isThreadSafe() to return false. ParallelFilterTest.testCastToSymbolInParallelFilter covers it.
  29. Cast-to-LONG256, to_long256(LLLL) and min/max(decimal128) shared scratch buffers across parallel workers. Same family as 28. AbstractCastToLong256Function (13 concrete subclasses), LongsToLong256Function, and MinMaxDecimal128Func each hold mutable Long256Impl or Decimal128 scratch fields and missed the isThreadSafe()=false override (the sibling MinMaxDecimal256Func already 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.testLong256FunctionsInParallelFilter and ParallelGroupByFuzzTest.testParallelDecimal128MinMax cover the regressions. QueryRunner also gains an "unsatisfiable-shortcircuit" skip when one side returns 0 rows from a folded contradiction and the other throws ImplicitCastException on an out-of-range cast.
  30. approx_count_distinct parallel HLL undercounted over cast(... as SYMBOL). Found by the storage-divergence oracle: primary returned 47, shadow 59 on approx_count_distinct((c)::SYMBOL) over identical data. The call routes to ApproxCountDistinctIntGroupByFunction, which hashes arg.getInt(record). For cast-to-SYMBOL the id is a per-instance counter on AbstractCastToSymbolFunction, so each worker's clone hashed {0..k_w-1} into the same HLL buckets and the parallel merge collapsed the union. The function had supportsParallelism() returning true unconditionally; fix is to delegate to UnaryFunction.super.supportsParallelism(), which forces serial GROUP BY when the arg is non-parallel. Long and IPv4 variants got the same delegation. ParallelGroupByFuzzTest.testParallelApproxCountDistinctOverCastToSymbol covers it.
  31. GROUP BY aggregates returned supportsParallelism()=true unconditionally, 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 to UnaryFunction.super.supportsParallelism() or BinaryFunction.super.supportsParallelism() (the latter ANDs left and right). For the covariance subtree the delegation is consolidated on AbstractCovarGroupByFunction. count(<long_constant>) is deliberately kept as true because 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 whose supportsParallelism() is false.
  32. Cast-to-DECIMAL64 shared a per-instance Decimal64 buffer across parallel-GROUP BY workers. Found in differential JIT mode on SELECT 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. AbstractCastToDecimal64Function writes into an inherited decimal field and reads it back via getDecimalNN, but didn't override isThreadSafe(), so the planner shared one instance across workers. The sibling DECIMAL128/256 base already declared isThreadSafe()=false. Fix: add the override; while here, fold the only-subclass parent ToDecimal64Function into AbstractCastToDecimal64Function so the buffer and accessors live next to the cast() they belong to. ParallelGroupByFuzzTest.testParallelGroupByCastDoubleToDecimal64 covers it.
  33. FilteredRecordCursorFactory constructor tripped on nested filter wrappers. Found by the bind-variable differential on SELECT * FROM (SELECT sym AS k, count() AS cnt FROM t) WHERE (cnt IS NOT NULL) AND (:b0::VARCHAR != 'UX'). SqlOptimiser splits 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, producing Filter(Filter(GroupBy(scan))) and tripping the outer constructor's assert !(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 a FilteredRecordCursorFactory, AND-combine the two filters with a short-circuit ChainedAndFilter and reroute the wrapper to the inner base. Same row set, one cursor wrapper instead of two. GroupByTest.testNestedFilterAcrossGroupByWithBindVariable covers it.
  34. All-constant explicit GROUP BY failed compilation with "group by column does not match any key column is select statement". Found by the bind-variable differential on shapes like SELECT true AS e0, 'J' AS e1, max(cnt) AS a0 FROM (...) GROUP BY e0, 2. SqlOptimiser skips effectively-constant GROUP BY entries when another key remains, but left them in groupByModel.getGroupBy() so validateGroupByColumns then looked them up in an aliasToColumnMap that no longer carried them. Bind variables hid the bug because :bN::TYPE is a FUNCTION, not effectively-constant, so the cast survived the drop. Fix: also remove the dropped entries from groupBy so the validator and downstream tempBoolList/nonAggSelectCount accounting 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.testGroupByConstantsOnlyMultiKey covers it.
  35. IN list compilation tripped on bind variables wrapped in ::CHAR / ::SYMBOL / ::NULL. Found by the bind-variable differential on WHERE c IN ('Y', 'I', :b0::CHAR) and WHERE s IN ('A', :b0::SYMBOL). InCharFunctionFactory.newInstance reads every list element at compile time via getChar(null) / getStrA(null); InSymbolFunctionFactory.newInstance defers STRING / VARCHAR / UNDEFINED runtime-constants to init() but the SYMBOL / NULL / CHAR branches still read eagerly. A bind variable wrapped in those casts isn't bound yet, so NamedParameterLinkFunction.getBase()'s assertion fired. Fix: apply the deferred-list pattern already used in InStrFunctionFactory / InVarcharFunctionFactory. InCharFunctionFactory grows 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 through getChar. InCharTest and InSymbolTest.testBindVarTypedCastInList cover both.
  36. Positional ORDER BY mis-resolved against an inner GROUP BY model. Found by the bind-variable differential on SELECT 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 1 also throws "Invalid column: cast". SqlOptimiser.rewriteCountDistinct lifts the count_distinct argument into a synthetic inner GROUP BY model whose alias is drawn from the AST token; for an explicit cast the token is literally cast. rewriteOrderByPosition then resolved ORDER BY 1 against the deepest baseGroupBy / baseOuter rather than the user's outermost SELECT projection, so 1 rewrote to cast and the outer alias check failed. Fix: resolve positional ORDER BY refs against model.getColumns() directly. OrderByExpressionTest.testOrderByPositionAfterCountDistinctRewrite covers it.
  37. PushdownFilterExtractor evaluated a bind-variable-cast literal at compile time and tripped its assertion. Found at the 1k budget on WHERE NOT ((:b0::BYTE)::DECIMAL(38, 1) >= c0) over a c0 DECIMAL(38, 1) column. When the literal's storage tag or scale doesn't match the column's, rescaleDecimalForPushdown reads the literal's raw value through DecimalUtil.load(... null) to rebuild it. The read walks down the cast chain to CastStrToByteFunctionFactory.Func.getByte which calls arg.getStrA(null) on the bind variable - whose base field isn't set until init() - and NamedParameterLinkFunction.getBase() asserts. Runtime-constants can't drive parquet row group statistics anyway. Fix: gate the rescale on f.isConstant(); runtime-constants fall through to the existing "rescale failed" branch which adds the function to the condition's value list. ParquetTest.testDecimalFilterWithBindVariableSkipsPushdown covers it.
  38. Non-keyed aggregate over a constant cast or bind projection returned an empty cursor on filtered-empty input. Found by the bind differential on 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=0 also returns no rows. isEffectivelyConstantExpression only 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 walks node.args; the SELECT-list switch lifts a bare bind to the outer projection when an aggregate forces a GROUP BY model implicitly; GroupByUtils.validateGroupByColumns accepts BIND_VARIABLE alias roots. GroupByTest.testNonKeyedAggOverConstantCastProjection and four siblings cover the rule and exceptions.
  39. Parquet pushdown truncated overflow-folded long constants on narrow-int columns. Found by the storage-divergence oracle on c > (-286452 * (-952151 * -382988)) over BYTE c: native returned every row, parquet returned 0. FunctionParser folds the subtree to LongConstant(-1.04e17); ParquetRowGroupFilter.prepareFilterList did (int) f.getLong(null) for BYTE / SHORT / INT columns and flipped the sign back to +1.7e9, pruning every row group. Fix: a clampLongToInt helper saturates non-null longs into [INT.MIN_VALUE+1, INT.MAX_VALUE] while preserving the LONG_NULL -> INT_NULL sentinel mapping; the BYTE / SHORT / INT branches route both INT- and LONG-typed value functions through it. ParquetTest.testIntOverflowConstantFilterOn{Byte,Short,Int}Column cover the three paths.
  40. JIT IR walked unfolded AST arithmetic at the column's narrow width. Found in differential JIT mode on the same shape as 39: the Java filter sees the post-fold 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.descend detects a pure-constant integer arithmetic subtree via tryFoldConstantArith, and when (int) longResult != longResult emits a single IMM I8 in place of the subtree, with markFoldedI8Imm() on PredicateContext recording the I8 in both localTypesObserver and globalTypesObserver so getExecHint() reports MIXED_SIZE_TYPE and the backend picks the scalar path. Without the type observation, a predicate like NOT (c5 < ((K*K) - (L - c5))) with c5 FLOAT looked single-size (only F4 from the column), the AVX2 convert() table had no entry for a 4-element i64 YMM vs. 8-element f32 YMM, and the I8 - F4 sub fell through unchanged into vpsubq -- producing garbage and dropping NULL c5 rows at any frame >= 8 rows (below that the scalar tail covers it). NarrowI64WidenDetector observes I8 on the fold root so the existing hasI4 && hasI8 widening fires for INT columns; the scalar-mode forcer also fires when needsNarrowI64Widening is set because SX_I64 is not implemented on the SIMD path. CompiledFilterIRSerializerTest.testConstantArith* and CompiledFilterRegressionTest.testConstantOverflowFold* cover it.
  41. String / varchar comparison short-circuit on NULL constant flipped under negation, with the both-null edge requiring an honour-equality-convention shape. Found by the bind-variable differential on WHERE upper(c4) IS NULL OR NOT (upper(upper('BGZT')) > null): literal returned the rows where upper(c4) IS NULL, bind admitted every row. Lt{Str,Varchar,StrVarchar,VarcharStr}FunctionFactory short-circuited to BooleanConstant.FALSE when one operand was a NULL constant. SqlOptimiser.optimiseBooleanNot rewrites NOT (X > Y) into X <= Y, and <= is built via NegatingFunctionFactory(>) which naively inverts BooleanConstant.FALSE to TRUE. The literal form's all-constant path fell through to the runtime Func instead, which honors the negated flag through Chars.lessThan / Utf8s.lessThan, so only the bind path inverted. A first attempt replaced the short-circuit with an "always false" NegatableBooleanFunction that ignored the negated flag, but that was wrong for the both-null case: Chars.lessThan(null, null, true) and Utf8s.lessThan(null, null, true) return TRUE under QuestDB's NULL = NULL -> true convention (matching the in-PR DECIMAL fix in Bug 26), so c <= null over a NULL row produced 0 rows under the literal short-circuit and 1 row through the runtime Func reached 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 into LtStrFunctionFactory.StrNullSideFunc (checks arg.getStrA(rec) == null) and LtVarcharFunctionFactory.VarcharNullSideFunc (checks arg.getVarcharA(rec) == null); both return negated && argIsNull(rec). The mixed factories pick the variant matching the non-null operand's type at construction. Behavior change documented above. LtStrNullReproTest.testNullComparisonAgainstNullValueHonoursEqualityConvention covers all eight (op x null-side) directions for both VARCHAR and STRING with a bind-variable parity check.
  42. InSymbolFunctionFactory$Func falsely reported isConstant() = true when the IN's LHS was constant and the RHS held a deferred bind variable, NPEing at parse time. Found by the bind-variable differential on WHERE (abs(0.023278::FLOAT))::SYMBOL IN (((0.065706 + :b0::SHORT))::VARCHAR, :b1::STRING). Func extends BooleanFunction implements UnaryFunction, whose default isConstant() only inspects arg. The all-constant fast path in newInstance short-circuits to BooleanConstant only when both arg.isConstant() and deferredValues == null, so a constant LHS paired with a bind-variable RHS still constructs a Func - yet the inherited default still answered true based solely on arg. FunctionParser.parseFunction then folded the IN call via functionToConstant -> getBool(null) before init() had set testFunc, NPEing. Fix: override Func.isConstant() to require arg.isConstant() and every deferred entry's isConstant() to be true. InSymbolTest.testBindVarRhsWithConstantLhs covers it.
  43. JIT C++ ColumnValueCache returned a register that was never loaded on the OR_SC fast path. Found in differential JIT mode on NOT (c0 IS NULL) AND c1 IN (c0, c1) over (c0 FLOAT, c1 DOUBLE). Predicates sort by priority (IN before c0 != null) and the mixed F4/F8 widths force scalar mode and short-circuit AND serialization. serializeIn emits BEGIN_SC, c1==c1, OR_SC(2), c0==c1, AND_SC(0), END_SC(2); the inner c0==c1 reads c0 via read_mem, which both emits c.movss(reg, mem) and pushes (column, type, reg) into ColumnValueCache. At runtime, c1==c1 is true for any non-NULL c1, so OR_SC takes its forward jump to END_SC and the movss is skipped. The next predicate's MEM c0 then hits the cache and returns the same reg -- never written on the jumped-to path -- so the F4 NE against the IMM NaN compares stale bits. Fix: ColumnValueCache gains size() / truncate(); emit_code on both x86 and aarch64 snapshots value_cache.size() at BEGIN_SC and truncates back at END_SC so entries added inside the block do not leak past it. CompiledFilterTest.testInShortCircuitDoesNotLeakColumnCacheAfterEndSc covers it.
  44. MinCharGroupByFunction.merge() stored the surviving char via putInt, overrunning the 2-byte CHAR slot in FlyweightPackedMapValue. 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 crashing OrderedMapVarSizeCursor.hasNext() with SIGSEGV on a fraction of runs; the rest were silent. Fix: use putChar to match every other Min/Max group-by function. ParallelGroupByFuzzTest.testParallelStringKeyGroupByWithMinCharFunction covers it.
  45. IsIPv4OrderedGroupByFunction wrote the running IPv4 value via putLong, overrunning its 4-byte map slot. computeFirst / computeNext promoted the IPv4 to long via Numbers.ipv4ToLong and stored it with putLong, corrupting the next map value column. Fix: store the raw IPv4 int via putInt, matching the IPv4 column width. IPv4Test.testIPv4IsOrderedKeyed covers it.
  46. InStrFunctionFactory returned the wrong result for IN over CHAR(0). Found by the bind-variable differential on WHERE (0.664321)::CHAR IN ((0.492662)::CHAR, 'Z'): literal returned 0 rows, bind every row. With all-CHAR constants the dispatcher picks in(Sv) over in(Av) (constant CHAR matches STRING via supportImplicitCastCharToStr, and InStrFunctionFactory is registered first); the LHS is then wrapped in CastCharToStrFunctionFactory, which maps CHAR(0) to a NULL string, but parseToString added CHAR(0) list elements as the 1-char NUL string, so the set never matched the null LHS. Fix: mirror the cast in parseToString so CHAR(0) is added as null, restoring parity with = over CHAR and with InCharFunctionFactory. InCharTest.testBindVarLiteralDivergenceForNulChar covers it.
  47. InVarcharFunctionFactory and InSymbolFunctionFactory had 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.parseToVarchar added a CHAR(0) list element as a 1-byte NUL Utf8String and InSymbolFunctionFactory's eager and deferred CHAR branches added it as a 1-char NUL string, while CastCharToVarcharFunctionFactory / CastCharToSymbolFunctionFactory map CHAR(0) to a typed NULL constant. Fix: route CHAR(0) list elements (and Func.deferredValueToString for InSymbol) through set.add(null). InVarcharTest.testCharNulInListMatchesNullVarcharRow and InSymbolTest.testCharNulInListMatchesNullSymbolRow cover the all-const fold, the runtime-constant init() / deferred path, and CHAR(0) alone, alongside an explicit NULL, and against a non-zero CHAR.
  48. Function dispatcher routed raw IPv4 args to STRING signatures without inserting an implicit cast. Found by the bind-variable differential on (195683)::IPv4 = LONG256_col. ArgSwapping picks EqLong256StrFunctionFactory's =(HS) since IPv4's OVERLOAD_PRIORITY lists {IPv4, STRING, VARCHAR}, but FunctionParser.createFunction never inserted a cast and IPv4Function.getStrA / getVarcharA are final-and-unsupported, so the factory crashed with UnsupportedOperationException. The literal form failed at compile time; the bind form's runtime-const branch deferred decoding to init() and the optimizer elided the entire subtree (it sat behind OR (NOT (c != c))) before init() ran, so it legitimately succeeded. Fix: add an IPv4 -> STRING wrap in createFunction next to the existing UUID -> STRING wrap; the factory then receives a CastIPv4ToStr whose getStrA returns dotted-quad form and EqLong256StrFunctionFactory throws a clean ImplicitCastException on 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). QueryRunner also gains isFoldOrderAsymmetry, scoped to the bind axis, so the elidable-subtree fold-order asymmetry is a benign skip rather than a divergence. EqLong256StrFunctionFactoryTest and three new IPv4Test cases cover it.
  49. 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 -> DECIMAL via Decimal.ofString). MapValue.getDecimalNN only restores the raw bytes, so the running aggregate loaded back from the map carried its scratch's stale scale (initially 0); Decimal*.compareTo aligns scales by 10^delta, making the running value look ~10^scale times larger than every newcomer so min replaced on every row, with the parallel merge picking by race. max was unaffected because it routes through the static Decimal*.compare that ignores scale. Fix: MinMaxDecimal{128,256}Func.computeNext / merge setScale(arg.getScale()) on the loaded scratch before the comparison; both sides come from the same arg and must share scale. ParallelGroupByFuzzTest.testParallelDecimal{128,256}MinMaxOverCast cover it.
  50. avg(DECIMAL128, target_scale) corrupted the running sum on factory reuse when per-shard sums overflowed into the 256-bit accumulator and target_scale != arg_scale. AvgDecimal128Rescale256GroupByFunction.merge's "both shards overflowed" branch called scale-aware Decimal256.add; the map only persists raw bytes, so on a second cursor open decimal256A.scale carried over from the prior drain's calc() (which does setScale(arg-scale) then divide(target-scale)) while decimal256B.scale stayed at default 0, and the add rescaled by 10^delta and corrupted the running sum. Audit follow-up to 50; non-rescale Avg* and all Sum* were already safe (pinned scale in ctor or scale-blind uncheckedAdd throughout). Fix: switch the suspect line to Decimal256.uncheckedAdd, matching the surrounding branches. ParallelGroupByFuzzTest.testParallelAvgDecimal128RescaleOverflowFactoryReuse covers it -- drains the same factory twice and catches one group at ...99.9 instead of ...99.0.
  51. EqStrCharFunctionFactory's constant-string branch short-circuited CHAR(0) to "always false". Same shape as 46/47 but for = / != instead of IN. 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 via supportImplicitCastCharToStr), and newInstance then read strFunc.getStrA(null), got null because CharFunction.getStrA maps CHAR(0) to null, and short-circuited to NegatedAwareBooleanConstantFunc -- ignoring that the runtime path's Chars.equalsNc(null, '\0') returns true. The literal form folded the whole comparison at compile time and never visited this factory. Fix: split the str == null check from the str.length() != 1 check and, in the null branch, fall back to ConstStrFunc(charFunc, (char) 0) so the non-constant CHAR side is probed against CHAR(0) per row. EqStrCharFunctionTest.testCharNulConstantMatchesCharNulRow covers it.
  52. SqlOptimiser.pushDownLimitAdvice propagated the outer LIMIT onto baseModel through aggregating rewrites, undercounting post-aggregation rows. Found by the storage-divergence oracle on SELECT (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's limitAdviceLo=31 even 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 new LIMIT_PUSH_DOWN_ROW_COUNT_BLOCKERS mask 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 asserts translationIsRedundant for it, so it never reaches the push-down. LimitTest.testLimitNotPushedBelow{Distinct,GroupBy,Window,HorizonJoin,SampleBy} cover the rule.
  53. JIT vs Java filter divergence on nested INT arithmetic in a LONG-context predicate. Found by QueryFuzzTest (s0=56701203663320, s1=1778240196323) on c0 <= ((-732674 * a.c5) + -238927) over c0 LONG, c5 INT, c5 = 10000: the JIT returned the row, the Java filter did not. The JIT's NarrowI64WidenDetector pre-walks the predicate, sees a LONG operand and widens every narrow leaf to i64 via SX_I64, so the inner -732674 * 10000 lifts to long before the addition. The Java filter's AddInt.getLong recursed via right.getInt(rec) / left.getInt(rec), so the inner MulInt returned its int32-wrapped product (-7_326_740_000 truncates to 1_262_956_096); the outer +(-238927) widened too late and the comparison saw a different value. Fix: MulInt, AddInt, SubInt, and NegInt's getLong() overrides now call .getLong() on subtree operands, mirroring the JIT pre-pass. Single-level shapes such as (a * b) > l are unaffected because leaf IntColumn / IntConstant return identical values via getInt() and getLong(). Behavior change documented above. CompiledFilterTest.testNestedIntArithmeticWidenedToLongInLongContext covers it.
  54. SqlCodeGenerator picked a sym-ordered master factory for a time-series join, stripping the timestamp metadata. Found by the storage-divergence oracle on SELECT b.val, b.sym, a.sym FROM t a SPLICE JOIN u b WHERE a.ts IN '...'::DATE ORDER BY 3 where a.sym is indexed: primary threw "left side of time series join has no timestamp", shadow (no index on sym) ran fine. generateTableQuery saw the order-by advice on the indexed key, the single-partition interval intrinsic, and no residual filter, and built SortedSymbolIndexRecordCursorFactory for 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 (the SortedSymbolIndexRecordCursorFactory branch and the order-by-key-column shortcut inside the indexed-sym WHERE branch) now consult executionContext.isTimestampRequired() -- already pushed by generateJoins for exactly this signal -- and fall through to a normal PageFrameRecordCursorFactory when 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.testSpliceJoinIndexedSymbolMasterWithOrderByPreservesTimestamp covers 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.

  1. SqlCodeGenerator.compileWorkerFiltersConditionally leaked partial worker filters when per-worker compilation failed mid-loop. The helper built per-worker copies of a non-thread-safe boolean filter in a for loop without a try/catch, while its sibling compileWorkerFunctionsConditionally already had the right guard. If compileBooleanFilter threw on iteration N>0 (parser error, JIT error, allocation failure), the N already-constructed filters were stranded in a local ObjList and 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 in try { ... } catch (Throwable th) { Misc.freeObjList(workerFilters); throw th; } matching the sibling method's pattern. TopKFilterCompilationLeakTest.testPerWorkerFilterLeakOnPartialCompileFailure covers it via a new test-only test_throwing_filter() SQL function (in the engine.functions.test package, dev-mode gated like npe() and alloc()) that allocates native memory per newInstance and throws on a configurable Nth call; both Assert.assertEquals(2, CLOSE_COUNT.get()) and assertMemoryLeak catch the regression independently.

Test config tuning

The fuzzer's earlier SAMPLE BY clause occasionally produced LimitOverflowException from the ORDER BY sort, not because of a bug but because the test config caps cairo.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.INTERVALS now starts at 5m, which keeps the worst-case row count comfortably inside 128 × 128 KB while still exercising every SAMPLE BY shape.

Intentional limitations

  • Joins stay on direct, real tables. ASOF/LT need a designated timestamp on both sides, which long_sequence() does not carry; mixing wrapped sources into joins was kept out of scope.
  • No wrap-of-wrap. A subquery or CTE's base is always direct or virtual, never another subquery/CTE. The grammar is kept flat to keep scope bounded.
  • Inner SAMPLE BY is not supported in subqueries/CTEs - its output schema depends on the base's ts and the bucket interval, which would require propagating a richer schema through FuzzTable.
  • No DISTINCT, UNION, window functions, or multi-arg scalar functions - each is a straightforward future extension once this base lands.
  • The test is inherently non-deterministic between runs (the seed changes each time) and has variable skip rate (~20-25%) - a run that skips more queries exercises less surface, which is the price of a random generator with a conservative error allowlist.

Test plan

  • mvn -Dtest=QueryFuzzTest test -pl core passes at the default 100-query budget.
  • mvn -Dtest=QueryFuzzTest test -pl core -Dquestdb.fuzz.queries=10000 runs 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.sql produces a corpus file suitable for offline analysis.
  • Each fix has a targeted regression test in the closest existing test class (SampleByTest, GroupByTest, SqlCompilerImplTest, LengthFunctionFactoryTest, CompiledFilterTest, WhereClauseParserTest, ParquetTest, etc.).

TODOs

  • Add differential fuzzing for bind variables: replace constants in projection / WHERE / aggregate slots with named bind variables for the bindable types and compare result sets
  • Configure query thread pool of size 4 when running queries
  • Convert all/some partitions to parquet with a chance when generating tables
  • Add an index to a symbol column with a chance when generating tables

To be done in future PRs

  • Add support for more scenarios: LATEST ON, WINDOW JOIN, HORIZON JOIN, etc.
  • Add covering index randomly on generated table columns
  • Fail randomly on metadata / column read: all resources must be properly released in such case
  • Fail randomly on native memory allocations: all resources must be properly released in such case

@puzpuzpuz puzpuzpuz self-assigned this Apr 23, 2026
@coderabbitai

coderabbitai Bot commented Apr 23, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: dba44cae-ec50-41ef-955f-9e299539edfb

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch puzpuzpuz_query_fuzzer

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@puzpuzpuz puzpuzpuz added the SQL Issues or changes relating to SQL execution label Apr 23, 2026
puzpuzpuz and others added 26 commits April 24, 2026 09:57
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.
… 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.
puzpuzpuz and others added 9 commits May 9, 2026 13:50
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.
@bluestreak01

Copy link
Copy Markdown
Member

@puzpuzpuz — review findings from a deep pass over the PR. Critical and moderate items below; minor/nits omitted.

Critical

C1. CastFloatToSymbolFunctionFactory regresses on -0.0f after the bug-7 sentinel change

AbstractCastToSymbolFunction.symbolTableShortcut now uses Numbers.INT_NULL (= Integer.MIN_VALUE = 0x80000000) as its empty-slot sentinel (AbstractCastToSymbolFunction.java:57). The class doc states "INT_NULL itself never reaches this map because it is filtered upstream". That holds for CastInt/Byte/Short/CharToSymbol, but not for the FLOAT cast:

  • CastFloatToSymbolFunctionFactory.Func.getSymbol(rec) filters via Numbers.isNull(float), which only catches NaN/Infinity (Numbers.java:1050-1052).
  • The key is Float.floatToIntBits(value).
  • Float.floatToIntBits(-0.0f) == Integer.MIN_VALUE == Numbers.INT_NULL. (Verified empirically.)

For every -0.0f row, keyIndex(key) returns a positive "empty" slot, putAt(keyIndex, key, next++) writes a key equal to the sentinel back into the slot, and the slot remains indistinguishable from empty. The cache never hits, symbols.add(...) grows on every row, and count_distinct(cast(f AS SYMBOL)) is inflated — exactly the silent unbounded growth class the bug-7 fix was meant to eliminate, now re-introduced through a different door.

CastDoubleToSymbolFunctionFactory is incidentally safe because it still uses new LongIntHashMap() (default -1L sentinel) and Double.longBitsToDouble(-1L) is a NaN that Numbers.isNull(double) filters.

Fix: in CastFloatToSymbolFunctionFactory.Func.getInt/getSymbol, normalize -0.0f to 0.0f before floatToIntBits (collapses with 0.0f), or construct the map with a sentinel floatToIntBits cannot produce (any NaN bit pattern other than 0x7FC00000). Add CastTest.testFloatToSymbolHandlesNegativeZeroAsKey.

C2. PageFrameMemoryPool.remapRemainingColumns can overwrite filter-column addresses when a filter column and a non-filter column share the same parquet column

PageFrameMemoryPool.java:648-669 iterates all readParquetColumnCount query columns and rewrites pageAddresses[q] whenever q's parquetIdx is in parquetIdxToDecodeSlot (the second-pass map of non-filter parquet columns).

Trigger query shape: SELECT k, k AS k2, count() FROM x WHERE k > 0 over a parquet partition.

  • First pass (openParquet(frameIndex, {q_of_k}, include=true)) decodes k into chunk 0; remapColumns() sets pageAddresses[q_k] to the FULL chunk.
  • Second pass (openParquet(frameIndex, {q_of_k}, include=false)) clears parquetIdxToDecodeSlot, then adds pq_k → 0 for q_k2 (which shares parquetIdx with the filter column k).
  • decodeRemainingColumns writes the COMPACTED buffer to columnOffset + 0.
  • remapRemainingColumns(columnOffset) iterates ALL query columns. For q_k (the filter column), it finds parquetIdxToDecodeSlot.get(pq_k) == 0 and overwrites pageAddresses[q_k] with the compacted buffer.

Downstream, PageFrameMemoryRecord (filter side) reads at the absolute row index against a compacted buffer — wrong row reads or out-of-bounds.

The new testProjectionRepeatedColumnAggregateOverParquet / testProjectionTriplyRepeatedColumnAggregateOverParquet tests have no WHERE clause, so the late-materialization path never runs and the regression is uncaught.

Fix: in openParquet(..., include=false), do not add an entry to parquetIdxToDecodeSlot if that parquetIdx is reachable from any column already in filterColumnIndexes. Equivalently: have remapRemainingColumns only rewrite slots whose owning q is in the second-pass q-list. Add a regression test of the SELECT k, k AS k2, agg(...) FROM x WHERE k > 0 shape over a parquet partition.

Moderate

M1. Bug 53 (nested-int getLong recursion) was not applied to DivIntFunctionFactory

AddInt/SubInt/MulInt/NegInt recurse via .getLong(); DivInt does not — it inherits IntFunction.getLong() which is Numbers.intToLong(getInt()). So in (a * b) / c > some_long, the outer DivInt.getLong() returns intToLong(MulInt.getInt() / c) — wrapping the inner product at i32 width. The JIT path widens up front via SX_I64, re-introducing the exact divergence bug 53 closed for +/-/*. Add a getLong() override mirroring SubInt, plus a /-shaped variant of testNestedIntArithmeticWidenedToLongInLongContext.

M2. tryExtractOrTimestampIntrinsics rollback skips on exception

The new "transactional" rollback only runs on the false return branch. If tryAccumulateTimestampFunction(...) throws SqlException (e.g. functionParser.parseFunction fails inside the OR walk), the exception propagates past the rollback and leaves the model in a partially-stamped state. Practical reachability is bounded by WhereClauseParser.clear() running between requests, but the "transactional" comment overpromises. Wrap in try { ... } catch (Throwable t) { revertNodes(orIntrinsicNodes); model.clearIntervalFilters(); model.intrinsicValue = savedIntrinsicValue; throw t; }.

Also: RuntimeIntervalModelBuilder.freeAndClear()'s own JavaDoc warns it is rollback-only and "otherwise double-frees Functions still owned by the built model" — worth tightening or routing ownership transfer more granularly.

M3. tryFoldConstantArith silently emits IMM I8 with Long.MIN_VALUE; multiplication overflow that wraps to a valid int slips through (CompiledFilterIRSerializer.java:1615-1647)

Two related cases:

  • A constant subtree like 0 - 9_223_372_036_854_775_807 - 1 yields Long.MIN_VALUE. The fold emits IMM I8 Long.MIN_VALUE, which downstream LONG comparisons treat as a NULL sentinel.
  • For *, if (long) a * b overflows but the wrapped result fits in INT, (int) longVal == longVal is true and the fold emits IMM I4 wrappedInt, matching neither Java integer wrap nor Java long arithmetic.

Reject (throw NumericException) on either case, or document the precondition that Java fold must be bit-equivalent.

M4. Async error rebuild loses errno and criticality

PageFrameReduceTask.buildError() / UnorderedPageFrameSequence.buildError() always rebuild as CairoException.nonCritical(). The original errno / isCritical() is dropped. Filter/cast errors are non-critical by construction so the impact is small today, but if a critical exception ever leaks here it is silently downgraded — masking real bugs and bypassing crash-on-critical handling. Capture ce.getErrno() and ce.isCritical() in setErrorMsg and reapply in buildError. Also: UnorderedPageFrameSequence.dispatchAndAwait() OOM/cancelled short-circuits bypass buildError() entirely and lose errorMessagePosition (OOM) or errorMsg (cancelled).

M5. catch (Throwable e) swallows AssertionError

SqlCodeGenerator.generateQuery0Inner and generateSelectVirtualWithSubQuery (bug 11 fix) now catch and rethrow Throwable. Catching OOME to free native memory before the JVM dies is reasonable, but catching AssertionError masks intermediate state useful for diagnosis under -ea. The catch correctly rethrows, so assertions are still raised — consider narrowing or at least logging the type.

M6. Bug 31 (8-aggregate supportsParallelism audit) is only exercised for corr

testParallelGroupByCorrelationOverNonParallelArg covers corr. The other 7 aggregates audited in the same fix (covar_samp, covar_pop, regr_slope, regr_intercept, twap, last(ARRAY), mode(BOOLEAN), sparkline) have no equivalent parallel test. A regression in any of them would not be caught. Add a parameterized test running each aggregate against a non-parallel arg (e.g. cast-to-symbol) and asserting no merge divergence.

M7. PR title, scope, labels, and TODOs section

  • Title feat(sql): randomized query fuzzer with bug-class fixes does not repeat a verb (CLAUDE.md: fix(sql): fix ...). Suggested: feat(core): add randomized query fuzzer and fix multiple SQL correctness and resource-leak bugs.
  • Scope (sql) is narrow — PR touches JIT C++, parquet, async, parallel filters, std/. (core) fits better.
  • Missing labels: storage (parquet pushdown, page frame memory record), Performance (parallel top-K leak fix), rust (Rust binary rebuilds in the diff).
  • "TODOs" section uses - [x] checkboxes; CLAUDE.md prohibits checkboxes in PR descriptions.

M8. Commit message hygiene

~64% of local commits exceed the 50-char title limit; ~29% have empty bodies. Commit da0dfac6e8 bundles two unrelated fixes (bugs 14 and 15). A squash-merge to master with a single well-formed message mitigates this; otherwise the branch history violates the CLAUDE.md standard.


Strengths noted in passing: 53 of 55 bugs have identifiable regression tests; the three behavior-change blocks in the PR description are exemplary; the fuzzer harness (worker pool, seed reproducibility, error-class allowlist, JIT/storage differential modes) is well-structured.

Recommended pre-merge actions, in order:

  1. Fix C1 (normalize -0.0f) + add CastTest.testFloatToSymbolHandlesNegativeZeroAsKey.
  2. Fix C2 (gate remapRemainingColumns to second-pass q-list) + add the repeated-parquet-column-with-WHERE regression test.
  3. Apply M1 (DivIntFunctionFactory.getLong() override).
  4. Tighten title/labels and replace [x] checkboxes in the PR description.
  5. Broaden the bug-31 test matrix.

M2-M5, M8 are good follow-ups but not stop-ship by themselves.

puzpuzpuz added 2 commits May 21, 2026 21:37
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.
@puzpuzpuz puzpuzpuz changed the title feat(sql): randomized query fuzzer with bug-class fixes feat(core): add randomized query fuzzer and fix multiple SQL correctness and resource-leak bugs May 21, 2026
@puzpuzpuz puzpuzpuz added rust Pull requests that update rust code storage Performance Performance improvements labels May 21, 2026
puzpuzpuz added 3 commits May 21, 2026 23:41
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.
@puzpuzpuz

puzpuzpuz commented May 21, 2026

Copy link
Copy Markdown
Contributor Author

@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.

Critical

C1 — CastFloatToSymbol -0.0f regression — confirmed, fixed (d1f88e0bdd).
Reproduced: approx_count_distinct(f::SYMBOL) over five -0.0f rows returned 7 instead of 3 — floatToIntBits(-0.0f) == INT_NULL collides with the shortcut map's empty-slot sentinel, so each -0.0f row mints a fresh id. (count_distinct/DISTINCT/GROUP BY dedup by string and stayed correct, which is why it hid for a while; approx_count_distinct hashes the id and exposed it.)

One deviation from your suggested fix: I went with the special-sentinel option (a NaN bit pattern 0x7FC00001 that floatToIntBits can never return) rather than normalizing -0.0f -> 0.0f. QuestDB's float formatting prints -0.0 distinctly, and both the constant-fold path and CastDoubleToSymbol keep -0.0 as its own symbol — normalizing would have made the column path diverge from those. Regression test CastTest.testFloatToSymbolHandlesNegativeZeroAsKey (verified red before, green after).

C2 — remapRemainingColumns overwrite — real in isolation, but I could not reach it via SQL. Defensive guard in d1f88e0bdd.
The overwrite logic is genuinely wrong as written, so remapRemainingColumns now skips filter columns (filterColumnIndexes threaded through decodeRemainingColumns). But I could not produce a reproduction, and I believe the path is currently unreachable:

  • I instrumented populateRemainingColumns to throw the instant a filter column shares a parquet index with a remaining column (the exact precondition). It never fired across the storage-diff fuzzer (several thousand queries) or ~8 hand-crafted shapes — including the exact k, k AS k2 shape, abs(k)*k keys, inner-subquery duplications, and non-keyed aggregates.
  • Every plan put SelectedRecord above the Async op: the optimizer pushes runtime filters down to the base scan, below any duplicating projection. So the late-materialization frame never carries a duplicated parquet column, and the duplicated-frame path (the bug 21/22 SelectedPageFrameCursor) only occurs without a runtime filter.

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.

Moderate

M1 — DivIntFunctionFactory — confirmed, fixed (d1f88e0bdd). Reproduced a JIT-on/JIT-off divergence on a (a*b)/c predicate over a LONG column; DivInt.getLong inherited intToLong(getInt()) so the inner product wrapped at int32. Added the getLong() recursion mirroring Add/Sub/Mul/Neg. Regression test in CompiledFilterTest (verified red/green).

M2 — agreed, comment softened (894db2039c). It's comment-accuracy, not a functional bug: a thrown SqlException aborts the compile and clear() resets the parser before reuse. The comment now scopes the rollback to the graceful non-extractable return and notes the throw case is safe for that reason.

M3 — could not reproduce a divergence; left as-is. Two parts:

  • The "* overflow wraps to a valid int -> emits IMM I4" case: descend only emits IMM I8, and only when (int)v != v. When the folded value fits in int it descends normally instead — it never emits an I4 for a folded subtree, so that case doesn't arise.
  • The Long.MIN_VALUE case: the JIT emits IMM I8 Long.MIN_VALUE and the Java filter folds the same subtree to LongConstant.NULL; both treat it as NULL, so they agree (and Long.MIN_VALUE is the LONG null sentinel anyway).

Verified empirically — instrumented markFoldedI8Imm to confirm the fold path actually executes for these shapes, and JIT-on matched JIT-off in every case. If you have a counterexample I'll dig back in.

M4 — agreed, addressed (894db2039c). PageFrameReduceTask and UnorderedPageFrameSequence now capture errno in setErrorMsg and rebuild via critical(errno); isCritical() derives from errno, so criticality is preserved, and errno == NON_CRITICAL reduces to the previous nonCritical() behaviour. Unit tests in PageFrameReduceTaskTest (critical round-trip + non-critical guard; verified red before the fix). I left the OOM/cancelled short-circuits as-is — position/message there are cosmetic.

M5 — left as-is. The catch (Throwable) rethrows, so AssertionError still propagates under -ea; no diagnostic state is lost. Narrowing it wouldn't change behaviour, so I kept the broad catch for the OOME-cleanup intent.

M6 — agreed, added (894db2039c, comment polish in 653ffe5351). New testParallelGroupByAggregatesOverNonParallelArg covers twap, mode(BOOLEAN), sparkline, last(ARRAY): each runs over a cast-to-symbol argument and asserts the plan drops from Async Group By to serial GroupBy and that the result still matches the plain (parallel) form. Verified it goes red when a delegation is reverted to return true. Separately, biased the suite's enableParallelGroupBy / enableJitCompiler / convertToParquet to true in 80% of runs (cccac90f46) so those paths get exercised more often.

M7 — done (PR title/scope, labels, and the [x] checkboxes in the description).

M8 — noted. Leaving the branch history as-is since it's squash-merged.

@mtopolnik

Copy link
Copy Markdown
Contributor

[PR Coverage check]

😍 pass : 810 / 896 (90.40%)

file detail

path covered line new line coverage
🔵 io/questdb/griffin/engine/join/AsyncWindowJoinRecordCursor.java 0 3 00.00%
🔵 io/questdb/griffin/engine/table/AsyncGroupByRecordCursor.java 0 2 00.00%
🔵 io/questdb/griffin/engine/groupby/SampleByFillRecord.java 0 4 00.00%
🔵 io/questdb/griffin/engine/functions/groupby/MinCharGroupByFunction.java 0 1 00.00%
🔵 io/questdb/griffin/engine/functions/cast/CastDateToSymbolFunctionFactory.java 1 2 50.00%
🔵 io/questdb/griffin/engine/functions/cast/CastTimestampToSymbolFunctionFactory.java 1 2 50.00%
🔵 io/questdb/griffin/engine/functions/math/DivFloatFunctionFactory.java 5 9 55.56%
🔵 io/questdb/griffin/engine/functions/math/AddFloatFunctionFactory.java 5 9 55.56%
🔵 io/questdb/griffin/engine/functions/lt/LtVarcharFunctionFactory.java 8 13 61.54%
🔵 io/questdb/griffin/engine/functions/lt/LtStrFunctionFactory.java 8 13 61.54%
🔵 io/questdb/griffin/engine/table/FilteredRecordCursorFactory.java 10 16 62.50%
🔵 io/questdb/cairo/sql/PageFrameFilteredMemoryRecord.java 4 6 66.67%
🔵 io/questdb/griffin/engine/table/ParquetRowGroupFilter.java 10 13 76.92%
🔵 io/questdb/griffin/engine/functions/math/AddDoubleFunctionFactory.java 7 9 77.78%
🔵 io/questdb/griffin/engine/functions/math/MulFloatFunctionFactory.java 7 9 77.78%
🔵 io/questdb/griffin/engine/functions/math/SubFloatFunctionFactory.java 16 20 80.00%
🔵 io/questdb/cairo/sql/async/PageFrameSequence.java 5 6 83.33%
🔵 io/questdb/griffin/engine/functions/bool/InCharFunctionFactory.java 51 60 85.00%
🔵 io/questdb/griffin/model/RuntimeIntervalModelBuilder.java 6 7 85.71%
🔵 io/questdb/griffin/engine/groupby/GroupByUtils.java 19 22 86.36%
🔵 io/questdb/griffin/engine/functions/test/TestThrowingFilterFunctionFactory.java 23 26 88.46%
🔵 io/questdb/griffin/SqlOptimiser.java 12 13 92.31%
🔵 io/questdb/griffin/engine/table/PushdownFilterExtractor.java 36 39 92.31%
🔵 io/questdb/griffin/engine/functions/math/SubIntFunctionFactory.java 13 14 92.86%
🔵 io/questdb/griffin/SqlCodeGenerator.java 28 30 93.33%
🔵 io/questdb/jit/CompiledFilterIRSerializer.java 122 131 93.13%
🔵 io/questdb/cairo/sql/async/UnorderedPageFrameSequence.java 21 22 95.45%
🔵 io/questdb/cairo/sql/PageFrameMemoryPool.java 41 43 95.35%
🔵 io/questdb/griffin/engine/functions/bool/InSymbolFunctionFactory.java 34 35 97.14%
🔵 io/questdb/griffin/engine/functions/groupby/AbstractCovarGroupByFunction.java 1 1 100.00%
🔵 io/questdb/griffin/engine/functions/groupby/RegressionInterceptFunctionFactory.java 1 1 100.00%
🔵 io/questdb/griffin/engine/table/AsyncFilteredNegativeLimitRecordCursor.java 3 3 100.00%
🔵 io/questdb/griffin/engine/functions/math/AddIntFunctionFactory.java 13 13 100.00%
🔵 io/questdb/griffin/engine/table/AsyncFilteredRecordCursor.java 3 3 100.00%
🔵 io/questdb/griffin/engine/functions/cast/AbstractCastToSymbolFunction.java 5 5 100.00%
🔵 io/questdb/griffin/engine/functions/cast/CastFloatToDecimalFunctionFactory.java 3 3 100.00%
🔵 io/questdb/griffin/engine/functions/math/NegIntFunctionFactory.java 2 2 100.00%
🔵 io/questdb/griffin/engine/functions/groupby/TwapGroupByFunction.java 1 1 100.00%
🔵 io/questdb/griffin/engine/functions/math/DivDoubleFunctionFactory.java 9 9 100.00%
🔵 io/questdb/cairo/map/OrderedMapFixedSizeRecord.java 1 1 100.00%
🔵 io/questdb/griffin/engine/functions/groupby/RegressionSlopeFunctionFactory.java 1 1 100.00%
🔵 io/questdb/griffin/engine/functions/eq/EqStrCharFunctionFactory.java 3 3 100.00%
🔵 io/questdb/cairo/sql/PageFrameMemoryRecord.java 6 6 100.00%
🔵 io/questdb/griffin/engine/functions/lt/CompareDecimal128Function.java 3 3 100.00%
🔵 io/questdb/griffin/model/IntrinsicModel.java 2 2 100.00%
🔵 io/questdb/griffin/engine/functions/bool/InStrFunctionFactory.java 2 2 100.00%
🔵 io/questdb/griffin/engine/functions/math/MulIntFunctionFactory.java 13 13 100.00%
🔵 io/questdb/griffin/engine/functions/math/AddLongFunctionFactory.java 9 9 100.00%
🔵 io/questdb/griffin/WhereClauseParser.java 25 25 100.00%
🔵 io/questdb/griffin/engine/functions/groupby/LastArrayGroupByFunction.java 1 1 100.00%
🔵 io/questdb/std/LongIntHashMap.java 4 4 100.00%
🔵 io/questdb/griffin/engine/functions/lt/CompareDecimal256Function.java 3 3 100.00%
🔵 io/questdb/griffin/engine/functions/long256/LongsToLong256FunctionFactory.java 1 1 100.00%
🔵 io/questdb/griffin/engine/table/SelectedRecordCursorFactory.java 14 14 100.00%
🔵 io/questdb/griffin/engine/functions/lt/LtDecimalFunctionFactory.java 17 17 100.00%
🔵 io/questdb/griffin/engine/functions/eq/EqDecimalFunctionFactory.java 15 15 100.00%
🔵 io/questdb/griffin/engine/functions/cast/AbstractCastToDecimal64Function.java 14 14 100.00%
🔵 io/questdb/griffin/engine/functions/groupby/SparklineGroupByFunction.java 4 4 100.00%
🔵 io/questdb/griffin/engine/functions/math/MulLongFunctionFactory.java 9 9 100.00%
🔵 io/questdb/griffin/engine/functions/SymbolFunction.java 1 1 100.00%
🔵 io/questdb/griffin/engine/functions/cast/CastLongToSymbolFunctionFactory.java 2 2 100.00%
🔵 io/questdb/griffin/engine/functions/math/SubDoubleFunctionFactory.java 9 9 100.00%
🔵 io/questdb/griffin/engine/functions/cast/CastDoubleToDecimalFunctionFactory.java 3 3 100.00%
🔵 io/questdb/griffin/engine/functions/cast/CastDoubleToSymbolFunctionFactory.java 1 1 100.00%
🔵 io/questdb/cairo/sql/async/AsyncQueryErrorKind.java 7 7 100.00%
🔵 io/questdb/griffin/engine/functions/groupby/MinDecimalGroupByFunctionFactory.java 12 12 100.00%
🔵 io/questdb/griffin/engine/functions/groupby/CorrGroupByFunctionFactory.java 1 1 100.00%
🔵 io/questdb/griffin/engine/functions/cast/AbstractCastToLong256Function.java 1 1 100.00%
🔵 io/questdb/griffin/engine/functions/math/DivIntFunctionFactory.java 14 14 100.00%
🔵 io/questdb/cairo/RecordSinkFactory.java 1 1 100.00%
🔵 io/questdb/griffin/engine/functions/groupby/ApproxCountDistinctIntGroupByFunction.java 1 1 100.00%
🔵 io/questdb/griffin/engine/functions/cast/CastStrToSymbolFunctionFactory.java 1 1 100.00%
🔵 io/questdb/griffin/engine/functions/groupby/ApproxCountDistinctIPv4GroupByFunction.java 1 1 100.00%
🔵 io/questdb/griffin/FunctionParser.java 2 2 100.00%
🔵 io/questdb/griffin/engine/functions/groupby/IsIPv4OrderedGroupByFunction.java 4 4 100.00%
🔵 io/questdb/griffin/engine/functions/lt/CompareDecimal64Function.java 3 3 100.00%
🔵 io/questdb/griffin/engine/functions/math/DivLongFunctionFactory.java 9 9 100.00%
🔵 io/questdb/cairo/sql/async/PageFrameReduceTask.java 21 21 100.00%
🔵 io/questdb/griffin/engine/functions/groupby/AvgDecimal128Rescale256GroupByFunction.java 1 1 100.00%
🔵 io/questdb/griffin/engine/functions/groupby/ModeBooleanGroupByFunction.java 1 1 100.00%
🔵 io/questdb/std/IntIntHashMap.java 4 4 100.00%
🔵 io/questdb/griffin/engine/functions/groupby/ApproxCountDistinctLongGroupByFunction.java 1 1 100.00%
🔵 io/questdb/griffin/engine/functions/math/MulDoubleFunctionFactory.java 9 9 100.00%
🔵 io/questdb/griffin/engine/functions/cast/CastFloatToSymbolFunctionFactory.java 1 1 100.00%
🔵 io/questdb/griffin/engine/EmptyTableRecordCursorFactory.java 2 2 100.00%
🔵 io/questdb/griffin/engine/functions/bool/InVarcharFunctionFactory.java 2 2 100.00%
🔵 io/questdb/griffin/engine/functions/lt/LtStrVarcharFunctionFactory.java 2 2 100.00%
🔵 io/questdb/griffin/engine/functions/lt/LtVarcharStrFunctionFactory.java 2 2 100.00%
🔵 io/questdb/griffin/engine/functions/math/SubLongFunctionFactory.java 10 10 100.00%

@bluestreak01 bluestreak01 changed the title feat(core): add randomized query fuzzer and fix multiple SQL correctness and resource-leak bugs chore(core): add randomized query fuzzer and fix multiple SQL correctness and resource-leak bugs May 22, 2026
@bluestreak01
bluestreak01 merged commit 1843d9b into master May 22, 2026
56 checks passed
@bluestreak01
bluestreak01 deleted the puzpuzpuz_query_fuzzer branch May 22, 2026 11:02
jovfer added a commit that referenced this pull request May 22, 2026
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>
RaphDal pushed a commit that referenced this pull request May 29, 2026
puzpuzpuz added a commit that referenced this pull request Jul 28, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug Incorrect or unexpected behavior Core Related to storage, data type, etc. Performance Performance improvements rust Pull requests that update rust code SQL Issues or changes relating to SQL execution storage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants