Changelog
All notable changes to ElyraSQL are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
[1.12.4] - 2026-10-09
Fixed
-
The page cache no longer grows without bound under a write workload (redb 2.6.3 → 2.6.4).
remove()left the key's entry in the LRU queue behind and a laterinsert()of the same key pushed another, while the only trim ran insideremove()behind a size test the queue could stay under. A server whose page cache never reaches its configured limit therefore grew for as long as it ran — which is every long-lived deployment with a steady write load. Entries now carry a sequence number, so a stale queue entry is recognisable and an insert over a live key replaces it in place. -
A subquery inside a
CASEarm or a function argument is seen as a subquery.SELECT (SELECT 1) + 1answered 2 whileSELECT ABS((SELECT -1)),SELECT COALESCE((SELECT NULL), 7),SELECT CASE WHEN 1 IN (SELECT 1) THEN 1 ELSE 0 ENDand the same withEXISTSwere all refused — plain MySQL, every one of them. Three walkers over the same expression tree disagreed about where a subquery can appear: the router that picks which engine answers aFROM-lessSELECTand the pass that resolves subqueries both stopped atBinaryOpand never descended into aCASEarm or a function's arguments, whilemap_expr— which had already grown those arms for the same reason — did. The router now asks the complete walker, which is now the only one; the resolver descends as far. This also makes an uncorrelated subquery work inside an aggregate:SUM(CASE WHEN t.col IN (SELECT k FROM s) THEN 1 ELSE 0 END)returns a number where it used to be refused. -
A correlated subquery in an aggregate expression is refused when the statement is planned, not when a row arrives. It was the catch-all of the per-row expression evaluator, so whether the statement was accepted depended on whether a row ever reached the expression: the same query was accepted on an empty table and refused on the first matching row. A limitation that only appears once there is data cannot be caught by a test fixture, an empty development database or a CI run — it is discovered in production, on the largest table. The single-table aggregate path now refuses it up front, as the join path already did, and names the aggregate it found it in.
-
The row evaluator no longer blames
WHEREfor an expression that is not in it. It answers projection items,CASEarms, aggregate arguments andORDER BYkeys, and said "expression not supported in WHERE" for all of them — pointing at the one clause the expression was not in. A subquery now says what it is; anything else names no clause.
[1.12.3] - 2026-10-04
Fixed
-
\_and\%in aLIKEpattern match literally, as in MySQL. The backslash was dropped when the string literal was read, leaving a wildcard:'axb' LIKE 'a\_b'and'500' LIKE '50\%'were true. A search that escapes its input that way -- Laravel'saddcslashes($term, '%_'), for one -- matched rows it should not have. Literals now keep the backslash before_and%(LENGTH('\_')is 2, as in MySQL), andLIKEtreats the backslash as its escape character unlessESCAPEnames another. Text in a user variable also keeps its backslashes when substituted back into a statement (SET @v = 'a\\b'read back asaand a backspace). Checked against MySQL 8.4 on 17 cases, including stored views and both prepared-statement paths. -
DDL commits the open transaction first, as in MySQL.
CREATE,ALTER,DROP,RENAME,TRUNCATE(of tables, indexes, views, triggers, procedures), account statements,LOCK TABLES,ANALYZE TABLEand a newBEGINran inside an open transaction, so aROLLBACKafterINSERT ...; CREATE TABLE ...undid theINSERTas well -- which MySQL had already committed. Worse, a secondBEGINin an open transaction silently discarded its writes, where MySQL commits them. These statements now commit the open transaction before they run, even when they then fail; temporary tables do not, as in MySQL.REFRESH MATERIALIZED VIEWstays inside the transaction, and the refresh the engine runs on reading a stale view no longer risks committing the reader's transaction. Checked against MySQL 8.4 on 18 sequences. In a read-only transaction, DDL now commits it and runs, as in MySQL, instead of being refused.ANALYZE TABLEnames the table with its database, as MySQL does. -
SHOW TABLESapplies its filter and lists views.LIKEandWHEREwere ignored, soSHOW TABLES LIKE 'nope'listed every table -- and a tool that checks for a table that way always found it. They now filter as in MySQL:LIKEcase-sensitively, as table names are, with the pattern in the column header (Tables_in_elyra (st%)), andWHEREover the result's columns. Views are listed too, andSHOW FULL TABLESaddsTable_type(BASE TABLEorVIEW).
[1.12.2] - 2026-10-04
Fixed
CALLreturns the procedure's result sets. ASELECTin a procedure body ran, but its rows were dropped: the client got only an OK. EverySELECT-- in loops, handlers and nestedCALLs too -- now comes back as its own result set, followed by theCALL's OK with the rows the last statement affected, as MySQL sends them, over the text and the binary protocol. The server now offersCLIENT_MULTI_RESULTS, which clients such asmysql_asyncand sqlx only use when offered; a client without it gets MySQL's 1312. Inside procedures, a local named in the select list keeps its name as the column's (SELECT nreturned a column named aftern's value), and an alias that is also a local's name is left alone (SELECT x AS xwas a syntax error).
[1.12.1] - 2026-10-04
Fixed
-
SELECT ... INTO @varassigns the variable. The clause was ignored -- the row came back as a result set and the variable stayed NULL -- or, once the variable held a value, the statement failed as a syntax error, since@varwas replaced by its value before parsing (INTO 5). It now works as in MySQL 8.4: after the select list or at the end, into several variables, with no result set; no row leaves the variables unchanged, a second row is error 1172 (after the first is assigned), and a column-count mismatch 1222. In a stored procedure,SELECT ... INTOalso assigns locals (DECLARE n INT; SELECT COUNT(*) INTO n FROM t, which failed the same way), and no row raisesNOT FOUNDfor a handler. ADECIMALorBIGINT UNSIGNEDin a user variable now reads back as a number rather than text (SET @x = 1.5gave'1.5'). -
Read-only transactions refuse writes.
START TRANSACTION READ ONLYwas accepted but not enforced: anINSERT,UPDATEorDELETEin it ran, and only a laterROLLBACKundid it. Every write -- includingREPLACE, an upsert,SELECT ... FOR UPDATE, DDL and temporary tables -- is now refused before it runs, with MySQL's error 1792 (SQLSTATE 25006). The forms around it that were refused or failed to parse now work as in MySQL:START TRANSACTION READ WRITE,... WITH CONSISTENT SNAPSHOT,SET TRANSACTION READ ONLY | READ WRITEfor the next transaction,SET SESSION TRANSACTION READ ONLYand@@transaction_read_onlyfor the session (which also covers autocommit statements), and the combinedISOLATION LEVEL ..., READ ONLYform. An unscopedSET TRANSACTIONinside an open transaction is refused with 1568. Checked statement by statement against MySQL 8.4. A stale materialized view is read as last refreshed in a read-only transaction, since refreshing it would be a write. -
DROP DATABASEtells the truth about a name that holds nothing. Every table lives in one database, which a connection may select under any name (USE probe_rois accepted, and the catalog reports that name, as Laravel expects). Such a name then looked like a database that could not be dropped:DROP DATABASE probe_roanswered "not supported". From any connection not using that name it is now MySQL's 1008 Can't drop database 'probe_ro'; database doesn't exist, which is the case -- nothing is stored under it -- and a no-op withIF EXISTS. Dropping the connection's own name orelyra, which would drop every table, is still refused, with a message that says so. The docs now describe the aliasing;frameworks.mdsaidDB_DATABASEhad to beelyra.
[1.12.0] - 2026-09-28
Security
-
Column grants hold in every statement that reads, not only a plain
SELECT. A user grantedSELECT(pub)on a table could read its other columns with a set operation (SELECT sec FROM t UNION SELECT 'x'), or copy them out withINSERT INTO mine SELECT sec FROM t: the column check returned early for any statement that was not a singleSELECT. Every table a statement reads -- set operations, subqueries, the source of anINSERT ... SELECT, the tables of anUPDATEorDELETE, and a restricted table anUPDATEorON DUPLICATE KEY UPDATEwould copy within -- is now checked; a column-restricted table is readable only through a plain single-tableSELECTof granted columns. The SELECT-privilege check also covers those statements now; it used to apply only to aSELECT ... FROM. -
Replicas enforce column grants made after they started, and follow the primary's DDL. A replica applies its primary's writes straight to storage, below the SQL session, but the engine's schema caches were invalidated only by statements run through a session. So on a replica:
- a column grant (
GRANT SELECT(public) ON t TO u) created on the primary after the replica had looked for one was not enforced:ucould read every column oftthere until the replica was restarted; - after an
ALTER TABLEon the primary, the replica kept serving the old definition (a new column was "unknown"); - a trigger created on the primary was missing from the replica's cache, so a cluster follower promoted to leader could write without it.
The storage layer now keeps a schema generation, bumped after every commit that touches a schema key (table definitions, views, accounts, grants, roles, triggers) on every path, and the table-definition, trigger, privilege and feature caches are checked against it. The "do any column grants / materialized views exist" flags are also per database now; they were shared by every database in the process. Verified end to end with a primary and a replica, and by tests that fail on the previous release.
- a column grant (
Fixed
-
An account configured with the
writetier can write.--auth user:pass:write(and--user) accounts have no stored record, and the per-privilege check looked for their grants, found none and fell back to read, so such an account could notINSERT,UPDATEorDELETEanything. A configured account is now governed by its configured tier, as documented; grants apply to stored accounts (CREATE USER), which are unchanged. -
Integer aggregates are exact on every path. The fast columnar paths -- used for two or more aggregates, a
GROUP BYon one numeric column, and the opt-in column cache -- carried every numeric column as a double, so past 2^53 answers were wrong with no error:SUM(a), COUNT(*)over2^53+1and1gave9007199254740992, andMAX(a)a neighbouring integer. Worse, a table with aBIGINT UNSIGNEDcolumn had every other column's values counted twice on those paths (SUM(a), COUNT(a)gave120, 6for60, 3), because the row decoder did not recognise the unsigned value and its fallback pushed the row again. Integer columns now aggregate as integers throughout, the decoder understands unsigned values, and a value a path cannot keep exactly sends the query to the general aggregator instead of being rounded. The general aggregator also roundedSUM/AVGoverBIGINT UNSIGNEDandBOOL, now exact. Verified against MySQL 8.4 on every path, with new differential cases; no measurable speed change.
Changed
-
Privileges work as in MySQL: new accounts start with none, and reads need a grant. A new account (
CREATE USER,CREATE ROLE) could read every table; it now starts withUSAGEonly, as in MySQL. Reads are checked per table:SELECTgranted globally or on the table reads it, column grants read those columns, and a table grant reads its table even after a globalREVOKE SELECT-- which used to refuse it, as it did a column grant. AnUPDATEorDELETEwith aWHEREneedsSELECTon its table, as in MySQL. A matrix of eight accounts by seven statements gives MySQL 8.4's answers exactly. Accounts created before this keep the globalSELECTthey had by default (REVOKE SELECT ON *.* FROM ualigns one); accounts configured at startup keep their configured tier. -
AVGover an integer column or expression isDECIMAL, with four decimals, as in MySQL:AVG(a)over(10, 21, 2^53+1, 1)is2251799813685256.2500. It was declaredDOUBLE, which discarded the exact quotient every path already computed.MIN/MAXof an integer expression (MIN(a*3)) is nowBIGINTrather thanDECIMAL, also as in MySQL. -
A single aggregate uses the columnar path, and aggregation uses up to 8 cores. The columnar scalar path required two or more aggregates, on the assumption that the streaming path was as fast for one; measured,
SUM,MIN,COUNTorAVGalone over 20M rows is 2.1x faster there (574 -> 271 ms) andCOUNT(*)2.4x. Aggregation parallelism was capped at 4 workers on the assumption that the scan is memory-bandwidth bound; it is CPU-bound, and 8 workers were 1.2-1.7x faster than 4 on a 16-core machine (beyond 8,COUNT(*)slowed again). The default is now the available cores, capped at 8, so a 4-core machine is unchanged.ELYRASQL_AGG_WORKERSstill overrides. -
An aggregate over a primary-key range scans just that range, in parallel.
SUM(...) ... WHERE id > non an integer primary key fetched the matching rows into memory first -- every one decoded and held -- then aggregated them on one core, so over half a table it was three times slower than aggregating all of it. It now runs the same parallel, decode-in-place scan as a full-table aggregate, over only the keys in range, re-applying the filter to every row. On 20M rows:SUM ... WHERE id > 10M1761 -> 281 ms,COUNT(*)likewise 1731 -> 278 ms, a rangeGROUP BY1972 -> 477 ms. A range spanning fewer than 10,000 keys keeps the direct fetch, so point and short range queries are unchanged. -
A columnar
GROUP BYover a primary-key range reads just that range. The columnar grouped path scanned the whole table whatever the filter, so... WHERE id = 777 GROUP BY gover 20M rows took 200 ms andWHERE id > 15Mas long as no filter at all; zone maps could not help, because they skip by the values of other columns. A range or equality on a single integer primary key now reads only those keys, split across the workers:id = 777200 -> 0.2 ms,id > 15M210 -> 58 ms. The key's range is used even when another column in the filter has an index. -
The column cache aggregates in tight loops, one pass per column. With
ELYRASQL_COLUMN_CACHE_MBset, every value went through a per-cell type dispatch, and each aggregate re-read its column, soSUM(x), MIN(x), MAX(x)took three passes. A column now goes to the aggregate in whole slices (NULLs gathered out in runs), aggregates over the same column share one pass, an integer sum is computed exactly in vectorisable halves rather than as a 128-bit add per value, and a large float batch is summed in eight lanes. On 20M cached rows:SUM19 -> 11 ms,SUM, MIN, MAX56 -> 11 ms. A float batch of 1024 or more values can now differ from a strictly left-to-right sum in the last digit, as results merged from parallel workers already could; smaller batches keep MySQL's order exactly. -
EXPLAINno longer claims zone maps that do not run. Every columnarGROUP BYwas reported asAggregate: columnar group, zone maps, including with zone maps off. It now names, primary-key range,, zone mapsor, columnar cacheonly when that is what will run. -
Statements no longer re-read the schema. A point
SELECTread its table's view record twice to learn it was not a view; anINSERTorUPDATEread the table's declared column widths; and everyUPDATEandDELETElisted every table definition in the database to find foreign keys pointing at its own, so it slowed down with the number of tables. These reads now go through one cache of committed schema reads, checked against the storage layer's schema generation (the same one that keeps replicas correct), and the referencing-table lookup is cached per table. A pointSELECTreads only its row: 73 -> 45 µs of server CPU. With 200 tables, anUPDATEby key takes 1.08 ms of CPU instead of 1.63. -
Privilege checks no longer read storage on every statement. Every
SELECT ... FROMlooked up the user's global privileges and roles -- two storage reads, each a hop to a blocking thread, even for an admin -- and every write by a non-admin user its table grants as well. Those reads are now cached, tagged with a generation of thesys::keyspace that the storage layer bumps after every commit touching an account, grant or role, on every path (sessions, replicas, cluster followers). AGRANTorREVOKEtherefore applies to the very next statement of every connection, as before; inside a transaction reads still go through the transaction. A point query now costs 73 µs of server CPU instead of 112, and 8 clients reach 40,400 queries/s instead of 31,600. -
LOAD DATA INFILEis 2.4x faster. It turned the file intoINSERTstatements as SQL text, 50,000 rows each, and handed that text back to the SQL parser -- which spent more time tokenising the file than the insert spent storing it. The batches are now built as statements directly and inserted through the same path (same coercion, defaults, triggers, privileges and errors; the statement built is checked equal to the one parsed from the old text). One batch exists at a time, where the whole file used to be held as SQL strings. 20M rows (three numeric columns): 76 -> 32 s. MySQL 8.4 takes 26 s; what remains is the single storage writer's B-tree inserts. -
Expressions are compiled once per scan, not interpreted per row. A column reference used to be resolved by name against the schema on every row -- two passes plus a fresh
Veceach time -- a literal re-parsed, and a comparison's collation re-derived. Aggregate arguments (SUM(col*2+1)), aggregate filters and theWHEREof a streamingSELECTnow compile to a small form with column indexes and parsed literals, evaluated through the same operator code as before (nodes without a compiled form stay interpreted, so results and errors are unchanged). The interpreter's own column lookup is also a single allocation-free pass now, and a streaming scan moves projected values instead of cloning them. On 20M rows:SUM(col*2+1)1071 -> 697 ms, anORfilter 1103 -> 664 ms, a filteredSELECT4.1 -> 3.0 s. -
The page cache is sized to available memory. ElyraSQL used redb's fixed 1 GiB default, so a scan over any larger table found nothing cached and re-read every page from the file through a syscall -- a third of an aggregate's CPU time on a 2.6 GB table, with the file already in the OS cache. The default is now a quarter of the memory the process can use: physical memory, or the cgroup limit in a container when that is lower (floor 64 MiB).
ELYRASQL_PAGE_CACHE_MBoverrides it, and the size is logged at startup. The cache fills only as pages are read. On 20M rows, aggregates became 1.7-2.7x faster; a cache smaller than the scanned table gives almost nothing, because a sequential scan evicts its own pages.
[1.11.4] - 2026-09-26
Security
- rustls 0.23.45 (RUSTSEC-2026-0285). rustls's TLS 1.3 handshake accepted handshake messages sent at the wrong encryption level when they followed a key-changing message in the same record. The transcript is still authenticated, so a handshake cannot be altered or completed through it; the advisory rates it CVSS 5.3, low confidentiality impact only. ElyraSQL uses rustls wherever it speaks TLS -- the MySQL listener, replication and the cluster control plane (as both server and client), and outbound embedding calls through ureq -- so every release from 0.9.9 through 1.11.3 shipped an affected version (0.23.41). The advisory was published on 2026-09-14, two days after 1.11.3, which is why that release's audit was green. rustls is now 0.23.45 and rustls-webpki 0.103.15, in both lockfiles; no code change.
[1.11.3] - 2026-09-12
Fixed
-
The expression-depth limit now follows the caller's stack (#116). A deeply nested expression (
1+1+1..., a hugeORchain, nested functions) is refused before parsing so it cannot overflow the stack, but the fixed limit (2000) was calibrated to a server worker's stack. On a smaller one -- anelyra-embedhost thread, or a unit-test harness thread, both supported since 1.10.0 -- a statement well inside the limit could still overflow and abort the whole process (panic = "abort"), because the evaluator's frame is large (~18 KiB per level in debug). The expression-nesting ceiling is now the smaller ofELYRASQL_MAX_EXPR_DEPTHand what the calling thread's stack can actually hold, so a too-deep expression is refused with a clean too deeply nested error instead. The server's 16 MiB worker stacks keep the full ceiling; only a small caller sees a lower one. Set-operation (UNION/INTERSECT/EXCEPT) chains -- executed iteratively, not recursed per branch -- stay bounded by the configured ceiling regardless of stack, so their behaviour is unchanged. -
SET @varinside a stored procedure reaches the session (#120). A user variable assigned in a procedure body landed in the procedure's local scope and was discarded whenCALLreturned, soCREATE PROCEDURE p() BEGIN SET @total = 6; END; CALL p(); SELECT @totalanswered unknown column instead of6.@-names now write to the session (as MySQL does, wherever they are assigned) while bare names stay procedure locals, and a body reads@-vars from the session too -- so an accumulator loop over@totalworks and a body sees a@varthe caller set. A local and a session variable of the same name no longer collide. Verified against MySQL 8.4, with a new stored-procedure section in the differential harness (its statement forms were uncovered before). -
NOW()is frozen per statement. Each call read the clock fresh, soSELECT NOW() = NOW()was0, andINSERT ... VALUES (NOW(), NOW())stored two different timestamps -- an ORM settingcreated_atandupdated_attogether got mismatched values. The whole family (NOW,CURRENT_TIMESTAMP,LOCALTIME/LOCALTIMESTAMP,CURDATE,CURTIME,UNIX_TIMESTAMP()and theUTC_*forms) is now resolved to one instant, captured at the start of the statement, across every expression and subquery in it -- matching MySQL. Done in the statement pre-pass (asLAST_INSERT_ID()already is), so the value keeps itsDATETIME/DATE/TIMEtype.SYSDATE()deliberately stays live, as MySQL leaves it. Verified against MySQL 8.4. -
||honoursPIPES_AS_CONCAT(#124). It was rejected outright ("operator not supported"). It now follows MySQL: logicalORin the default mode, string concatenation (NULL-propagating, likeCONCAT) whenPIPES_AS_CONCATis set -- which sqlx sets on every connection, so the flag was visible in@@sql_modewhile the operator did nothing. Resolved per statement from the session mode, so it toggles live and is per session. Every shape checked against MySQL 8.4, in both modes.
Added
-
UTC_TIMESTAMP(),UTC_DATE(),UTC_TIME()andCONVERT_TZ()(#123). The engine evaluates every temporal function in UTC (@@system_time_zoneisUTC), so the UTC_* forms were the one temporal function whose value was already present under another name; they now exist.CONVERT_TZ(dt, from, to)shifts by the difference of two fixed offsets (+HH:MM,-HH:MM,SYSTEM,UTC), preserving fractional seconds and propagating NULL. A named zone (Europe/Oslo) returns NULL, needing time-zone tables this build does not carry -- as MySQL does with none loaded. Offset arithmetic checked against MySQL 8.4. -
Session time-zone offsets are honoured (#125).
SET time_zoneused to reject every non-UTC value, so a client that set+02:00was refused andNOW()was UTC-only. A numeric offset ([+-]HH:MM, within±14:00) is now accepted and applied: the local now-family (NOW,CURRENT_TIMESTAMP,LOCALTIME/LOCALTIMESTAMP,CURDATE,CURTIME) reads in the session zone, theUTC_*forms stay in UTC, andFROM_UNIXTIME(n)/UNIX_TIMESTAMP(dt)interpret their value in the session zone (viaCONVERT_TZ), whileUNIX_TIMESTAMP()with no argument stays an absolute instant. A named zone (Europe/Oslo) is still refused -- it needs a zone table with the DST rules a fixed offset cannot express. Converting storedTIMESTAMPcolumns on read is tracked separately. Every offset checked against MySQL 8.4.
Added
-
GROUPING(). AWITH ROLLUPsubtotal row and a group whose key is genuinely NULL both showNULL, and nothing in the row told them apart -- a client had to guess from row position, which is fragile enough that one built on this server offered subtotals only for two grouping columns, where position is unambiguous.GROUPING(col, ...)now returns MySQL's bit mask (leftmost argument most significant) in the projection,HAVINGandORDER BY, soIF(GROUPING(region), 'Total', region)labels a pivot margin the way it does on MySQL. Every value is checked against MySQL 8.4; misuse reports MySQL's codes and wording (1111 withoutROLLUP, "Argument #N ... is not in GROUP BY"). -
EXPLAINnames the aggregation path. A grouped query answeredtype=ALLwith an emptyExtra, identical for the vectorised path and the one that spills to disk -- the server's defining feature was invisible to the client, and a user could not see when a rewrite had pushed a query off the fast path.Extranow saysAggregate: columnar scalar,Aggregate: columnar group, zone maps,Aggregate: parallel streaming,Aggregate: partitioned, spilling,Rollup: N aggregation passes, orAggregate: materialised over join. The classifier calls the same plan checks execution does.
Fixed
-
ORDER BY COUNT(*)works whenCOUNT(*)is aliased in the projection. Output rows are sorted by output column, and the output column ofCOUNT(*) AS cisc, so resolvingORDER BY COUNT(*)by name failed with "unknown output column" -- while the unaliased andORDER BY cspellings worked, which is why it hid. Generated SQL nearly always aliases, so three of four generated forms failed. The same forregion AS r ... ORDER BY region. ORDER BY expressions that name a projection item are now resolved to its position, in both the grouped and the rollup path; a rollupORDER BYmay also name an aggregate that is not projected. -
A rolled-away column is NULL inside expressions, not only as a bare item.
CONCAT(region, '!')on a subtotal row read the base column instead of NULL. The substitution is now recursive over the projection andHAVING(and deliberately notWHERE, which filters base rows before aggregation). -
docs/olap.mdsaidROLLUPwas not supported. It has been for some time.
[1.11.2] - 2026-09-07
Rust applications can connect. sqlx opens every connection with a statement
ElyraSQL refused -- a scalar subquery as a SET value, and time_zone as a
session variable -- so no sqlx application got as far as its first query. Both
are accepted now, and a duplicated @@sql_mode flag the same statement produced
is fixed alongside. Nothing else changes; no on-disk format change and no
upgrade steps. A 1.11.1 database opens in 1.11.2 unchanged.
Fixed
-
sqlx (Rust) can connect. sqlx-mysql runs one statement on every new connection, before the application's first query:
SET sql_mode=(SELECT CONCAT(@@sql_mode, ',PIPES_AS_CONCAT,NO_ENGINE_SUBSTITUTION')),time_zone='+00:00'Both halves were refused with error 1235 -- a scalar subquery was not a valid
SETvalue, andtime_zonewas not a recognised session variable -- so no sqlx application could connect at all, including anything generic overAnyPool, which cannot turn the statement off.docs/frameworks.mdhad said sqlx works out of the box; it did not, and now does.A
(SELECT ...)in aSETvalue now runs as a query and must return exactly one row and one column, as in MySQL.time_zoneis a session variable, echoed by@@time_zone/@@session.time_zonewhile@@global.time_zonestaysSYSTEM, exactly as MySQL 8.4 reports it. Only spellings that mean UTC are accepted (+00:00,SYSTEM,UTC): ElyraSQL already evaluates every temporal function in UTC, so+00:00is honoured exactly -- which is what sqlx'schrono/timetypes assume. A non-zero offset is refused with a reason rather than stored and silently not honoured (#125). Two neighbouring gaps surfaced by the same measurement are tracked separately:UTC_TIMESTAMP()andCONVERT_TZ()are not implemented (#123), and||is not a concatenation operator even withPIPES_AS_CONCATset (#124).Reported from Grove, where the convert tool could not reach ElyraSQL through sqlx's
Anydriver. -
@@sql_modeis a set, not a list with duplicates.SET sql_mode = CONCAT(@@sql_mode, ',NO_ENGINE_SUBSTITUTION')stored the flag twice when it was already present, returning a string MySQL never produces. Now deduplicated case-insensitively with the first spelling kept and empty items dropped. The client's order is preserved; MySQL's canonical ordering is a cosmetic difference this does not close.
[1.11.1] - 2026-09-02
A security release. Upgrade, and read the note if you store binary data.
Two defects, both confirmed against a running server before being fixed. The first corrupted data silently: binary parameters bound through prepared statements lost every byte that was not valid UTF-8, with no error. The second let one malformed stored procedure abort the whole server, for every client, on every restart. Nothing else changes; a 1.11.0 database opens in 1.11.1 unchanged.
The fix for the first cannot repair rows already written. docs/installation.md
says how to find them.
Fixed
-
Security: binary data bound through a prepared statement is stored exactly. A parameter that was not valid UTF-8 -- an image, a hash, ciphertext, anything a driver sends as bytes over the binary protocol -- was rendered into SQL text through
from_utf8_lossy, which replaced every invalid byte with U+FFFD and raised no error. Seven bytes went in, fifteen came back:sent: 00 80 ff fe 27 5c c3 got: 00 ef bf bd ef bf bd ef bf bd 27 5c ef bf bdEvery client using server-side prepared statements for BLOB columns was affected (PDO without emulated prepares, Go
database/sql, JDBC withuseServerPrepStmts, Python connectors withprepared=True), silently. Bytes that are not valid UTF-8 are now rendered as a hex literal,x'..', which is what the engine already uses to spellValue::Bytesas SQL. Text parameters keep the quoted form, so comparison under a case-insensitive collation is unchanged. Data already written through the old path is corrupted on disk and must be re-inserted. -
Security: a stored procedure with a nameless cursor no longer aborts the server.
DECLARE CURSOR FOR ...(no cursor name) hit anunwrap()in the body parser. Under the release profile'spanic = "abort"that is not a failed statement but the whole process, for every client. Worse, the body was parsed only atCALL, so an admin's typo was stored, survived restarts, and detonated for whoever called it -- a restart loop.The parser now returns an error, and a procedure body is parsed at
CREATE PROCEDURE, so a body that cannot be parsed is refused with the statement in front of the person who wrote it. Two property tests now feed the body parser arbitrary and keyword-shaped input and require that it never panics, the same invariant the SQL preprocessors already carry.
Internal
cargo auditis honest again. It passed with four warnings nobody saw, because CI fails only on vulnerabilities.mysql_async(the test client) was on a yanked release and is bumped to 0.37.1; the three that remain -- one unsound advisory and two yanked crates, all transitive through that same dev-dependency and none in the shipped binary -- are now documented in.cargo/audit.tomlwith the same reasoning the rest of the allowlist carries.
[1.11.0] - 2026-08-27
Two ways to reach the database that did not exist before. An embedding index
lets the database keep a vector column in step with a text column by itself, and
elyrasql mcp serves a schema and its data to an AI agent over stdio. Two fixes
under them, one of which lifts a limit MySQL never had.
No on-disk format change and no upgrade steps: a 1.9.x or 1.10.0 database opens in 1.11.0 unchanged, and a 1.11.0 database still opens in either.
Added
-
elyrasql mcp: serve a database to an AI agent. A Model Context Protocol server over stdio, so an agent can introspect the schema and run SQL with no server, port or driver involved:elyrasql mcp --data app.edbThree tools --
list_tables,describe_table,query-- returning JSON rather than rendered tables, withDECIMALkept as a string so its scale survives a model reading prices.Read-only by default. Writes are refused by the engine's privilege check rather than by looking for dangerous keywords in the SQL, which is the only way to refuse them reliably.
--allow-writesgrantsWrite, neverAdmin:INSERT/UPDATE/DELETEbecome possible whileDROP TABLE, schema changes andGRANTstay refused. Results are truncated at 200 rows by default and always say when they were, since a model that cannot tell a partial answer from a complete one will reason from the partial one.Implemented without a new dependency, and tested by driving the real binary over real stdio.
-
Declarative embedding indexes.
CREATE EMBEDDING INDEX ... ON t(text) INTO vec USING MODEL '...'makes the database keep a vector column in step with a text column, so an application stops orchestrating re-embedding:CREATE EMBEDDING INDEX body_ix ON articles(body) INTO embedding USING MODEL 'text-embedding-3-small'; INSERT INTO articles (body) VALUES ('data protection law'); -- that is allWhat needs embedding is derived from the data — a hash of the
(model, text)pair against the hash recorded when the vector was written — rather than from a write hook, so bulk loads, restores, binlog replay and replication apply are covered too. Nothing in theINSERT/UPDATEpath changed.Writing back runs in a serializable transaction, so an
UPDATElanding while the provider is being called is never overwritten; the row stays pending and is picked up next sweep. Failures back off exponentially and dead-letter after five attempts, whichSHOW EMBEDDING INDEXESreports asRetrying/Failed. Editing the text clears the state. Sweeps are driven by--embedding-sweep-secs(30 by default,0disables) and do nothing at all while no provider is configured.Verified end to end against a real provider (Ollama,
all-minilm, 384 dimensions) over the MySQL protocol: three semantic queries sharing no words with their target each ranked it first.
Fixed
-
A flat
UNIONchain no longer fails at 65 branches (#112). Set operations parse left-associatively, soA UNION B UNION CisSetOp(SetOp(A, B), C). Execution recursed into that, costing one nesting level per branch untilMAX_QUERY_NESTINGrefused the statement:ERROR 1105 (HY000): query error: query nesting exceeds 64 levelsMySQL 8.4 runs a thousand branches without complaint, and generated SQL emits one branch per entity as a matter of course. This was found by a script that built one
SELECT COUNT(*)per table: fine at 25 tables, refused at 90.A
UNIONchain now runs as a flat list. Uniform chains only -- the walk stops at any branch whose operator or quantifier differs, because(A UNION B) UNION ALL Cis neither a three-wayALLnor a three-wayDISTINCT, andINTERSECT/EXCEPTare not associative the wayUNIONis. Both keep the pairwise path and their existing results, checked against MySQL 8.4 across fifteen shapes including mixed operators, mixed quantifiers, parenthesised sub-chains,ORDER BY/LIMIT/OFFSET, NULLs and collation.The branch queries are also built field by field rather than by cloning the statement, which previously copied the entire chain once per branch -- quadratic in the branch count, and the first thing to overflow the stack, about three times sooner than parsing the same statement does.
Set-operation keywords now count toward the pre-parse depth guard, since parsing and dropping the tree still recurse even though executing it no longer does. A chain past the limit is refused with a parse error rather than aborting the process; a server runs 2,000 branches, up from 64.
That budget is shared with expression depth, so a statement that combines thousands of operators and thousands of set-operation branches can now be refused where only one of the two used to count. Both halves genuinely deepen the same tree, so counting them together is the honest accounting -- but it is the one shape this release accepts less of than 1.10.0 did.
-
A database file is released when its handle is dropped, not shortly after (#110). The storage writer runs on a detached thread that holds the storage -- and so the file lock -- until it observes its job queue close, with nothing synchronising that back to whoever dropped the handle. A server opens its file once per process and never noticed; opening and closing the same file in a loop, which is what an embedded test suite does, hit it every time:
storage error: Database already open. Cannot acquire lock.Db::close_waiter()returns a handle that waits for the release, and dropping anelyra_embed::Databasenow uses it. Reopening the same path immediately is deterministic rather than retried into working -- pinned by a test that runs the loop with the retry budget set to zero.A handle that cannot be released because a
Connectionoutlived itsDatabaseis reported rather than waited on indefinitely. -
A held file lock is its own error kind.
Error::StorageLockedcarries the path and maps to MySQL 1015 (ER_CANT_LOCK), where before it was an opaqueError::Storagestring.elyra-embedhad to match on the storage engine's message text to recognise it, which is the pattern removed from the catalog errors in 1.9.6; it now matches on the variant. -
ai_embed()now has a test that speaks HTTP. The request construction,Authorizationheader and response parsing had no coverage at all — every test stopped at the function boundary. There is now an integration test with a real socket and a real HTTP exchange.
Internal
- One statement tokenizer, not two.
users.rscarried its own word/string/ symbol tokenizer forGRANTandCREATE USER; embedding-index DDL needs the same one. It moved tosqllex, which already exists because seven copies of a SQL scanner were once six too many.
[1.10.0] - 2026-08-26
ElyraSQL now runs without a server. The engine opens a database file in-process, as a library, with a C ABI for languages that reach Rust through an FFI. That is the whole release.
The minor bump is for the new surface, not for changed behaviour: nothing an existing client reads is different, there is no on-disk format change, and there is no upgrade note. A 1.9.x database opens in 1.10.0 unchanged, and a 1.10.0 database still opens in 1.9.x.
Added
-
Embedded mode: the engine as an in-process library.
elyra-embedopens an.edbfile directly — no server, no socket, no wire protocol — andelyra-embed-capiexposes the same API over a C ABI for hosts that reach Rust through an FFI.let db = Database::temporary()?; // deleted when it drops let conn = db.connect(); conn.execute("INSERT INTO users (name) VALUES ('Ada')")?;The SQL semantics are identical to the server's because it is the same engine:
elyra-enginealready depended on storage and nothing above it, so this adds a second entry point rather than a second implementation. Verified in both directions against the released 1.9.9 image — a file written in-process is served and queried over the MySQL protocol, and a row the server wrote reads back in-process, with the same values.The facade owns a Tokio runtime and blocks, so callers need none of their own. Calling it from inside an async context returns an error instead of the panic
block_onwould otherwise raise. -
A database file can be reopened immediately after closing. The storage writer runs on a detached thread that holds the storage handle — and so the file lock — until it observes its job queue close, with nothing synchronising that back to whoever dropped the handle. A server opens its file once per process and never sees this; opening and closing the same file in a loop, which is what an embedded test suite does, hit it constantly.
Database::opennow waits the window out (Config::lock_wait, 2s by default) while still failing on a genuinely concurrent open. The underlying handle lifecycle is unchanged and still cannot be awaited deterministically (#110).
[1.9.9] - 2026-08-21
A security release. Upgrade if you run replication.
Three independent holes in the replication surface, all reachable in 1.9.8. The
first needs no credentials and no handshake: connecting to the replication port
returns a full copy of the database. If you run a primary with
--replication-listen, or a replica, treat this as urgent — and read the upgrade
note, because the fixes are deliberately breaking.
Fixed
-
Security:
elyrasql replicanow requires accounts on its MySQL listener. The replica subcommand had no auth flags at all and always started with open authentication (any username/password logged in as Admin), exposing the full replicated data set to anyone who could reach the port. It now accepts--user/--password/--auth USER:PASS:ROLEexactly likeserve, and refuses to start credential-less unlessELYRASQL_ALLOW_OPEN_AUTH=1explicitly opts in. -
Security: replication authentication is now mutual and mandatory. A replica applies everything its primary sends, but it never verified the primary's identity: any host accepting TCP connections on the primary's address could impersonate it (no TLS by default) and feed the replica arbitrary data — fabricated tables, tampered rows, corrupted catalog state — which the replica then served to clients. The primary must now prove knowledge of
ELYRASQL_CLUSTER_SECRETback to the replica before any data flows, and a replica refuses to start without a secret unlessELYRASQL_ALLOW_OPEN_AUTH=1explicitly opts in. Primary and replica must be upgraded together (the handshake changed). -
Security: the replication endpoint now requires authentication on every bind address, loopback included. The endpoint hands a full copy of the database to every connecting peer, so an unauthenticated listener let any local process (or SSRF payload) that could open a TCP connection exfiltrate all data. Startup is refused unless
ELYRASQL_CLUSTER_SECRETis set orELYRASQL_ALLOW_OPEN_AUTH=1explicitly opts in. Non-loopback binds were already refused; this closes the loopback gap.
[1.9.8] - 2026-08-21
One contribution, and it changes numbers. Exact DECIMAL arithmetic closes the
largest remaining MySQL-compatibility gap: division, modulo, ROUND,
TRUNCATE, MOD(), AVG and SUM all evaluated in binary floating point where
MySQL evaluates them exactly.
Results and result types both change, so there is an upgrade note in the installation docs. Nothing touches your data, and every change moves toward MySQL rather than away from it.
Fixed
-
Exact DECIMAL arithmetic, matching MySQL. Division, modulo,
ROUND,TRUNCATE,MOD(),AVGandSUM(aggregate and window) evaluated in binary floating point where MySQL evaluates them exactly. The values agreed only while they stayed small and round:SELECT ROUND(1.005, 2); -- was 1.00, MySQL 1.01 SELECT SUM(p) OVER (); -- was 25.050000000000004, MySQL 25.05ROUND(1.005, 2)is the shape that matters: 1.005 has no exact binary form, so rounding it throughf64rounds down. That is a money column.Results now follow MySQL's rules, all measured against 8.4 rather than taken from the manual: division gives the dividend's scale plus
div_precision_increment(4), so10 / 3is3.3333and7.00 / 2is3.500000, rounding the last digit half away from zero; modulo keeps the wider scale;ROUND/TRUNCATEkeep the narrower of the requested digits and the value's own scale, soROUND(1.5, 5)stays1.5;AVGfollows the division rule; andSUMwidens toDECIMALeven over integers, because the total can exceed the column's range.ABSkeeps its argument exact rather than detouring throughf64. -
A string bound on a numeric key no longer drops rows.
k > '1.5'on anINTprimary key coerced the bound to 2 and kept the strict>, so row 2 was never returned. Bound coercion already compensated for rounding, but only when the literal was aFLOATorDECIMAL— a string one was assumed value-preserving. Pre-existing, and reachable through any scalar subquery whose result is rendered as text.
[1.9.7] - 2026-08-20
Nothing here changes an answer a client reads, and there is no upgrade note. One change widens the MySQL differential harness; the other keeps the build green against Rust 1.98's new lints, and touches real code to do it -- behaviour is identical, but the SIMD distance kernels and the hex-literal decoder were rewritten.
Internal
-
The MySQL differential compares result column types and affected rows, not just returned rows and error codes. Both gaps had let real divergences through to users, found by hand rather than by CI: booleans in arithmetic came back as
DOUBLEwhere MySQL sendsBIGINT(every value matched, so every case passed), andINSERT ... ON DUPLICATE KEY UPDATEreported 1 affected row where MySQL reports 2, in five shapes across two code paths, with nothing looking at the count at all. Both were fixed in 1.9.6; this is what stops the next one.Column types are compared under an equivalence relation rather than exactly. Integer widths and the string/blob family are folded together — no client can observe those — while
DOUBLEis deliberately kept distinct from the integer family, which is what makes a boolean-as-float regression visible. Comparing exactly flagged 88 of 210 cases; folding plus skipping all-NULL columns brings that to 19, and those 19 are two named families tracked with their reason.DML runs as its own ordered battery, because those cases are stateful by nature: the same statement returns a different count the second time, which is exactly the convention under test.
Verified by running the new harness against the pre-fix binary, where it reports precisely the twelve divergences it was built to catch and nothing else.
-
chunks_exactwith a constant size becomesslice::as_chunks. Rust 1.98's Clippy flagschunks_exactwhere a const-generic chunk size is known, and CI tracks stable, so every pull request started failing the Clippy gate on the day it rolled out. The rewrite is also better code:as_chunks::<8>()yields&[[f32; 8]], so the lanes go straight into the vector type and the fallibletry_into().unwrap()that ran on every iteration of both inner loops is gone. The hex-literal decoder gets the same treatment: its length is already known to be even, so the pair destructures directly with no indexing.
[1.9.6] - 2026-08-19
Four contributions, and every one of them changes an answer a client reads: the value of a string literal, the number of affected rows, the type of a boolean in arithmetic, and the error code for a name collision. Two of them were returning wrong data with no error at all.
This is not a patch release. Three behaviour changes and one operational one are in the upgrade note in the installation docs. Nothing touches your data.
Fixed
-
String literals containing a backslash-escaped quote are no longer mis-parsed by the pre-parser. Several rewriters run over raw SQL before it reaches the parser, to cover MySQL syntax
sqlparserdoes not accept. Each carried its own copy of the quote-tracking walk -- seven copies, none of which understood backslash escapes, which is MySQL's default and what PDO andmysql_real_escape_stringemit. After\'the scanners believed they were back in code, so everything to the end of the literal was treated as rewritable:SELECT 'a\'b!c' -- returned a'b(NOT (c)), silently wrong SELECT CONCAT('a\'', '!b') -- returned a'(NOT (b)), silently wrong INSERT INTO t SET n = 'O\'B, Jr.' -- rejected with a syntax errorAll three are valid MySQL; the first two returned wrong data with no error at all. The seven scanners are replaced by one shared lexer that also understands doubled quotes, backtick identifiers (which take no backslash escape), and
--,#and/* */comments -- none of which the old scanners handled either, so a!, comma or keyword inside a comment could be rewritten too. -
Affected-row counts follow MySQL's changed-row convention. MySQL reports rows changed, not rows matched, and upserts carry a specific convention on top of that. Every count below was wrong, and the 1-vs-2 distinction is the documented way a client tells an insert from an update — so
updateOrCreate-style flows read the wrong answer:statement was MySQL INSERT ... ON DUPLICATE KEY UPDATE, row updated1 2 ... set to the values it already had 1 0 ... one insert plus one update in one statement 2 3 REPLACEreplacing an existing row1 2 UPDATEthat matched a row but changed nothing1 0 All seventeen shapes in the new regression test were measured against MySQL 8.4 rather than read off the manual, which documents only the 1-or-2 pair — not, for instance, that
REPLACEwith an identical replacement reports 1 because no delete happens. -
A boolean used as a number is an integer, not a float. MySQL has no separate boolean type: a comparison,
!orNOTyieldsTINYINT0/1, so it belongs on the exact integer path. It was falling through to the float one, soSELECT TRUE + 1gave2.0where MySQL gives2— and likewise(1 = 1) + 1,(2 > 1) + (3 > 2),!0 + 1and-!0. Clients that check the column type, or round-trip the value through a typed language, sawDOUBLEwhere MySQL givesBIGINT. -
The MySQL error code no longer depends on the wording of the error message.
Error::Catalogcarried only a string, so the code a client received was recovered by matching prefixes of the human-readable message — fivestarts_withchecks and acontains. Rewording an error silently changed the code, and clients branch on exactly that: an ORM reports 1146 and 1054 very differently, and a migration tool may treat 1050 as success.CatalogandDuplicatenow carry the kind explicitly, so the mapping is amatchon an enum with no string inspection anywhere. One code changes as a result, which is the fragility itself showing:CREATE VIEWover an existing table name reported 1146 (no such table) because its message says "exists" rather than "already exists". MySQL answers 1050, and so do we now.
Changed
-
Release binaries abort on panic instead of unwinding. A panic in a connection task used to unwind that task alone and leave the process up — but if it unwound while holding one of the
std::sync::Mutexguards the server keeps (session state, cluster state, the metrics registry), that mutex stayed poisoned for the lifetime of the process and every laterlock().unwrap()panicked in turn. The result was a server that still accepted connections and passed a health check while failing every query.Aborting turns that into a clean crash — the same path the crash-recovery and soak/chaos suites already exercise on every run, against a storage engine that is crash-safe by design. The process must be supervised;
packaging/elyrasql.servicealready setsRestart=on-failure, anddocs/deployment.mdnow covers the container and orchestrator equivalents.
[1.9.5] - 2026-08-19
Fifteen contributions, and the theme is boundaries: what the wire protocol will accept, what an unauthorized user can reach, what a replica can claim, and how much memory one statement may take. Seven of these close real holes.
Four changes can stop a working deployment from starting or change what it reports. All four are in the upgrade note in the installation docs. Nothing touches your data.
Fixed
-
Bound parameters could escape their string in a commented placeholder. A
?inside a SQL comment was counted as a parameter, so its value was rendered into the comment; a value containing a newline then ended the comment and the remainder became executable SQL. Placeholder scanning is now aware of--,#and/* */. -
A malformed multi-packet payload could panic the connection.
PacketReadercopied buffered bytes without checking the destination's remaining capacity, so a large enough buffered leftover panicked instead of filling the buffer. Continuation-packet assembly is also bounded byELYRASQL_MAX_ALLOWED_PACKET, which it was not before: a client could otherwise grow server memory without limit, before authenticating. -
Column-level grants were not enforced outside the top-level
FROM. Derived tables, scalar subqueries, CTEs and set-operation arms bypassed column restrictions entirely, and column references inGROUP BY/HAVINGwere not collected, so a restricted column could be read throughGROUP BY secret. Column grants inherited through a role were also not resolved. -
A replica could acknowledge a log position it had never been sent, which satisfied a
--sync-replicascommit barrier without the write having been replicated — durability the server reported but did not have. Acknowledgements are now clamped to what was actually sent to that replica. -
Partial or invalid cluster TLS silently ran in plaintext. Setting only one of
ELYRASQL_CLUSTER_TLS_CERT/_KEY, or pointing them at an unloadable certificate, logged a warning and continued unencrypted — so an operator who had configured TLS did not have it. Both now fail closed. -
A failed Raft control plane no longer leaves the node running. Its task was spawned and its error discarded, so a node could serve with a dead consensus loop.
-
DISTINCTandGROUP BYsplit numerically equalDECIMALvalues. In-memory grouping keys renderedDECIMALthrough its debug representation, so1.0and1.00— the same number at different scales, which is what aUNIONof differently-declaredDECIMALcolumns produces — keyed differently:SELECT DISTINCT v FROM (SELECT 1.0 AS v UNION ALL SELECT 1.00 AS v) t;returned two rows where MySQL returns one. Keys now canonicalise the scale, exactly at any
i128magnitude.GROUP BYhad a second copy of the same encoding in the analytics kernel and now shares one implementation, which also gives it theUInthandling it never had. On-disk index keys use a separate encoding and were never affected. -
Exact window
SUMabove 2^53. Integer window sums accumulated through anf64, so consecutive integers past 2^53 could not be represented. Frames containing only integers now sum exactly ini128; mixed frames use a range tree, so a frame sum no longer subtracts one prefix from another and cannot lose precision to cancellation.AVGdivides by the count of numeric values, not the frame width. -
Date and time parsing could overflow rather than reject. Out-of-range years, hours, minutes and seconds are validated, and the epoch arithmetic is checked, so a hostile literal produces an error instead of a wrapped value.
-
ai_embed()ran before authorization. Embedding calls were resolved before every statement-level permission check, so a user who could not run the statement could still make the server issue an outbound HTTP request carrying the configured API key. They are now resolved after the gates. -
A DDL rewrite inside a transaction could exceed
ELYRASQL_TXN_MAX_BYTESby the size of the savepoint undo log, because the rewrite budget counted only the staged write set while the write path enforces both.
Added
-
ALTER TABLE ... ADD PRIMARY KEYon a populated table rewrites in bounded memory, building an unreachable storage generation in bounded commits and switching a small generation pointer atomically at cutover, then reclaiming the previous generation in the background and resuming interrupted cleanup at startup. 50–57% faster with roughly half the peak RSS on 20k–500k rows. Explicit transactions and multi-statementALTERkeep the original single-transaction path, now bounded byELYRASQL_TXN_MAX_BYTES. -
Scalable execution paths for composite index ranges, correlated
EXISTS, selective indexed joins, spill-backedDISTINCT(ELYRASQL_DISTINCT_MAX_BYTES, byte-bounded and cancellation-aware, with owner-only spill files), incremental window aggregation and bulk loading. -
Fuzz targets for the wire protocol and the core parsers, covering the pre-authentication surface that previously had none.
Changed
-
SHOW PROCESSLISTshows only the caller's own connections unless the account isAdmin. -
Slow-query and audit output is redacted. String literals are replaced and credential statements (
CREATE USER,ALTER USER,SET PASSWORD) are elided entirely; the audit log file is created owner-only on Unix. -
There is no login lockout. Locking an account on repeated failures let unauthenticated traffic deny service to a valid user, so the mechanism was removed rather than kept.
ELYRASQL_AUTH_MAX_FAILURESremains as a logging threshold;ELYRASQL_AUTH_LOCKOUT_SECSis gone. Rate limiting that cannot be abused this way is not implemented yet, and the limitations page now says so instead of documenting a defence that no longer exists. -
The Raft control plane refuses to bind to a non-loopback address without authentication. Set
ELYRASQL_CLUSTER_SECRET, bind to localhost, or opt out explicitly withELYRASQL_ALLOW_OPEN_AUTH. -
ai_embed()is bounded: request size, response size, a 30-second timeout, at most eight concurrent calls and a capped cache. Its HTTP client moved to ureq 3; the proxy environment is deliberately not honoured, so an ambientHTTP_PROXYcannot reroute a request carrying the provider API key.
Internal
-
Dependabot no longer rewrites the Rust toolchain pins.
dtolnay/rust-toolchain@1.88.0names a Rust version, not an action version, so an automated "upgrade" silently rewrote the MSRV gate and the release toolchain. The cargo group is also split by update type: in Cargo a 0.x minor bump is a breaking change, so one group let a single breaking update block ten safe ones. -
The manual fuzz workflow validates its duration input instead of interpolating it into a shell command.
[1.9.4] - 2026-08-03
Seven contributions, and for the first time in a while none of them is a correctness bug. This release is about the machinery around the engine: how it is built, how it is shipped, and what it tells clients about itself.
Two changes can affect a working deployment, and both are in the upgrade note in the installation docs: the container image no longer has a shell, and result-column metadata reports different (correct) values. Neither touches your data.
Fixed
-
Result columns advertise their declared width and the right collation. Every character column reported the unbounded
TEXTcapacity under a utf8mb3 collation, so clients computed nonsense widths: PyMySQL reported 21845 for aVARCHAR(32)where MySQL 8.4 reports 128, because it divides the advertised byte length by the charset's bytes per character.VARBINARY(16)reported 65535 rather than 16.Columns now carry the declared width from the sidecar added in 1.9.1, and the collation is
utf8mb4_0900_ai_cion character columns andbinaryon everything else, which is what MySQL sends and what clients key their length arithmetic off.SHOW VARIABLESalready reported utf8mb4 everywhere; the wire disagreed with it.Checked field by field against MySQL 8.4: collations match on all ten types tested, widths on nine. The remaining gap is
INT, reported asBIGINT/20 where MySQL saysINT/11 — the storage type is a 64-bit integer, and changing the advertised type also changes binary-protocol encoding, so it is left for its own change. The full comparison is indocs/mysql-compatibility.md.Tables written before 1.9.1 have no sidecar and keep the unbounded width. Nothing needs to be rebuilt.
-
elyrasql serveexits on Ctrl-C. SIGINT was previously ignored, so the only way to stop a foreground server wasSIGTERMorSIGKILL. It is a clean exit rather than a connection drain — in-flight connections are dropped, andredbkeeps the database consistent regardless. Installing the handler also overrides an inheritedSIG_IGN: a server started with SIGINT ignored will now exit on it.
Changed
-
The container image is built
FROM scratchinstead of Alpine: only the static binary,passwd/groupfor the non-root user, the data directory, and a writable/tmpfor sort and aggregate spills. 22.3 MB → 13.6 MB (−39%), and no OS packages means no OS CVEs to triage.There is no shell in the image.
docker exec <container> shand anything built on it — debugging one-liners, shell-based health checks, init wrappers — no longer works; use the MySQL protocol from outside the container. This is the one change here that can break a working deployment. Seedocs/deployment.md, which also covers where spill files now live. -
Release binaries are built with fat LTO via a new
[profile.dist].[profile.release]stays on thin LTO, so local and CI builds are unaffected. Measured: full scanCOUNT2.79 → 1.68 ms,GROUP BY2.21 → 1.76 ms, other workloads within noise; binary 6.7% smaller. -
Release binaries are additionally profile-guided (PGO). The release workflow trains on generated SQL dumps and the benchmark suite, then rebuilds with the merged profile. Measured cold on aarch64, six runs per side with a fresh server and data directory each: index nested-loop joins −14.6% (20.6 → 17.6 ms) and vector index build −4.9% (985 → 937 ms), with sub-2 ms workloads unchanged. Binary a further 13% smaller.
The training set is load-bearing: a deliberately thinned one made the same code 32% slower, because a profile optimises for what it saw and pessimises the rest. The release step therefore verifies a profile was actually produced before using it, and annotates the run when it was not — a release built without a profile is still a supported release, it just is not silently one. Details in
CONTRIBUTING.md.Cost: about 2m45s per target, so roughly 8 minutes added to a three-target release. Nothing on PR builds.
Internal
- The SQL dump differential harness runs nightly. Added in 1.9.1, it found
the
DECIMAL/UNSIGNEDrange gap on its first run and was then never wired into CI — so nobody noticed it had been broken since 1.9.1, because the testbench is a separate Cargo workspace whose lockfile goes stale on every version bump whilerun.shrunscargo run --locked. A tool nobody runs is a tool that quietly stops working. Five schema models now run nightly against MySQL 8.4, comparing full table digests; the release checklist inCONTRIBUTING.mdnow covers the lockfile. - CI runs tests with
cargo-nextest: 5m44s → 46s wall for the same tests, because the suite is dominated by wire tests that each stand up a server and wait. Note that nextest does not run doctests; the workspace has none today.
[1.9.3] - 2026-08-02
A small release: no behaviour changes, no new SQL, nothing to check before upgrading. It exists because the change it carries is worth having sooner than the next feature release.
Changed
-
WHEREfiltering on the streaming join paths takes a fast path. AnAND-connectedWHEREis split at plan time and every simplecol <op> colcomparison resolved to column indices once, instead of resolving each reference twice per row — once for the value and again for the collation, each walk ending in a string comparison. Profiling putpredicate::matchesat 95% of CPU in the streaming aggregate path.Measured on this release's tree: the heaviest wire test drops from 14.5s to 1.9s in debug builds and 2.2s to 1.7s in release, and the full wire suite from 22.3s to 17.2s. Together with the nested-loop fast path in 1.9.2 the suite has gone from 73s to 17s in debug, which is the difference between a test run people wait for and one they don't.
Release-mode gains are smaller, because this removes interpretive overhead the optimiser already handled. Anything the shape detector does not recognise —
OR, expressions, literals — falls through to the unchanged evaluator; all of those were checked against MySQL 8.4 along with collation-sensitive comparisons and theLEFT JOIN ... IS NULLanti-join. -
The fuzz workflow uses
actions/upload-artifact@v7, so every artifact action in the repository is on the same major.
[1.9.2] - 2026-08-02
Eleven contributions, and a theme: the things we never exercised were the
things that did not work. A spilling sort that no test had ever made spill.
SET statements accepted and discarded. Result metadata nothing read back.
Virtual catalog tables nothing queried. Each was fine right up to the first time
something real depended on it.
Two return or lose data silently and are the reason to upgrade: an anti-join
that dropped its WHERE, and SET autocommit=0 that committed anyway.
Three changes tighten validation, so statements that used to succeed can now
fail — a string longer than its VARCHAR(n), and the two error-code corrections
below. No on-disk format change; 1.5.x through 1.9.1 open unchanged.
Fixed — wrong or lost data
LEFT JOIN ... WHERE nullable IS NULLreturned the rows it was meant to exclude. AWHEREpredicate over the nullable side was pushed beneath the outer join, which changes what NULL-extension means, so the standard anti-join idiom silently returned every row. It needed a compoundON(ON a.id = b.id AND b.k = 'x') and the plain execution path — addingORDER BYorGROUP BYgave the right answer, so the shape that was wrong was the bare one WordPress and Drupal generate for "rows without this meta key".SET autocommit=0was accepted and discarded, so work committed anyway. A second connection could see the "uncommitted" rows immediately, andROLLBACKdid nothing. An application building a transaction withautocommit=0rather thanSTART TRANSACTION— which several drivers do by default — had the appearance of a transaction and none of the substance.sql_mode(includingANSI_QUOTES),NO_AUTO_VALUE_ON_ZERO,FOREIGN_KEY_CHECKS, isolation level andgroup_concat_max_lenwere discarded the same way and are now applied, with session values kept independent of the globals.- A spilling
ORDER BYfailed outright withBad file descriptor(ESQL-60). The external merge sort wrote each run through a handle fromFile::create— which opens write-only — and the merge phase then seeked that same handle back to 0 and tried to read it, so every sort that exceededELYRASQL_SORT_MAX_ROWSreturned an I/O error instead of rows. Since that budget defaults to a million rows, it took an unboundedORDER BYover a large table to reach, which is why nothing in CI or the benchmarks had ever written a run file: the sweep tops out at 8193 rows and the benchmark's unbounded sort stays in memory at 200k. The memory-boundedORDER BYdescribed inBENCHMARKS.mdanddocs/performance.mdtherefore never worked. Run files are now opened read-write, and a unit test forces a small budget so the spill path is exercised on every run — including the single-run case, which skips the k-way merge. SELECTwithoutFROMignoredWHERE,LIMITandOFFSET.SELECT 1 WHERE 1=0andSELECT 1 LIMIT 0both returned a row. MySQL's unsigned all-onesLIMITsentinel withOFFSETis also accepted now.- Numeric string coercion was inconsistent per call site.
'12' + 1raised 1366,SUM(text_column)returnedNULLrather than a total, andCAST('4.5x' AS DOUBLE),ABS('-3q')andROUND('2.7t')all returnedNULL. One shared MySQL-compatible coercion now covers arithmetic, unary numerics, casts, scalar functions,SUM/AVG, window fallbacks and the OLAP paths, preservingNULLwhile coercing malformed non-NULL strings to zero.
Fixed — schema and metadata
- Inline index prefix lengths (
KEY meta_key (meta_key(191))) are accepted. This is the DDL WordPress ships, so installation stopped at the firstCREATE TABLE; it also drove a large cluster of Drupal failures. The prefix is parsed and the index covers the whole column, which is stricter than MySQL rather than looser. - Declared column types survive.
TINYINT(1),VARCHAR(n),CHAR(n)and exact numeric declarations were collapsed to coarse storage types, soSHOW COLUMNSreportedBIGINTwhere MySQL reportstinyint(1)— the declaration every ORM reads to decide a column is boolean. Type names, widths, character lengths, precision and scale are now kept in a catalog-compatible sidecar and reported throughSHOW COLUMNS,SHOW CREATE TABLEandinformation_schema.COLUMNS, and carried throughLIKE, CTAS,ALTER, rename and drop.CHAR/VARCHARlimits are now enforced in strict mode. information_schemaconstraint tables are complete.TABLE_CONSTRAINTSandCHECK_CONSTRAINTSdid not exist — and a missing virtual table raises 1064, which reads as your SQL is wrong rather than this server lacks the table, so Rails schema dumping, Django introspection and Adminer stopped on valid queries.REFERENTIAL_CONSTRAINTSis complete, andCOLUMNS.PRIVILEGESandROUTINES.DTD_IDENTIFIERare populated.- Result columns carry real metadata. Every column arrived as
VAR_STRING, length 1024, with no flags. NativeDATE,DATETIME,TIME,NEWDECIMALandJSONtypes are reported now, along withNOT_NULL,PRI_KEY,UNIQUE_KEY,AUTO_INCREMENTandUNSIGNEDflags, per-column lengths and decimal scales — so a typed client stops hydrating dates and decimals as strings. Composite-index members are no longer marked individually unique.
Changed
- Nested-loop joins over a simple cross-relation comparison take a fast path, skipping the row clone and the general predicate evaluator for pairs the condition rejects. The wire suite drops from 73s to 22s in debug builds (release is unchanged — the optimiser already handled it), which is where CI and contributors spend their time. Anything the shape detector does not recognise falls through to the unchanged evaluator.
- 63 transitive dependencies removed by moving
mysql_common0.32 → 0.37, includingbindgen,clang-sys,cmakeandzstd-sys— so the build no longer needs a C toolchain for crates we never used. - All CI workflows are on
actions/checkout@v7(Node 24).
[1.9.1] - 2026-08-02
Four fixes for the same underlying blind spot: rows of the statement being executed were invisible to the checks that guard it. A foreign key looked at the table as it stood before the statement, a cascade did the same, and neither followed a key that pointed at its own table. Add a fourth, unrelated, that had simply never been checked at all — integer width — and the effect was a database that accepted schemas and data MySQL refuses, and refused inserts MySQL accepts.
All four were found by differential testing rather than by a report, and each is verified against MySQL 8.4 on identical data.
Three of these tighten validation, so statements that used to succeed may now
fail: a value too wide for its column, a duplicate column name, and
DELETE FROM t on a self-referencing table without ON DELETE CASCADE. Nothing
is rewritten on upgrade — a database cannot start rejecting data it already
holds — and there is no on-disk format change: 1.5.x through 1.9.0 open
unchanged.
Fixed
-
A multi-row
INSERTcould not satisfy a self-referencing foreign key from its own rows (ESQL-58).INSERT INTO emp VALUES (1,NULL),(2,1),(3,2)was refused with 1452 while MySQL accepts it: the key was checked against the table as it stood before the statement, so a row referencing one earlier in the same batch found no parent. Every dump tool batches inserts, which made any table with aparent_id-shaped key impossible to restore. Rows earlier in the statement (and the row itself, for(5,5)) now count — while a forward reference still fails, as it does in MySQL, because that is a genuine ordering error rather than a batching artefact. -
A self-referencing
ON DELETE CASCADEdid not cascade (ESQL-59). Deleting the root of a hierarchy left its children behind, pointing at a row that no longer existed — a table violating its own foreign key. The helper that finds referencing tables skipped the table being deleted from, so a key pointing at its own table never fired. Cascades now also run to a fixed point, so they reach grandchildren instead of stopping after one level, with a depth bound and a visited set so a cycle terminates. Relatedly,DELETE FROM ton a self-referencing table withoutCASCADEis now refused with 1451 like MySQL, instead of quietly deleting rows that were still referenced. -
Integer width is enforced (ESQL-56).
TINYINTaccepted 300,SMALLINT32768,INT2147483648 — storage is 64-bit for every integer type, and nothing carried the declared width, so MySQL's 1264 never happened. Widths are now recorded and checked forTINYINT/SMALLINT/MEDIUMINT/INT, signed and unsigned, and maintained throughADD/DROP/MODIFY COLUMN.They live in a separate catalog key, not on
ColMeta:TableDefis bincode-encoded and bincode is positional, so a new field would make every existing catalog undecodable. A table created before this release simply has no widths recorded and keeps the old behaviour until it is recreated. -
ADD COLUMNaccepted a name the table already had (ESQL-57), producing two identically named columns — one unreachable, both occupying a slot in every stored row, and DDL that could not be replayed. Now 106042S21, matching MySQL, on bothALTER TABLE ... ADD COLUMNandCREATE TABLE, and compared case-insensitively as MySQL does.
[1.9.0] - 2026-08-02
Upgrade promptly. This release fixes two bugs that could return or write the wrong data with no error at all, and one that could take the server down.
Seven contributions from @HelgeSverre, landed as a reviewed stack. Every entry below was verified against MySQL 8.4 on identical data before merging — in several cases the table contents afterwards were compared, not just the statement's return code, because the failure mode was a successful-looking statement.
The pattern across all seven is the same one 1.8.0 started: these are not features we were missing, they are places where we answered confidently and wrongly. None of them came from a bug report.
Fixed — wrong data
NATURAL JOINandJOIN ... USINGexecuted as cross joins. The join condition was ignored entirely, so the result was a cartesian product presented as a join — no error, plausible-looking rows, and a row count that only looks wrong if you already know the answer. On two 2-row tables sharing two columns,SELECT * FROM a NATURAL JOIN breturned 4 rows where MySQL returns 1, with the shared columns duplicated in the output. Both forms now match MySQL exactly, including the parts that are easy to get half-right: aNATURAL JOINcoalesces every shared column, aUSING (k)emits the join column once and moves it to the front of the select list, andSELECT kafterUSING (k)is unambiguous rather than an error. Collation and coercion are preserved identically on the optimized and fallback join paths, so the fast path cannot disagree with the slow one.- A database qualifier was ignored, so writes aimed elsewhere hit local
tables.
UPDATE nosuchdb.t SET ...andDELETE FROM nosuchdb.texecuted against the localtand reported success — verified by inspecting the table afterwards: the row really was updated and another really was deleted. Reads behaved the same way, inFROM, in joins and in subqueries. A migration runner pointed at the wrong environment, a dump replayed with its originaldb.tablenames, or an ORM with a second connection could all modify data they never addressed. Qualifiers are now validated consistently across SELECT, CTEs, views, DML, DDL, routines, triggers, introspection and prepared statements, and invalid single-tableUPDATE/DELETE/upsert targets are rejected before any row changes.GRANT/REVOKEscopes are parsed structurally at the same time, so a quoted dotted or wildcard-looking table name can no longer redirect a privilege grant. UPDATE ... SET unknown_qualifier.col = ...succeeded without writing anything. A typo in a column qualifier produced a successful statement that changed nothing — an application would log a save and move on. It is now rejected with MySQL's 1054 before the mutation runs.
Fixed — crashes and lost objects
- A chain of nested views could kill the server process. Around 600 levels
of stored views overflowed the native stack and aborted the whole process,
taking every other connection with it — reachable by any account that can
CREATE VIEW. Deep-but-legitimate nesting now grows the stack near the guard page, and a hardMAX_QUERY_NESTINGbound catches the rest, including cyclic view definitions that no amount of stack can survive. The result is an ordinary query error with the session still usable. Stored views compose already-parsed queries, which is why the text-level complexity guard never saw this. - A failed
ALTER TABLEcould leave the table half-changed — or gone.ALTER TABLE t ADD COLUMN b INT, DROP COLUMN nosuchfailed and addedb;ALTER TABLE t ADD COLUMN c INT, RENAME TO nosuchdb.t2failed and renamed the table away, so the next statement gotno such table. EveryALTER TABLEnow runs inside a private transaction checkpoint that rolls back catalog, rows, indexes, counters, foreign keys and renames together, while preserving prior work and client savepoints inside an explicit transaction.
Fixed — queries that should have worked
- A CTE was invisible from inside a subquery or a set-operation branch, and
reported as
1146 no such table, which sends you looking in entirely the wrong place.WITH a AS (...) SELECT ... WHERE id IN (SELECT id FROM a)andWITH a AS (...) SELECT * FROM a UNION ALL ...both work now. CTE rewriting models declaration-point scope, shadowing, forward references and recursive-reference rules explicitly instead of substituting text, bounds expansion depth and width, cleans up failed materialization, and no longer lets a CTE capture a physical table of the same name at the wrong point. - Relation aliases are case-sensitive again.
SELECT ... FROM t AS T WHERE t.id = 1was accepted; MySQL rejects it. Column names and output aliases stay case-insensitive, and the lookup is Unicode-aware rather than ASCII-folded.
Fixed — result metadata
- A quoted identifier containing a dot was split down the middle. Result
metadata recovered the source table by re-parsing a joined
"table.column"string, so a column`a.b`in a table`we.ird`came back as tablea, columnb. Qualifiers are now kept structured through planning and execution rather than flattened into a string and re-split. This corrects the metadata work added in 1.8.0.
Known gaps
ALTER TABLE ... ADD COLUMNaccepts a name that already exists, producing a table with two identically named columns where MySQL raises 1060. Found while probing theALTERatomicity fix; it is an input-validation gap rather than an atomicity one and is tracked in ESQL-57.- Integer width remains advisory (see 1.8.0);
UNSIGNEDis enforced.
[1.8.0] - 2026-08-01
A correctness release, and an unusually direct demonstration of why differential testing is worth the trouble: every bug below was found by asking MySQL what it does, rather than by a report. Four came out of reviewing 1.7.0's own contributions, one from a contributed test harness on its first run, and one from re-reading a CI log that everybody had already given up on.
Three of them were silent — a CREATE TABLE ... AS SELECT that lost half its
query, a column constraint that was enforced on some columns and not others, and
result metadata a client could not use — which is the kind that survives a test
suite written by the people who wrote the engine.
A minor rather than a patch bump: INT UNSIGNED columns now refuse negative
values they previously accepted, and several error codes changed to the ones
MySQL uses. Both are observable, even though both are corrections. There is no
on-disk format change and no migration — 1.5.x, 1.6.x and 1.7.x databases open
unchanged.
Fixed — silent wrong behaviour
-
CREATE TABLE ... AS SELECTover an aggregate was truncated at the first parenthesis. The preprocessor that strips MySQL table options (ENGINE=,DEFAULT CHARSET, ...) located the column list by looking for the first(in the statement. In a CTAS that paren usually belongs to the query —COUNT(*), a derived table, any function call — so the statement was cut at that paren's partner:CREATE TABLE t AS SELECT g, COUNT(*) AS c FROM u GROUP BY gbecameCREATE TABLE t AS SELECT g, COUNT(*), which then failed withunknown column: g. The stripper now declines any statement that is a CTAS orLIKEbefore it looks for a column list. Materialized views run through CTAS, soCREATE MATERIALIZED VIEW v AS SELECT ... GROUP BY ...— the main reason to want one — works for the first time (ESQL-54). -
UNSIGNEDis enforced on every integer width (ESQL-56). OnlyBIGINT UNSIGNEDmapped to the unsigned type;TINYINT/SMALLINT/MEDIUMINT/INT UNSIGNEDwere stored as signed, so the same schema was enforced inconsistently —INSERT INTO t (unsigned_int_col) VALUES (-1)succeeded and read back-1, while the identical insert into aBIGINT UNSIGNEDcolumn was refused. Laravel generates both shapes (foreignId()is an unsigned big integer,unsignedInteger()is not), so an application got the constraint on some columns and not others. Width is still advisory — every integer is stored as 64 bits, and that is now stated plainly in the data types page rather than left to be discovered.A column created by an earlier version keeps the type it was created with, so it goes on accepting negatives until the table is recreated; there is no migration and no on-disk change.
Found by the differential SQL-dump harness in #26, on its first fixture.
-
SHOW CREATE TABLEnow echoesCHECKandFOREIGN KEYconstraints. They were enforced but invisible, so a schema dumped throughSHOW CREATE TABLEsilently lost them and schema-diff tools saw a table that did not match the one they had. Referential actions are included (ON DELETE CASCADE), MySQL's implicitNO ACTIONis not, and the emitted DDL is accepted back with the constraints still live.
Fixed — client-visible metadata and errors
-
Result metadata names the source table again (ESQL-55). MySQL puts the bare column name in the name field and the relation in the
tablefield, and 1.7.0 adopted the first half without the second — so a client had no way at all to tell the twoidcolumns of a join apart: not by name (they collide, as in MySQL) and not by metadata. The qualifier was available all along, in the internal"alias.col"names, and was simply dropped when the output schema shortened them. It is now carried through to the wire for joins, single-table scans (reporting the alias, as MySQL does) and projected columns, and left empty for expressions and aggregates — which is also what MySQL reports. Verified column-for-column against MySQL 8.4 on seven query shapes.Schemacarries this in aserde(skip)field, so the catalog encoding is byte-identical and existing databases are untouched; a test asserts that. -
t.*works over a single-table scan.SELECT np_a.* FROM np_awas refused withunknown table qualifierbecause a single-table scan carries bare column names, so there was no qualifier to match — while the same wildcard over a join worked. Naming the relation being read is now accepted; naming a relation that is not in the query still errors. -
Out-of-range column values report MySQL's code. Storing a value a column cannot hold answered 1366 (
ER_TRUNCATED_WRONG_VALUE, SQLSTATEHY000); MySQL answers 1264 (ER_WARN_DATA_OUT_OF_RANGE, SQLSTATE22003), which is what a client checking for an overflow looks for. -
Catalog errors report the MySQL code clients branch on. Everything the catalog refused answered 1146 (
ER_NO_SUCH_TABLE), so an unknown column looked like a missing table to an ORM. Unknown columns are now 1054 (ER_BAD_FIELD_ERROR, SQLSTATE 42S22), a duplicate index name 1061, an unknown index 1176, and "already exists" 1050; anything unrecognised keeps 1146.
Fixed — test infrastructure
- The soak/chaos suite can no longer hang a CI job (ESQL-53). A connect attempt had no timeout of its own, so a server that accepted the socket from the listen backlog while mid-restart and never wrote the handshake blocked the chaos loop indefinitely — observed once as a job that sat silent for 45 minutes before being cancelled by hand. Connects are now bounded and treated as "not ready" (the caller already retries until its deadline), worker joins time out with the worker's index rather than blocking, and the workflow has a 45-minute cap so a hang is reported within the hour instead of burning a runner until the six-hour default.
Known gaps
- Integer width is advisory. Every integer type is stored as 64 bits, so a
value too wide for its declared column (
300into aTINYINT) is accepted where MySQL raises 1264. The signedness is enforced, on every width, as of this release. Persisting the declared width would change the catalog encoding, so it is tracked separately in ESQL-56 with an approach that avoids that;docs/sql/data-types.mddocuments the current behaviour.
[1.7.0] - 2026-08-01
A compatibility release, and one that arrived from outside: both halves came in as pull requests from @HelgeSverre, who ran the migration and test suites of four commercial Laravel codebases against isolated ElyraSQL instances, reduced every failure to a generic MySQL reproduction, and checked the ambiguous ones against MySQL 8.4 rather than guessing. The largest of those suites had 469 migration files; two complete application suites passed 278 tests / 764 assertions and 169 tests / 762 assertions against ElyraSQL.
The theme is that almost none of these were engine bugs. They were places where ElyraSQL answered a question correctly but differently — a column label, a coercion under a session SQL mode, a version string, a metadata field — and every one of those differences becomes a driver-specific workaround in somebody's application. There are 103 new end-to-end tests through the MySQL wire protocol covering them.
Two genuine wrong-results bugs were found on the way, and both had been present for some time. Neither came from a report: one fell out of running a join battery differentially against MySQL, the other out of the threshold sweep finally being able to run.
Also in this release: native Apple Silicon binaries, built and tested on an arm64 macOS runner in CI and attached to every tagged release from this one on.
No on-disk format change and no migration — a 1.5.x or 1.6.x database opens
unchanged, and a 1.7.0 database still opens in either. It is a minor rather than
a patch bump because the advertised MySQL version changes what version-gated
clients generate, and because CREATE DATABASE now refuses instead of quietly
succeeding.
Fixed — wrong results
- A fractional literal was rounded into an integer index range, shifting the
result by a row.
k > 1024.5on anINTprimary key meansk >= 1025, but the bound was coerced the way anINSERTvalue is coerced — rounded to the nearest representable value — and the strict>was then applied to the rounded bound, dropping row 1025. Four lookup paths reusedcoerce(), which prepares a value for storage, to prepare a literal for comparison; those are different jobs. A bound now keeps its meaning by flipping its inclusivity when coercion moved the value, which is exact rather than approximate: coercion rounds to the nearest representable value, so nothing lies strictly between the literal and its coerced form.=,INandBETWEENhave nowhere to record a flipped inclusivity, so an inexact literal declines the index and lets the scan's filter answer — closingk = 1024.5, which returned a row it should not have. Only the index paths were affected (k+0 > 1024.5was always right), and both directions were wrong:<matched one row too few before this release,=since the point lookup was written. Found by the threshold sweep at n=2048, whereAVG(k)lands on.5andWHERE k > (SELECT AVG(k) FROM a)counted one short. - A join
ONcondition spanning both tables was hashed as an equi key (carried from 1.6.0; see that entry). Verified again here by the join battery that now runs on every pull request.
Added
- Laravel migrations run unmodified. MySQL index and foreign-key drop forms,
ordered and limited
UPDATE, column backfills for added columns, removal of indexes with dependent columns, rename/introspection metadata, collation metadata, index prefixes, and catalog results that honour the selected database. - Native Apple Silicon support is release-gated. CI now builds and runs the
full workspace test suite on an arm64 macOS runner using the minimum supported
Rust toolchain. Tagged releases add an
elyrasql-<version>-macos-aarch64archive and checksum alongside the existing Linux artifacts. The macOS build targets macOS 11.0, verifies the Mach-O architecture/minimum OS/signature, and is executed again after archive extraction. - 103 end-to-end tests through the MySQL wire protocol, plus unit and property regressions, covering every compatibility path changed here.
Changed
-
CREATE DATABASErefuses instead of silently succeeding. ElyraSQL is a single logical schema in one file, so reporting success made callers believe they had an isolated database when every connection still shareselyra. The conditional forms are honest no-ops and still succeed —CREATE DATABASE IF NOT EXISTSmeans "make sure this exists", which is what Laravel'sMigrateCommand, container entrypoints and our own benches ask for — as doesDROP DATABASE IF EXISTSfor a database that does not exist here. An unconditionalCREATE DATABASE other, or dropping the live database, now fails loudly. -
SELECT *over a join returns MySQL's column labels. The bare column name is returned and the qualifier belongs in the result metadata; ElyraSQL was inventing labels likenp_a.id. Clients that key rows by name now see the same collisions MySQL produces, which is what their code expects. -
The MySQL compatibility version advertised to clients is now 8.0.12. The previous 8.0.0 prefix made Laravel and other clients suppress window-function SQL they can safely send to ElyraSQL; version-gated clients may now generate different queries after connecting.
-
The documented minimum Rust version is now 1.88. The locked dependency graph already required 1.88 (notably
timethrough the wire-protocol stack), so the previous 1.82 claim could not reproduce a locked build. The macOS CI lane now tests the corrected minimum directly.
Fixed
- MySQL coercion is aligned across session SQL modes — unsigned values,
integer and decimal rounding, invalid temporal input in non-strict mode,
numeric string prefixes in comparisons (
'123tail'compares as123, a string with no numeric prefix as0), and exact-decimal overflow. - Correct wire results for explicit auto-increment IDs, transaction status in the result-set flags, prepared and dynamic result types encoded from the declared column type, and MySQL-compatible column metadata.
- Query fixes: correlated subqueries (including over joins), aggregated
correlated join filters, grouped join ordering and projections, representative
rows for grouped wildcards, scalar subqueries in
INSERT ... VALUES, wildcard expansion in window projections, ordering by hidden source values, unordered DML limits, upserts on unique secondary-key conflicts, and inner-relation shadowing. - SQL dumps ingest byte-for-byte. Exact decimal and hexadecimal literals are preserved, substitutions no longer reach inside quoted text, and observed SQL is truncated only on UTF-8 boundaries.
- Build metadata now reports the real target.
@@version_compile_os,@@version_compile_machine, and theirSHOW VARIABLESequivalents no longer claim that Apple Silicon builds are Linux/x86_64. - Crash-leaked sort and aggregation files are reclaimed on macOS. Unix
process liveness now uses a non-signalling
kill(pid, 0)check instead of the Linux-only/procfilesystem, while treating ambiguous results as live.
Known gaps
- Result metadata still reports an empty
tablefield where MySQL reports the source table, so a client cannot disambiguate duplicate column names except by position (ESQL-55).
[1.6.0] - 2026-07-29
A performance release built on one finding: two slow-join reports were the same defect measured from two directions, and fixing it once turned into a rewrite of how row-oriented paths handle rows. Row shapes that used to scale with the width of a row now scale with what the query actually reads.
Measured on 200,000 rows, both binaries run alternately against the same data file (medians of 5, two rounds; MySQL 8.4 on the same host for reference):
| shape | 1.5.1 | 1.6.0 | MySQL 8.4 | |
|---|---|---|---|---|
ORDER BY int LIMIT 100, 12-column rows |
95 ms | 31 ms | 3.0x | 55 ms |
ORDER BY int LIMIT 100, 3-column rows |
44 ms | 22 ms | 2.0x | 28 ms |
ORDER BY text LIMIT 100, 12-column rows |
98 ms | 39 ms | 2.5x | 57 ms |
1:1 join on a PK, COUNT(*), 12-column |
492 ms | 150 ms | 3.3x | 91 ms |
1:1 join on a PK, COUNT(*), 3-column |
270 ms | 106 ms | 2.5x | 71 ms |
| 1:N join emitting 40M rows, 12-column | 33 298 ms | 1130 ms | 29x | 535 ms |
| 1:N join emitting 40M rows, 3-column | 12 112 ms | 768 ms | 16x | 529 ms |
join + ORDER BY ... LIMIT 100 |
514 ms | 157 ms | 3.3x | 31 ms |
ORDER BY with no LIMIT (control) |
1951 ms | 1964 ms | — | 1869 ms |
COUNT(*) scan (control) |
3.0 ms | 3.0 ms | — | 9.7 ms |
The controls are the point of the table as much as the gains: an unbounded sort and a plain scan did not move, so nothing was traded away. The per-change numbers in the entries below are deltas against the build each change landed on, which is why they are smaller than the release-over-release figures here.
It also removes a class of query that used to be refused: a non-equi join
(ON a.id < b.id, a BETWEEN band join) now streams instead of materialising
into the memory ceilings. And it fixes a wrong-results bug in join planning
that was found by verifying the above differentially against MySQL rather than by
a report.
A minor rather than patch bump: non-equi joins answer queries that previously errored, which is observable behaviour. There is no on-disk format change and no migration — 1.5.x databases open unchanged, and 1.6.0 databases still open in 1.5.x.
Fixed
- A join
ONcondition spanning both tables was hashed as if it were an equi key, returning wrong results (ESQL-51).ON a.v + b.v = 4returned 2 500 000 rows where MySQL returns 1 250 000, andON a.id + b.id = 5000returned one row too many — silently, with no error. Side attribution (refs_in_schema) resolved column references through the resolver's bare-name fallback, sob.vmatched the left schema'sa.vand the whole sum was judged left-only; the partner was then hashed under the constant4and probed witha.v + b.vevaluated on the left row alone, whereb.vresolves back toa.v. A qualified reference now has to match the qualifier when the schema is a joined (qualified) one; bare references and single-table schemas are unchanged, so ordinary equi joins keep their hash path. Found by running a join battery differentially against MySQL 8.4 while working on ESQL-39; present since joins on expressions were supported.
Added
-
Non-equi joins stream instead of being refused (ESQL-39).
ON a.id < b.id, aBETWEENband join, or anyONwith no equality to hash on had no key to build a hash table from, so the chain declined and the whole product was materialised — where it hit the fail-safe ceilings added in 1.4.13/1.5.0. The ceilings had to fill before they could refuse: on 20,000 rows, a three-wayON a.id < b.idunderCOUNT(*)grew the server to 2136 MB and then failed after 1.3 s.Such a step is now the same unkeyed step a comma cross join uses, with the
ONcondition applied per pair, streamed into the spilling sorter/aggregator. The same query keeps the server at its 34 MB idle footprint and answers — or, when the work is genuinely astronomical, is interrupted byELYRASQL_QUERY_TIMEOUT_MSwith the session still usable. Being honest about the trade: memory is now bounded but the time is not, and the join row ceilings no longer bound these shapes, so a timeout is the right control.When an
ONmixes an equality with other conditions (ON a.k = b.k AND a.x > b.x), the equality is still hashed and the rest applied as a residual, so that shape staysO(n+m)rather than becoming a nested loop. A residual is anONcondition, not aWHERE: pairs it rejects are unmatched, so a LEFT join still NULL-extends the left row.Verified against MySQL 8.4 on 5000-row tables: 15 join shapes (non-equi, band,
LEFTwith residual, equality-plus-residual,GROUP BYandORDER BY ... LIMITover each) all identical, 0 divergences, plus the 203-case differential battery, the 15-size threshold sweep and the robustness scenario.
Performance
-
One row decoder instead of two (ESQL-52). Full-row decoding went through
bincode/serde while projected decoding used the hand-written decoder added for ESQL-49. The hand-written one is ~17% faster on a wide row containing text (47 ms vs 56 ms per 200k 16-column rows), so both paths now share it andbincodeis kept only as the authoritative fallback for anything it does not recognise. End to end this is modest, because ESQL-49 already routes the hot shapes through the projected decoder:SELECT DISTINCTover a text column 112 → 99 ms, a text-key join 1775 → 1718 ms, everything else within noise. -
Streaming joins no longer allocate per emitted row (ESQL-50). With decoding fixed by ESQL-49, what was left was allocation and copying per combination: a 200k-row 1:1 join spent a third of its time merely tearing down the partner hash table. The join path was rebuilt around reuse and borrowing:
- The combined row is assembled in one scratch buffer per chain depth and the
consumer borrows it, so an aggregate that keeps nothing (
COUNT(*),SUM) or a top-N row that loses admission costs no allocation at all. - The left half is copied once per driving row instead of once per combination.
- Partner rows are stored flat —
nvalues per row in one allocation per join key — rather than aVecper row inside aVecper key. For a unique key that innerVecexisted to hold exactly one row. - Join keys are encoded into a reusable buffer when probing and stored inline (up to 22 bytes, which covers every integer and short string key), so neither building nor probing the table allocates per row.
- A wide partner whose columns the query mostly ignores has only the read
positions written per combination, the rest staying at the
NULLlaid down once per driving row. - Join keys and qualified column references are resolved to a column index once
at plan time instead of by name per row;
predicate::eval_rowno longer builds aStringfor a qualified reference on every row it evaluates, which helps filters andORDER BYkeys too, not just joins.
Measured on 200k rows (medians of 5): 1:1 join on a primary key 214 → 132 ms; a 1:N join emitting 40M rows 8621 → 981 ms with 12-column rows and 3741 → 765 ms with 3-column rows — so the width sensitivity that identified the original defect is gone (1.28x, from 2.3x). Joined
ORDER BY ... LIMIT224 → 138 ms. Non-join shapes are unchanged, as intended.Two measurements changed the design and are worth recording. Choosing the copy strategy per row cost 24% on a 40M-row join even when the branch never went the other way (739 → 917 ms), so the strategy is a const parameter and chains that cannot benefit are compiled without the branch. And selective copying is only faster when it skips enough columns — a 7-column partner got slower with it — so the cheaper strategy is chosen per join from the widths.
- The combined row is assembled in one scratch buffer per chain depth and the
consumer borrows it, so an aggregate that keeps nothing (
-
Row paths no longer decode columns the query never reads (ESQL-49, covering ESQL-47 and ESQL-48). Two reports — joins being slow, and
ORDER BY ... LIMITbeing slow — turned out to be the same defect measured from two directions: row-oriented paths materialised every column of every row. Since a row is one encoded blob, each unreadTEXTcolumn costs aStringallocation, so the cost scaled with row width rather than with anything the query asked for.Both paths now decode only the columns a query references. Unread columns are skipped in place and left as
NULLplaceholders at their original positions, so filters, projections,ORDER BY, aggregates and nested join steps keep indexing rows exactly as before — no index remapping, and therefore no class of silently-wrong results.ORDER BY ... LIMIT k: the sorter grew an admission test (Sorter::admits), which is the same comparisonpushmakes. The scan evaluates the sort keys from a partial row, asks whether the row would be kept, and decodes the full row only if it would. WithLIMIT 100over 200k rows that is ~199,900 rows that are no longer built and immediately dropped. Measured on 200k rows, 12 columns: 94 ms → 29 ms (3.2x); narrow rows 45 ms → 21 ms.- Streaming joins: the join chain is now resolved (catalog only) before any row
is read, so the combined schema — and with it the set of columns the query
reads — is known before both the partner hash tables and the driving scan
decode anything. Join keys are always materialised, whatever the projection
says. Measured on a 200k-row 1:1 join of two 12-column tables:
COUNT(*)488 ms → 226 ms, and the width sensitivity that identified the defect flattened (wide/narrow 1.8x → 1.35x).
SELECT *, rewrittenRIGHTjoins (whose output columns are permuted after the join) and any expression that can't be attributed to columns statically decode every column, exactly as before. Verified with the 203-case MySQL differential battery (0 divergences), the threshold sweep at all 15 sizes (51/51 identical at each), the robustness scenario (13/13 invariants) and the full workspace test suite.
Notes
ESQL-47andESQL-48, filed against 1.5.1, are both closed by this release. They were reported as separate problems (joins 10-16x MySQL;ORDER BY ... LIMIT k~2x) and turned out to be the same defect — row-oriented paths materialising every column of every row — which is why they were fixed once, as ESQL-49, rather than patched twice.- The honest trade in non-equi joins: memory is now bounded but time is not, and
the join row/byte ceilings no longer bound those shapes. An
O(n x m)join answers instead of being refused, so bound it withELYRASQL_QUERY_TIMEOUT_MS, which interrupts one promptly and leaves the session usable. There is still no index-driven inequality (band) join, which is where MySQL wins these outright (ON b.id BETWEEN a.id AND a.id + 2: 32 ms there, 3507 ms here). Documented in limitations rather than left to be discovered. - Attempted, measured and reverted:
Value::Text(Arc<str>), to make text clones a refcount bump (ESQL-52). It is a trade, not a win — anArc<str>costs ~25% more to build and ~60% more in a build/read/drop cycle, so a text-key join gained 19% while a plainWHERE s LIKE '...'scan lost 38%. Filters and scans are more common than fanout joins on text keys, so it was reverted rather than shipped on the strength of the one number that motivated it. The measurements are on the issue so the experiment is not repeated blindly. What survived is the decoder unification above. - Verification for the whole release, all against a reference MySQL 8.4 on the same
host: the 203-case differential battery (0 divergences), the threshold sweep (51/51
queries identical at each of 15 row counts bracketing every internal threshold), a
20-shape join battery written for this work (non-equi, band,
LEFTwith residual, equality-plus-residual, multi-step chains, NULL keys,GROUP BY/ORDER BYover each — 0 divergences), the robustness scenario (13/13 invariants including crash-recovery and resource exhaustion), the full workspace suite, clippy and fmt. - No migration. No on-disk format change: 1.5.x databases open unchanged, and databases written by 1.6.0 still open in 1.5.x. Verified by round-tripping a database between the two builds, including non-ASCII text, JSON, NULLs and a text index lookup.
1.5.1 - 2026-07-29
A hardening patch found by auditing 1.5.0 rather than by a bug report: the collation migration introduced in 1.5.0 held every write for a table in memory, which could exhaust memory at startup on a large database -- a failure mode worse than any query failing, because the server never comes up.
Fixed
-
The collation migration could run out of memory on a large table (ESQL-44). All index entries and re-keyed rows for a table were accumulated into one
Vecbefore a single commit. Measured on a table with a non-ASCII text primary key and a text index: 441 MB at 300k rows, growing linearly to 954 MB at 900k. A table an order of magnitude larger would not have started at all.Writes are now flushed per scan batch, old index entries are deleted in batches before the rebuild, and the duplicate-detection set is scoped to the current batch (rows from earlier batches are already committed, so a storage probe finds those). Peak memory at 900k rows: 954 MB → 437 MB, and now sub-linear — 3x the rows costs 1.27x the memory rather than 3x.
Batching replaced per-table atomicity with idempotent resume: an already re-keyed row encodes to the key it is already stored under, and the version marker is only written once every table completes, so an interrupted upgrade is simply redone on the next start. The 1.5.0 documentation claimed per-table atomicity and has been corrected rather than left to mislead.
Collision reporting is unchanged and was re-verified across the new batch boundary: a colliding pair seeded 6000 rows apart is still caught, names both keys, and refuses to start.
Notes
Also audited and found not to be problems, recorded so the checks are not repeated
blindly: usernames are compared as raw bytes and are unaffected by the accent-
insensitive collation (ådmin, admın, ADMIN are all rejected against an admin
account); privilege enforcement, hostile-input handling and the durability invariants
are unchanged.
Two long-standing performance gaps were measured and filed rather than rushed:
joins that emit many rows are 10-16x MySQL (ESQL-47) and ORDER BY ... LIMIT k on
an unindexed column is ~2x (ESQL-48). Both were verified against a 1.4.15 binary and
are not regressions from 1.5.0. They sit in the hot join and sort paths, where
four wrong-results bugs have shipped before, so they get their own focused work.
1.5.0 - 2026-07-29
A behaviour-changing release: the default collation becomes MySQL's
utf8mb4_0900_ai_ci, so non-ASCII text sorts and compares by base letter. That
changes which rows a query returns for Nordic and other European text, and it
changes the bytes under which text is stored -- so existing databases are migrated
automatically on open. Alongside it, comma cross joins now stream instead of being
materialised, which was the last shape that could exhaust memory.
The threshold sweep against MySQL 8.4 now passes with an empty allowlist: all 51 queries at 15 row counts return byte-identical results. Every previously tolerated divergence is gone.
On-disk change: text indexes and text primary keys are re-keyed on first open by 1.5.0. The migration runs before connections are accepted and commits one table at a time, so a crash leaves each table fully on one side. Databases whose indexed text is pure ASCII are not rewritten. Downgrading to 1.4.x after upgrading is not supported.
Changed
-
The default collation is now
utf8mb4_0900_ai_ci, matching MySQL 8 (ESQL-44). The default character set was alreadyutf8mb4. The default collation wasutf8mb4_general_ci: case-insensitive but accent-sensitive, ordering non-ASCII text by codepoint. That putÆrligafterzzand madeWHERE s > 'cat'match a different set of rows than MySQL — wrong answers for Nordic and other European text, not merely a different sort order.Text now folds to the base letters MySQL gives the same primary weight, including the expansions:
æ→ae,ß→ss,œ→oe,ø→o,å→a,é→e. So'café' = 'cafe','Straße' = 'Strasse', and ordering interleaves with ASCII:Ærlig, ål, Ape, ape, cafe, café, cat, øl, Strasse, Straße, zz.The folding table is derived from MySQL's own weight strings rather than written by hand, so it cannot disagree with the collation it implements. (Reading the spec through the
mysqlCLI, which connects aslatin1_swedish_ci, initially reported'Æ' = 'æ'as false — measuring through a correctly configured client was necessary to get this right.)The advertised collation string changed in the same release as the semantics, never before it. Reporting
utf8mb4_0900_ai_ciwhile implementing something else would mislead ORMs and migration tools that read@@collation_server.Known limitation: characters with their own primary weight in MySQL (
þ,ŋ,ı) are case-folded but not reduced to ASCII, and non-Latin scripts order by codepoint rather than full UCA weights. -
Automatic migration for existing databases (ESQL-44). The collation folding feeds the on-disk key encoding, so this changes the bytes under which text is stored: secondary indexes,
UNIQUEconstraints and text primary keys. Without a migration a row written asæblewould afterwards be looked up asaebleand not be found — silent row loss.Databases now carry a collation version. On open, an older database has its text index entries rebuilt and its text-primary-key rows re-keyed, before any connection is accepted, so no query can observe a half-migrated keyspace. The migration is idempotent and the version marker is written only once every table is done, so an interrupted upgrade resumes on the next start. Two rows whose keys become equal under the new folding (
æandae) are reported as a collision naming both keys rather than one silently overwriting the other.Pure-ASCII keys fold identically and are not rewritten, so most databases migrate with no data movement at all.
-
Window functions are 25–35% faster (ESQL-46). The projection was rebuilt for every row:
map_exprcloned the whole expression tree, the window list was searched by deepExprequality, a literal node was allocated from the value, and the result was re-interpreted — per row, per projection item. Each item is now classified once (is a window call / contains one / contains none), so the ordinary shapes do no expression work at all. Partition keys are also raw collation bytes instead of aStringbuilt by mapping each byte throughas char(which re-encodes every byte ≥ 0x80 as multi-byte UTF-8) with a clone per row, and an unpartitioned window skips the hashing entirely. Measured on 200k rows:LAG/LEAD48.5 ms → 32.9 ms (parity with MySQL),SUMover a partition 64.5 ms → 40.9 ms, partitionedROW_NUMBER77.0 ms → 54.0 ms. The remaining gap is materialising every row before computing, which needs streaming windows.
Added
-
Cross joins now stream, so their product is never buffered (ESQL-39). A comma-separated cross join (
FROM a, b, c WHERE ...) was the shape that grew the process to 97 GB before the OS killed it, and after 1.4.13 it was merely refused by the row ceiling. It is now streamed: the join chain gained an unkeyed step, and one driving row's expansion recurses per step instead of being collected, so memory is O(chain depth) rather than O(product). Partners are still materialised — bounded by their table size, exactly as the existing keyed steps are — but the product is not, and the product is what explodes. Measured:COUNT(*)over 27 million combinations completes with resident memory flat at 16 MB, and the original 4000³ workload peaks at 19 MB instead of 97 GB.GROUP BY,ORDER BY … LIMIT, aggregates, and a comma entry mixed with an explicitJOINall stream too, and all match MySQL 8.4 exactly.The shapes the chain still declines — non-equi joins,
FULL, derived tables, a partner larger than the row cap — continue to materialise and remain bounded by the ceilings below. -
Materialising joins are now bounded by memory, not just row count (ESQL-39). The 1.4.13 ceilings counted rows, which is a poor proxy: 20M rows measured about 5.4 GB for a narrow schema, but a wide row costs many times a narrow one for the same count, so the row ceiling could be satisfied while memory was not.
ELYRASQL_JOIN_MAX_BYTES(default 2 GiB) bounds the bytes buffered across all concurrent joins, with each join sampling the width of the rows it actually emits (left ++ right, not just the driving row — sampling only the left side under-estimated a two-table join by roughly half). Verified with 1.5 KB rows and eight concurrent cross joins against a 512 MiB ceiling: the byte ceiling is what binds (14 604 refusals naming it, none naming the row ceiling), the process stays healthy, and a legitimate join afterwards still works, so nothing leaks.Documented honestly as approximate: reservations are taken in blocks and the allocator retains freed memory, so peak RSS ran ~2.3x the ceiling in that test. It is a safety net that keeps the process alive, not an accounting guarantee. Real spilling for the shapes that still materialise remains open on ESQL-39.
Fixed
-
A statement containing non-ASCII text could panic the connection. The keyword sniffers that detect user-management and procedure statements sliced the SQL by byte offset (
sql[..kw.len()]), which panics when that offset lands inside a multi-byte character:SELECT 'æ'='ae'has a character spanning bytes 8..10 and"drop user"is 9 bytes long, so the worker aborted and the client saw a lost connection. Both sniffers now compare bytes. Found by running the release reproduction against the Docker image rather than trusting a clean test suite — the accent-insensitive collation makes such literals ordinary, so this moved from obscure to reachable. -
ORDER BYon a non-projected column failed alongside a window function.SELECT amt, RANK() OVER (ORDER BY amt) FROM t ORDER BY idwas rejected with "ORDER BY references unknown output column", while the same query without the window function worked and MySQL accepts both. The window path only had the output columns to sort by; it now falls back to the base row, which is still index-aligned with the output. Found by a regression test written for the optimisation below — the test caught a compatibility gap rather than a regression.
1.4.15 - 2026-07-28
Query-planning release. Three related defects made ElyraSQL do far more work than necessary on ordinary filters, all found by measuring against a reference MySQL rather than by inspection:
- a range on a secondary index was executed as an index walk regardless of how much
of the table it matched, so
WHERE amt > 0cost 124 ms where the identical row set written as a non-index-usableamt <> -1cost 2.7 ms; col IN (...)was not recognised as index-usable at all, so even a five-element list scanned the whole table;- and when a scan was right,
INwas interpreted per row, so a 500-element list meant 100 million comparisons.
Measured on 200 000 rows: amt > 0 124 ms → 16.5 ms, IN (500 literals)
102 ms → 5.7 ms, IN (5 literals) 4.5 ms → 1.3 ms, and a scalar-subquery
filter 68.7 ms → 12.2 ms — the last one had been recorded as a separate problem
and turned out to be the range defect all along. Several of these are now faster than
MySQL on the same data.
Because plan selection changed, correctness was verified more widely than usual: the threshold sweep against MySQL 8.4, the 203-case differential battery, and the client suites (Laravel/Eloquent, PHP native prepares, PyMySQL). That last group earned its place — it caught a regression in this very work, described below. No on-disk format change.
Fixed
-
col IN (...)on an indexed column now uses the index (ESQL-46). It was not recognised as index-usable at all, so eveng IN (1,2,3,4,5)scanned the whole table and tested membership per row — 4.51 ms against MySQL's 0.33 ms, which does five index lookups. Values are now looked up through the index and unioned by storage key. Keys are collected before any row is fetched, so a list covering too much of the table (the same budget as a range) falls back to a scan having paid only for key lookups, and the rows that do qualify are fetched in one batched read rather than one per value. Measured on 200k rows:IN (5 values)4.51 ms → 1.26 ms. Long lists are handled by the compiled predicate (below) rather than the index. List elements are coerced to the column's type before being encoded as keys — PDO binds integers as quoted strings, soid IN ('1','2')on an INT primary key is the ordinary shape from Laravel'swhereIn; without coercion the lookup found nothing where a scan found the rows. -
INis now a set membership test in the compiled predicate (ESQL-46). When a scan is the right plan,column IN (numeric literals)compiles to a hash-set test instead of walking the list for every row — 500 literals over 200k rows was 100M comparisons. Values are hashed canonically so0.0/-0.0agree, and the set's span is exposed as zone-map bounds so chunks outside it can still be skipped. Measured on 200k rows:IN (500 literals)101.8 ms → 5.7 ms (0.62x MySQL),IN (subquery ~500)81.2 ms → 30.8 ms, andINon an unindexed column 3.3 ms (0.35x MySQL). Shapes whose three-valued semantics the compiled form cannot reproduce — aNULLelement, a non-numeric column, a non-literal element, an empty list — are declined so the interpreter keeps ownership of them. -
A secondary-index range was used regardless of how much of the table it matched (ESQL-46). An index range fetches every matching row by key, which is roughly an order of magnitude dearer per row than a sequential decode, so a wide range was far slower than simply scanning:
COUNT(*) FROM perf WHERE amt > 0took 124 ms, while the identical row set expressed asamt <> -1— not index-usable, therefore scanned — took 2.7 ms. Ranges now fall back to a scan pastELYRASQL_INDEX_RANGE_MAX_FRACTION(default 6%) of the table, decided after the index keys are walked but before any row is fetched, so a misjudged range costs only a key-only walk. Row counts come fromANALYZEstatistics when present, otherwise from a key count cached per table and write epoch. Primary-key ranges are untouched, being sequential reads.Measured on 200k rows:
amt > 0124 ms → 16.5 ms,amt > 4999962.8 ms → 9.2 ms,g > 25050.1 ms → 9.0 ms, and selective ranges unchanged (amt > 990001.2 ms). This also removed what looked like a separate scalar-subquery problem:WHERE amt > (SELECT AVG(amt) FROM perf)went 68.7 ms → 12.2 ms, now slightly faster than MySQL — the subquery was never the cost, the wide range was.
Added
- Scenario suite runs in CI (
.github/workflows/scenarios.yml). The threshold-sweep, robustness and security scenarios added in 1.4.14 now gate every push and pull request, plus a nightly run:- Threshold sweep replays one query battery at row counts bracketing each
internal threshold and diffs against a real MySQL 8.4 service. Known
divergences are allowlisted by exact SQL with the issue that will remove
them; a near-miss of an allowlisted query still fails, which was verified
deliberately (
ORDER BY s ASCis not covered by the entry forORDER BY s). - Robustness asserts durability and concurrency invariants against a server it
repeatedly
SIGKILLs. - Security gates on per-action privilege enforcement and hostile input; the performance section is skipped there, since ratios on a shared runner are too noisy to gate a build. This closes the gap that let three wrong-result bugs reach released versions: every one of them hid below the thresholds the previous tests exercised.
- Threshold sweep replays one query battery at row counts bracketing each
internal threshold and diffs against a real MySQL 8.4 service. Known
divergences are allowlisted by exact SQL with the issue that will remove
them; a near-miss of an allowlisted query still fails, which was verified
deliberately (
1.4.14 - 2026-07-28
Correctness release — upgrade from 1.4.13 is strongly recommended. Two aggregate
bugs in 1.4.13 returned wrong values without any error. COUNT(DISTINCT) was
affected in both directions at once: it was multiplied by the parallel worker count
(so the answer depended on the machine's CPU count) and simultaneously deflated by
key collisions. The two defects partly cancelled, which is exactly why they survived
release. GROUP_CONCAT also ignored its ORDER BY entirely.
These were found by a new threshold-sweep scenario harness that replays one query
battery at sizes bracketing every internal threshold and diffs every result against
a reference MySQL 8.4 — built because the wrong-result bugs fixed in 1.4.13 had all
hidden below those thresholds. Robustness and security were verified in the same
pass: 13/13 durability and concurrency invariants hold (acknowledged commits survive
SIGKILL, no torn transactions after a mid-write kill with six concurrent writers,
totals conserved across ~3000 concurrent transfers) and 21/21 privilege and hostile-
input checks pass. No on-disk format change.
Fixed
- DISTINCT aggregates were multiplied by the parallel worker count (ESQL-42).
Present in 1.4.13 and earlier.
COUNT(DISTINCT g)returned8 x workersfor 8 distinct values, so the answer depended on the machine's CPU count and was only correct with a single worker. Parallel aggregation merges partial results additively, which is right for COUNT/SUM/MIN/MAX but double-counts a value seen by two workers.SUM(DISTINCT)andGROUP_CONCAT(DISTINCT)were wrong the same way;AVG(DISTINCT)looked correct only because numerator and denominator were inflated equally, which is why spot checks missed it. DISTINCT counts are now derived from the merged set, and aggregations containing a DISTINCT stay on the single-pass path. Verified identical for 1/2/4/8/16 workers on 20k rows and differentially against MySQL 8.4. GROUP_CONCATignored itsORDER BY(ESQL-43). The clause parsed but was never applied, so values were concatenated in scan order --GROUP_CONCAT(s ORDER BY s DESC)returned the same string as with no ordering. Sort keys are now collected per value and applied when the aggregate finishes, so the result is well-defined even when partial aggregates are merged. Fixing it exposed a second defect: sort-key columns were not declared as columns the aggregate reads, so the scan decoded them as NULL, every key compared equal and the values silently stayed in scan order -- only visible when ordering by a column other than the concatenated one. Verified against MySQL 8.4 across 12 shapes (ASC/DESC, multiple keys, DISTINCT,SEPARATOR, and per group).COUNT(DISTINCT)undercounted (ESQL-45). Present in 1.4.13 and earlier, and the same root cause as the join-key bug fixed in 1.4.13 (ESQL-40) in a second place: the aggregator's distinct set was keyed by a collation key pushed throughfrom_utf8_lossy, so values whose encoding contained a non-UTF-8 byte collided.COUNT(DISTINCT g)over 500 distinct integers returned 258. It stayed hidden because ESQL-42 inflated the same results by the worker count while this deflated them, so the two bugs partly cancelled. The set is now keyed on raw bytes, which also means merging partials unions it correctly -- soCOUNT(DISTINCT)keeps its parallelism (200k rows: 17.7ms single-threaded, 2.7ms on 16 workers), and only aggregates whose value merges additively (SUM/AVG/GROUP_CONCAT/STDDEV/VARwith DISTINCT) stay single-pass.- Integer-returning functions over an aggregate came back as text.
LENGTH(GROUP_CONCAT(s))reached the client as the string"23"instead of the number23(likewiseCHAR_LENGTH,ASCII,INSTR, and the date-part functions), because computed-column type inference defaulted toText. They are now typed as integers, so arithmetic and client-side decoding behave as in MySQL.
Added
- Threshold-sweep scenario harness (
tests/scenarios/). Both wrong-result bugs fixed in 1.4.13 (and ESQL-42 above) shipped because every existing test sat below the internal thresholds where they lived. The harness replays one query battery at sizes bracketing each threshold (1, 2, 127, 128, 129, 255, 256, 257, 2047, 2048, 2049, 4095, 4097, 8193) and diffs every result against a reference MySQL 8.4, so a bug that only appears past a byte boundary, a join-strategy switch or a spill partition surfaces as a divergence.
1.4.13 - 2026-07-28
Correctness release — upgrade from 1.4.12 is strongly recommended. Two bugs in 1.4.12 returned wrong results for ordinary joins: hash-join keys collided for integers in 128..255 (so a 1:1 join could return a cartesian product), and a bare aggregate over a join appended 256 spurious zero rows. Neither raised an error, so affected queries looked like they worked. Both are fixed and covered by the differential battery, which grew from 183 to 203 cases against real MySQL 8.4.
The release also stops a single query from being able to kill the process
(unbounded join buffering), keeps the server responsive under CPU-heavy queries
without any configuration, and makes ELYRASQL_QUERY_TIMEOUT_MS genuinely
enforceable. No on-disk format change.
Fixed
-
Wrong join results: the hash-join key was corrupted (ESQL-40). Present in 1.4.12. The key was a collation key pushed through
from_utf8_lossy, but a collation key is an order-preserving binary encoding — so every byte that is not valid UTF-8 became U+FFFD and unrelated values collided into one key. Every integer in 128..255 hashed identically, soSELECT COUNT(*) FROM t a JOIN t b ON a.id = b.idon 300 rows returned 16556 instead of 300: those 128 ids formed a cartesian product. It affected ordinary equi-joins (hash join and the streaming N-table path) on integer keys in that range and on non-ASCII text keys; small tables escaped it only because the index nested-loop path handles them. The hash tables are now keyed on the raw collation-key bytes. Verified exact for n = 10…5000, zero mismatched pairs, and 9 join shapes matching real MySQL 8.4. -
A bare aggregate over a join returned 256 spurious zero rows (ESQL-41). Present in 1.4.12.
SELECT COUNT(*) FROM p JOIN q ON …returned 257 rows — the right value, then 256 rows of0, constant regardless of table size.SpillAgg::finalizefinalised all 256 spill partitions including the empty ones, and for an aggregate with no GROUP BY finalising an empty group set correctly means "zero rows in, one row out". Empty partitions are now skipped; an empty join still returns a single0, andGROUP BYover a join was never affected. Clients reading only the first row (most ORMs, for a scalar aggregate) saw the correct value, which is why this went unnoticed. -
A materialising join could exhaust memory and kill the process (ESQL-39).
FULL, non-equi, derived-table and cross joins buffer their output, with no cap and no spilling: 8 concurrent 3-way cross joins over a 4000-row table took the process from 97 MB to 97 GB RSS, after which the OS killed it — no panic and nothing logged. A single authenticated client could do this with one short query. Buffered rows are now bounded per join byELYRASQL_JOIN_MAX_ROWS(default 10M) and across all concurrent joins byELYRASQL_JOIN_MAX_ROWS_TOTAL(default 20M), both reserved through an RAII guard so a join that finishes, errors or unwinds returns its share. The per-join cap alone would not have bounded the server, since memory scales with concurrency. The same workload now plateaus at 5.4 GB with the server healthy, and an unconstrained join fails with a message naming the limit rather than being killed. Streaming joins are unaffected — they never hold the join output. -
A CPU-heavy query no longer makes the server unresponsive (ESQL-38). Statement execution shares the async runtime's workers with the connection listener and every other session, so a long synchronous stretch — a join product, a sort, aggregation over materialised rows — monopolised a worker. With enough concurrent heavy queries the server stopped accepting connections entirely: measured with no query timeout configured, a fresh
SELECT 1could not complete within 12 s. Those stretches now run throughblock_in_place, which hands the worker over and lets the runtime bring up a replacement, and the streaming join loops yield between driving rows. No configuration is needed, so this protects the default setup: 32 concurrent runaway queries (2× the core count) now leave a new connection answered in under 0.1 s. The query deadline from the previous entry still applies on top when configured. -
ELYRASQL_QUERY_TIMEOUT_MSnow actually stops a runaway statement (ESQL-32). It previously wrapped execution in a wall-clock timeout, which can only take effect at an.awaitpoint — so a long stretch of synchronous CPU work ignored it entirely, and work already handed to a blocking thread ran to completion even after the client had given up. Measured before the fix: a 3-way cross join with a 2-second timeout gave the client nothing for 25 s and kept a core busy afterwards. The engine now carries a per-statement deadline and checks it inside its hot row loops — table scans, the nested-loop product, hash-join build and probe, join-chain expansion, sort-key evaluation, and each parallel scan worker (the case a wall-clock timeout can never reach). Every runaway shape tested (cross join, exploding equi-join withORDER BY/GROUP BY, high-cardinalityGROUP BY, expression sort over 800k rows) now aborts at the deadline with zero CPU left running, and with the timeout set the server kept answering new connections while 16 runaway queries were in flight. The deadline is armed per statement by a guard, so it is cleared on every exit path, and a trigger or procedure body inherits the outer statement's deadline rather than starting a fresh budget.ELYRASQL_QUERY_TIMEOUT_MSremains off by default, as in MySQL — with no timeout configured there is still no deadline to check (ESQL-38). -
REGEXPcompiled its pattern once per row. Found by profiling the above: aWHERE s REGEXP '...'scan spent essentially all its time inRegex::new, recompiling the same constant pattern for every row. Compiled patterns are now cached (bounded, so patterns built from column values cannot grow it without limit), shared byREGEXP/RLIKE,REGEXP_REPLACEandREGEXP_SUBSTR. ACOUNT(*)with aREGEXPfilter over 800k rows went from not finishing inside a 2-second budget to 0.12 s. -
REGEXPignored the operand's collation (ESQL-37). MySQL applies the operand's collation toREGEXP/RLIKE, and its default collation is case-insensitive, soSELECT 'Hello' REGEXP 'h'returns 1 there but returned 0 here — silently different rows for any query relying on it. Case-sensitivity now follows the collation the same way comparisons already did: case-insensitive by default, case-sensitive for a_binoperand, with an inline(?-i)still overriding it.REGEXP_REPLACE/REGEXP_SUBSTRreceive already-evaluated values, so they use MySQL's default (case-insensitive) behaviour, which is also what MySQL returns forREGEXP_REPLACE('a1B2','[b]','x')→a1x2. All expectations were taken from real MySQL 8.4 before implementing, and 12 REGEXP cases were added to the differential battery (now 195 cases, 0 divergences).
1.4.12 - 2026-07-27
Hardening release. A code audit found two denial-of-service vectors that a single authenticated client could use to take the server down; both are fixed at the root. The rest of the release closes the remaining unbounded-resource gaps — connections, prepared statements and streamed parameters are now all bounded with MySQL-compatible limits, errors and defaults — and completes inter-node encryption by wrapping the Raft control plane in TLS. No on-disk format change.
Added
-
TLS encryption for the Raft control plane (ESQL-31). Leader election (
RequestVote) andAppendEntriesnow run over TLS when the cluster TLS variables are set, completing inter-node encryption (replication was already covered by ESQL-30). It reuses the sameELYRASQL_CLUSTER_TLS_CERT/_KEY(the certificate each node presents),ELYRASQL_CLUSTER_TLS_CA(roots used to verify peers), andELYRASQL_CLUSTER_TLS_SERVER_NAME. Each node verifies its peers' certificates, so a node that cannot verify a peer cannot form the cluster with it. The control-plane framing (send/recv),handle_control, andappend_rpcwere generalised over the transport, and peer connections are now a boxed TCP-or-TLS stream. Plaintext remains the default (with a warning). Verified end-to-end: a 3-node cluster elects a leader and replicates writes via Raft over TLS with the correct CA, and forms no cluster (no leader elected, all writes rejected) when peers present a certificate a wrong CA cannot verify. -
Connection admission control (ESQL-33). The listener accepted connections without limit, so a client could exhaust memory and file descriptors just by connecting. Concurrent connections are now bounded by
ELYRASQL_MAX_CONNECTIONS, mirroring MySQL'smax_connectionsand its default of 151. A surplus connection receives error 1040 (Too many connections) as its first packet — the same thing MySQL does, so clients report the real reason instead of a bare connection reset — and refusals are exposed aselyrasql_connections_refused_totalfor alerting. Slots use the same RAII permit pattern as prepared statements, so they are returned when a connection ends however it ends.0disables the limit. -
A connection slot is reserved for administrators (ESQL-36). As in MySQL, one connection beyond
ELYRASQL_MAX_CONNECTIONSis admitted, and it is served only if it authenticates as anAdminaccount — so an operator can still connect to diagnose andKILLsessions on a saturated server. A non-admin that takes the slot is refused with 1040 after authenticating, deliberately reporting "too many connections" rather than an authentication failure, which would wrongly suggest bad credentials (verified: a wrong password still returns an auth error, not 1040). This required a decision that can only be made once the account is known, soelyra-wiregained apost_auth_checkhook in its shim contract (defaulting to "allow", so other implementors are unaffected). -
Streamed statement parameters are bounded (ESQL-35).
COM_STMT_SEND_LONG_DATAappended to a per-statement buffer that is only cleared by an execute, so a client that streamed data and never executed could grow memory without limit — side-stepping the statement cap below, since one statement was enough. Accumulated parameter data is now bounded byELYRASQL_MAX_ALLOWED_PACKET; passing it releases the buffer immediately and fails the followingEXECUTEwith error 1153 (Got a packet bigger than 'max_allowed_packet' bytes), after which the statement is reusable. The error is deliberately reported at execute time becauseCOM_STMT_SEND_LONG_DATAhas no reply in the protocol — which is also where MySQL reports it. Verified with a raw-protocol client: 3 MiB streamed against a 1 MiB budget left the server at 14 MB RSS, and legitimate long data (300 KiB in three chunks) still assembles correctly. -
Prepared statements are capped server-wide (ESQL-34). A client could previously
COM_STMT_PREPAREwithout ever closing, growing the per-connection statement map without limit. The number of live prepared statements is now bounded across all connections byELYRASQL_MAX_PREPARED_STMTS, mirroring MySQL'smax_prepared_stmt_countand its default of 16382; past the limit a prepare is answered with error 1461 (Can't create more than max_prepared_stmt_count statements) and the connection remains usable. The limit is global rather than per connection for the same reason MySQL's is: a per-connection cap bounds nothing while the connection count itself is unbounded (ESQL-33). Slots are tracked with an RAII permit, so they are returned when a statement is closed, when a connection ends without closing its statements, and on an unwind — a leaked global counter would otherwise eventually refuse every prepare server-wide. Wire behaviour differential-verified against real MySQL 8.4 (identical: three prepares accepted at a limit of three, then error 1461 with SQLSTATE 42000).
Fixed
- Denial-of-service:
NTILE()iterated its bucket argument.NTILE(N)loopedNtimes regardless of table size, soNTILE(1000000000000)on a 10-row table pinned a CPU core indefinitely — and the work continued after the client disconnected. With one such query per core the server stopped answering new connections entirely (measured: 16 queries → 1449% CPU, a freshSELECT 1timing out). Buckets are now assigned per row (O(rows)), which also matches MySQL for a bucket count larger than the row count (each row in its own bucket, surplus buckets empty). Distributions differential-verified against real MySQL 8.4 (NTILE(3)/(4)/(7)/(20)). - Denial-of-service: unbounded string expansion.
REPEAT,SPACE,LPADandRPADallocated whatever the arguments asked for, soSPACE(10000000000)requested 10 GB andREPEAT('x', 200000000)grew the process to 414 MB from a single query. They now returnNULLpastELYRASQL_MAX_ALLOWED_PACKET(default 64 MiB) — the same behaviour as MySQL pastmax_allowed_packet, whose default is also 64 MiB (verified against MySQL 8.4).
Changed
- The string-expansion budget introduced in this same unreleased cycle is now
named
ELYRASQL_MAX_ALLOWED_PACKET(wasELYRASQL_MAX_STRING_BYTES). It is the same 64 MiB default, but it now also bounds streamed statement parameters, so one knob covers both — exactly as MySQL'smax_allowed_packetdoes. No released version used the old name. ELYRASQL_QUERY_TIMEOUT_MSis now documented accurately. It can only interrupt a statement that yields; it does not preempt a long synchronous CPU loop (verified: a 2 s timeout did not stop theNTILEspin above). The previous wording implied the client was always unblocked. Treat it as a latency bound for I/O-waiting queries, not a CPU-DoS guard (ESQL-32).
1.4.11 - 2026-07-22
Robustness & cluster-security release: memory fail-safes for IN (SELECT) /
DISTINCT, and optional TLS encryption for the replication transport. No on-disk
format change.
Added
- TLS-encrypted replication (ESQL-30). The primary↔replica stream (which
carries the whole data set) can now be encrypted: set
ELYRASQL_CLUSTER_TLS_CERT/_KEYon the primary andELYRASQL_CLUSTER_TLS_CAon the replica. The replica verifies the primary's certificate (no accept-any mode, so it is not MITM-vulnerable), giving confidentiality + server authentication; combined withELYRASQL_CLUSTER_SECRETthis is mutual authentication. Plaintext remains the default (with a loud warning). The Raft control plane is not yet TLS-wrapped (ESQL-31). - Fail-safe memory bounds for
IN (SELECT ...)andDISTINCT(ESQL-28). AnIN (SELECT ...)over more thanELYRASQL_IN_SUBQUERY_MAXrows (default 1,000,000) or aDISTINCTover more thanELYRASQL_DISTINCT_MAXrows (default 5,000,000) now errors with a clear message instead of buffering an unbounded set and risking OOM (rewriteIN (SELECT)as aJOIN/EXISTS).
Testing
- Locked in N-table left-deep join streaming (ESQL-29) with a regression test:
chains of two or more
INNER/LEFTequi-joins feedingORDER BY/GROUP BYstream through the spilling sorter/aggregator (the join output is never fully materialised). The remaining materialising cases (FULL, non-equi, derived table,RIGHT-in-a-chain) are correct, rare, and documented.
1.4.10 - 2026-07-22
Vector release: the HNSW index is now maintained incrementally and persisted
across restarts, so write-heavy and post-restart vector workloads no longer pay a
full graph rebuild. The on-disk cache lives in a sibling <data>.vidx/ directory,
so the authoritative single file is unchanged.
Added
- Persisted HNSW vector index (ESQL-27). The built graph is now saved to a
sibling cache directory
<data>.vidx/, so a restart loads it and reconciles any changes since — no cold-start rebuild from a full table scan. It is kept outside the authoritative single file (like<data>.raftstate), so it is not replicated, not in backups, and does not touch the global write sequence that gates the column cache; a missing / corrupt / wrong-version snapshot safely falls back to a rebuild. The snapshot is written on first build and on compaction (not on every write). Verified end-to-end: an index survives a server restart and returns correct nearest-neighbours without rebuilding. - Incremental HNSW vector-index maintenance (ESQL-26). A write to a
vector-indexed table no longer forces the next ANN query to rebuild the whole
graph. The cached index is reconciled against storage instead: only the rows
inserted / updated / deleted since the last reconcile are applied to the
existing graph (new vectors inserted via
Hnsw::insert_one, removed or superseded ones soft-tombstoned and filtered from results), detected by a content hash so all of INSERT/UPDATE/DELETE are correct. A single insert into a 500k-row index now adds one node instead of rebuilding 500k. A full rebuild is reserved for the first build, a change as large as the table, or compaction when too many nodes are tombstoned. Verified end-to-end (insert/update/delete reflected inVEC_DISTANCEsearch) with recall-preserving tests. The graph is still memory-only (rebuilt cold on restart — ESQL-27).
1.4.9 - 2026-07-21
Reliability & cache-efficiency pass from a second codebase review. Also documents several architectural gaps honestly (with tracking issues) rather than rushing correctness-sensitive rewrites. No on-disk format change.
Fixed
- Lock-poison recovery. The vector-index registry, the OLAP column cache, and
the HNSW scratch-buffer pool now recover a poisoned lock via
into_inner()instead of.unwrap()panicking. These guard only self-healing caches / reusable scratch pools, so a panic in one query no longer cascades into a whole-process crash on the next lock acquisition (worst case: a stale/missing cache entry that is rebuilt). - The MySQL connection handler no longer panics on a TLS-capability mismatch (carried from 1.4.8): the one connection is dropped with a clean error.
Changed
- Column cache eviction is now approximate-LRU instead of arbitrary: each
cached table carries an atomic
last_usedtick bumped on read (no lock upgrade), and eviction drops the least-recently-used entries to fit the budget.
Documented (known limitations, with tracking issues)
- Vector (HNSW) index rebuilds fully on any table write and is not persisted (cold start) — best for read-heavy / batch-updated embedding workloads today (ESQL-26 incremental maintenance, ESQL-27 persistence).
WHERE col IN (SELECT ...)andDISTINCTcollection are in-memory (no spill); correlated subqueries run asO(N×M)nested loops (ESQL-28).- Joins of more than two tables / complex expressions use the materialising path (ESQL-29).
- Intra-cluster Raft/replication traffic is authenticated (cluster secret) but not encrypted (ESQL-30).
- Isolation: all four standard levels are accepted;
SERIALIZABLEand snapshot are the two engines (snapshot is at least as strong asREAD UNCOMMITTED/READ COMMITTED/REPEATABLE READ), and@@transaction_isolationreportsREPEATABLE-READ.
1.4.8 - 2026-07-21
Hardening pass from an external review — safer defaults, a query timeout, bounded memory on an edge path, and more direct tests. No on-disk format change.
Security
- Safe-by-default open auth. With no accounts configured (every client would
be
Admin), the server now refuses to start when bound to a non-loopback address. Override by configuring accounts, binding to localhost (the default, so local dev is unchanged), or settingELYRASQL_ALLOW_OPEN_AUTH=1. - Replication exposure guard. The replication endpoint (authenticated only
when
ELYRASQL_CLUSTER_SECRETis set) refuses a non-loopback bind without a secret unlessELYRASQL_ALLOW_OPEN_AUTH=1; warns loudly when unauthenticated.
Added
- Per-query timeout
ELYRASQL_QUERY_TIMEOUT_MS(0 = off): a statement running longer returns a clean error and unblocks the client. ELYRASQL_SERIALIZABLE_MAX_RANGE(default 5,000,000): aSERIALIZABLEcommit whose validation would materialize a larger scanned range now aborts fail-safe instead of risking unbounded memory.
Changed / Fixed
- The connection handler no longer panics on a TLS-capability mismatch; the one connection is dropped with a clean error.
- The replica restarts with a clear, deliberate
EX_TEMPFAIL(75) exit on a resync-driven re-bootstrap instead of a bareexit(1).
Testing
- Direct unit tests for the
ORDER BYplanning helpers inexec.rs(previously covered only indirectly), plus unit tests for the bind-exposure classifier. - Benchmarks now also run on a monthly CI schedule (still non-gating).
1.4.7 - 2026-07-20
Performance release: sorting on a nullable column now uses the secondary
index in both directions (the last grid-sort gap). Adds a companion indexnull::
keyspace for single-column indexes; no change to existing on-disk data, and
indexes built before 1.4.7 keep working (rebuild to pick up the new behaviour).
Added
-
NULL-indexed ordered walks (removes the
ASC-on-nullable full sort). Single-column B-tree indexes built on 1.4.7+ now store NULL-keyed rows under a companionindexnull::keyspace (keyed by the clustered primary key, never unique). AnORDER BY <nullable col> [ASC|DESC] LIMIT— with or without a PK tiebreaker — is then a complete MySQL ordering by walking the value entries and the NULL entries in one snapshot: NULLs first forASC, last forDESC, each ordered by the primary key. This closes the previous fallback whereASCon a nullable column with few/zero NULLs degraded to a full sort (now sub-millisecond at scale). Index maintenance (INSERT/UPDATE/DELETE, CREATE INDEX backfill, TRUNCATE/DROP/RENAME) keeps the NULL entries consistent; multiple NULLs are still allowed in aUNIQUEindex. Indexes built before 1.4.7, and composite indexes with a nullable column, use the previous handling. -
Primary-key tiebreaker on indexed
ORDER BY ... LIMIT. A non-unique secondary index stores(value, clustered primary key), so walking it also orders by the trailing PK.ORDER BY <indexed col> DESC, id DESC— the usual stable-pagination sort a grid emits — is now served by the index walk instead of a full sort (dropped from ~6 s to sub-millisecond at scale). All order terms must share a direction and any trailing terms must be the primary-key columns in order. On a nullable column a tiebreaker only stays on the fast path when the NULL block is not reached (e.g.DESCwith enough non-NULL rows); otherwise it falls back to the sorter, since the NULL block cannot be tiebroken cheaply.
1.4.6 - 2026-07-20
Performance release: deep OFFSET on paged grids no longer reads the skipped
rows. No on-disk format change.
Added
- Cheap deep
OFFSETon indexedORDER BY ... LIMIT. With no residual filter, the leadingOFFSETrows are now stepped over at the index/clustered level without reading their rows (the data key is not dereferenced), so paging deep into a result costs index steps rather thanoffsetrow reads. On 600k rowsORDER BY revenue LIMIT 40 OFFSET 500000dropped from ~290 ms to ~18 ms; reverse-PK deep offset is likewise cheap. Applies to the primary-key andNOT NULLsecondary-index walks; a residual filter still reads pre-offset rows (they must be counted).
Notes
- Sorting
ASCon a nullable column that holds (almost) no NULLs still falls back to a full sort:ASCplaces NULLs first, so the walk must establish the NULL block before emitting the head, and confirming an empty NULL set is not cheap without NULLs in the index. Declare such a columnNOT NULLto keep it on the fast path in both directions. Indexing NULL keys (to remove this fallback) is planned as a separate, carefully-tested change.
1.4.5 - 2026-07-20
Performance release: nullable sort columns now use the secondary index for paged grids (top-N without a full sort). No on-disk format change.
Added
- Nullable columns on indexed
ORDER BY ... LIMIT. ANOT NULLindex is no longer required: a nullable single-column index now serves an orderedLIMIT, with the NULL-keyed rows (which indexes omit) spliced back in as a block — last forDESC, first forASC, matching MySQL's NULL ordering. The NULL block is fetched by a budgeted clustered scan, so the common grid default (ORDER BY <col> DESC LIMIT non a mostly-populated column) stays a sub-millisecond top-N instead of a full sort. Very rare NULLs on anASCwalk fall back to the sorter (bounded byELYRASQL_ORDER_SCAN_BUDGET). Composite indexes still require every column to beNOT NULL.
1.4.4 - 2026-07-20
Performance release: filtered paged grids (WHERE ... ORDER BY <col> LIMIT n) are
now served without a full sort. No on-disk format change.
Added
- Filtered indexed
ORDER BY ... LIMIT. AWHEREfilter is now applied as a residual during the ordered index/clustered walk, so a filtered grid page (WHERE ... ORDER BY <indexed col> LIMIT n) is served without a full sort too (previously only unfiltered orderedLIMITs were accelerated). An examine budget (ELYRASQL_ORDER_SCAN_BUDGET) caps the walk so a very selective filter falls back to the memory-bounded sorter (cheap — few matches) instead of degrading into a near-full point-read scan. On 300k rows aWHERE active=1 ORDER BY revenue DESC LIMIT 40runs in ~0.5 ms.
1.4.3 - 2026-07-20
Performance release: ordered LIMIT (paged grids) no longer sorts the whole
table. No on-disk format change.
Added
- Indexed
ORDER BY ... LIMIT(top-N without a full sort). A paged, orderedLIMITwith noWHEREfilter is now served by an ordered walk that stops afterOFFSET + LIMITrows instead of sorting the whole table:ORDER BY <primary-key prefix> DESC LIMIT n— reverse clustered scan (forward/ASC was already fast).ORDER BY <indexed column(s)> ASC|DESC LIMIT n— ordered secondary-index walk, when every column of that index isNOT NULL. On a 300k-row table this took the three previously-unaccelerated grid sorts from ~5–8 s (full sort) to well under 1 ms. Nullable sort columns, filtered orderedLIMITs, and orderedLIMITs inside a transaction fall back to the existing memory-bounded sorter (correct, not yet index-accelerated).
1.4.2 - 2026-07-17
Analytics release: percentile aggregates and GROUP BY on an expression — the
pieces an observability/metrics workload needs (time-bucketed p50/p95/p99). No
on-disk format change.
Added
- Percentile aggregates
PERCENTILE(col, p)/QUANTILE(col, p)(fractionpin 0..1) andMEDIAN(col), with exactpercentile_cont(linear- interpolation) semantics — for latency percentiles (p50/p95/p99) in metrics workloads. Composes withWHERE/GROUP BY; an empty group isNULL. GROUP BYan expression, not just a plain column — e.g. time-bucketingGROUP BY DATE_FORMAT(ts, '%Y-%m-%d %H:%i:00')orGROUP BY status DIV 100. The projection of the same expression returns the group value. (Verified against MySQL in the differential suite.)
Fixed
- Computed-column type inference now reports
DIVas an integer and the bitwise operators asBIGINT UNSIGNED(previously text), so e.g.SELECT n DIV 5 ... GROUP BY n DIV 5returns an integer column.
1.4.1 - 2026-07-17
Join-streaming release. Streams two-table RIGHT JOIN (closing ESQL-6, the last
backlog item), so all equi-join shapes are now memory-bounded. No on-disk format
change.
Changed
- Streaming
RIGHT JOIN. A two-tableRIGHT JOINfollowed byORDER BYorGROUP BYnow streams (rewritten to the equivalentLEFT JOINwith the output columns reordered back to the query's(A, B)order), so it is bounded by the partner hash table plus the sorter/aggregator rather than the full join size — joiningINNER/LEFT/RIGHTequi-joins on the streaming path.FULL, non-equi, derived-table, and multi-join-chainRIGHTjoins still use the correct materialising path.
1.4.0 - 2026-07-16
Search release. Completes the search chapter with faceted counts, reusing the same engine as full-text and vector search. No on-disk format change.
Added
- Faceted search via a
FACET(col[, top_n])aggregate. It returns a JSON object of{value: count}over the matched rows (ordered by count, optional top-N cap), computing every facet plus the hit count in a single pass. As an ordinary aggregate it composes withWHERE, full-textMATCH ... AGAINST, vector filters andGROUP BY— the counts side of a faceted search, reusing the same engine as full-text and vector search. Works in the server and the embedded engine.
1.3.0 - 2026-07-16
Access-control release. Enforces individual DML privileges per table, closing the last documented gap in the privilege model. No on-disk format change (legacy per-table grants upgrade in place).
Changed (security)
- Fine-grained privilege enforcement. The individual DML privileges
INSERT,UPDATEandDELETEare now enforced separately, per target table, instead of a single coarse "write" tier. A user granted onlyINSERTcan no longerUPDATEorDELETE, andREVOKEing one write privilege leaves the others intact. Per-table grants are stored as a privilege set (legacy grants migrate automatically); role-inherited grants are included. Admin/open-auth connections are unaffected (full access), reads remain allowed at the baseline, and DDL is still gated at theADMINtier.
1.2.0 - 2026-07-15
MySQL-semantics release. Adds an automated differential test harness that runs 180 edge-case queries against ElyraSQL and a real MySQL 8 in CI, and fixes the correctness divergences it surfaced. No on-disk format change from 1.1.x.
Added
- MySQL differential harness (
tests/compat/differential/mysql_diff.py) and a CI workflow (mysql:8.4service) that fail on any non-allowlisted divergence in rows, NULLs, or error/no-error — a permanent guard against MySQL-semantics regressions. - New functions:
ISNULL,STRCMP,BIT_COUNT,TO_DAYS,INSERT,CONV,ORD,BIN,OCT,CRC32. - The
DIVinteger-division operator and the!logical-NOT prefix operator.
Fixed (MySQL semantics)
- NULL propagation: arithmetic with a NULL operand (
NULL + 1) returned an error → now NULL.NOT NULL/!NULL→ NULL. - Three-valued logic:
AND/OR(NULL AND 1→ NULL, not 0),IN(1 IN (NULL, 2)→ NULL), andBETWEEN(1 BETWEEN NULL AND 5→ NULL) now follow SQL three-valued logic. - Math domain errors (
SQRT(-1),LN(0),LN(-1)) return NULL instead of NaN/inf. LENGTHnow returns the byte length (CHAR_LENGTHstays characters);SUBSTRING(s, 0)returns''.CASTto integer rounds instead of truncating (CAST(3.7 AS SIGNED)= 4);UNSIGNEDwraps (CAST(-1 AS UNSIGNED)= 18446744073709551615); non-numeric text casts to its leading integer prefix (or 0).- Invalid dates are rejected rather than rolled over:
CAST('2024-02-30' AS DATE)→ NULL (also affects date parsing generally). DATE_ADD/date + interval on a time-less date yields aDATE, not aDATETIME.- Integer division
DIVtruncates toward zero;DIV 0→ NULL. - Bit aggregates
BIT_OR/BIT_AND/BIT_XORreturnBIGINT UNSIGNED.
Notes
- A few divergences are intentional and documented in the harness allowlist:
ElyraSQL is stricter about implicit string→number coercion in arithmetic and
comparison (
0 = 'abc'is 0, not 1), and it does not replicate MySQL's bare!!xquirk (it treats!!xas consistent double negation).DECIMAL/TIMEresults are sent as text (values identical).
1.1.3 - 2026-07-14
Security release. Completes the expression-depth denial-of-service guard first shipped in 1.1.1, which missed two attack shapes. No on-disk format change from 1.1.x. Upgrading from 1.1.0/1.1.1/1.1.2 is strongly recommended.
Security
- Completed the expression-depth DoS guard from 1.1.1. The initial guard
estimated AST depth from a hand-picked set of operator tokens and tracked only
open-bracket nesting, so it missed two shapes that still overflowed the
worker stack and aborted the process: JSON
->/->>chains (x -> '$' -> '$' ...) and token-balanced postfix chains (x[0][0]...,f()()...). The guard now treats every operator token the tokenizer can emit as depth-contributing and accumulates depth when a group/subscript/call closes, so all deep-AST shapes are rejected before parsing. A statement separator (;) resets the estimate so multi-statement batches of shallow statements aren't falsely rejected. Verified against arrow, longarrow, subscript, call, paren, function-nesting, arithmetic, boolean and bitwise chains at 300k terms (all rejected; server stays alive).
1.1.2 - 2026-07-14
Correctness release for integer/floating-point arithmetic. No on-disk format change from 1.1.x.
Fixed
- Integer arithmetic no longer silently saturates (#15). Signed 64-bit
arithmetic was evaluated in
f64and cast back, so a result past theBIGINTrange was silently clamped (e.g.9223372036854775807 + 1returned9223372036854775807) — a correctness/data-integrity foot-gun for computed writes. Integer+,-,*(and unary-) are now computed exactly and raiseERROR 1690 (22003) BIGINT value is out of rangeon overflow, matching MySQL, in both the scalar and row (WHERE/UPDATE) paths. x % 0now returnsNULL(was0), matching MySQL, for both%andMOD()— consistent withx / 0, which already returnedNULL.DOUBLEoverflow now returnsNULLinstead ofinf/NaN(e.g.POW(10,308) * 10), matching MySQL's out-of-range behaviour.
1.1.1 - 2026-07-14
Security release. Fixes two denial-of-service issues in the same class (unbounded recursion on hostile input → worker-stack overflow → process abort). No on-disk format change from 1.1.0. Upgrading is strongly recommended.
Security
- Fixed a remote denial-of-service (reported privately). A single query with a
deeply-nested flat expression — e.g.
SELECT 1+1+1...or... WHERE id=1 OR id=1 OR ...with tens of thousands of terms — built a left-deep AST whose depth is O(N). Evaluating it, and even dropping it, recursed O(N) frames deep and overflowed the worker thread stack, which aborted the entire server process (dropping every client at once), not just the offending connection. Unauthenticated in the default open-auth (dev) mode; any authenticated user otherwise. ElyraSQL now rejects over-deep expressions with a normal SQL error before parsing (so the pathological AST is never built — the parser, evaluator, and AST destructor never recurse unboundedly). The limit is configurable viaELYRASQL_MAX_EXPR_DEPTH(default 2000). Wide-but-shallow queries (longINlists, large multi-rowINSERTs) are unaffected. - Fixed a related JSON denial-of-service found while auditing for siblings of
the above. A deeply-nested JSON document (
[[[[...]]]], ~200k levels) passed to a JSON function such asJSON_VALID/JSON_EXTRACTrecursed through the JSON parser (and the value's recursive destructor) and overflowed the worker stack, again aborting the whole process. The JSON parser now enforces a maximum nesting depth (200 levels, matching the on-write validator); an over-deep document is treated as invalid JSON instead of crashing.
1.1.0 - 2026-07-14
Robustness release. Adds a soak/chaos test harness and, on its first run, fixes a real isolation bug it uncovered. No on-disk format change from 1.0.
Fixed
- Snapshot-consistent autocommit aggregates. A single autocommit aggregate
(e.g.
SELECT SUM(x) FROM t) could, under concurrent writes, return a value that never existed in any consistent state — because the parallel and batched aggregate scan paths each opened their own MVCC read snapshot, so different parts of one aggregate observed the table at different commit points. Every single statement now reads through one pinned snapshot: the parallel clustered-range scans, theCOUNT(*)fast path, and the spilling (partitioned) aggregation all share a single point-in-time view. This restores snapshot isolation for autocommit aggregate reads. (In-transaction aggregation already read the session snapshot and was unaffected.)
Testing
- Soak / chaos harness (
crates/elyra-cli/tests/soak.rs). Many concurrent connections run atomic transfers against a fixed-total set of accounts while a global bank invariant — total balance conserved, never negative — is checked continuously. A second test repeatedlySIGKILLs and restarts the server mid-write and re-checks the invariant after every crash-recovery, exercising crash consistency under sustained load. Short by default so it runs per-PR; env-tunable (ELYRASQL_SOAK_SECS/WORKERS/ACCOUNTS/KILL_MS) with a nightly workflow for long runs. This harness found the aggregate-isolation bug above on its first CI run.
Notes
- Cross-engine benchmarks were re-run on the fair native-Linux environment and are unchanged by the isolation fix — ElyraSQL remains fastest of the three on every aggregation query.
1.0.0 - 2026-07-13
First stable release. ElyraSQL is a robust, MySQL-compatible SQL server in Rust:
a single ACID file, a broad SQL surface, vector + full-text + hybrid search, and
parallel OLAP aggregation. This release closes a wave of correctness, robustness
and compatibility work and commits to Semantic Versioning from here on. No
on-disk format change from 0.9.x (.edb files upgrade in place).
Correctness fixes
SELECT DISTINCTnow deduplicates. It was previously a no-op on the base scan path (returned duplicate rows); it now dedups on the projected output, beforeOFFSET/LIMIT, and is collation-aware.- Native (binary) prepared statements no longer desync across repeated
COM_STMT_PREPAREon one connection. Root-caused to a use-after-free and a buffer-padding bug in the wire packet reader when a client (e.g. PDO/mysqlnd) pipelines commands. PDO withEMULATE_PREPARES=falsenow works. - Process-global catalog cache is keyed by
(database, table), so multiple databases in one process can't serve each other's schema. - Fixed a UTF-8 slicing panic in
UPDATE/DELETELIMITstripping (found by the new fuzzer).
SQL surface
BIGINT UNSIGNEDis a first-class type (Value::UInt): columns store and read values abovei64::MAXexactly, and all bitwise operators (&|^<<>>and unary~) return correct 64-bit unsigned results with exact unsigned arithmetic.GROUP BY ... WITH ROLLUP— subtotal + grand-total rows, re-aggregated per level soAVG/MIN/MAXstay correct.INSERT ... SET col = valand comma-style multi-tableUPDATEare accepted (rewritten to the standard forms).- Per-column
_bin/BINARYcollation is honored inORDER BY,GROUP BY,DISTINCTand equi-join keys (not justWHERE/UNIQUE/indexes). ENUM/SETvalue validation — a non-member value is rejected.- Qualified wildcard
alias.*in the projection.
Performance / robustness
- Streaming joins.
INNER/LEFTjoins — explicit, comma, and N-table left-deep chains — followed byORDER BYorGROUP BYstream the driving table through a spilling sorter/aggregator, so a large fact-to-dimensions join is bounded by group/sort state, not the full join output size. - Native prepared statements:
describe_queryreports an exact result-column count (incl.*over joins) atPREPARE.
Testing
- End-to-end wire integration tests (independent
mysql_asyncdriver), crash-recovery/durability tests, a committed Laravel/Eloquent + PyMySQL + native-PDO compatibility harness in CI, property tests (value round-trips, aggregation/ORDER BY invariants), and acargo-fuzztarget for the preprocessing+parse pipeline. All gated in CI.
Notes
- Deferred (documented in limitations): streaming
RIGHT/FULL/non-equi/derived-table joins (the materialising path is correct; only a rare OOM risk), and pre-commit 2-phase replication.
0.9.9 - 2026-07-12
Wire-protocol release. ElyraSQL now owns its MySQL wire layer, which unblocked three things a third-party dependency held back. No on-disk format change.
First-party wire layer
- Forked
opensrv-mysqlinto the in-treeelyra-wirecrate (Apache-2.0, attribution preserved). ElyraSQL now maintains and extends its own MySQL wire-protocol implementation instead of depending on an unmaintained upstream.
TLS: rustls 0.23
- Server TLS moved from rustls 0.22 to rustls 0.23 (via
tokio-rustls0.26), using the pure-Rust ring provider (no aws-lc/OpenSSL; static musl builds keep working).rustls-webpkiis now 0.103.13, so the four RUSTSEC webpki advisories no longer apply. Note: rustls 0.23 requires X.509 v3 certificates (all modern/CA-issued certs qualify).
Authentication: caching_sha2_password
- Implemented
caching_sha2_password(MySQL 8's default auth plugin), opt-in viaELYRASQL_AUTH_PLUGIN=caching_sha2_password. Full authentication runs over TLS (cleartext) or a plaintext connection (RSA-OAEP public-key exchange, 2048-bit ring key generated on first use); the recovered password is checked against the existingSHA1(SHA1(pw))digest, so no credential storage change and the password is never persisted in the clear. The default staysmysql_native_password(works with every client). The full Laravel/Eloquent suite passes authenticating with caching_sha2_password.
Native prepared statements
describe_queryis now count-complete: it reports an exact result-column count (with best-effort types) atPREPAREfor any single SELECT with an explicit projection, so binary (native) prepared-statement drivers read the result set instead of desyncing. Emulated/client-side prepares remain the recommended setting for the widest compatibility.
Notes
- The
rsacrate's Marvin timing advisory (RUSTSEC-2023-0071, no fixed release) is documented and scoped in.cargo/audit.toml: RSA runs once per connection in the opt-in non-TLS caching_sha2 path only; TLS or native_password avoid it. - dependabot now pins
nom/mysql_common(the vendored wire crate uses their current APIs).
0.9.8 - 2026-07-12
MySQL-compatibility release, driven by running real MySQL clients and the Laravel/Eloquent stack against ElyraSQL and closing every gap that surfaced. No on-disk format change.
Laravel / framework support
- A full Laravel Eloquent workload runs cleanly: migrations (
Schema::createwith$table->id(),foreignId()->constrained(), indexes), model CRUD with correctlastInsertId,hasMany/belongsTo, eager loading,withCount, query-builder joins/aggregates/groupBy+having,updateOrInsert, transactions, and cascading deletes. - New Framework Integration guide with recommended settings for Laravel, PDO/Symfony, Python (PyMySQL/ Django/SQLAlchemy), Rust (sqlx) and Node (mysql2).
- CREATE TABLE now tolerates trailing table options (
ENGINE=,DEFAULT CHARSET/CHARACTER SET,COLLATE '...',AUTO_INCREMENT=,ROW_FORMAT,COMMENT, ...) so Laravel/mysqldump/ORM DDL parses. ALTER TABLE ADD FOREIGN KEY/ADD INDEX/KEY/UNIQUE(with backfill).- Unsigned and extended column types (
BIGINT UNSIGNED,MEDIUMINT,DOUBLE PRECISION,TINY/MEDIUM/LONGTEXT+BLOB,NVARCHAR, ...). information_schema.columnsreportsCOLLATION_NAME,COLUMN_COMMENT,GENERATION_EXPRESSION,CHARACTER_SET_NAME(schema introspection).- The OK packet now sets
SERVER_STATUS_IN_TRANS, soPDO::inTransaction()andcommit/rollBackbehave correctly (transactions were silently auto-committing before). The OK packet also carrieslast_insert_id, so driverlastrowid/getGeneratedKeyswork afterINSERT.
SQL surface
- Session functions
LAST_INSERT_ID(),ROW_COUNT(),FOUND_ROWS(). @@system variables (@@version,@@session.*,@@global.*,sql_mode,character_set_*, ...); unknown ones return NULL.- Operators:
<=>(null-safe equal),IS [NOT] TRUE/FALSE/UNKNOWN, row/tupleIN((a,b) IN ((...),(...))). - Subqueries in the SELECT list (scalar,
EXISTS), including alongsidet.*. HAVINGreferencing aggregates not in the SELECT list.- Scalar functions:
MD5/SHA1/SHA2,HEX/UNHEX,FORMAT,FIND_IN_SET,FROM_UNIXTIME,DAYNAME/MONTHNAME,PI/RADIANS/DEGREES,CHAR,TIME_TO_SEC/SEC_TO_TIME,SOUNDEX,REGEXP_REPLACE/REGEXP_SUBSTR,CONVERT(). - Aggregates:
STDDEV/STDDEV_POP/STDDEV_SAMP,VARIANCE/VAR_POP/VAR_SAMP,BIT_OR/BIT_AND/BIT_XOR. - Window functions:
NTILE,FIRST_VALUE,LAST_VALUE,NTH_VALUE. UPDATE/DELETE ... LIMIT nis accepted (the row limit is not enforced).
Known limitations
- Binary (native) prepared-statement parameter binding is not yet reliable with
PDO/mysqlnd; use client-side/emulated prepares (
PDO::ATTR_EMULATE_PREPARES => true). PyMySQL and sqlx bind client-side and are unaffected. - Parser-level:
INSERT ... SET, comma multi-tableUPDATE,GROUP BY ... WITH ROLLUP, and the<</>>/~bitwise operators are not parsed.
0.9.7 - 2026-07-12
OLAP acceleration release. No on-disk format change; fully compatible with 0.9.3–0.9.6 data files. The default behaviour is unchanged — every new accelerator below is opt-in.
Query performance (always on)
- Vectorised (columnar) grouped aggregation.
GROUP BYon a single numeric column with numeric aggregates keys each group exactly in an FxHash map and accumulates into flat per-groupf64/i64arrays, decoding only the needed columns — no byte-key encoding or per-rowValuedispatch. A pushed-down compiled predicate filters on the same path. On native Linux (1M rows):GROUP BYtop-10 93→54 ms (≈1.6× ahead of PostgreSQL), low-cardinality 64→46 ms, filtered aggregation 53→46 ms. - Single-pass hybrid
GROUP BYspill. Aggregation now keeps groups in memory and spills only the rows whose group does not fit to disk partitions, instead of routing every row through disk. When the working set fits, nothing spills. - Streaming index nested-loop join.
FROM a JOIN b ON a.k = b.<indexed> [WHERE …] LIMIT n(no GROUP BY/aggregate/ORDER BY/DISTINCT) scans the driving table incrementally, probes the indexed partner per row, and stops as soon as enough rows are produced — bounded memory, early termination (e.g.LIMIT 5over 100k driving rows in ~0.5 ms).
Opt-in accelerators
-
ELYRASQL_SYNC— commit durability.full(default) fsyncs every commit;normalreturns before the fsync and flushes in the background (ELYRASQL_SYNC_INTERVAL_MS, default 200 ms), greatly increasing small-batchINSERTthroughput (~14× on single-row autocommit inserts) for a bounded crash-loss window. Never risks corruption; same tradeoff as MySQLinnodb_flush_log_at_trx_commit=2/ PostgreSQLsynchronous_commit=off. -
ELYRASQL_COLUMN_CACHE_MB— in-memory columnar cache (default 0 = off) for repeated unfiltered aggregations: a table's numeric columns are materialised once and reused, skipping the scan (cached 4-aggregate scalar over 200k rows ~0.8 ms). -
ELYRASQL_ZONE_MAPS— data-skipping for filtered aggregations (default off): per-chunk column min/max let aWHERE col <op> valueskip blocks that cannot match. Big win for data with locality (time-series, monotonic ids); selective filter on 500k rows ~2.2× faster.All three are race-free by construction: a monotonic write sequence written inside every write transaction invalidates cached state on any committed write (insert/update/delete, COMMIT, replication, DDL), so they never serve stale data. Filtered aggregations still run the predicate on every surviving row, so zone maps never affect correctness.
Security / tooling
cargo auditnow runs in CI, with.cargo/audit.tomldocumenting each reviewed advisory (the rustls-webpki chain is transitive via opensrv-mysql's rustls 0.22 and unreachable server-side).- Compatible dependency updates.
0.9.6 - 2026-07-12
OLAP performance release. No on-disk format change; fully compatible with 0.9.3–0.9.5 data files.
Headline result (native Linux, all engines on one host — see
benchmark_analyse.md): on 1M rows, ElyraSQL is the fastest of ElyraSQL,
PostgreSQL 17 and MySQL 8.4 on every OLAP query — global aggregation, low- and
high-cardinality GROUP BY, top-N, and filtered aggregation — and 2–5× ahead of
MySQL.
OLAP
- Vectorised (columnar) scalar aggregation. Multi-aggregate queries without
GROUP BYover numeric columns extract each column into a contiguousf64array per batch and aggregate with tight, SIMD-friendly loops instead of per-rowValuedispatch.SUM/AVG/MIN/MAXover 1M rows ≈ halved. - Compiled filter predicate. A
WHEREthat is a conjunction ofcolumn <cmp> numeric-literalis compiled once with pre-resolved column indices and evaluated with native comparisons, instead of re-resolving column names and walking the expression per row. Filtered aggregation on 1M rows dropped from ~87 ms to ~53 ms (now ahead of PostgreSQL). - Fast bare
COUNT(*). Counts keys across parallel clustered ranges without decoding row values, and seeds the result directly (~24 ms → ~8 ms locally). ELYRASQL_AGG_WORKERStunes aggregation parallelism (default min(cores, 4); aggregation is memory-bandwidth bound, so more workers can be slower).
Tooling
bench/olap.py— OLAP benchmark harness (1M-row analytical queries)..github/workflows/benchmark.yml— native-Linux CI benchmark against MySQL and PostgreSQL; run withgh workflow run benchmark.yml. This is the fair, representative environment (a laptop hypervisor penalises ElyraSQL's parallel, memory-mapped scans).benchmark_analyse.mdrefreshed with the native-Linux OLAP + core-SQL results.
0.9.5 - 2026-07-12
Performance release focused on aggregation. No on-disk format change; fully compatible with 0.9.3/0.9.4 data files.
Headline result (200k rows, each engine measured alone on the box): ElyraSQL is
now the fastest of the four on full-table COUNT and GROUP BY — ahead of
MySQL 8.4, Percona 8.4 and PostgreSQL 17. See benchmark_analyse.md.
Aggregation
- Bounded table-keyspace scan for parallel-split planning. The planner that
splits a table for parallel aggregation was walking backwards through the
entire database (every secondary-index entry and other table) to find a
table's last row, making full-scan
COUNT/SUM/GROUP BYscale with total database size rather than table size. It now bounds the probe to the table's own keyspace. On a 200k-row table sharing the file with another 200k table and two secondary indexes,GROUP BYdropped from ~17 ms to ~4.4 ms. - Aggregation parallelism capped at 4 by default (
ELYRASQL_AGG_WORKERSoverrides). Full-scan aggregation is memory-bandwidth bound; beyond ~4 workers the coordination overhead makes it slower. - Allocation-light grouping. The group-by aggregator uses an insertion-
ordered map (one key allocation per group instead of two) and moves group
state during parallel merges instead of cloning it — markedly faster
high-cardinality
GROUP BY.
Result transfer
- Buffered wire writes (plain connections). The MySQL protocol writer issued
one
write_vectoredsyscall per result row against an unbuffered socket; a 64 KiB buffer now coalesces rows, helping any query that returns many rows to a fast client.
Tuning
ELYRASQL_AGG_WORKERS— degree of parallelism for full-scan aggregation (1 = single-threaded; default min(cores, 4)).
0.9.4 - 2026-07-12
Performance release: a focused campaign on scan, aggregation and per-query
overhead, benchmarked head-to-head against MySQL 8.4, Percona 8.4 and
PostgreSQL 17 (see benchmark_analyse.md). No on-disk format change; fully
compatible with 0.9.3 data files.
Headline result (200k rows, same host/client): ElyraSQL now beats MySQL and
Percona on full-table COUNT and bulk insert, matches PostgreSQL on
full-scan COUNT, and is competitive on indexed COUNT and range
ORDER BY. Full-scan COUNT improved ~10x (48 ms -> ~5 ms) and
ORDER BY pk LIMIT ~50x (29 ms -> ~0.6 ms) versus 0.9.3.
Query engine
- PK-ordered
LIMITfast path —ORDER BY <pk> LIMIT nscans in clustered order and stops as soon as enough rows are collected, instead of materialising and sorting the whole result set. - Projection-aware decoding — scans materialise only the columns a query
actually reads, skipping (without allocating)
TEXT/JSONcolumns it never touches. - Zero-copy scanning — full-table scans decode straight from borrowed storage bytes inside a single read transaction, with a reused row buffer, so there is no per-row copy or allocation.
- Parallel clustered aggregation — for integer-primary-key tables, a full-scan aggregate splits the keyspace into ranges aggregated in parallel.
- Covering-index
COUNT—COUNT(*)whose filter is an equality covered by an index is answered by counting index entries, with no row fetch. - Faster
GROUP BY— an allocation-free group-key hot path with a fast (FxHash) aggregation map.
Per-query overhead
- Table-definition cache — autocommit queries resolve their schema from an in-memory cache (epoch-invalidated on DDL) instead of reading it from storage every time.
- Common-path check elimination — materialized-view refresh checks, per-column mask lookups, and redundant privilege lookups are skipped entirely unless the corresponding feature is actually in use.
Tooling
bench/compare.py— identical portable workload across ElyraSQL, MySQL, Percona and PostgreSQL.benchmark_analyse.md— the 0.9.4 cross-engine comparison and analysis.
0.9.3 - 2026-07-10
AI-native search release: hybrid full-text + vector retrieval and in-SQL embedding generation — the RAG/AI-app stack in one MySQL-compatible file, no external search engine. No on-disk format change.
Hybrid search
-
HYBRID(text_col, 'query', vec_col, vector)— a first-class ranking primitive that fuses a vector (HNSW) ranking and a full-text ranking with Reciprocal Rank Fusion (RRF, k=60), honouring the query's structuredWHEREfilter:SELECT id, title, HYBRID(body, 'data privacy', embedding, ?) AS score FROM docs WHERE lang = 'en' ORDER BY score DESC LIMIT 10;The text side uses a
FULLTEXTindex when present (otherwise a scan), the vector side uses the HNSW index, and the fused relevance is exposed via the projection alias. One query, one file — no Elasticsearch/pgvector/reranker.
In-SQL embeddings
-
ai_embed('text')— calls an OpenAI-compatible/v1/embeddingsendpoint (cloud, or a local Ollama/LM Studio/llama.cpp/vLLM server) and returns the vector, so query vectors and stored values are generated directly in SQL:SELECT id, HYBRID(body, 'privacy', embedding, ai_embed('privacy')) AS score FROM docs ORDER BY score DESC LIMIT 10; INSERT INTO docs VALUES (1, 'some text', ai_embed('some text'));Resolved in an async pre-pass (each unique text embedded once and cached, then treated as a vector literal), so all downstream vector operations are unchanged. Configured via
ELYRASQL_AI_EMBED_URL/_KEY/_MODEL. HTTP viaureq+ring, so the static musl builds keep working. Constant arguments only (ai_embed('query')); per-rowai_embed(column)is future work.
0.9.2 - 2026-07-10
MySQL client & driver compatibility release, driven by testing real GUI tools
and a Rust sqlx client against ElyraSQL. No on-disk format change.
Query engine
LIKE/ILIKEinWHEREare now supported (they were rejected before):%/_wildcards,ESCAPE,NOT LIKE, case-insensitive under the default collation — so contains/prefix search works.- Numeric/string comparison coercion: comparing a numeric column to a string
literal (
id = '5',IN ('5','6'),price = '10.50',id > '4') now coerces per MySQL rules. This also fixes bound parameters not matching numeric columns (drivers render params as string literals). - Expressions over aggregates:
ROUND(SUM(x),2),SUM(a)/COUNT(*),SUM(qty*price),COALESCE(SUM(x),0)+n, and scalar expressions over group columns likeUPPER(status)— with or withoutGROUP BY. - Positional
ORDER BY(ORDER BY 2,ORDER BY 1 DESC). VERSION(),DATABASE(),USER(),CURRENT_USER(),CONNECTION_ID(),CURRENT_ROLE()work as scalar functions in any context (not just as an exact-match intercept).CREATE/DROP DATABASEandSCHEMAare accepted as no-ops (single-file database), so tools and migrations that issue them proceed.
Introspection
information_schema: addedengines,schemata,views,events,routines,triggers;KEY_COLUMN_USAGEgainedPOSITION_IN_UNIQUE_ CONSTRAINTandREFERENCED_TABLE_SCHEMA/NAME/COLUMN_NAME(foreign-key discovery). Database name unified toelyra.SHOW:VARIABLES,STATUS,COLLATION,DATABASES,WARNINGS,TABLE STATUS,FUNCTION/PROCEDURE STATUS(incl. theWHEREform), andPROCESSLIST(now handled in-engine, so it works over the prepared path too).mysql.userlists accounts (always including the built-inroot).
Drivers
- Opt-in prepared-statement column description (
ELYRASQL_STMT_DESCRIBE, default off): describes a simpleSELECT's result columns atPREPAREtime so drivers like sqlx resolve result columns by name. Off by default because strictlibmysqlclient-based clients mishandle it; verified with a real sqlx harness that it enables by-name resolution and survives multiple prepares on one connection.
0.9.1 - 2026-07-10
MySQL client compatibility release. Real GUI tools (DBeaver, Workbench) and drivers fire a cluster of introspection queries on connect and to populate their schema tree; ElyraSQL now answers them, and a few everyday query forms that errored now work. No on-disk format change.
Session / introspection queries
SHOW [GLOBAL|SESSION] VARIABLES [LIKE ...]returns a MySQL 8.0-compatible system-variable set (character sets, collations, timeouts,max_allowed_packet,lower_case_table_names,sql_mode,version*, ...) withLIKEfiltering.SHOW STATUS,SHOW COLLATION,SHOW DATABASES,SHOW WARNINGS/ERRORS,SHOW TABLE STATUS, andSHOW FUNCTION/PROCEDURE STATUS(including theWHEREform, which the SQL parser rejects, handled pre-parse).
information_schema virtual tables
- Added
engines,schemata,views,events,routines, andtriggers(views/routines/triggers reflect the actual stored objects). The reported database name is now consistentlyelyra(matchingTABLE_SCHEMAandTables_in_elyra).
Query engine
- Expressions over aggregates:
ROUND(SUM(x), 2),SUM(a)/COUNT(*),SUM(qty*price),COALESCE(SUM(x), 0) + n, and scalar expressions over group columns likeUPPER(status)— with or withoutGROUP BY, and over an empty input (yieldsNULL). Previously these errored. - Positional
ORDER BY(ORDER BY 2,ORDER BY 1 DESC) in both the aggregated and plain paths.
Dependencies
- Applied safe in-range dependency updates; pinned crates that define the
on-disk format / SQL parsing / SIMD API (bincode, redb, sqlparser, wide) and
the opensrv-pinned rustls stack against breaking Dependabot bumps. Bumped the
Alpine runtime image and GitHub Actions. The four
rustls-webpkiadvisories are not reachable (server-only TLS, no client-cert/CRL validation) and are transitively pinned byopensrv-mysql.
0.9.0 - 2026-07-10
Robustness, correctness & security hardening release. A broad review of the query engine, transaction layer, vector search, privilege model and network/ disk I/O, tightening production safety without changing the on-disk format.
Correctness
- Signed-zero / NaN grouping.
GROUP BY,DISTINCTand hash joins now canonicalize float keys, so-0.0and+0.0group together (as SQL requires) and all NaNs collapse to one key. - Total ordering.
Value::total_cmp's fallback no longer comparesDebugstrings (which allocated per comparison and sorted10.0before2.0); it uses a numeric / stable per-type order. - Full-text stemming. Replaced the ad-hoc suffix stripper (which mangled
string→str,running→runn) with the Snowball algorithms (rust-stemmers); multilingual viaELYRASQL_FULLTEXT_LANGUAGE(defaultenglish;nonedisables stemming). - Transaction ORDER BY now uses the disk-spilling sorter inside transactions too (via the snapshot+overlay cursor), not just in autocommit.
- GROUP BY consults column statistics to go straight to the spilling path
when a large group count is predicted, avoiding a wasted in-memory pass and
re-scan (run
ANALYZE TABLEto benefit).
Stability
- JSON validator depth limit (
MAX_JSON_DEPTH) stops deeply nested input from overflowing the thread stack. - O(1) savepoints.
SAVEPOINTrecords an undo-log marker instead of cloning the whole staged write set (previously O(writes × savepoints));ROLLBACK TOreverts only changes since the savepoint. - Bounded transaction buffer. Uncommitted writes past
ELYRASQL_TXN_MAX_BYTES(default 1 GiB) are rejected with an error instead of exhausting memory. - Single-flight vector index rebuilds. A burst of queries after a write now triggers exactly one HNSW rebuild while the rest await and share it, instead of a thundering-herd of parallel full-table scans.
- Temp-file hygiene. Sort/aggregation spill files are size-guarded on read (a corrupt file can't trigger a giant allocation) and stale files from a SIGKILLed process are reclaimed at startup (only confirmed-dead PIDs).
Security
- Fine-grained global privileges.
GRANT/REVOKE ON *.*now add/remove individual privileges as a set, so revoking one privilege no longer collapses an admin account to read-only.SHOW GRANTSlists the exact set. - DROP USER purges the account's global, per-table, per-column and role grants, so a recreated same-name user can't inherit stale privileges.
- Constant-time password comparison (
ct_eq) closes a hash timing side channel. - Bounded frame/record reads. Every length-prefixed read (cluster,
replication, binlog, spill files) rejects oversized lengths before allocating,
via the configurable
ELYRASQL_MAX_FRAME_MB(default 1024 MiB), turning a corrupt file or malicious packet into an error instead of an OOM crash.
Performance
- HNSW visited-set pooling removes an O(N) heap allocation per vector search.
- SIMD distance kernels (
wide::f32x8, 8-wide) accelerate L2 / inner-product / cosine on the hot ANN path. - Cooperative yielding (
yield_now) in stored-procedureWHILE/LOOP/REPEATloops keeps a long procedure from starving the async runtime.
Known limitations (documented)
- Multi-table joins still materialize before sort/group (streaming join output
is planned); an unanalyzed high-cardinality
GROUP BYmay still fall back with a second scan; internal cluster/replication traffic is authenticated (ELYRASQL_CLUSTER_SECRET) but not yet encrypted (mTLS is planned — use a private network/VPN meanwhile).
0.8.10 - 2026-07-10
Consensus hardening & security release — making the Raft write path production- viable and strengthening password handling.
Raft write-path throughput
- The leader holds persistent
AppendEntriesconnections to followers (reused across rounds,TCP_NODELAY) instead of a fresh connection per round. - The Raft log is now append-only (fsync only new entries; the whole log is no longer rewritten per write), and the leader fsyncs a round's entries once.
- Committed entries are applied together through the DB group commit.
- Together these lift concurrent cluster write throughput from ~60/s to ~500/s (16 connections) / ~800/s (32); a single sequential write stays fsync-latency-bound.
Leader lease (liveness + linearizable leader reads)
- The leader renews a lease each round it confirms a quorum and steps down if it cannot within the lease window (below the election timeout). A leader partitioned from its quorum now relinquishes leadership — in-flight writes fail fast and a healthy majority elects a new leader — rather than hanging. A lease-valid leader is guaranteed to be the leader, so its local reads are linearizable without a quorum round-trip.
Raft log compaction
- The replicated log no longer grows unbounded: once entries are applied and replicated to every member, each node discards them (keeping the snapshot boundary term for the consistency check); the applied state machine is the snapshot. Compaction advances only to the slowest member's replicated index.
Password hardening
- New passwords must satisfy a strength policy (
ELYRASQL_PASSWORD_MIN_LEN, default 8;ELYRASQL_PASSWORD_REQUIRE_MIXED, default on;ELYRASQL_PASSWORD_POLICY=offto disable). - Repeated failed logins trigger a temporary account lockout
(
ELYRASQL_AUTH_MAX_FAILURES, default 10;ELYRASQL_AUTH_LOCKOUT_SECS, default 60), logged.
0.8.9 - 2026-07-09
Consensus release: the Raft log is now on the live cluster write path.
Raft replicated-log write path (pre-commit / 2-phase)
- In
clustermode, every write is proposed through the Raft log: the leader appends the entry, replicates it viaAppendEntries, commits it once a quorum has durably logged it, and only then applies it and acknowledges the client. Followers append (with the AppendEntries consistency check + conflicting-suffix truncation) and apply up to the leader's commit index. - Votes use the §5.4.1 election restriction on the log, so failover is no-data-loss: an acknowledged write is on a quorum's durable log and any elected leader already has it. A write cannot be acknowledged without a quorum.
- New plumbing:
elyra_storage::WriteOp+Consensustrait +Db.set_consensus/apply_op_local; the single-node write path is unchanged when no consensus layer is installed. - Known limitation: a leader partitioned from its quorum blocks writes (until it can replicate or the client times out) rather than stepping down proactively (no leader lease yet).
Verified
- 3-node cluster: writes commit via quorum and replicate; followers reject writes; killing the leader preserves all acknowledged writes on the new leader (no data loss); no commit without a quorum.
0.8.8 - 2026-07-09
Partitioning release.
Partitioning
CREATE TABLE ... PARTITION BY RANGE|LIST|HASH (<pk column>) (...)records a partitioning scheme (managed primary-key ranges), exposed ininformation_schema.partitions.ALTER TABLE t DROP PARTITION p/TRUNCATE PARTITION pcheaply delete a partition's rows (range/INdelete with index cleanup). Queries with a PK predicate prune automatically via clustered range scans.- Also fixed a stale docs line:
ON UPDATEreferential actions are enforced.
Notes / deferred
- Partitioning is single-node (managed PK ranges, not physical files, not enforced on INSERT). Horizontal write scale-out across nodes would require distributed sharding and is out of scope by design.
- Wiring the Raft log into the live cluster write path (leader append →
quorum commit → apply, for pre-commit 2-phase durability) is intentionally
not bundled here: it is a correctness-critical change that warrants a
dedicated release with partition/failover testing (the tested log core landed
in 0.8.6). Today's HA remains async replication + quorum/
--sync-strict+ the LSN-aware election restriction (no data loss for acknowledged writes).
0.8.7 - 2026-07-09
SQL-surface & usability release.
Named windows
SELECT ... OVER w ... WINDOW w AS (PARTITION BY ... ORDER BY ...), includingOVER (w ...)that inherits a named window and adds local clauses.
Materialized-view auto-refresh
- Materialized views now auto-refresh on read when a base table has changed since the last refresh (detected via per-table write counters). This is a full recompute, not incremental delta maintenance.
Notes
caching_sha2_passwordremains unimplemented: the latest publishedopensrv-mysql(0.7.0, what we use) does not drive its multi-round auth exchange. MySQL 8 clients negotiate down tomysql_native_password.
0.8.6 - 2026-07-09
Programmability, security & consensus-foundation release.
Materialized views
CREATE MATERIALIZED VIEW v AS <select>materializes the result into a real table;REFRESH MATERIALIZED VIEW vrecomputes it;DROP MATERIALIZED VIEW vremoves it. Refresh is explicit (no auto-refresh).
Per-column privileges
GRANT SELECT(col, ...) ON t TO urestricts a user to reading only those columns oft; querying an ungranted column (via the projection,SELECT *, or aWHERE/ORDER BYreference) is denied. Enforced for single-base-table selects; a restricted table in a join/subquery is denied (deny-safe).
Raft log core (consensus foundation)
- New unit-tested
raftlog: an ordered persistent log with the AppendEntries consistency check + conflicting-suffix truncation, the quorum/current-term commit rule, apply-only-when-committed, and the §5.4.1 election restriction. Routing the live cluster write path through it (for pre-commit 2-phase durability) is the remaining integration step.
Notes
caching_sha2_passwordremains unimplemented: the MySQL-protocol library does not drive its multi-round auth exchange. MySQL 8 clients negotiate down tomysql_native_password.
0.8.5 - 2026-07-09
Planner, security, and durability release.
Histogram-based cardinality
ANALYZE TABLEbuilds an equi-height histogram per column (reservoir sample), exposed as a JSONHISTOGRAMininformation_schema.column_statistics. The planner estimates WHERE-predicate selectivity from histograms to order joins by estimated (not just raw) row counts.
Roles, per-database grants & audit log
CREATE ROLE/DROP ROLE,GRANT <role> TO <user>/REVOKE <role> FROM <user>; users inherit the global and per-table grants of their roles.GRANT ... ON db.*is accepted (maps to a global grant, single database).--audit-log <path>appends one line per executed statement (timestamp conn_id user OK|ERR sql).
LOAD DATA INFILE & auth hardening
LOAD DATA INFILE '<server path>' INTO TABLE t [FIELDS/LINES TERMINATED BY] [IGNORE n LINES] [(cols)]bulk-loads a server-side file (ADMIN required;\N= NULL).- Connection salts now use the OS CSPRNG. (
caching_sha2_passwordis not implemented — the wire library lacks its multi-round exchange; MySQL 8 clients negotiate down tomysql_native_password.)
Crash-safe cluster elections
- Election state (current term + vote) is persisted to
<data>.raftstate, so a restarted node never double-votes in a term (Raft safety). Full Raft log replication (pre-commit 2-phase durability) remains a dedicated milestone.
0.8.4 - 2026-07-09
High-availability & query-planner release.
Zero-data-loss failover (election restriction)
- Cluster leader election now enforces the Raft election restriction: a node
only votes for a candidate at least as up-to-date (by LSN) as itself, so an
elected leader holds every quorum-acknowledged write. Together with
--sync-strictthis makes failover no-data-loss for acknowledged writes. (The sync barrier still runs after the local commit; this is not a pre-commit 2-phase protocol.)
Dynamic cluster membership
- Add/remove cluster members at runtime with
elyrasql cluster-ctl --node <addr> --action add|remove --peer id@host:port. The leader advertises the membership in heartbeats and followers adopt it. Add one node at a time and start it before adding.
Cost-based JOIN reordering + merge join
- Explicit INNER-join chains over base tables are reordered cost-based (drive from the smallest relation, extend along equi-join predicates; alias-aware).
- Large INNER equi-joins whose inputs are already sorted on the join key (clustered primary-key scans) use a streaming merge join.
Stored procedures: cursors & handlers
DECLARE ... CURSOR FOR,OPEN,FETCH ... INTO,CLOSE, andDECLARE {CONTINUE|EXIT} HANDLER FOR {NOT FOUND | SQLEXCEPTION | SQLSTATE '...' | <code>} <action>.
0.8.3 - 2026-07-09
Scalability & robustness release — hardening the write path and high availability.
Pessimistic locking
LOCK TABLES t READ|WRITE/UNLOCK TABLEStake real blocking table locks (aWRITElock blocks other readers and writers; aREADlock blocks writers). Conflicting statements from other sessions block until release, or fail with1205(lock wait timeout).LOCK IN SHARE MODEis accepted as a synonym forFOR SHARE. Zero overhead when no explicit lock is held.
Quorum / synchronous replication
--sync-replicas Nmakes each commit wait forNreplica acknowledgements;--sync-strictfails the commit-confirmation on timeout instead of silently degrading to asynchronous (no silent data-loss window). Per-replica ack tracking replaces the single high-water mark.
Incremental replica catch-up
- A reconnecting replica streams only the binlog delta since its last applied LSN instead of re-copying the whole database, falling back to a full snapshot only when the binlog is disabled or the needed segments were purged. Replicas reconnect transparently on stream drops. The LSN counter is resumed from the binlog across restarts (correct binlog ordering + working catch-up).
Write throughput
- Validated transactional commits are now group-committed: many concurrent transactions fold into one write transaction (one fsync) instead of one fsync each, while preserving first-committer-wins ordering and write-write conflict detection. (The single writer remains inherent to the ACID single-file design; there are no parallel writers or sharding.)
0.8.2 - 2026-07-09
High-availability & feature-completeness release.
Automatic failover
clustermode: Raft-style leader election (terms, majority votes, heartbeats, step-down). The elected leader accepts writes and serves replication; followers are read-only and replicate from it. On leader failure a surviving node is automatically elected. Leader-only writes provide fencing; a majority quorum avoids split-brain. Data replication remains asynchronous.
Stored procedures
IN/OUT/INOUTparameters, session@uservariables, and full control flow:LOOP,REPEAT ... UNTIL, labeledLEAVE/ITERATE(in addition toIF/WHILE).
Full-text search
CREATE FULLTEXT INDEXbuilds a persistent inverted index maintained on INSERT/UPDATE/DELETE and used to accelerateMATCH ... AGAINST; light English stemming folds regular word forms.
Spatial
POINT/GEOMETRYcolumns (WKT) withPOINT,ST_X,ST_Y,ST_Distance,ST_AsText,ST_GeomFromText.
0.8.1 - 2026-07-09
Programmability release: triggers, procedural stored procedures, and full-text search.
Triggers
- Row-level
CREATE TRIGGER name {BEFORE|AFTER} {INSERT|UPDATE|DELETE} ON t FOR EACH ROW <body>/DROP TRIGGER, withNEW.col/OLD.col. BEFORE bodies supportSET NEW.col = expr; AFTER bodies run arbitrary DML per affected row. Firing is depth-guarded against runaway recursion.
Stored procedures
- Parameters (
IN), local variables (DECLARE,SET), and control flow (IF/ELSEIF/ELSE,WHILE), interpreted over the procedure body.
Full-text search
MATCH(col, ...) AGAINST('terms' [IN BOOLEAN MODE])— scan-based relevance scoring (natural-language OR-of-terms, or boolean+/-).
Fixed
- The fast INSERT path now persists the
AUTO_INCREMENTcounter, so consecutive auto-increment inserts no longer reuse ids.
0.8.0 - 2026-07-09
Programmability & log-management release.
Binary log management
- The binlog is now a directory of rotating segment files, rotating at
ELYRASQL_BINLOG_SEGMENT_MB(default 128 MB). SHOW BINARY LOGSlists segments and sizes;PURGE BINARY LOGS TO '<name>'deletes older segments.--binlogandbinlog-replaynow take a directory.
Stored procedures
CREATE [OR REPLACE] PROCEDURE name() BEGIN ...; END,CALL name(), andDROP PROCEDURE— statement-list macros executed through the engine, with a recursion-depth guard. (Parameters, variables and control flow are not yet supported.)
0.7.0 - 2026-07-09
Durability & recovery release: point-in-time recovery, richer statistics, and semi-synchronous replication.
Point-in-time recovery
- Optional append-only binlog (
--binlog) records every committed write-set with an LSN and timestamp. elyrasql binlog-replay --data <f> --binlog <f> [--until-lsn N | --until-time-ms T]replays onto a restored backup (or an empty file) up to a chosen point — exact, idempotent recovery.
Statistics
ANALYZE TABLEnow collects per-column statistics (distinct-value count, null count, min/max), exposed viainformation_schema.column_statistics.- The planner drives a comma cross-join from the smallest analyzed table.
Replication
- Semi-synchronous mode (
--semi-sync-ms): a commit waits for a replica to acknowledge before returning, degrading to asynchronous on timeout or when no replica is attached. Replication is now bidirectional (replicas acknowledge applied LSNs).
0.6.0 - 2026-07-09
Scale & availability release: replication, partitioned aggregation spill, cost-based joins with statistics, and a Prometheus metrics endpoint.
Replication & HA
- Asynchronous primary → replica replication. A primary streams LSN-tagged
committed write-sets (
--replication-listen); a replica bootstraps from a snapshot and applies the stream (elyrasql replica), serving read-only queries. Idempotent, ordered application means replicas never diverge; failover is manual (a replica file is a complete database).
Aggregation
GROUP BYwith many distinct groups now falls back to partitioned spill aggregation (bounded memory) instead of erroring, completing the OOM-safety story alongsideORDER BYspill.
Query planning
- Equi hash joins now cover INNER / LEFT / RIGHT with a cost-based build side (INNER builds the smaller relation; RIGHT no longer degrades to nested-loop).
ANALYZE TABLErecords row-count statistics, surfaced asinformation_schema.tables.TABLE_ROWS.
Observability
- Prometheus/OpenMetrics endpoint (
--metrics-listen,GET /metrics) exposing the server counters, plus a/healthprobe.
0.5.0 - 2026-07-09
Operations & data-model release: observability, memory-bounded sorts, per-column collation, and scoped privileges.
Observability
SHOW STATUS/SHOW GLOBAL STATUScounters (uptime, connections, Questions/Queries,Com_*, Errors, Slow_queries), withLIKE 'prefix%'.SHOW [FULL] PROCESSLISTlisting live connections and their current query.- Slow-query log:
--slow-query-ms/ELYRASQL_SLOW_QUERY_MSlogs statements at or above the threshold with their duration.
Memory safety
ORDER BYis now memory-bounded: a top-N heap forORDER BY ... LIMIT, and an external merge sort that spills to temp files for large sorts (ELYRASQL_SORT_MAX_ROWS).GROUP BYfails gracefully pastELYRASQL_GROUP_MAX_GROUPSinstead of risking an out-of-memory crash.
Collation
- Per-column
COLLATE ..._bin/BINARYopt-in to case-sensitive behavior forWHEREcomparisons,UNIQUE,PRIMARY KEYand secondary indexes (text is still case-insensitive by default).ORDER BY/GROUP BY/joins still use the default collation.
Access control & integrity
- Per-table
GRANT/REVOKE(ON <table>): raises a read-only account's level for specific tables; reads stay globally allowed. Deny-safe when a target is indeterminate.SHOW GRANTSlists global and per-table grants. ON UPDATEreferential actions enforced (CASCADE / SET NULL / RESTRICT) when a parent's referenced key changes.
0.4.0 - 2026-07-09
Production-readiness release: backup, real user management, and a MySQL-style case-insensitive default collation.
Backup & restore
- Hot backup with
BACKUP TO '<path>'(admin): copies the whole database from a consistent MVCC snapshot into a fresh file without blocking writers. - Offline
elyrasql backupandelyrasql restoreCLI subcommands. - The backup is a complete database file — start a server on it or copy it back.
Users & access control
- Persistent accounts stored in the database file (survive restarts):
CREATE USER,DROP USER,ALTER USER/SET PASSWORD,GRANT,REVOKE,SHOW GRANTS. - New accounts start read-only;
GRANTraises them,REVOKElowers them. Privileges map to the coarse global read/write/admin levels (the object clause is parsed but not scoped). Passwords stored asSHA1(SHA1(pw)). - Authentication consults startup bootstrap accounts plus persistent accounts; open dev mode applies only when no account exists.
Collation
- Default case-insensitive collation for text, applied consistently across
comparisons,
ORDER BY, indexing,GROUP BY,DISTINCT, joins, set operations, andUNIQUE/PRIMARY KEY. - On-disk change: text key encoding is now case-folded. Databases created before 0.4.0 that use text primary keys or text indexes should be reloaded.
0.3.0 - 2026-07-09
Data-integrity release: the constraints a production database must enforce.
Constraints
- UNIQUE constraints are now enforced (previously stored but not checked).
Column-level
UNIQUE, table-levelUNIQUE(...), andCREATE UNIQUE INDEXall reject duplicates (error1062), including duplicates within a single statement; multipleNULLs are allowed. - FOREIGN KEY constraints are enforced. INSERT/UPDATE require a matching
parent row (primary key or unique index, error
1452); DELETE on the parent appliesRESTRICT/NO ACTION(block),ON DELETE CASCADE(delete children), orON DELETE SET NULL. - CHECK constraints (column- and table-level) are enforced on INSERT and UPDATE, passing on TRUE or NULL per SQL semantics.
Transactions
- SAVEPOINT, ROLLBACK TO SAVEPOINT, and RELEASE SAVEPOINT.
- SELECT ... FOR UPDATE / FOR SHARE: optimistic row locking — a locked row changed by another transaction aborts the locking transaction at commit (lost-update prevention without blocking).
Fixed
- Three-valued logic for comparisons:
NULL = x,x >= NULL, etc. now evaluate to NULL (UNKNOWN) instead of false. WHERE still excludes them, CHECK passes, and SELECT shows NULL — matching SQL semantics.
0.2.1 - 2026-07-09
Performance and robustness pass, verified on Linux (1,000,000-row workloads).
Performance
- Bulk
INSERT~5-6x faster (~33k → ~190k rows/s in a container, ~240k on fast-fsync storage). The 0.2.0 duplicate-key check did one storage read per row (each opening its own read transaction); it now:- detects duplicates inside the write transaction itself for plain
INSERT(redb returns the previous value — no existence read), and - batches the existence check into a single read for
IGNORE/REPLACE/ON DUPLICATE KEY UPDATE.
- detects duplicates inside the write transaction itself for plain
- Group commit for
INSERT: the writer coalesces queued plain/insert jobs into one transaction (one fsync), falling back to per-statement application only when a group contains a duplicate — so concurrent write throughput is preserved. GROUP BY~3.4x faster on low-cardinality groups (~927ms → ~273ms over 1M rows): the group key is a compact binary encoding instead ofDebug-formatting every row's key columns.- Statement dispatch inspects only a short prefix instead of lowercasing the whole (possibly large) SQL text.
0.2.0 - 2026-07-09
A large expansion of SQL coverage on top of the 0.1.0 foundation, turning ElyraSQL into a broadly MySQL-compatible engine.
Queries
- Subqueries in
WHEREand the SELECT list — uncorrelated and correlated, including correlated subqueries over joins (IN, scalar,EXISTS). - Derived tables (
FROM (SELECT ...) AS t). - Common table expressions (
WITH), including chained CTEs andWITH RECURSIVE. - Window functions (
OVER):ROW_NUMBER,RANK,DENSE_RANK, running and partitionSUM/COUNT/AVG/MIN/MAX,LAG/LEAD, and explicitROWS/RANGEframes. HAVING.- Set operations:
UNION,UNION ALL,INTERSECT,EXCEPT. FROM-lessSELECT(e.g.SELECT 1,SELECT NOW()).
DML
INSERT ... SELECT.- Upserts:
REPLACE,INSERT IGNORE, andON DUPLICATE KEY UPDATE(with correct secondary-index maintenance and duplicate-key error1062). - Subqueries in
UPDATE/DELETEWHERE(uncorrelated and correlated). - Multi-table
UPDATEandDELETE(joins in mutations).
DDL
CREATE TABLE ... AS SELECT,CREATE TABLE ... LIKE,TRUNCATE TABLE.CREATE VIEW/DROP VIEW(including column lists and views over views).ALTER TABLE ... MODIFY/CHANGE COLUMN, andALTER COLUMN SET/DROP DEFAULTandSET/DROP NOT NULL(with data re-coercion on type change).- Column
DEFAULT(constants and functions),AUTO_INCREMENT, and stored generated columns. ENUM/SET,BINARY/VARBINARY, andBITcolumn types.
Functions
- Date/time:
NOW/CURRENT_TIMESTAMP/CURDATE/CURTIME,YEAR/MONTH/DAY/HOUR/MINUTE/SECOND,QUARTER/DAYOFWEEK/DAYOFYEAR,EXTRACT,DATE_ADD/DATE_SUB/TIMESTAMPADD,DATEDIFF/TIMESTAMPDIFF,WEEK/YEARWEEK,DATE_FORMAT,STR_TO_DATE,LAST_DAY, and thed + INTERVAL n UNIToperator. - String:
CONCAT/CONCAT_WS,UPPER/LOWER,SUBSTRING/SUBSTRING_INDEX,LEFT/RIGHT,TRIMfamily,REPLACE/REVERSE/REPEAT,LPAD/RPAD,INSTR/LOCATE,FIELD/ELT, andREGEXP/RLIKE. - Math, conditional (
COALESCE/IFNULL/NULLIF/IF/CASE),CAST(including exactDECIMALandBINARY),UUID(). - JSON:
JSON_EXTRACT/->/->>,JSON_ARRAY/JSON_OBJECT,JSON_SET/JSON_INSERT/JSON_REPLACE/JSON_REMOVE,JSON_CONTAINS/JSON_LENGTH/JSON_KEYS/JSON_TYPE/JSON_VALID/JSON_QUOTE. - Aggregates:
GROUP_CONCAT, conditional aggregates (SUM(CASE ...)),COUNT(DISTINCT expr). - Bitwise
&,|,^.
Transactions
- Write-conflict detection (first-committer-wins, MySQL error
1213). - Opt-in serializable isolation with read-set and scanned-range validation.
Introspection
SHOW TABLES,SHOW COLUMNS,DESCRIBE/DESC,SHOW CREATE TABLE,SHOW INDEX/SHOW KEYS.- Queryable
INFORMATION_SCHEMA:tables,columns,statistics,key_column_usage.
Numerics & wire protocol
- Exact
DECIMALarithmetic (+,-,*) and exactSUM(DECIMAL). - Value-driven result column typing (computed columns report the right wire
type; no spurious
.0). DATE/DATETIME/TIMEprepared-statement parameters decoded from the binary protocol.
Fixed
DateTimevsDATEcomparison (previously always false).DROP TABLEleft orphaned secondary-index entries.INSERTaffected-row count included index-entry writes.
Docs & project
- MkDocs Material documentation site, contributing guide, issue/PR templates, security and conduct policies, Dependabot configuration.
0.1.0
Initial release: single-file ACID storage (.edb), MySQL wire protocol,
core CRUD with WHERE/ORDER BY/LIMIT, indexes, aggregation and GROUP BY,
joins, prepared statements, authentication and TLS, vector search (exact +
HNSW), parallel OLAP aggregation, and transactions with snapshot isolation.