QuestDB Enterprise 4.1.0 brings enterprise-grade OIDC, workload isolation and tighter access control, and includes everything in QuestDB 10.1.0. It contains breaking changes, so read the breaking changes section before upgrading.
Breaking Changes
Native JWT validation now requires the standard
expclaim and rejects expired or non-expiring ID tokens, which previously could still be accepted. The validator also no longer requires a literalgroupsclaim, soacl.oidc.groups.claimcan select another claim such asroles. Clients that relied on long-lived or expired ID tokens must now refresh them.This feature lets operators cap how much native memory a single user, group or service account may use per query, as an override of the global per-query memory limit. Limits are set with
ALTER USER,ALTER GROUPorALTER SERVICE ACCOUNT ... SET MEMORY LIMIT, for exampleALTER USER johndoe SET MEMORY LIMIT 512M, and cleared with0orUNLIMITED. The effective limit resolves by precedence: a user's own limit, then the most restrictive limit among its groups, then the global per-query limit; service accounts use only their own limit. The limit applies to user queries on primaries and replicas, including backgroundCOPY ... TO, while materialized view refresh and WAL apply keep their own workload limits.SHOW USERS,SHOW GROUPSandSHOW SERVICE ACCOUNTSgain amemory_limitcolumn, which changes their output shape.On a table with a cold partition,
UPDATE,ALTER TABLE ... ALTER COLUMN ... ADD INDEXandALTER TABLE ... ALTER COLUMN ... TYPEused to compile successfully and then suspend the WAL table when applied, because cold partitions are read-only and keep no local data to index or convert. These statements are now rejected at compile time and the table keeps running. The newcairo.sql.all.partition.operations.allowedsetting (defaultfalse) disables the check and restores the previous behavior.Replicas now append newly downloaded WAL data to existing segments in place instead of replacing whole segments on every download, and live WAL segments are always uploaded trimmed. The
replication.primary.upload.truncatedsetting has been removed. Deployments that never set it tofalseupgrade as usual, replicas first. If a primary setsreplication.primary.upload.truncated=falseinserver.confor throughQDB_REPLICATION_PRIMARY_UPLOAD_TRUNCATED, remove the setting, restart the primary on the old version, wait until every replica has caught up, and only then upgrade replicas first and the primary last; otherwise rows archived raw by the old primary can be left zero-filled on an upgraded replica.Users no longer see tables, views, materialized views and live views they hold no permission on. Such objects are missing from
tables(),SHOW TABLES,information_schema,pg_catalogand other catalog queries, and statements that name them fail exactly like they do for a missing object, soSHOW CREATE TABLE secretreturnstable does not existandDROP TABLE IF EXISTS secretdoes nothing. An object is visible to the built-in admin, to principals withDATABASE ADMIN,SYSTEM ADMINor any permission granted on all tables, and to principals holding any table-level or column-level permission on it, including through groups or an assumed service account. Holding any permission discloses the whole schema, and visibility grants no access by itself. This is on by default and changes error messages: users now receive the missing-object error where they previously receivedAccess denied.acl.oidc.sub.claimandacl.oidc.groups.claimnow accept a comma-separated list of claims in priority order, such aspreferred_username,oidandroles,groups, so one configuration accepts Entra ID users, managed identities and service principals; a missing, null or empty claim falls through to the next one. A JWT validated locally must now carry anissclaim that matchesacl.oidc.issueror the issuer from the discovery document, which can be disabled withacl.oidc.issuer.validation.enabled=false. HTTP sessions now end with the credential that opened them: a request whose credential is rejected no longer falls back to the session cookie, and changing a password, dropping a REST token or disabling or dropping a user ends its sessions. Logins with null or empty claims, duplicate claim lists, and local JWT mode without an expected issuer are now rejected.This feature adds the
SUBSAMPLEclause, which reduces a time-series result to a representative subset of its rows on the server before anything is sent to the client. UnlikeSAMPLE BY,SUBSAMPLEreturns original rows rather than aggregates, so every column of every selected row comes back unchanged and tooltips, joins and downstream filters keep working on real data points. The clause is written asSELECT ... FROM ... [WHERE ...] [SAMPLE BY ...] SUBSAMPLE method(args) [ORDER BY ...] [LIMIT ...], sitting afterWHERE,SAMPLE BY,GROUP BYandWINDOWand beforeORDER BY, and the query must expose the table's designated timestamp. Six methods are available:uniform(points)selects evenly spaced rows while retaining the first and last;cadence(stride[, seed])keeps every stride-th row with an optional phase offset, where an omitted seed means offset zero,NULLmeans a fresh random offset per execution and any other value derives a deterministic offset;m4(column, points)keeps the first, min, max and last row per time bucket for pixel-accurate line charts;minmax(column, points)keeps only the min and max per time bucket;lttb(column, points[, gap])applies Largest-Triangle-Three-Buckets selection with pinned endpoints and an optional interval string that splits the series at gaps and downsamples each segment independently; andsdt(column, compdev)applies Swinging Door Trending compression where exact linear interpolation between retained endpoints has absolute error of at most twicecompdev. For example,SELECT * FROM trades WHERE symbol = 'BTC-USD' AND ts IN '2024' SUBSAMPLE lttb(price, 2000);returns two thousand points from a year of trades. All six methods are also exposed as window functions returning a boolean keep-flag, withORDER BYrequired, custom framing disallowed andPARTITION BYsupported bysdtonly. Rows are selected in ascending timestamp order but returned in the incoming row order;m4,minmaxandlttbignore rows whose value isNULLthough those rows still count toward the input cap;uniform,cadence,m4,minmaxandlttbreject inputs abovecairo.sql.subsample.max.rows(default 100000000) whilesdtstreams under the usual query-memory limits;sdtis not supported inside a join or in an aggregation context and requires a constant, non-negative, finitecompdev. The clause composes withWHERE,SAMPLE BY,GROUP BY,PIVOT, CTEs, subqueries, joins,UNION,ORDER BY,LIMIT, cursor reuse and bind variables, but is rejected at creation time in live views. As a breaking change,subsamplebecomes a SQL reserved word alongsidesampleandunnest: stored data is unaffected, but SQL referring to a table, column or alias namedsubsamplemust now double-quote the identifier, for exampleSELECT "subsample", ts FROM readings;, and an unquoted implicit table alias such asSELECT * FROM t subsamplenow parses as the start of aSUBSAMPLEclause and must be written asFROM t AS "subsample". Ingestion paths that bypass the SQL parser, such as Influx Line Protocol, can still create tables and columns with this name; only SQL references need quoting.Previously
HORIZON JOINsilently ignored non-key predicates in theONclause, letting excluded right-hand rows contribute to aggregates, and filtering the right-hand table in a subquery was rejected unless the predicate became a timestamp interval. This feature supports right-hand predicates in both forms, applying the filter beforeASOFmatching so that each left-hand row and offset finds the latest qualifying right-hand row, for example:SELECT h.offset, avg(q.price) FROM trades t HORIZON JOIN quotes q ON (t.sym = q.sym AND q.venue = 'A') LIST (0s) AS h GROUP BY h.offset;Predicates may reference right-hand columns, constants and bind variables, including
IN, comparisons and functions, with or without equality join keys, across one or multiple right-hand tables, and in both serial and parallel execution. A failed predicate does not prevent an older qualifying row from matching, and no qualifying row yields a null right-hand match. This is a breaking change: queries that previously ran while silently ignoring an unsupportedONpredicate now fail withunsupported HORIZON join expression, left-only or mixed non-keyONpredicates andWHEREclauses referencing right-hand columns are rejected, and a constant-false predicate against a subquery or CTE right-hand side fails withright-hand side of HORIZON JOIN can only be a table with an optional filter. The change also corrects two wrong-result bugs in symbol-to-timestamp comparison: a cached symbol key match table that was not cleared on re-initialisation returned stale results afterTRUNCATE TABLE, and negation was not propagated sosym != $1with a TIMESTAMP bind variable returned the result ofsym = $1. TheASOFlookup now caches answers found at right-hand frame boundaries, keeps a per-key record of positions below which no row qualifies, and switches to forward scanning under a filter when backward scan cost exceeds configured thresholds, which speeds up unfiltered queries over sparse right-hand data while leaving dense cases roughly unchanged.A
HORIZON JOINwithout aggregate functions now returns one row per left-hand row and offset, duplicates included, instead of grouping its output by every selected column asSELECT DISTINCTdoes. This is a breaking change:SELECT DISTINCTor an explicitGROUP BYrestores the previous result, views over such queries return the new rows after upgrade because they compile from their stored SQL, materialized views keep the grouped rows in intervals stored before the upgrade and needREFRESH MATERIALIZED VIEW <name> FULL, and such a query now carries the left-hand designated timestamp when it selects the left-hand timestamp column, so anINSERT INTO ... SELECTwithout a column list fails unless that column sits at the target's designated timestamp position. The optimiser classifies an aggregate-free,GROUP BY-freeHORIZON JOINas a projection before top-down pruning, and the code generator builds a row-preserving factory for one or many right-hand tables that emits rows in left-hand row order then offset order and applies right-hand filters, with unmatched right-hand rows reading as typed NULLs. Matching runs on worker threads while offsets times right-hand tables stay at or below 100, and falls back to single-threaded matching above that while the left-hand filter still runs on workers. The keyedASOFlookup now applies its forward-scan switch to a window of small gaps and records a per-key miss bound so an absent key does not rescan right-hand history in every batch, and serial aggregating window eviction uses a ring buffer instead of shifting the window, removing quadratic behaviour when the largest offset exceeds the left-hand time range. Additional fixes include preserving grouping keys of aggregatingHORIZON JOINsubqueries that an outer query does not reference, closing a native memory leak when a select list names a right-hand column both qualified and bare, and pointing theWHERE clause of HORIZON JOIN can only reference left-hand side columnserror at the offending column rather than the operator.This change pushes an outer partition-key filter into a
LATEST ONsub-query so QuestDB searches only for the requested keys instead of resolving the latest row for every key and filtering afterwards. For example,SELECT double_02 FROM (SELECT symbol_01, double_02 FROM sample_table LATEST ON timestamp_01 PARTITION BY symbol_01) WHERE symbol_01 = 'sym01'now takes the requested-key path, cutting a single-key lookup over 1,000,000 rows from roughly 183 us to 9 us without an index and from 316 us to 6.5 us with a bitmap index. It supports one SYMBOL partition key with or without an index, equality in either direction including NULL andIS NULL,INlists and ORed equalities, STRING/VARCHAR parameters for non-indexed keys, and combinations with simple outer comparisons, outerORDER BYand a limited set ofLIMITcases; more complex shapes such as joins, multiple partition keys, calculated columns and function-based comparisons are left unchanged. The breaking part is that some partitionedCREATE TABLE AS SELECTstatements that relied on implicit designation of the destination timestamp now fail withpartitioning is possible only on tables with designated timestamps, because multi-key lookups through a covering index return rows in key order rather than ascending timestamp order and correctly advertise unordered output. Affected statements must name the destination timestamp explicitly, for exampleCREATE TABLE dst AS (SELECT s, v, ts FROM (SELECT s, v, ts FROM ct LATEST ON ts PARTITION BY s) WHERE s IN ('a', 'b')) TIMESTAMP(ts) PARTITION BY DAY, or addORDER BY tsto the outerSELECT. The change also accepts a performance tradeoff for unindexed multi-key queries: when one requested key is inactive and another busy key dominates the intervening rows, the restricted scan repeats membership checks and can run slower than before, and it fixes anAssertionErrorin sub-query predicate pushdown when an alias or table alias is quoted and contains a dot.Previously,
UPDATEstatements reaching a Parquet partition,ALTER TABLE ... ALTER COLUMN ... ADD INDEXon a table with a cold (remotely-served) partition, andALTER TABLE ... ALTER COLUMN ... TYPEon a table with a read-only partition all compiled successfully and then failed when applied, suspending WAL tables. These statements are now rejected at compile time with a clear error describing the offending table and partition. TheUPDATEcheck applies to WAL tables only and covers the whole table, so a WAL table with any Parquet partition now rejects everyUPDATE, including one whoseWHEREclause only reaches native partitions; in practice a WAL table with aTO PARQUETstorage policy or a manually converted partition can no longer be updated. The workaround isALTER TABLE ... CONVERT PARTITION TO NATIVE, which is not available for cold partitions. Non-WAL tables are unaffected,ADD INDEXover a local Parquet partition keeps working, and the checks run after authorization and are skipped on WAL apply, on read-only nodes and for views so that existing permission, replica and view errors take precedence. The newcairo.sql.all.partition.operations.allowedconfiguration property (defaultfalse) disables the three compile-time checks and restores the previous behaviour, including the risk of table suspension; it is read at startup and is not reloadable.Intervals in
SAMPLE BY,timestamp_floor()and materialized viewREFRESHmust be a positive number followed by a one-letter unit, such as5m,100Tor1h. Malformed intervals were previously misread without warning:1minwas interpreted as 1 nanosecond, which could produce unaggregated rows and out-of-memory errors,100msbecame 1 second anddaybecame 1 year. Such intervals now fail withInvalid unit, and a NULL or emptytimestamp_floor()unit fails withinvalid unitinstead of an internal error. As a breaking change, existing queries and views using malformed intervals will fail after upgrading and must be rewritten with one-letter units:1mfor minutes,100Tfor milliseconds,2Ufor microseconds and1dfor days.This fix resolves wrong results, compile failures, hangs and a JVM crash across
HORIZON JOIN,WINDOW JOIN,dateadd()projections,ORDER BYandSAMPLE BY. Several changes are breaking: adateadd()projection now keeps a designated timestamp only when its argument is the designated timestamp column itself and its unit has a fixed duration, soSAMPLE BY, windowRANGEframes andASOF,LT,SPLICE,HORIZONandWINDOW JOINover otherdateadd()projections now fail to compile,CREATE TABLE ... AS ... PARTITION BYandCOPY ... PARTITION_BYover them require an explicitTIMESTAMP(col), and materialized views built on such projections become invalid when reopened; bind variables in theTIME ZONE,FROMorTOof aSAMPLE BYwithFROMorTOand a stride shorter than a day are now rejected at code generation instead of failing with an internal error; queries no longer return the extraORDER BYcolumn, which is now added as a hidden column, so selecting it by name or relying on it inINSERT INTO ... SELECTwithout a column list fails; andSAMPLE BYwithFILLover aTIMESTAMP(col)source whose first row has a NULL timestamp now fails withSAMPLE BY designated timestamp cannot be NULLrather than returning out-of-range timestamps, while withoutFILLNULL-timestamp rows form their own bucket. For joins,ORDER BYon a column not selected by a groupingHORIZON JOINnow reportsORDER BY expressions must appear in select list, bare bind variables inHORIZON JOINandWINDOW JOINselect lists compile and return the bound value, nested window functions such asrow_number() OVER () + 1are properly rejected, and keyedHORIZON JOINno longer ignores its join key or throws when the record sink falls back to the looping implementation. Filters overdateadd()columns no longer fail withunknown function name: and_offset(...). On theSAMPLE BYcursor path, numerous defects are corrected, including leftover rows from an earlier partial read, ignored numeric time zones underLIMIT, random or wrong aggregates for the bucket where the clock moves back, a crash from uninitialised memory with not-keyedFILL(PREV)andFROM, a hang after a DST fall-back with keyedSAMPLE BY 1dandFILL, backwards timestamps at a spring-forward, wrongapprox_percentile()values before a key's first row, a hang with a month or year stride and negativeWITH OFFSET, an extra bucket beforeFROM, a skipped bucket atFROM, compile failures for negative fill values such asFILL(-1), zeros instead of NULL for mixed fill lists, a time zone applied twice withFROMand a sub-day stride, and incorrect end-fill behaviour withTO.This change makes an HTTP session stop outliving the credential that opened it. Previously, a request carrying the
qdb_sessioncookie together with anAuthorizationheader that the server rejected was still served from the session, so an expired or revoked token, or an old password, kept working for as long as the cookie did. NowHttpConnectionContextuses the session only when the request carries noAuthorizationheader, or when the session verified the same credential within the credential revalidation interval, tracked through a keyed SHA-256 fingerprint of the header; otherwise the credential is verified again and, if rejected, the request receives 401 and the session is closed. A request vouched for by the session inherits that session's auth type, and credentials that must be checked on every request opt out of reuse viaHttpSessionCredential.isVerificationReusable(). Sessions are now opened, extended or rotated only after a request has been authorized and has passed the connection limit, so refused requests no longer keep a session alive, while a refused request asking to close the session withsession=falsestill closes it. New API inio.questdb.cutlass.httpincludesHttpSessionCredential,HttpAuthenticator.getSessionCredential(), andHttpSessionStoremethodsdestroySessions(),extendSession(),getCredentialRevalidationInterval(),isCredentialVerified()andsetVerifiedCredential(), withcreateSession()now taking the credential and header fingerprint. QuestDB open source uses a revalidation interval of 0, so withhttp.userandhttp.passwordset every credential sent alongside a session cookie is verified and a client sending a wrong password with a valid session cookie now receives 401 instead of a result; QuestDB Enterprise usesacl.oidc.cache.ttlas the interval. The change also fixesAbstractJsonParser.copy(), whose native copy buffer was never rewound byclear(), causing pooled and reused parsers such asPreferencesParserand the QuestDB Enterprise OIDC parsers to grow their buffer for their entire lifetime;clear()now rewinds it. Additionally, theserver.conftemplate documents the claim lists accepted byacl.oidc.groups.claim, adds the missingacl.oidc.sub.claimentry, and describes whatacl.oidc.cache.ttlmeans for HTTP sessions, and the Java client version moves to 1.3.11-SNAPSHOT.
New Features
This feature adds a boot-time, instance-local override that keeps recent partitions on one instance for longer without changing the replicated
DROP LOCALstorage policy or affecting other instances. It is configured withstorage.policy.drop.local.override(orQDB_STORAGE_POLICY_DROP_LOCAL_OVERRIDE), mapping table directory names to intervals inDROP LOCALsyntax, with*as a catch-all, for examplestorage.policy.drop.local.override=trades~12=30 DAYS;quotes~7=7d. When the effective retention grows over a partition that is already cold, the instance rehydrates it from object storage, restoring it as a sealed read-only Parquet partition; the retry interval is set withcold.storage.policy.rehydrate.retry.interval(default 5 minutes).This feature lets administrators isolate user query workloads that share one QuestDB Enterprise instance. Administrators create, alter, rename and drop resource groups in SQL and map users or groups to them with explicit priorities; unmapped queries use the built-in
DEFAULTgroup. Each group controls admission withmax_active_queries,max_queued_queriesandqueue_timeout, shares CPU under contention according tocpu_weight, and bounds tracked native memory withmemory_limit, integrating with per-principal query memory limits. A newRESOURCE GROUP ADMINpermission gatesCREATE/ALTER/DROP RESOURCE GROUP,ALTER USER/GROUP/SERVICE ACCOUNT ... SET RESOURCE GROUPandresource_groups(). Resource Groups are enabled by default throughresource.groups.enabledand require Fiber-host execution; when that requirement is not met and the setting is left at its default, they are disabled automatically at startup.This feature adds
active_permissions(), which reports the effective permissions of all persisted users, groups and service accounts including permissions inherited from groups, andactive_grants(), which reports direct grants only. External SSO/OIDC identities are excluded because they have no persisted ACL entry, both functions requireLIST USERSandUSER DETAILS, and they return an empty result when ACL is disabled. Enterprise functions that depend on the caller's security context are now rejected when compiling materialized views and live views. The read-only replica's access list refresh is also bounded, so a session that cannot refresh within 30 seconds fails closed instead of waiting indefinitely.This feature extends persisted metrics in
sys.metricswith QuestDB Enterprise metrics, including cold storage metrics and fleet-wide replication uploader and downloader metrics such as replication lag and stalled, behind and failing table counts. Per-table series are not persisted, and monotonic totals are retained when per-table replication metrics are disabled or tables are removed. Tables with replication explicitly disabled are excluded from the current lag and failure gauges without losing their history. Restoring a backup now keeps the node-localsys.metricstable instead of refusing the restore.This feature lets third-party browser applications connect to QWP ingestion and query endpoints using QuestDB Enterprise REST tokens or OIDC tokens while keeping access control enforced. The origin allowlist
qwp.browser.allowed.originsis reloadable. Cross-origin browser connections authenticate only through thequestdb.qwp.authorizationWebSocket subprotocol, and session cookies, service account cookies andAuthorizationheaders are refused on them, so a page on a listed origin cannot act as a logged-in Web Console user. Malformed OIDC tokens are no longer written to logs, and the ACL WAL apply fence at startup, promote and heal now times out only when progress stalls rather than on the total wait.A view created
WITH AUDITrecords each read insys.view_audit, including the timestamp, principal, view name and ID, latency, status, and the resolved values of itsAUDITEDvariables as canonical JSON. Values are captured per execution, so bind variables andnow()-based defaults reflect each read of a cached plan, and a nested audited view whose parameters are fully audited by the outer view records no extra row. Reads publish into a bounded queue and are never slowed or refused, so the audit trail is lossy under extreme load and drops are logged.CREATE VIEW v_trades AS ( DECLARE OVERRIDABLE AUDITED @syms := ('AAPL','MSFT') SELECT ts, symbol, price FROM trades WHERE symbol IN @syms ) WITH AUDIT;This feature extends the opt-in merge-append partition layout, which appends late-arriving rows to a partition instead of rewriting it, to QuestDB Enterprise operations. Backups preserve the composite layout and back up column files to their physical extent, incremental backups detect changes within a partition from its geometry metadata, and the backup manifest version advances to 1.4 with backward compatibility. Storage policies fold composite partitions into a plain layout before Parquet conversion, and replicas handle the compaction of merge-append partitions.
This feature allows same-origin browser WebSocket connections to QWP ingestion and query endpoints, authenticating them through the existing
qdb_sessioncookie. Because browser JavaScript cannot set custom upgrade headers, durable acknowledgements, result compression, ingestion batch caps and query batch row limits can now also be negotiated through URL parameters and QWP messages. Upgrades with anOriginthat is not same-origin with the request host are rejected, and existing header-based negotiation for Node.js and other non-browser clients is unchanged.This feature adds a policy-neutral execution and shared memory-tracker lifecycle layer on top of the Fiber scheduling foundation, allowing an optional controller to associate query-scoped resource state with Fiber-hosted SQL execution, coordinate execution at cooperative boundaries, and preserve ownership across protocol and parallel-query hand-offs. It covers optional controller integration at Fiber dispatch boundaries, query-lifecycle ownership across mount and unmount, a cooperative callback at existing SQL circuit-breaker checks, explicit identification of engine-owned system SQL, propagation of query-scoped resource accounting across Java and native execution, a shared per-query tracker provider and pool with a protected limit-resolution hook, and Fiber-owned scratch storage (
FiberLocal) for mutable helpers such as sinks, paths, format compilers and preallocated exceptions that fiber-executed code reaches through static accessors. A grant applies to one mounted execution segment and is released when that segment unmounts; query-lifetime state may survive an unmount but active execution capacity may not, protocol retries and parallel work preserve the same logical query ownership, and owned drains return control to host jobs after their elapsed-time budget so queued work resumes on the next drain. AFiberLocalvalue belongs to its mounted Fiber and is never shared between Fibers, whileCarrierLocalremains the store for carrier-describing state, and the steady-state dispatch and cooperative-check paths remain allocation-free. Ordinary per-query trackers share the acquisition, reuse, release and shutdown implementation, with a configured limit captured at acquisition and changes applying to subsequent workload invocations; concurrent provider close and tracker release drain retained native blocks, and dirty tracker reuse fails without freeing a block that outstanding allocations may still reference. This layer defines mechanism only — no resource group policy, SQL, configuration, principal mapping, enforcement or observability — so existing behaviour remains the default when no controller is installed.This feature introduces a typed metric snapshot API alongside the existing Prometheus scrape API and persists global metrics into the non-WAL, day-partitioned
sys.metricstable. It supports configurable sampling, TTL, metric exclusions, virtual metric throttling, schema evolution, and Parquet rollover. Persistence is controlled bymetrics.persist.enabled, which defaults to enabled, andMetricsPersistenceJob.getTableName(CairoConfiguration)derives the metrics table name in a single place. The job guards against backwards clock movement and disables persistence safely when an error occurs.This feature lets browser applications on other origins connect to QWP on
/write/v4and/read/v1through a reloadable, exact-originqwp.browser.allowed.originsallowlist. An empty list keeps the same-origin-only behavior, and clients that send noOriginare unaffected. Cross-origin browser upgrades authenticate only through thequestdb.qwp.authorizationWebSocket subprotocol: session cookies are ignored, no session is created, andAuthorizationheaders are refused, so a same-site page cannot act on behalf of a logged-in Web Console user. Credentials are never echoed in the response or written to logs.This feature adds the
AUDITEDmarker toDECLARE, so a view can record what a read of it resolved its parameters to, and lets a declared variable hold the list that anINfilter tests against:CREATE VIEW v AS ( DECLARE OVERRIDABLE AUDITED @sym := 'ETH-USD', AUDITED @since := now() - 1h SELECT * FROM trades WHERE symbol = @sym AND timestamp > @since );AUDITEDis independent ofOVERRIDABLEand is accepted only in theDECLAREblock that opens a statement or a view body; a nested use fails withAUDITED is only allowed in the top-level DECLARE block. The parser captures one audit record per view reference site, carrying the view name, id and each audited parameter with the expression it resolved to, and attaches those records toSELECT,INSERT ... SELECT,CREATE TABLE AS SELECT,UPDATEandEXPLAINstatements. An audited flag is stored in the_viewfile as an optional extra block, so views that are not audited keep their previous on-disk form and older builds skip the unknown block. Background contexts such as materialized view refresh, live view refresh, WAL apply and view compilation record nothing. DeclaredINlists, such asDECLARE @syms := ('AAPL','MSFT') SELECT * FROM trades WHERE symbol IN @syms, are spliced intoINat parse time and produce the same node as a written-out list, so every existingINoverload applies, each member keeps its own type, bind variables work as members, and a declared TIMESTAMP list still plans as an interval scan; a declared list used anywhere else fails withdeclared list can only be used on the right-hand side of IN. The change also fixes a long list of parser, optimiser, PostgreSQL Wire Protocol and HTTP issues: each read of a declared subquery and each reference to a declared variable now gets its own copy,WITHscoping is lexical so a view body no longer sees the caller's CTEs, a set operation's trailingORDER BYandLIMITnow apply to the whole set operation in joins and later branches, table-function factories close exactly once, stale view plans are detected in page frame cursors, a failedUPDATEcloses its plan, PostgreSQL Wire Protocol no longer rolls back a transaction's rows on a plan retry,/expretries a staleEXPLAIN, and multi-statement batch andPIVOTsubquery retries no longer re-run the wrong statement. New compile-time safeguards bound parse memory with a per-statement parse budget that fails withstatement is too complex to parse [models=<n>, max=<m>], and limit a statement to 100 declared subquery reads and 100,000 expanded expression nodes. Note that some previously accepted statements change behaviour or are now rejected, including set operations whose trailingORDER BYreferenced a name defined only by the last branch, and affected stored views are marked INVALID on recompile.This feature lets a partition hold several pieces over one set of column files, modelled on Parquet where the partition is the file and a piece is a row group. A backdated commit appends at the tail and repoints only the piece it touched, instead of rewriting the whole partition into a fresh directory, so a write into a cold partition copies the rows it touches rather than the partition. The geometry lives in a per-partition
_geometryfile that is read only when a query or commit lands on that partition, and reads produce one page frame per piece. Superseded copies remain as dead space until compaction (JOIN, MOVE-TAIL, MAKE-PLAIN, REWRITE) reclaims them from the writer after a commit or from a background sweep. The behaviour is controlled bycairo.o3.partition.merge.append.enabledand is off by default; a merge-append table takes no WAL lag and disables the in-order block fast-append path. Regardless of the flag, every non-read-only server now runs apartition-compactionworker that sweeps WAL tables everycairo.partition.compaction.check.interval(default 2 minutes, negative disables it), compacting idle Parquet partitions that hold dead row-group bytes aftercairo.partition.compaction.idle.timeout(default 1 hour) and folding logical partitions back into one directory aftercairo.partition.compaction.squash.idle.timeout(default 30 minutes); copies are built off a reader snapshot on the sweep thread and published as a metadata-only swap, so ingestion is never paused. WAL tables now keep mid-partition splits up tocairo.o3.partition.max.splits(default 20), withcairo.o3.mid.partition.max.splitsapplying to non-WAL tables only, and a folder counts as cold when none of the lastcairo.partition.compaction.hot.commits(default 10) commits wrote to it. Adaptive WAL apply block sizing is on by default viacairo.wal.apply.block.size(default 75 MiB, 0 restores previous sizing). Other behaviour changes:table_partitions()andSHOW PARTITIONSgaindeadRows,lastWriteTimestampandpieceCountcolumns, which changes the result-set arity for existing callers; the sorted-symbol-index fast path is removed so those queries now scan and sort; parallelCOPYalways creates its target table as non-WAL; and Influx Line Protocol over UDP no longer writes WAL tables, creating new tables as non-WAL and dropping rows for existing WAL tables with a critical log entry. While a table holds any composite partition its_metarecords a higher storage version, so an older binary refuses to open it; to downgrade, fold every composite partition back to plain on a current binary first, which is also what a writer does automatically when the flag is turned off.This feature adds
PERCENTILE_DISC()andPERCENTILE_CONT()as bothGROUP BYaggregate functions and window functions, together withQUANTILE_DISC()andQUANTILE_CONT()aliases and LONG input support forPERCENTILE_DISC(), which preserves the input type instead of returning DOUBLE. Multi-percentile variants accept aDOUBLE[]list and returnDOUBLE[], including a new array variant ofAPPROX_PERCENTILE(), for exampleSELECT percentile_disc(val, ARRAY[0.25, 0.5, 0.75, 0.95, 0.99]) FROM t. Window usage supports whole-partition frames only, such asSELECT percentile_disc(val, 0.95) OVER (PARTITION BY grp) FROM t, and the percentile argument must be a constant or runtime constant such as a bind variable. Aggregate selection uses introselect so exact percentiles run in O(n) time on average andPERCENTILE_DISC()/PERCENTILE_CONT()support parallelGROUP BY. The feature also fixes several defects:APPROX_PERCENTILE()andAPPROX_MEDIAN()returned another group's value for groups without rows and now return NULL; percentiles outside [0, 1] are rejected;PERCENTILE_DISC()returns the first value whose cumulative distribution reaches the percentile, matchingCUME_DIST();PERCENTILE_CONT()no longer overflows between extreme values; sliced and multi-dimensional percentile lists are handled correctly; NULL percentile lists fail with a clear error; window variants are charged to the per-query memory tracker; andSAMPLE BY ... FILL(LINEAR)is now rejected at compile time rather than failing at runtime.
Improvements
This improvement lets eligible cold storage reads suspend their fiber while remote chunks download, freeing the query worker for other work instead of blocking it. Download completion, cancellation, statement timeout and engine shutdown wake the parked read, and execution that cannot suspend falls back to a blocking wait. HTTP, PostgreSQL Wire Protocol, QWP, materialized view and parallel reduce cold reads all use the same suspension path.
This improvement makes
account_keyoptional in the Azure Blob Storage object store configuration, so storage account keys no longer have to be embedded in thereplication.object.storeURI. When the key is omitted, authentication falls back through a credential chain covering environment credentials, client certificates, client secrets, workload identity and finally IMDS managed identity on Azure VMs. Existing configurations that setaccount_keycontinue to work unchanged.When a replica registers a replicated table it creates the table files, including per-symbol-column map files. These files are now created with the configured commit mode, so replicas running with the default
nosynccommit mode also skip the per-column synchronous flushes, making table registration faster on wide tables.This improvement adds an index fast path for
LIKE,ILIKE, regex (~) and their negations (NOT LIKE,NOT ILIKE,!~) on indexedSYMBOLcolumns, replacing the previous full data-frame scan with a per-row boolean filter. Positive patterns are matched against the symbol table once to collect the matching symbol keys, which then drive the existing per-key bitmap-index merge, while negated patterns scan the index complement — every unmatched symbol key plus the NULL key — reproducingNOT INsemantics, including matching rows with NULL symbols. When the selected columns are fully covered by a posting index, the matched keys are routed through the covering merge so covered columns are read directly from the posting sidecars instead of the base column files. The matched-key set is resolved at cursor open, so symbols added between compilation and execution are included, and a runtime-adaptive gate performs bounded posting-row costing and selects the covering or bitmap route only when the estimate is exact and within that route's admitted share of selected rows (2% for covering, 5% for bitmap); broad, unknown or probe-budget-exhausted patterns fall back to the ordinary scan and filter. The cheaper sequential merge is used when no consumer relies on order, and the table-ordered heap merge only when one does. Supporting optimizations include a dual-mode k-way merge that switches to an O(log N) heap above sixteen keys, a true array-backed binary min-heap inIntLongSortedList, and a memoized sparse-generation prefix sum on the covered-read path over O3-split partitions. Index-route admission is bounded by a block-hop budget shared across a whole cursor open, so probing costs at most the configured threshold in block reads per open regardless of posting-list length, frame count or matched-key count. Behaviour is controlled bycairo.sql.symbol.pattern.index.enabled(defaulttrue) andcairo.sql.symbol.pattern.index.threshold(default100), and the/*+ no_symbol_pattern_index(tab) */hint forces the scan-and-filter plan while/*+ no_covering(tab) */forces the bitmap path over the covering path. Selective patterns such asSELECT * FROM tab WHERE sym LIKE 'r%'measured dozens of times faster, while broad patterns pay only a small bounded costing overhead before falling back.This improvement adds a shared
REQUIRES_ENTERPRISE_SECURITY_CONTEXTSQL execution requirement so that functions depending on the security context are rejected before construction when compiling a materialized view or a live view. The first offending function name and position are retained for reporting, and materialized-view queries are validated again after optimization. Along with the restriction, cursor factories instantiated by the optimizer forFROMandJOINtable functions are now freed when a compile path rejects the query before code generation runs, so a rejectedCREATE MATERIALIZED VIEWorCREATE VIEWno longer leaks them, andisCacheable()is read before generation on the query, materialized-view and view compile paths so a throw during generation cannot orphan the generated factory tree.ProjectableRecordCursorFactory.close()is now idempotent, matchingAbstractRecordCursorFactory, since ownership chains can close the same factory from more than one owner on error paths. A constant-timeremoveAtQuick()was added toCharSequenceObjHashMap, mirroring its lower-case UTF-8 twin, so bulk scope compaction does not become quadratic. QuestDB Enterprise functions adopt this requirement.This improvement fuses an eligible
GROUP BY, including aSAMPLE BYrewritten toGROUP BY, over a single INNER, LEFT or RIGHT equi-join into a newAsync Hash Join Group Byoperator, enabled by default when query workers are available. The operator builds one shared hash table and then joins and aggregates probe page frames on the workers, with large builds running filtering, radix partitioning and hash construction in parallel. INNER joins choose the smaller table as the build side at compile time, and interval-scan cases can re-select the smaller side on each execution. Eligibility requires page-frame inputs, supported key types and aggregates registered by implementation and argument type; other shapes keep the ordinary plan. Parquet builds decode only payload columns and copy the payload when the copy fits the configured budget and the probe is large enough to repay it. New settings control the behaviour:cairo.sql.parallel.hash.join.groupby.enabled(default true when query workers are configured),cairo.sql.parallel.hash.join.groupby.build.parallel.min.rows(300,000),cairo.sql.parallel.hash.join.groupby.build.rows.per.partition(32,768),cairo.sql.parallel.hash.join.groupby.payload.copy.max.size(128 MiB),cairo.sql.parallel.hash.join.groupby.payload.copy.min.probe.ratio(0.4) andcairo.sql.parallel.hash.join.groupby.payload.copy.parallel.min.probe.ratio(0.125). The change also carries supporting fixes toGROUP BYandSAMPLE BYprojection handling, RIGHT join predicate pushdown, repeated join-key encodings,SYMBOLlookup overhead and native JIT BYTE/SHORT negation, and makes weighted standard deviation return NULL for groups containing a contributing negative weight in both serial and parallel execution.This improvement adds reusable, task-scoped fibers to worker pools so suspendable SQL and protocol work can park without retaining the worker scheduler or allocating a new continuation for every wait. Fiber-host pools own a fiber runtime, ordinary worker jobs stay on the worker loop, and each fiber owns one long-lived continuation that yields between task incarnations so its stack chunk and task adapter are reused. Incarnation-tagged scheduling prevents stale readiness, cancellation and completion signals from acting on recycled tasks, while a wait coordinator arbitrates timer, WAL, capacity, cancellation and shutdown wakeups. Integrated execution paths include HTTP request execution and retries, CSV import retries, WebSocket egress, PostgreSQL Wire Protocol request execution and socket re-arming, ordered and unordered page-frame reduction, indexed
LATEST BYand parallelGROUP BYtask families, and materialized view refresh; producers reserve fiber capacity before claiming queue or I/O ownership, so saturation leaves work in its source queue. New settingsworker.fiber.max.live,worker.fiber.max.retainedandworker.fiber.mount.budgetbound live fibers, retained idle fibers and mounts per runtime drain, fiber-capable pools expose*.fiber.enabledswitches withLEGACYexecution retained as a supported mode, and metrics report live, outstanding, queued, mounted, finalizing, parked and retained fibers plus creation, retirement, mount, saturation and launch counters. Influx Line Protocol jobs remain ordinary blocking jobs,WorkerPool.haltWithin(long)becomes the retryable relative-budget wait with the previoushalt(long)retained as a compatibility bridge, and additional correctness work covers task-pool lease accounting, query registry pooling, rejection of WALUPDATEexpressions requiring live WAL progress, serialWINDOW JOINselection for non-parallel aggregates, and resource release on error paths.This improvement separates the scheduling paths for fiber work published by a worker inside its own runtime from work published by an external or cross-runtime thread. Each owner worker gains a bounded sequence-slot run queue: same-runtime work goes to the owner's local queue without waking a parked peer, falling back to the global queue when the local queue is unavailable, while external, cross-runtime and fallback publications wake one parked worker and prefer the fiber's last mounting worker as a best-effort placement hint. Active workers can steal local work, probe the global queue periodically, scan every source before parking, and recover committed local work if its owner exits. Queue publication remains the commit point, owned drains respect both a mount-attempt budget and a 10 ms elapsed-time budget, shutdown cleanup is forced through the global queue so it cannot jump ahead of older injected work, and
LEGACYpools keep their original worker loop with no additional allocation. Fatal job errors are now counted once through the outer worker handler, and bounded per-pool counters report publication route, selection source, successful wake-one claims and orphaned-shard transitions. Measured external resume latency drops by roughly 90% and same-runtime publication costs fall by 17-23%, with production-shaped HTTP throughput unchanged within noise.Joins whose two sides use different symbol tables translate master symbol keys into slave symbol keys so that comparisons happen on integers rather than strings, and the caches holding those translations previously lived on the heap and were never shrunk, retaining up to hundreds of megabytes for as long as the factory stayed in the query cache. This improvement moves both the
SymbolTranslatingRecordcaches and theSymbolToSymbolJoinKeyMappingcache off-heap into a new paged int-to-int map, releases them when the cursor closes, and charges them to the per-query memory tracker so thatcairo.query.memory.limit.bytesnow accounts for them. The newcairo.sql.join.symbol.translation.cache.capacityproperty (default 1,000,000 entries) bounds how far each translation cache grows and applies to hash,LT,HORIZONand multi-keyASOFjoins, whilecairo.sql.asof.join.short.circuit.cache.capacitycontinues to coverASOFjoins on a singleSYMBOLkey; both properties are re-read on each execution and can be reloaded at runtime. Dense symbol keys now use roughly a quarter to a half of the previous memory at comparable speed, and data sets whose keys form distant blocks run several times faster; joins with more distinct keys than the configured capacity trade some speed for bounded memory and can be returned to previous performance by raising the property.Calendar-aligned
SAMPLE BYruns as a parallelGROUP BYkeyed ontimestamp_floor_utc(), and with a named time zone that observes DST a day bucket previously cost two time zone offset lookups per row on top of the floor arithmetic. SinceSAMPLE BYfeeds the designated timestamp in order, this improvement makestimestamp_floor_utc()with a constant named time zone remember the last bucket and answer subsequent timestamps with a simple range check, falling back to direct arithmetic on a precomputed bucket width when the check misses, and remembering the current constant-offset segment of the time zone so that day buckets need no time zone lookup inside a segment. The cache is only used for fixed-width units that make a bucket at least 8 units of the timestamp resolution wide, and a bucket is stored only when the previous miss was nearby, so sparse or unordered timestamps take the plain arithmetic path. Queries such asSAMPLE BY 1d ALIGN TO CALENDAR TIME ZONE 'America/New_York'run roughly 65% faster and hourly buckets around 30% faster; because the cache holds mutable state, these functions now report themselves as not thread-safe and are cloned per worker, and direct calls on unordered, non-designated timestamp columns can be somewhat slower.Releasing a writable file mapping, for example after an out-of-order merge or a WAL apply, previously held a process-wide lock for the whole
munmapsystem call, so every other thread that needed to map or unmap a file had to wait, including queries, WAL apply and the transaction log readers that ingestion commits wait behind, which could cause multi-second commit stalls under heavy ingestion. This improvement holds the lock only for the cache lookup and performsmunmapafter releasing it, matching the behaviour already used for cached read-only mappings. A cache miss for a read-only mapping still callsmmapunder the same lock unlesscairo.file.async.munmap.enabled=true.On XFS, growing a file that ends mid-block makes the kernel zero the partial tail block and write it to disk synchronously, and because out-of-order writes size column files to the exact data length, every out-of-order commit waited for one disk write per column when the last partition's files were grown to the next append page; in production profiles this accounted for 41-51% of WAL apply time. This improvement allocates files that QuestDB appends to in whole pages on Linux so they never end partway through a page, covering out-of-order writes, WAL segment copies and
ALTER COLUMN TYPE, while small files that never grow keep exact sizing and other platforms are unchanged. On a test rig with added write latency, catching up a backlog of about 225,000 transactions dropped from roughly 12.7 seconds to about 9 seconds with forced write-back time falling to zero. Disk usage is unaffected where pages and filesystem blocks are both 4 KiB, while on 64K-page arm64 kernels each exactly-sized file can grow by up to 60 KiB.This improvement reduces per-row work in
LATEST ON ... PARTITION BY ...scans by sharingSYMBOLkey resolution between value lists and subqueries, rewriting eligibleORpredicates on a singleSYMBOLgrouping column into the existingINpath, and replacing ordered-map membership checks with compact packed hash sets forSYMBOLgrouping tuples. Scan filters are now JIT-compiled and evaluated in bounded batches of up to 2,048 rows directly against column memory, with a per-batch fallback to Java evaluation for unsupported filters or frames; the behaviour is controlled bycairo.sql.latest.by.jit.enabled, which defaults totrueand respects the global JIT mode, andEXPLAINidentifies the compiled path.SYMBOLliterals are no longer resolved to integer keys at compile time: index scan, covering-index,LATEST ON,SAMPLE BYfirst/last and excluded-value factories now resolve keys when a cursor opens, so factories retained acrossTRUNCATE TABLEor bind-variable rebinds follow the table's current key assignment, and JIT-compiled filters emitSYMBOLliterals as bind variables preloaded into registers before the row loop. The change also sets the symbol map null flag whenALTER COLUMN TYPE ... SYMBOLorATTACH PARTITIONproduce NULL values, with a startup migration repairing tables converted on earlier releases, and fixes_txntruncation so that creating a table with enoughSYMBOLcolumns to push the symbol section beyond one page no longer fails. On ClickBench-style data, filtered single-keyLATEST ONqueries run up to 7x faster and unfiltered two-SYMBOLgrouping about 2.9x faster. Batching bounds the matching-row buffer to 16 KiB but can evaluate up to 2,047 extra rows before stopping, widerSYMBOLsets can use more memory because empty slots reserve the full tuple width, and execution-time key resolution adds one symbol-table lookup per literal per execution.Previously
CREATE TABLE(andALTER TABLE ADD COLUMNfor a symbol column) msync'd the symbol offset (.o) and index key (.k) files of every symbol column regardless ofcairo.commit.mode, so a wide table paid two synchronous flushes per symbol column even under the defaultnosyncmode. This improvement honours the configured commit mode when creating symbol map files:nosyncskips the per-column msyncs,asyncuses async msync, andsynckeeps the synchronous flush, while table-level metadata (_meta,_cv,_todo,_name,_txnand the table directory) is still synced once per table in every mode. Asynchronous memory sync calls are now no-ops, sincemsync(MS_ASYNC)has been a no-op on Linux since 2.6.19 and Windows ignores the flag and flushes synchronously;server.confno longer documentsasyncas acairo.commit.modechoice, although the value is still accepted.
Bug Fixes
This fix addresses a replica that could serve a live view with no rows forever if it restarted while the view's base table was still being downloaded. The restart cancelled the base table download and dropped the pending retry for the view, and the boot-time retry ran before the base table existed, so nothing re-attempted registration afterwards. Live view registration is now retried once the base table arrives, and recovery publication, rollback and replicated drops are generation-aware so a recovered view can no longer displace or remove a same-name replacement.
A temporary object store transport error, such as an Azure Blob timeout or HTTP 504, during the upload of a backup partition data part was treated as an unknown write outcome and aborted the whole backup without using its retry budget. Because backup partition data is immutable and written to a unique key, these uploads are now retried with the existing bounded backoff and cancellation handling. Permanent errors such as TLS failures still fail immediately, and cold storage, WAL and backup control uploads keep their conservative single-attempt behavior.
Previously
SWITCH COLD STORAGE ROLE TO MANAGERreturned HTTP 500 when another instance already held the manager lock, the Web Console reported the instance as unreachable, and the server logged an unhandled exception even though lock contention is an expected refusal. Routine refusals now return HTTP 400 with an actionable message that names the instance holding the lock and the recovery path, without incrementing the unhandled-error metric. Promotion failures now return their concrete reason to the query, demotion no longer gets permanently stuck when the drain deadline passes or an ownership read fails, and only transitions with a genuinely unresolved remote mutation remain fenced.ALTER USERandALTER SERVICE ACCOUNT ... CREATE TOKENreturned zero rows on every execution of a cached plan after the first, while still creating the credential, leaving a live token that nobody could read or revoke individually. The same cached plans also skipped the permission check on reuse. This fix rewinds the result cursor and re-runs authorization on every execution for REST tokens and JWK tokens, includingCREATE TOKEN TYPE JWK WITH PUBLIC KEY.The ACL cleanup after
DROP TABLEandALTER TABLE ... DROP COLUMNwrote revokes for every permission at the dropped scope rather than only the permissions stored there. After a restart replayed the ACL, those revokes shadowed database-level grants, so the entity silently lost the permission at database level and tables created afterwards no longer inherited it. Cleanup now revokes only the permissions actually stored at the dropped scope and clears leftover grant options without revoking wider permissions.This fix extends the retry handling for immutable backup uploads to each partition's metadata file. Previously a transient transport error on that upload was treated as an unknown outcome and aborted the whole backup, even though replication uploads to the same bucket recovered from the same error. Partition metadata is written once per backup to a unique key, so it is now safely retried with the same bounded backoff, while manifests and other control objects keep the conservative path.
When a dropped table's local directory outlived its removal from the replication index, restarting the primary could treat it as a new table and retry uploading it from transaction zero even though its WAL segments had been purged. Retired tables are now recognized at startup, on hot promote and during later WAL events, their remaining upload pins are released, and stale index updates that would recreate them are rejected. Tables dropped before their first upload still upload their history to preserve point-in-time recovery.
S3 closes idle connections after about 6 seconds, while the object store HTTP client kept them for 90 seconds, so reusing a connection just as S3 closed it failed the request with
connection closed before message completed. The upload was retried without data loss, but each failure logged an error and could trip the elevated replication upload failure rate alert several times an hour. Idle connections are now dropped after 4 seconds for all object store traffic, including replication, backup and cold storage.Since 3.0.1, every routine TLS disconnect, such as a pooled QWP sender closing over
wss, was logged as an ERROR on the server because the check that downgrades these messages matched outdated error text. The TLS layer now classifies end-of-file, connection reset, aborted connections and broken pipes by error kind and logs them at INFO. The downgrade for browsers that reject a self-signed certificate is fixed the same way.A promote could reload the access list while a WAL apply worker was still applying recent ACL changes, such as a newly created user. The reload skipped the busy table without waiting, so the promoted instance came up without those users or groups and later statements failed with
Entity does not exist. Promote now waits for outstanding ACL WAL transactions to be applied before reloading the access list.A cold storage decode could return a ready result even though its query had already been cancelled, when download completion was registered before the cancellation. Cancellation is now checked against both bound cancellation signals after re-polling, on completion and timer resume, and before falling back to a blocking wait, so cancelled queries stop promptly and release their resources.
Selective equality predicates were not derived for the grouped child scan of a join when the value involved a cast, bind variable, arithmetic expression or runtime-constant function, so QuestDB scanned and grouped an entire table before joining it to a single selected row, and the missing predicate also prevented Bloom filter and row-group statistics pruning on Parquet partitions. This fix recognizes literals, bind variables, casts, arithmetic over constants and registered runtime-constant functions as effectively constant when the other equality operand is a single column, and derives equivalent filters across eligible equi-joins in a type-independent way, while keeping volatile expressions out of transitive propagation. It also corrects incorrect pushdown of single-table
ONpredicates in queries with multipleLEFT,RIGHTorFULL OUTER JOINs: predicate origin and referenced model are now tracked so an innerONpredicate cannot move below a nulling boundary, ordering dependencies prevent join reordering from crossing such boundaries, transitive facts are scoped by model and column with their provenance retained, and barrier-joinONregexes can no longer publish invalid column facts. Separately, a filtered query compiled while a table held only native partitions retained its native scan configuration after the partitions were converted to Parquet; eligible filtered page-frame plans are now associated with the partition-table version and the Parquet pruning capability present at compilation, cached plans are invalidated and recompiled on a native/Parquet capability transition, and eachTableReadersnapshot caches a Parquet-presence flag so steady-state cursor opens do not scan the partition list. AffectedRIGHTandFULLjoin shapes now return correct rows, at the cost of some inputs scanning more rows where a derived filter can no longer be safely applied.This fix addresses a race where a healthy live view on a QuestDB Enterprise replica could be durably invalidated while its base table was still catching up. The ahead guard in the live view refresh job previously compared the view's watermark against the cached in-memory sequencer head alone. On a replica, the downloader appends to the on-disk transaction log behind that cache and reconciles it only later, so the WAL apply job consumes new transactions straight from the file and the refresh legitimately advances the watermark past the stale cached head; the guard misread this transient catch-up as unrecoverable data loss. The guard now compares the watermark against the maximum of the cached sequencer head and the base tracker's
writerTxn, which covers the stale-cache window on a replica while remaining a no-op on a primary, where the cached head stays authoritative. Genuine data-loss cases still invalidate the view, and the invalidation log line now prints both heads (baseTableTxnfor the cached head andbaseWriterTxnfor the applied one) so an operator can see which one drove the decision. The fix narrows rather than fully closes the false-invalidation window: wherewriterTxnhas been reset to -1, the guard falls back to the previous behaviour.This fix ensures that a parallel
GROUP BYor horizon join failing inside the shard-merge phase reports the actual cause instead oftimeout, query aborted. Previously, a per-query memory limit breach, an mmap failure and an IO error all produced the same misleading timeout message; such queries now report the real failure, for examplequery memory limit exceeded [workload=QUERY, limit=..., used=...]. The shard-merge and parallel long top K jobs run on detached workers that cannot throw to the owner thread and previously signalled failure through a bare flag, dropping the exception. The post-aggregation circuit breaker now keeps the message, position, errno, error kind and the cancellation, interruption and out-of-memory flags alongside the flag they explain, so the owner rebuilds and rethrows the worker's exception verbatim, withreset()clearing the flag and the error together. Errors arriving after the breaker has already tripped are dropped so that a spurious abort cannot mask the real reason, and resolution follows a precedence order of the query's own circuit breaker, then the worker's recorded error, then the previous fallback. The asynchronous horizon join cursors publish through the same queue and receive the same treatment. As a result, cancelling a query during the merge phase now reportscancelled by userrather thantimeout, query aborted, so tooling matching on the old text will see a different string.This fix resolves a defect where covered queries returned correct row IDs but
NULLor empty covered payloads. The posting index writer keeps lazily-created read-only memory mappings of the covered source columns, and its lazy initializer early-returns whenever the arrays are non-null. A full seal with name-based covers maps one column at a time to bound peak memory, and the per-column unmap zeroed the array entries while leaving the arrays allocated. From that point the initializer early-returned on stale arrays, every covered read resolved to a zero address, and the covered-value writers silently wrote theNULLsentinel. This affected both a post-seal generation flush on commit and an incremental seal, which re-reads covered values for every key of a dirty stride and therefore rewrote previously correct values asNULL. The name-based per-column seal loops now reset the lazy mapping state when they finish, normally or exceptionally, so the next covered read lazily re-maps; the one-column-at-a-time memory bound is preserved at the cost of one open, mmap and close per covered column per seal-to-flush transition. The address-based O3 path was unaffected. The bug shipped in 9.4.0 and later, and sidecar files written by affected versions may contain persistedNULLcovered payloads, so tables usingINDEX TYPE POSTING INCLUDE(...)built under those versions should have their index recreated.This fix adds two generation-aware primitives to the live view registry, mirroring the compare-and-swap discipline already applied to table tokens:
registerViewIfAbsent(instance)performs a put-if-absent style publication that refuses to displace an existing same-name entry and reports the current owner instead, andremoveView(name, expected)performs an expected-value removal that atomically no-ops when another generation owns the name and takes the owner's fan-out entry with it only when it does own it. Both update the name map and the base-table fan-out under the same per-base write lock, preserving the registry's torn-read contract. The registry is name-keyed with silent put and remove semantics, so a recovery path publishing a recovered live view without holding the table-name lock could, when stalled across a completed replicatedDROPplus a same-nameCREATE, displace the healthy replacement's registry entry, after which the recovery's own rollback removed it. The replacement then stayed token-published but unregistered, absent fromlive_views(), skipped by the idle refresh scan and with itsSELECTsilently served from disk as empty until a restart, while the displaced instance's native state leaked. The new primitives make cross-generation displacement structurally impossible at the registry level; the change is purely additive and the existingregisterViewandremoveViewmethods are untouched.This fix preserves the rebase markers when a dropped table directory is cleaned up. The WAL apply job's dropped-table sweep deleted every plain file in the directory except
_txn,_metaand the WAL locks, including the_rebase_sourcemarker that the QuestDB Enterprise uploader relies on to record the old directory as a rebase source rather than a drop, which would otherwise wipe the table on the replica. The two paths meet whenALTER TABLE ... REBASE WALcannot tombstone the directory itself, since its removal of_txnand_metais best-effort and loses to open handles on Windows; the directory then still looks like a table, the WAL purge job pings the apply job, and the drop lands on the next boot before the uploader reads the marker. The sweep now skips_rebase_newand_rebase_source, matched by their shared_rebase_prefix. Skipping only those entries is deliberate: the sweep still removes_txnand_metaso the directory stops looking like a table and can be reclaimed, avoiding an endless ping-pong between the two jobs and a leaked directory, and the WAL purge job still deletes the marker along with the rest of the directory.This fix addresses a set of defects in the JIT filter compiler, which is enabled by default and therefore reachable from ordinary SQL.
UUIDrange predicates no longer crash the process:UUIDandLONG128ordering is now declined by the serializer and answered by the Java filter, while equality and inequality remain compiled.IPv4andCHARordering are lowered into existing comparison, equality and boolean operations that implement unsigned order and correct NULL semantics, so these predicates stay JIT-compiled and vectorized, including quotedIPv4literals; against a quoted literal the expansion collapses to a shorter form. NegativeSYMBOLliterals such asWHERE sy = -5now carry their sign through symbol-key resolution.BOOLEANcomparisons nested inside comparisons return correct rows, as the vectorized backend now harmonises comparison masks with raw boolean bytes and the AND/OR operations write to fresh registers instead of clobbering an operand that the rest of the predicate still reads. A comparison that reaches no column no longer inherits an unrelated column's type, repeated reads of one column are cached per loop body, bind-variable broadcasts are cached, an out-of-bounds constant-pool read in the scalar backend is corrected, andIPv4orCHARpredicates carrying arithmetic or a unary minus now run on the Java filter instead of compiling with incorrect semantics. On the storage side, a coveringPOSTINGindex no longer returns NULL or 0 for blocks whose values all equal the column type's maximum, covering-index sidecar block sizes are computed inlongto avoid anintoverflow, and a designatedTIMESTAMP_NScolumn now rejects values beyond2261-12-31 23:59:59.999999999instead of corrupting partition bookkeeping; Influx Line Protocol, QuestDB Wire Protocol and CSV import paths report such values as per-message or per-row errors that follow the configuredON ERRORor atomicity policy instead of distressing the writer, and a seeding live view that repeatedly faults on such a row now spends its retry budget and invalidates rather than retrying forever. An existing table that already holds out-of-range rows is not repaired, and rewriting such a row throughINSERT INTO ... SELECTorCREATE TABLE ... AS SELECTnow fails with the bound's message.This fix restores scheduling for materialized views that stopped being refreshed while every diagnostic surface reported them as healthy.
ALTER MATERIALIZED VIEW ... SET REFRESHon an immediate, non-period view now registers timers unconditionally, instead of skipping registration because there was nothing to remove; previously such a view persistedrefresh_type = 'timer'and reportedview_status = 'valid'while nothing scheduled it, and the missing refresh-intervals timer also pinned the base table's WAL segments so they accumulated on disk. The refresh job no longer drains its queue without bound: it yields after 32 tasks or a one-second elapsed-time budget, whichever comes first, so the timer job sharing the same workers is no longer starved by a base table that commits faster than its views refresh. Timers polled during a tick are now re-scheduled from afinallyblock, with each timer re-added under its own guard, so a throw mid-tick no longer permanently drops the rest of the batch. Timer registration now takes its definition from a live view state, so anALTERracing aDROPcannot recreate timers for a dropped view, and an expired timer whose state is missing or dropped is discarded rather than rearmed; invalid and pending-invalidation views keep their timers because they can recover. For visibility,materialized_views()gains a trailing INT columntimers_registeredreporting how many timers the timer job currently holds for the view — zero is expected only for immediate, non-period views — the state store logs the refresh requests it drops, the timer job reports at ERROR when the gap between ticks exceeds a minute, and a timer whose firing schedules nothing three times in a row is reported once, with a matching line when it recovers. Anything consumingSELECT *frommaterialized_views()positionally by width will see the extra column.This fix corrects a case where a covering index returned wrong values for rows that existed before the indexed column was added: querying
SELECT val FROM t WHERE sym IS NULLafter addingsymand an index such asALTER TABLE t ALTER COLUMN sym ADD INDEX TYPE POSTING INCLUDE (val)returned NULL forval, or dropped rows entirely when a whole partition predated the column. Because such rows have no index entry, the planner now also compiles the ordinary non-covering index scan as a backup whenever the key can be NULL, and each time the query opens it checks whether any partition it is about to read is missing values for the column, falling back to the backup plan if so. The trade-off is that a covering query whose key can be NULL gives up its page-frame cursor and runs serially, losing parallel filtering, parallel and vectorizedGROUP BY, and parallelASOFand window joins; the new/*+ force_use_covering */hint buys that back where the scanned partitions are known to have no rows predating the column, and is verified rather than trusted, raising an error instead of returning wrong rows. The check only considers partitions admitted by the query's time filter and compares the column's add time against the start of the first partition read. The same change also fixes incorrect includedSTRING,VARCHAR,BINARYandARRAYvalues on time-boundedPOSTINGcovering index queries that skipped delta blocks,LATEST ONreturning nothing for a bind variable bound to NULL on an indexed column, a pooled reader keeping a stand-in index reader after an out-of-order insert so that the NULL key matched every row, andSAMPLE BYwithfirst/lastover aPOSTINGindex throwing an error. Thecairo.wal.writer.madvise.modevalues are now documented inserver.conf, including the restart requirement.This fix makes Influx Line Protocol over HTTP authorize each insert as the identity of the request that carries it. Although the HTTP layer derives a fresh security context per request, the per-table write object cached per TCP connection captured a security context once when it was created and discarded the one passed on every later cache hit. Since the commit-time check is the only INSERT check on this path, whichever authenticated identity wrote to a table first on a keep-alive connection became the identity checked for every subsequent commit to that table on that connection, letting a less privileged identity inherit the first identity's INSERT permission. Realistic ways for two identities to share a connection include proxies and load balancers with upstream keep-alive pooling, multi-tenant gateways, and clients that deliberately downgrade to a restricted token mid-connection; the exposure was bounded to INSERT into tables already written on that connection, as the live identity was still checked for the HTTP permission, entity enablement,
CREATE TABLEandADD COLUMN. The cache now rebinds the security context on every hit, which is safe because rows are committed when the request completes and anything left over is rolled back before the next request starts. Influx Line Protocol over TCP and the QuestDB Wire Protocol were not affected, as both bind one identity per connection.This fix prevents a transient reader-pool exhaustion from permanently invalidating a materialized view. The SQL optimiser wraps reader-acquisition failures in a
SqlException, so the refresh job missed its existing retry path and treated the failure as a permanent compilation error; this affected both initial compilation and retries of cached queries, where a cached refresh could defer its first execution failure, discard the factory, and then invalidate the view when the next attempt recompiled against the still-full pool. Wrapped pool-acquisition failures now carry a typed flag that the refresh job propagates through rollback and resource cleanup into the timer-driven retry path, while preserving the exception type, error code, position and message that ordinary SQL callers receive; the flag is cleared when the exception flyweight starts a new error so a later SQL error cannot inherit retryability. The view can therefore catch up once readers become available without a new base-table commit or a manual refresh. The existing retry timeout and attempt limit are unchanged at 1 second and 10 retries, so prolonged contention can still invalidate the view, and ordinary HTTP queries are not switched into request retries.This fix allows
LAG()andLEAD()to return the neighbouring row's symbol, with or withoutPARTITION BY,ORDER BYandIGNORE NULLS, and the result behaves like any other symbol column in filters, joins,GROUP BYandORDER BY. Nested usage such asLAG(p)overp = LAG(v::symbol)now returns correct values. Along the way the fix corrects several related issues:SYMBOLequality now treats NULL as equal to NULL everywhere (so=may return more rows and!=fewer), comparing a symbol with a string no longer matches every row when both sides read the same dictionary,BOOLEAN-to-SYMBOLFIRST(),LAST()andMODE()return NULL rather thanfalsefor empty groups, equality onNOCACHE SYMBOLcolumns returns correct results for hash-colliding values, and window queries usingPARTITION BYwith aSTRING-to-SYMBOLcast now release their partition-expression state on cursor close.TRIM(),LTRIM()andRTRIM()onSTRINGandSYMBOLvalues no longer return an empty string when the trimmed result is exactly one character, soWHERE trim(sym) = 'a'now matches. Sub-queries inside select-list expressions no longer disturb preceding columns, and window functions whose argument contains a sub-query now work withPARTITION BY. Joins whose key combined aSYMBOL = SYMBOLpair with aSYMBOL = STRINGorSYMBOL = VARCHARpair no longer lose matches in hash,ASOF,LT,SPLICEandHORIZONjoins, since each side of a join now owns its symbol-encoding flags. Finally, aRIGHTorFULL OUTER JOINwith a non-equalityONclause now runs before laterINNER,CROSS,RIGHTandFULL OUTERjoins instead of being reordered behind them, which previously produced wrong rows without an error. Limitations: only a NULL default is supported, solag(sym, 1, 'x')fails;SYMBOLLAG()/LEAD()are not supported in live views;STRING/VARCHARarguments still require a::symbolcast;first_value,last_value,max,minandnth_valueoverSYMBOLnow fail at compile time with a clear error; a sub-query in a windowPARTITION BY,ORDER BYor frame bound now fails withquery is not allowed here; and because the non-equi outer join now runs first, a selective laterINNER JOINno longer shrinks its nested-loop input, so such queries can be slower.This fix corrects invalid HTTP 206 partial responses that caused browsers to abandon resumed downloads and execute truncated files, which left the Web Console stuck on "Loading" with an
Unexpected end of inputerror on slow connections. Byte ranges are now treated as inclusive on both ends, sobytes=0-0returns a single byte andContent-Lengthmatchesend - start + 1; theContent-Rangeheader now reports the last byte of the body rather than a value past the end of the file; theETagis quoted on 206 responses andIf-Rangeis honoured, falling back to a full 200 response when the validator is stale; and range bounds are validated properly, so a start at or beyond the file length and an inverted range return 416 withContent-Range: bytes */LENGTH, while an oversized end is clamped to the file length instead of being rejected. Suffix ranges and multiple ranges continue to return 416.Sealing a posting index could crash QuestDB or write a broken index when one group of 256 symbol keys held more than roughly 573 million rows in a single partition, or more than 2 GB of compressed row IDs, which can occur with a
SYMBOLcolumn that has few distinct values in a very large partition. An internal size calculation overflowed, causing the writer to pick a compact layout that did not fit its buffer. This fix calculates those sizes without overflow and falls back to the unbounded layout when the compact one would not fit. Indexes below these sizes keep the same on-disk layout, readers and query paths are unchanged so query speed is unaffected, and the writer only performs a few additional 64-bit additions per key when sealing; very large groups that previously overflowed now produce somewhat larger index files.A QuestDB instance could stop serving requests entirely after an
OutOfMemoryError, with every network worker blocked while waiting for a log ring slot. A log call claimed its ring slot at the start of the chain and published it only at the end, so any exception in between left a slot claimed but never published, which prevented the single log consumer from advancing and eventually blocked all producers. This fix makes log records format into a fixed-size, on-heap staging sink and claim the ring slot only at publication time, so a failed, abandoned or thread-dead chain loses at most its own message and can no longer wedge the queue. The staging record is now one per carrier and shared by all loggers, abandoned or nested chains are detected by the next chain on the same carrier and published with anABANDONEDmarker, and worker error reporting no longer throws: job names are materialized up front instead of callingtoString()while logging an unhandled job error, and appenders that call foreign code publish the partial message and rethrow on failure. As a consequence, with a full ring a non-waiting call now formats and then drops its message rather than becoming a no-op, waiting calls block at publication time instead of at the chain start, and lines from different threads can show slightly out-of-order timestamps since ordering follows publication time while timestamps come from the chain start.For statements executed at parse time, such as
TRUNCATE,RENAME TABLE,SET,BEGIN/COMMIT/ROLLBACK,REFRESH MATERIALIZED VIEWand access control statements, the server reported-1affected rows to QWP clients, which some clients rejected or reported as an invalid count. These statements now report0, as the protocol specification states. QWP senders also no longer time out when callingclose().This fix addresses three problems in parallel filtered queries. Queries that read two filtered table scans one after the other, such as a hash join whose build and probe sides both run as async filters or a
UNION ALLof two filtered scans, could hang indefinitely when both scans landed on the same reduce shard and the second scan had more page frames thancairo.page.frame.reduce.queue.capacity; while stalled, every worker thread sat idle and onlyCANCEL QUERYor a statement timeout ended the query. A page frame sequence now reduces locally whenever it is still active and has no frame of its own in flight, regardless of which runtime owns the current fiber, and a finished sequence suspends its collect subscription so it no longer gates the publisher, withtoTop()resuming it before the sequence dispatches again. This also removes the long-standing slowdown where frames of the second scan beyond queue capacity were filtered one at a time on the query thread instead of on the worker pool. Finally, aSELECT count()over a single-frame filtered scan, or a filtered window join, could return 0 instead of a cancellation or timeout error when the query was cancelled while that frame was being filtered; size calculation now re-checks that all frames were active after the loop.SELECT t.k FROM t JOIN s ON s.k = t.k WHERE t.vt IS NULL AND s.ct = 'u';A cursor that stops mid-way, for example a
LIMITinside a join's build side or a client that pauses fetching, still pins its shard, and sequences behind it fall back to reducing frame by frame on the query thread.This fix restores diagnostics and offset counting for
HORIZON JOIN. The JIT compiler could reuse anArithmeticExceptionwithout a message, causing a parallelHORIZON JOINto report onlyunexpected reduce error; the checked addition used by the serial iterator is now also used during heap initialization and subsequent iteration, so a timestamp overflow consistently produces a non-critical exception carrying the timestamp and the offset. For microsecond timestamps,RANGE FROM -9000000000000s TO 9000000000000s STEP 9000000000000sshould produce three offsets but signed subtraction overflow previously reduced it to one; the span is now divided as an unsigned value and the configured limit is checked before adding one or narrowing the count, with counts beyondlongreporting a truthful lower bound in the existing SQL limit error. The timestamp helper still inlines and retains its intrinsic, andRANGEcounting runs during query compilation so it adds no per-row work.This fix addresses three problems with O3 merges into Parquet partitions after
ALTER COLUMN TYPE. Previously, every O3 commit rewrote the entire Parquet file after a type change, because a column was treated as type-converted whenever its original writer index differed from its writer index; a column is now considered type-converted only when the type stored in the Parquet file also differs from the table's column type, so only the first merge after the ALTER rewrites the file and later merges take the in-place update path. Second, an indexedSYMBOLcolumn converted from another type kept a stale symbol index after an in-place O3 update, because the Parquet column lookup used the current writer index instead of the original one, causing indexed lookups to return rows that no longer matched the merged data. Third, after aVARCHAR->STRING->VARCHARround trip the table writer moves the column to the end of its metadata while the Parquet file stays untouched, and the Rust Parquet updater encoded new and replaced row groups positionally against the file schema, writing values into the wrong columns or suspending the table; columns are now reordered to the file schema by Parquet field id when replacing or inserting row groups, with a clear error returned for unmatched or duplicate ids. Additionally, horizon join timestamp overflow now reports a descriptivehorizon timestamp overflowerror instead of a bare internal reduce error.This fix makes buffered log records visible promptly and lets the logging subsystem recover from failed rolls. The rolling file writer previously waited for 100,000 idle job passes before flushing, which could hold low-traffic records in memory for several minutes; it now flushes a non-empty buffer one millisecond after the latest buffered record, keeps the idle-pass count as a fallback, opens the active log path in append mode during startup, and reserves numbered-file creation for configured size or time roll events, so restarts no longer accumulate empty numbered files. Failed rolls are now recoverable: the descriptor is reset immediately after the old file is closed, the roll deadline advances only after the new file opens, a flush always rolls when no file is open, and
close()releases the buffer, descriptor and paths before rethrowing a flush error, so a failed rename, expired-log removal or open no longer stops file logging until restart or writes log text into an unrelated file. In addition, only one log factory is now constructed per process: loggers requested during construction receive a deferred logger that is bound after initialisation, the logging worker pool starts without a logger so writer failures are reported on stderr instead of wedging the worker, and the writer rolls an existing file aside when an append open fails. This halves the logging threads, configuration reads and native logger memory of a default server start, and a malformedlog.confnow fails startup with a plain log error. A related overflow check was added to the zero-operand fast paths ofDecimal128andDecimal256addition and subtraction, so checked operations never return an out-of-range value.Concurrent PostgreSQL Wire Protocol queries could cause
EXPLAIN CREATE TABLE ... AS SELECTandEXPLAIN CREATE MATERIALIZED VIEWto throw aNullPointerExceptionwhen the compiler was reused before the plan was rendered, and could also produce incorrect table names or statement types inEXPLAIN INSERTandEXPLAIN UPDATE. This fix snapshots the table name and statement type information during compilation, so theEXPLAINfactory no longer retains a reusable execution model.Large QWP reads could keep growing server scratch memory after a partial frame was sent, and on large enough results this crashed QuestDB. The server now sends the rest of a split batch before reading more rows, keeping memory bounded to one batch; in a 50-million-row read, peak server memory dropped from 6.40 GiB to 462 MiB. Cancelling a query mid-stream no longer leaves unsent rows that the next query on the connection returned ahead of its own results, and a query failing mid-stream no longer breaks
SYMBOLdecoding for the next query.This fix corrects two
SAMPLE BY ... FILLdefects. Previously the bucket column was located in theGROUP BYoutput by name, so any TIMESTAMP output column sharing the timestamp's name was mistaken for the bucket, causing assertion errors, unbounded fill rows, too many rows for queries such asSELECT first(ts) AS ts, avg(val) FROM t SAMPLE BY 1h FILL(NULL), or dropped gap rows for a qualifiedtimestamp_floor('1d', t.ts) AS ts; the optimizer now records the bucket key node it creates and code generation resolves the bucket by that node regardless of alias. Second, an outerGROUP BYover a fill sub-query, such asSELECT ts, count() FROM (SELECT ts, avg(val) v FROM t SAMPLE BY 1h FILL(NULL)) WHERE v > 1.5 GROUP BY ts, received a second fill that re-inserted filtered gap rows and corrupted aggregates; the fill is now applied only when theGROUP BYoutput itself carries the recorded bucket node, while still running for fills inside a LATERAL body. As a result, a time-series operator over an outerGROUP BY tson a fill sub-query without anORDER BYnow fails withleft side of time series join has no timestampunlessORDER BY tsis added,SELECT first(ts) AS ts, avg(val) FROM t SAMPLE BY 1h FILL(0)now reportsInvalid fill value: '0'. Timestamp fill value must be in quotes, and a projection selecting both a qualified and unqualified timestamp copy consistently returns a keyed fill for either column order.