3 breaking2 features1 improvement18 fixes
QuestDB 10.2.0 adds GROUPING SETS, ROLLUP and CUBE, row-level retention for materialized views, a DEDUP SKIP mode that keeps the first-written row, and opt-in power-loss durability for WAL tables, enabled with cairo.commit.mode=adaptive. QWP ingestion of small messages is much faster, and this release fixes a large batch of wrong-result, crash and hang bugs in queries, out-of-order ingestion and Parquet partitions.
Breaking changes03
This feature introduces an
EXPIRE ROWSpolicy on materialized views that hides expired rows from every query, supportingWHEN <predicate>,KEEP LATEST,KEEP [N] HIGHEST|LOWESTand window predicate modes, for exampleCREATE MATERIALIZED VIEW v AS (SELECT * FROM base)combined with anEXPIRE ROWS WHEN ts < now()policy.Passthrough views mirror a base table,
ALTER MATERIALIZED VIEW ... SET EXPIRE ROWSand... DROP EXPIREchange a view's policy, and bothSHOW CREATEandmaterialized_views()report each view's policy and enforcement. A background job, controlled by thecairo.mat.view.row.expiry.cleanup.*settings, frees disk for monotonicWHENpolicies.Alongside the policy work, a cached re-partitioning
COPYno longer fails withtable does not existwhen rerun, refresh workers no longer spin at full CPU while another refresh holds a view's lock, refreshing a range emptied byREPLACEruns one query instead of one perSAMPLE BYbucket, an invalid view stays invalid until itsREFRESH ... FULLrebuild completes, views readingSELECT * FROM <view>go invalid whenALTER VIEWreorders columns, and new views store zero-argument functions with parentheses, such aspi(), inSHOW CREATE.Breaking changes:
CREATEnow rejectsLIMITin queries that read the base table and rejects base-table window functions nested in expressions orSAMPLE BYsubqueries, so replaying olderSHOW CREATEoutput containing suchLIMITclauses or window functions fails.Trade-offs include
KEEPand window policies ranking the whole view on every read, only monotonicWHENpolicies freeing disk while other policies merely hide rows, passthrough views inheriting baseSYMBOLindexes at the cost of ingestion speed and disk, slowerLATEST ONover aWHEN-policied view on selective filters, a restart duringREFRESH ... FULLleaving the view invalid until rerun,WINDOWandHORIZON JOINrejecting views without a timestamp-cutoff policy on the right, materialized and live views being unable to read a view with anEXPIRE ROWSpolicy, setting a policy invalidating dependent views at their next refresh, passthrough views keeping their creation-time columns, a renamed or replaced column landing in the old column on refresh underSELECT * FROM <view>, reads failing when a table read by a window policy's subquery is dropped, downgrading and then running DDL on a policied view silently dropping its policy,SAMPLE BYover aWHEN-policied view running single-threaded and 2-4x slower at 4-8 workers, filteredLIMIT -Nscanning forward and being about 10x slower,count()andmax(ts)scanning the whole view under a valueWHENpolicy,KEEP HIGHEST/LOWESTreads holding about 16 bytes of memory per view row, inherited covering (INCLUDE) indexes slowing WAL apply about 9x, and a cleanup sweep delaying the view's refresh until it ends.This feature adds a
DEDUP SKIPmode that keeps the first-written row and drops later duplicates, accepted byCREATE TABLE, CTAS andALTER TABLE ... DEDUP, while materialized views continue to useUPSERT.SHOW CREATE TABLEprints the mode,tables()shows only whether a table deduplicates,CREATE TABLE ... LIKEcopies the mode,DEDUP DISABLEresets it toUPSERT, and switching betweenUPSERTandSKIPon the same keys keeps materialized views valid.SKIPcommits that hold only duplicates leave native and Parquet partitions untouched, commits that only resend stored rows no longer rebuild the partition'sPOSTINGindex, and resending unchanged rows intoDEDUP UPSERTParquet partitions skips the rewrite, with mixed resent-and-changed commits skipping the rewrite of resent row groups.Parquet stores the column tops of
BOOLEAN,BYTEandSHORTonly as definition levels, so the_pmdecoder now records those rows where they sit instead of reading each chunk's null count as leading rows, andCONVERT TO PARQUETrecords each column's leading top in the file metadata, whichCONVERT TO NATIVEturns back into the native column top.This change also fixes a long list of correctness issues:
SET TYPE WALandREBASE WALno longer drop TTL,FORMAT PARQUETor Parquet encoding, converted tables keep their TTL and drop expired partitions,REBASE WALno longer suspends tables with non-timestamp dedup keys, dedup keys on columns added after Parquet conversion no longer suspend WAL apply, dedup merges onLONG256/DECIMAL256keys no longer crash native builds made with GCC 16, comparing two NULL arrays returns correct results after differently sized arrays, index scans during late writes to or afterCONVERT TO NATIVEof older Parquet partitions return correct rows, dedup merges into Parquet partitions no longer turn stored values NULL afterALTER COLUMN TYPE,BOOLEAN/BYTE/SHORTNULL handling is corrected across conversions, late rows tied with Parquet data now land after existing rows as in native,POSTING INCLUDEqueries no longer lose covered values afterDROP INDEXandADD INDEX,VACUUM TABLEremovesPOSTINGfiles of dropped columns and dropped or changed indexes, restarts and failed deletes no longer leave posting index files on disk,POSTINGindex queries no longer fail with "value file too short" after a failed commit, column files no longer leak when a column purge runs duringALTER TABLE, a failed commit no longer makes new queries wait until the writer reopens, failed disk flushes name the file in the error on macOS and Windows, and errors after anEXPIRE ROWSclause point at the rejected token.Breaking changes: a downgrade silently turns
SKIPtables intoUPSERTwithout an error, and older versions suspend WAL apply on a loggedALTER TABLE ... DEDUP SKIP.Trade-offs include each O3 worker keeping one more decode buffer for timestamp and key columns, late writes rewriting older Parquet partitions that have an indexed symbol column top, a late row at a Parquet row-group boundary possibly re-encoding one more row group, and first
UPSERTs mixing resent and changed rows overBOOLEAN/BYTE/SHORTNULLs being up to 47% slower.QWP previously accepted table blocks that carried the designated timestamp twice: once as the unnamed column produced by
at(...)and once as a named column matching the table's designated timestamp. Only one of the two was tracked as the designated timestamp, while the other was appended through the regular-column path into the same WAL column, interleaving 8-byte timestamps with the 16-byte timestamp and row ID pairs, so affected rows read back with wrong designated timestamps and the malformed column could later crash the out-of-order merge.This improvement rejects a second wire column that resolves to the designated timestamp before the columnar WAL write starts, so the client receives a terminal schema mismatch and the server applies none of the block's rows. The check inspects the block schema rather than row values, and because the Java client keeps a table's columns across flushes, a sender that uses both
at(...)and the named timestamp column on the same table is rejected on every later batch for that table, even when each row sets only one of them; such batches were previously accepted with wrong timestamps.Influx Line Protocol handles the same input by letting the named field override the line timestamp.
New features02
This feature extends
GROUP BYwithROLLUP,CUBEandGROUPING SETS, together with theGROUPINGandGROUPING_IDfunctions, and brings the same constructs toSAMPLE BY, whereFILLmay beNULL, a constant orNONE.ROLLUPandCUBEexpand into grouping sets; the executor scans the input once and keeps one aggregation map per set, nulling the keys each set rolls up.Eligible plans run on the parallel page-frame workers, which requires sets × (query workers + 1) ≤ 256, and larger queries fall back to a serial executor.
SAMPLE BYwith grouping sets rewrites to aGROUP BYon the time bucket, joining every set as a key, andFILLthen fills missing buckets for each grouping set and key combination using a hiddenGROUPING_IDto distinguish the sets.The new
cairo.sql.max.grouping.setssetting caps how many grouping sets one query expands to, defaulting to 4096. An explicit empty grouping set returns a grand-total row even when no rows match, eachGROUPING SETSelement requires parentheses such as(a)rather thana, and, as in PostgreSQL,GROUPING SETS ()or a trailing comma in the list is a syntax error.Plain
GROUP BYkeys may precedeROLLUPorCUBE, but notGROUPING SETS.SAMPLE BYgrouping sets rejectFILL(PREV)andFILL(LINEAR), require calendar alignment soALIGN TO FIRST OBSERVATIONfails with an error, reject a non-constant interval orFROMbound, reject duplicate grouping sets and more than 31 rolled-up keys withFILL, and return no rows over empty input even for theROLLUPgrand total.Expression keys must come from a subquery unless every grouping set keeps them,
GROUPINGtakes one column whileGROUPING_IDtakes up to 31, correlated grouping constructs insideLATERALfail with an error, and the constructs are not supported withHORIZON JOINor in materialized views. Grouping sets never vectorize, so a query is around 1.4x slower than the equivalentUNION ALLwhen run serially and up to 6.9x slower in parallel.This feature introduces an opt-in
cairo.commit.mode=adaptivethat separates durability work instead of flushing everything likesyncor nothing like the shippednosyncdefault. The writer's private WAL is flushed before sequencing; with a group window of zero the sequencer is flushed before the ordinary commit acknowledgement, and with a nonzero window the sequencer flush is batched so an ordinary acknowledgement can precede durability, with the local durable frontier advancing only through transactions the flush covered.WAL is applied to table files without a per-apply column flush, and materialized state is periodically flushed and saved as
_txn,_cvand_metainto one of two self-contained save-point files,_epoch.manifest.0and_epoch.manifest.1, each with a 120-byte header published last. Recovery restores a validated generation and replays retained WAL, keeping WAL and partition versions needed by both generations so fallback to the older one is usable.For
DETACHcompletion,RESUMEand direct logical partition changes, every selectable generation must cover the effect before completion is reported. Six new properties are added:cairo.adaptive.epoch.interval(60,000 ms server, 1,000 ms embedded) for save-point cadence,cairo.adaptive.epoch.max.rows(5,000,000) to bound the applied backlog,cairo.adaptive.commit.group.window(50 ms) for durable acknowledgement batching,cairo.adaptive.epoch.flush.on.close(true),cairo.adaptive.epoch.column.sync.batched(true) andcairo.wal.commit.writeback.drain(true); an unknowncairo.commit.modenow fails startup instead of silently selectingnosync.The
X-QWP-Request-Durable-Ackheader acceptslocal,replicatedorlocal,replicated, granted as a full set or not at all, withSTATUS_LOCAL_DURABLE_ACK(0x0E) reporting local durability; the open source edition grants the local tier only, while QuestDB Enterprise composes local and replicated on an eligible primary.wal_tables()gains thelocalDurableSeqTxn,durableEpochSeqTxn,recoveryIncarnationandlastEpochTscolumns, wherelastEpochTsreadsNULLafter a restart until the first WAL-apply batch publishes a save point. Insyncandadaptive, a failed flush suspends the affected table, an embedded engine fences itself and a standalone server halts, whereasnosyncandasynckeep base behaviour.Outside adaptive, torn
_txn/_cvchecks and V2 record checksums apply in all modes, saved WAL lag is reapplied from WAL on writer open or rollback, conversion to non-WAL replays pending WAL before deletion, andDETACHnow uses a temporary<partition>.<txn>.detachingdirectory finished with one atomic rename.Known limitations: QuestDB Enterprise replicas do not gain adaptive local recovery because the object store is their durable source, a backup can ship records past the local durable frontier, Windows has no directory flush so a power loss immediately after table creation could drop the table, and on macOS and Windows the first save point of every writer flushes every file of every partition and the sequencer.
A
SQLINSERThands its writer back after each statement, so each statement pays a sequencer flush as with a window of zero, and under production cadence a single-writer WAL commit costs roughly 12x anosynccommit while WAL apply is 9-31% slower.
Improvements01
This improvement changes
MmapCacheso that, with async munmap enabled throughcairo.file.async.munmap.enabled(off by default), the async munmap queue holds eviction hints rather than unmap commands. When the last user of a file's current cached mapping releases it, the mapping is not unmapped immediately: it stays in both caches with a zero reference count and a hint pointing at the live record is published under the cache lock, so a lookup of the same file range before the hint is consumed revives the mapping with no newmmapand incrementsmmapReuseCountas on any cache hit.Superseded mappings, async munmap being disabled, or a full queue retire the mapping at once, with the munmap performed synchronously when the queue is full. The consumer skips a hint under the lock if the record is already retired or in use again, otherwise copying the address, length and tag, retiring the record from both caches and calling
munmapoutside the lock.Every mapped record with a zero count has at least one queued hint, so every released mapping is eventually reclaimed, duplicate hints are harmless, and memory accounting is recorded only on the actual native
munmap; uncached mappings keep the raw-address queue path, with theirmunmapmoved out of the lock.No delay, TTL or LRU is added, so a mapping survives only until the consumer reaches its hint, and composite-partition WAL block apply sees no reuse because those segment mappings either start at a nonzero file offset or request a longer range than the cached one; gains come from code paths that release and re-request the same file range shortly after.
Bug fixes18
When
INSERT ... SELECThas a column list and a selected value cannot be converted, the "inconvertible types" error named the table column at that position in the list instead of the column the list names. ForINSERT INTO t (c, a) SELECT ...on a table(a INT, b INT, c CHAR), the error saidto=awhile pointing atc, becauseSqlCompilerImplread the column name with the list index.This fix makes it use the table column index, as the type lookup next to it already did. Only the error text changes; which inserts succeed or fail stays the same.
An out-of-order commit that failed part-way, for example on a failed mmap, could crash the server, hang, or leave its tasks queued for the next commit, and failed deduplication commits could leak native memory or unmap memory they had not mapped. This fix addresses several causes. On tables with dropped columns,
TableWriterandO3PartitionJobalso subtracted dropped columns from the count of tasks in flight when publishing a partition's column tasks failed part-way, so the count could reach zero while a task still ran, causing the writer to unmap memory that task was reading, or skip zero so the commit never returned; both now count live columns only.When the last column of an in-place append failed after the others had finished,
TableWritercompleted the partition twice, stopped waiting for the commit's other partitions and left their tasks queued for the next commit; the writer now leaves the count alone when no column is left unpublished. A failure while building partition paths, or a file release that threw during cleanup, could skip a count-down so the commit never returned; cleanup after a failed commit now logs a release that throws and carries on, so the commit fails with its original error, while a release that fails during an otherwise successful commit now fails the commit.When an out-of-order merge failed to read deduplication keys beyond the designated timestamp,
O3PartitionJobleaked the merge index, the key columns' file descriptors and a var-size key's aux mapping, and leaked them again on every failingRESUME WAL; it now hands each resource to its cleanup as soon as it acquires it.Finally, when mapping a deduplication key's LAG rows failed, for example once
vm.max_map_countran out,TableWriter.deduplicateSortedIndex()released whatever the slot's last user had left there, which could unmap a range another mapping owned and later crash the server, or throw and replace the error that suspended the table; it now zeroes the slots and records each mapping as soon as it exists.O3PartitionJoblog messages that printed nanosecond timestamps as microseconds are also corrected.HTTP clients can now follow Web Console redirects without timing out. With this fix the server marks redirect responses as empty and removes stray trailing bytes, allowing clients to reuse the connection.
This fix corrects several
SAMPLE BYproblems. With a copy of the timestamp or an expression over it in the select list, such asSELECT ts, ts AS ts2, count() ... SAMPLE BY 1d LIMIT 2,LIMITtook buckets in hash order instead of the first buckets in time, because theSAMPLE BYrewrite movedORDER BYto a wrapper model but leftLIMITbelow it;rewriteSampleBy()now movesLIMITto the wrapper as well, so it applies after the sort.Moving
LIMITexposed an older bug in window resolution, where a lateral body failed withInvalid column: ts2or sorted by the wrong column becausedoReplaceLiteral()resolved a window literal over a twice-selected column to an alias that only the inner virtual model exposes;rewriteSelectClause0()now renames these literals to the column name the window input exposes when it cancels the inner virtual model, and leaves them unchanged when the model stays.With
ALIGN TO FIRST OBSERVATION,FROMpartway through a month, or a positiveWITH OFFSET, the month and year samplers rounded a row that comes earlier in its month than the grid's day and time up to the next bucket, and strides of several months or years counted their buckets from January or 1970 instead of the anchor;round()now returns the last bucket start at or before the value, counting strides from the anchor month or year.When the refresh interval iterator yielded no interval, incremental refresh of a materialized view took the view's cached query factory, never returned or freed it and never advanced the watermark, leaving the view stuck in 'refreshing' and leaking memory;
findRefreshIntervals()now treats such an iterator as no intervals, andinsertAsSelect()frees the factory if it ever sees one.With a negative
WITH OFFSET, or aFROMon day 29 to 31,timestamp_floor_utc()put rows after the start of a bucket on the last day of a shorter month into the previous bucket, which failed incremental refresh with "timestamp out of replace range" and invalidated the view;floorMM()andfloorYYYY()with an offset now compare the value with the actual bucket start.A fill list that mixes
LINEARwith other values assumed equal bucket lengths and advanced the interpolation on every read of the column, so month strides,LIMIT, an outerWHERE, or reading the column twice produced wrong values; the fill cursor now passes the start of each gap bucket to the interpolation, and the results matchFILL(LINEAR)alone.Month and year floors with an offset or
FROMnow cost less per row,SAMPLE BY ... FILL(LINEAR)with a stride that divides a year runs within about 4% of before, strides that do not divide a year cost 3-6 ns more per row, and keyedSAMPLE BY 1M FROM ... FILL(LINEAR)runs 1.5-2% slower. Results change for the affected queries: window queries that select a column under several names and failed withInvalid columnnow compile,SELECT a, a AS b, row_number() OVER (ORDER BY a)over a table with a columnbnow sorts bya, strides that do not divide a year such as5Mnow start their buckets on the same months on theSAMPLE BYcursor as on theGROUP BYpath, and materialized views with a month or year stride and an offset may hold rows in wrong buckets from before the fix, whichREFRESH MATERIALIZED VIEW ... FULLrewrites.Timer refreshes with
EVERY nMorEVERY nyfollow the corrected grid, so a timer re-created partway through a period no longer skips a firing and multi-month cadences count fromSTARTrather than from January.After a base table column changed type, for example from
TIMESTAMPtoSYMBOL, a full refresh of a materialized view that reads the column filled it with wrong values, such as timestamps in the year 56663, and still reported the view as valid, because the refresh job recompiled the view query but kept the row copier generated for the old result types while the view table's own metadata version had not changed.With this fix
MatViewRefreshJobgenerates a new copier whenever it recompiles the query and checks every result column against its view column withRecordToRowCopierUtils.isCopierConvertible(), so a result type that the copiers cannot convert invalidates the view with an error that names both columns.isCopierConvertible()follows theINSERT INTO ... SELECTrule with two differences: it acceptsDOUBLEintoDATEandTIMESTAMP, which the copiers convert, so a view whose base column changed fromTIMESTAMPtoDOUBLEkeeps refreshing, and it rejects numeric and temporal values intoCHAR, for which the copiers write nothing.The row copiers did not implement the
SYMBOLtoTIMESTAMPconversion although the type check admits it; they now parse the symbol text with the target column's timestamp driver, for microsecond and nanosecond columns, inINSERT INTO ... SELECTas well as in view refresh, with errors for malformed text namingSYMBOLas the source type.As a behaviour change, a view whose recompiled query yields a type the copiers cannot convert now becomes invalid on refresh instead of refreshing with wrong values, including a base column changed from
BOOLEANtoBYTE, and such views need to be recreated. A known limitation remains: a numeric value copied into aDATEorTIMESTAMPview column keeps its raw value in the view column's unit, so after a chain of type changes that alters the base column's unit the view refreshes as valid with values off by a factor of 1,000.All three issues shared a single cause: an operator held a cursor or a raw address into an
OrderedMapacross a call that could grow the map and move its heap, then read freed memory. Depending on the contents of the freed memory, queries returned wrong rows, failed withInternalError: a fault occurred in an unsafe memory access operation, or crashed the JVM; the problem surfaced once a map outgrew its page, which with the default 32 KB page takes on the order of a thousand keys.For
RIGHTandFULLhash joins, the build map cursor was taken immediately after the build, while the probe phase could grow the map, so the scan over unmatched build rows walked the stale heap and reported matched rows as unmatched or crashed on garbage chain offsets; this fix takes the cursor when the unmatched-row scan starts and drops it ontoTop().For
SPLICE JOIN, the cursor cached value addresses of the current master and slave keys that could be invalidated by the other side'screateValue(), so it now caches the row ids it needs immediately aftercreateValue(). For keyedSAMPLE BY FILL, the keys cursor was taken after the key discovery pass even though the second pass could grow the map, producing repeated or garbage key values in fill rows, so every fill scan now takes a fresh cursor.Converting a split active WAL partition to Parquet could leave the replaced native column mappings open, causing later schema changes to fail. With this fix, native-to-Parquet conversion identifies the active partition by logical timestamp because it compacts all split siblings, while Parquet-to-native conversion retains exact physical matching because a mixed-format logical partition can still have an active native tail.
Queries running during out-of-order ingestion no longer return changed rows or boundary errors, and materialized view refreshes no longer become invalid under the same conditions. Late rows arriving before an added column's data no longer null out its values in running queries, and
ALTER TABLE SQUASH PARTITIONSno longer loses rows whenFORCE DROP PARTITIONleft a day's split partitions behind.Split partitions now squash sooner, since a query started just before the target partition was written no longer delays squashing. In ADAPTIVE commit mode, crash recovery no longer restores corrupt rows after a partition split, because a save point taken before the split now protects the rows it needs in the same way an open query does.
As a tradeoff, while a query older than a split is open, late rows at the split partition's last timestamp cause the partition to be re-split or copied. These problems were introduced after the 10.1.0 release, so previously released versions are not affected.
A covering index (
INDEX TYPE POSTING INCLUDE (...)) stores the values of the columns it includes in sidecar files, and the reader builds the sidecar name from the covered column's current name transaction; when the file was missing, every covered value of the partition was returned as NULL, including the designated timestamp.This fix addresses several paths that left those files missing or wrong. A snapshot or backup restore that rebuilds indexes previously pointed the covering seal at the covered columns' native files, which Parquet partitions do not have, so a covering
POSTINGindex on a Parquet partition is now rebuilt in its own pass that decodes the covered columns from the Parquet file.RENAMEandDROP INDEXon a covered column now hard-link the affected sidecars under the new name transaction in every partition, native and Parquet, with the old names retained for readers of earlier transactions until the seal purge removes them.UPDATEof a covered column now also rebuilds eachPOSTINGindex that includes an updated column in every updated partition, moving the index's own column to a new name transaction and queueing the old version for purge, so readers of earlier transactions keep the old index.Finally, when the chain published covered bytes for a slot but the sidecar file is missing, the reader now throws
covering index data is missing, rebuild the index or use the no_covering hintinstead of silently returning NULLs; a partition left without its sidecars by an older version therefore fails covering queries until a write to the partition reseals the index, the index is rebuilt, or the query uses/*+ no_covering */.An index already rebuilt by a restore over Parquet partitions keeps returning NULLs until it is rebuilt with
DROP INDEXfollowed byADD INDEX, sinceREINDEXskips Parquet partitions.This fix also corrects Parquet conversion of a split last partition, where the last-partition check ran before split siblings were squashed and left the writer's columns open on the replaced native directory, later causing
writer invariant violated: columns[] partially openduringALTER COLUMN TYPE.As tradeoffs, renamed or index-dropped covered columns keep old-named sidecar hard links until the seal is superseded,
RENAMEandDROP INDEXscan each partition directory once per covering index that includes the column, and a restore decodes a Parquet partition once per covering index on it.WAL apply no longer pauses when partition compaction holds the table writer, and such collisions no longer emit a misleading CRITICAL
unsolicited table locklog message.This fix changes symbol indexes on Parquet partitions to store index rows rather than partition rows, so that an out-of-order commit updating a Parquet partition in place no longer makes cursors opened before the commit read old-layout rows at new-layout row ids, which previously caused index scans to return rows of other keys, miss matching rows, or return nothing at all for POSTING indexes.
The
_pmfooter may now carry an index row map recording the first index row of every row group and the number of index rows the indexes cover;update_parquet_metadatakeeps the block, bloom filters and index rows of unchanged row groups, while rewritten row groups take fresh index rows past the committed count, and index updates only append rows for new row groups.Readers map rows through the footer, splitting a mapped partition into per-row-group frames, and every full index build over a Parquet file adds index rows in index row order. A footer without the map means identity, so older footers stay readable and no migration is required; however, once an update moves row groups to fresh index rows, an older binary will reject that partition's footer, index entries of replaced row groups remain until a rewrite or compaction rebuilds the index, and
SELECT DISTINCTover a POSTING index becomes slower after scattered late writes until the day is compacted or rewritten.This fix also stops
recordAt()from reading a recycled Parquet decode buffer by bumping the bind generation when buffers are acquired or evicted, and preserves column tops across conversions: Parquet to native conversion now publishes skipped leading rows as the column top and writes NULL rows for later empty row groups, in-place out-of-order updates only append to an index under a committed top of 0 and otherwise rebuild it in full, andCONVERT TO NATIVErebuilds rather than links an index under a nonzero top soIS NULLno longer returns top rows twice.Storage-policy conversion moves from the writer to the partition compaction scan job, which writes a whole logical partition into a single Parquet file in one pass and can build a Parquet copy of a native partition off a reader before installing it, including for non-WAL tables, refusing a swap when the open transaction or a squash could invalidate it.
CONVERT PARTITION TO PARQUETlikewise converts a non-active logical partition straight into one Parquet file instead of folding it first, with frame conversion writing an absent column as nulls with column top 0. Partitions written by QuestDB 9.3.4 to 10.0.1 may carry stale column tops whose indexes do not cover the data; index queries on such partitions now fail withindex does not cover parquet partition data, partition compaction rebuilds itinstead of returning wrong rows, and the compaction job rebuilds their indexes from row 0 and zeroes the stale tops.Finally, conversions flush the Parquet file, footer and partition directory under the table's commit mode, swaps flush renamed and table directories before the transaction file under SYNC, and index files built by a conversion or compaction are flushed like regular column files, while an out-of-order merge removes pre-existing index files in its new partition directory before writing them.
When a WAL table had a backlog of small out-of-order transactions spanning a wide timestamp range, WAL apply built a block that its radix sort could not index: the sort packs the timestamp offset and the transaction index into one 64-bit key and fails once both need more than 8 bytes, for example more than 256 transactions spanning about 8.9 years in microseconds or about 3.3 days in nanoseconds.
The writer then applied transactions one by one and could fall behind ingestion indefinitely. This fix makes
WalTxnDetails.calculateInsertTransactionBlock()track the minimum and maximum timestamp of the whole candidate block and stop extending it before the timestamp range plus transaction count overflows the sort key, using the table's native timestamp unit so that nanosecond tables are capped at a 1000x narrower time spread; blocks that would have overflowed are split into smaller blocks instead of being applied one by one, and the cap does not apply to blocks applied without the sort, that is non-dedup blocks of in-order, non-overlapping transactions from a single WAL segment.This fix also corrects WAL apply block sizing on Parquet tables, where a Parquet last partition has no native column files so the average record size was never measured and blocks were sized from an estimate counting each variable-size column as 28 bytes, making blocks hundreds of times too long on wide rows.
Until the column files have been measured, WAL apply now measures bytes per row itself, sampling the WAL segment of the first pending transaction before the first block and measuring the staged rows after each apply.
This fix corrects several unrelated sources of wrong results and internal errors. On vectorized
WINDOW JOINplans,SUM()over aLONG256column returned0x00or random values because the group by column sink had noLONG256case and the batch sum read uninitialized memory; the sink now copiesLONG256values.UNION,UNION ALL,INTERSECTandEXCEPTover geohash columns of the same storage width but different precision, such asGEOHASH(2c)andGEOHASH(3c), kept the trailing bits of the finer value instead of its leading bits and so returned the wrong cell and matched the wrong rows; the value is now narrowed with an explicit geohash cast, as was already done for differing storage widths.SAMPLE BYwithFILL(NULL)underALIGN TO FIRST OBSERVATION, with aFROMexpression, or in a mixed fill such asFILL(NULL, 0)failed withUnsupported typefor aggregates returningDATE,STRING,VARCHAR,SYMBOLorLONG256, which now have NULL placeholders, whileBOOLEANandCHARremain unsupported.length_bytes(),starts_with(),strpos(),nullif(),replace(),json_extract()andarg_max()failed with an empty internal error on anIPv4argument, andlength_bytes()on aSYMBOL:IPv4is now cast toVARCHARwhen the argument matches aVARCHARparameter, and symbol functions report the UTF-8 size of the symbol.An empty
IPv4field over the UDP line protocol stored NULL and then also appended two bytes to the column, shifting every later value, which is now avoided.Additionally, array bind variables accept NULL text as a NULL array, the generated record sink declares a larger stack for its chunked copy method so queries with around 600 or more
GROUP BYkey columns and a computedUUIDorLONG128key no longer fail, and the record-to-row copier now counts conversion code for geohash precision and timestamp unit changes so very wideINSERT ... SELECTstatements with such conversions no longer exceed the generated method size limit.This fix makes
ALTER COLUMN TYPEpreserve indexed results when decoding Parquet partitions, and prevents later Parquet conversions from suspending WAL tables after out-of-order writes.This fix addresses an overflow in
InterpolationUtil.interpolate(), which multiplied each value by a time distance in microseconds or nanoseconds before dividing. From about 2.5E298 with hourly buckets, and lower for wider buckets, longer gaps or nanosecond timestamps, the product overflowed to infinity, which reads asNULL.The method now keeps the original formula whenever its result is finite, so ordinary results are unchanged, and otherwise recomputes the value from the two weights, which lie between 0 and 1 between two buckets with data. Buckets that a keyed
FILL(LINEAR)extrapolates before a key's first row or after its last can still readNULLfor large values, as before, and the farther the bucket lies from those rows, the lower the value at which this starts.This fix ensures checkpoints preserve altered regular views despite WAL apply lag or a concurrent
ALTER VIEW. Comparing the captured view definition with the live transaction file could skip the checkpoint transaction file when WAL apply lagged or anotherALTERintervened, so QuestDB now builds an empty transaction file at the captured definition's sequencer transaction, making both describe the same view state.HTTP cookie expiry handling also no longer depends on request timing, while strict expiry and session-count checks are retained.
QuestDB 9.3.4 to 10.0.1 could leave stale column tops after merging out-of-order Parquet rows. This fix lets affected Parquet indexes be repaired online with
ALTER TABLE ... FORCE REINDEX PARQUET, whileADD INDEXand out-of-order writes repair affected local Parquet partitions automatically. The repair normalizes the affected metadata and rebuilds indexes in replacement directories while preserving active readers.FORCE REINDEXsupports column and partition selection, runs outside the WAL, and commits each partition separately,ADD INDEXincludes repairs in its own transaction, and out-of-order writes rewrite affected partitions. Repairs fail on busy writers, retain completed partitions after cancellation, and skip cold and soft-linked partitions.Parquet deduplication now matches merged Parquet keys using decoded row-group values instead of stale partition-level column tops, so duplicates are no longer retained, and
VACUUMreclaims dropped posting-index files by discovering retired bitmap and posting files independently of the current index type.This fix resolves concurrent parallel filter queries hanging PostgreSQL Wire Protocol or HTTP workers at full CPU. A query that helped reduce another query's frame could suspend while holding it, and queries queued behind that frame retried their collect without yielding the worker, so the suspended query never got a worker back.
PageFrameSequence.next()now parks the owner when a dispatch round publishes no new frame, and where there is no shared query pool fiber dispatcher to park on, the owner yields its worker instead, as do the other owner waits of parallel filter,GROUP BY, top-K andLATEST BYqueries. Parallel filters with a bind variable also no longer skip rows in batched PostgreSQL Wire Protocol fetches: resuming a suspended portal rewrote the connection's bind variables while workers were still reducing its dispatched frames, so the execution context now tracks the page frame sequences of its open cursors and waits for their dispatched frames before rewriting bind variables.Every wait in parallel query execution now wakes on the event it waits for through per-ring and per-query wait queues, with the timer only checking for timeouts, cancellation and disconnected clients, removing stalls of up to a second from missed wake-ups.
Additionally,
SeqTxnTracker.initTxns()wakeswait_wal_table()waiters after a WAL notification queue overflow, andSqlTimeoutExceptionclears its reused instance so repeated timeouts on one connection no longer return concatenated error messages.