All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
[0.1.47] - 2026-10-15
Fixed
Client.Localput the;after a bare statement word on the wrong line. In a text of several statements, a statement word with nothing after it but a;(SELECT 1;\nANALYZE;) was answered with the;on line 1, at a column past the end of that line. InfluxDB 3 Core 3.10.1 answersExpected: identifier, found: ; at Line: 2, Column: 8, and the double now does too.Client.Localread a text of many statements in quadratic time. Each statement was read after the whole text before it, blanked again. A statement is now read alone, from the line and column it starts at.
[0.1.46] - 2026-10-14
Fixed
The shipped usage rules said
Client.Localignores a database's retention.usage-rules/testing.mdsaid the double "stores no retention, so nothing expires in tests". The double applies it as InfluxDB 3 does:- a write is never refused for a point's age;
- a query hides a 10-minute chunk of a table once its newest point is older than the retention period.
The rule now says so, and how to write a point a test wants gone.
[0.1.45] - 2026-10-13
Fixed
The shipped usage rules described an older
Client.Local.usage-rules/query.mdsaid the double refuses InfluxQL'sGROUP BY time(...)andfill(); it answers both, everyfill()option included.- It also listed SQL
HAVINGas rejected; the double answers it. usage-rules/testing.mdsent CTEs andmedianto the integration tier, which the double runs.usage-rules.mdgavepoint/3's arguments as measurement, tags, fields. It ispoint(measurement, fields, tags: ..., timestamp: ...).usage-rules/write.mdsaid the batch writer discards every 4xx. It retries 408 and 429 as well.
The rules now list what the double refuses: joins other than
CROSS JOIN, set operations, subqueries, window functions, and atimecomparison inside an InfluxQLOR.
[0.1.44] - 2026-10-12
Fixed
- Local's
SHOW TAG KEYS ... WHEREcosts a line in its condition (design document2026-10-11_review-of-influxql-show-cost.md). Over a measurement with no tag, everytimein the condition was checked against its neighbours by a walk of the token list, a square in the condition's length. Each token is now read in one pass. A chain of 500timecomparisons cost 7.6 times one of 125; it now grows as the condition does. - Local's Flux engine, as InfluxDB 2.7 (design document
2026-10-10_review-of-local-flux.md):- An integer or unsigned
sum()wraps in 64 bits, andmean()divides the wrapped sum. They used to return the exact sum. - String literals read Flux's escapes, so
r.t == "a\\b"matches the taga\b, andyield(name: "\"q\"")names"q". Other escapes and${interpolation are refused. - An argument given twice is the engine's 400,
duplicate keyword parameter specified. An argument a stage does not take, a number with an exponent, an RFC3339 time without itsT, and a result named by an expression are refused by name. They used to be read. -0is not pushed to storage, as on the engine.
- An integer or unsigned
- The Flight transport and the HTTP client (design document
2026-10-10_review-of-flight-and-http.md):- A decimal at a negative scale over Flight (
CAST(1234 AS DECIMAL(5,-2))) is the integer HTTP gives,1200. The whole query used to fail with a decode error. - A decimal column of a width Arrow does not define is
{:unsupported_arrow_type, "Decimal<width>", column}. A width of 0 used to read the body without end. - A Flight channel opened just as
:connect_timeoutran out is closed. It used to stay open with no owner, and a result arriving late could be left in the caller's mailbox. accept_partial:orno_sync:with a value that is not text is{:error, {:invalid_value, key, value}}from the HTTP client. It used to raise.execute_sql/3,list_databases/1andlist_buckets/1return an error for an answer of the wrong shape (an array holding something that is no object, abucketsthat is no list). They used to raise, andexecute_sql/3returned a body such astrueas{:ok, true}.
- A decimal at a negative scale over Flight (
- Connections, the batch writer facade and query parameters (design document
2026-10-10_review-of-connections.md):finch_name: nilstarts the connection's own Finch pool, which the HTTP client then uses. It used to start none, so every request failed with no pool.- A connection's registration is made and released by the connection's supervisor alone.
add_connection/2erased the name after a failed start andremove_connection/1after a removal, which could erase a connection another call had just started under the same name, and raised for a name that is not an atom. The release shuts down the connection it registered with the client that made it, even if the name was erased or the configured client changed in the meantime. flush/2,stats/2andwrite_batched/3return{:error, :no_batch_writer}when the writer stops with the call still waiting in its mailbox. They used to exit the caller.- Two query parameters with one name (
[a: 1, a: 2],%{:a => 1, "a" => 2}) are{:error, {:invalid_param, "a", :duplicate_key}}. One of them used to be sent, picked by the order of the entries. - The line count of each write and batch is one scan that builds no list.
LineProtocol's docs listed=and"among the characters a v2 measurement escapes; it escapes only spaces and commas, as the code does.
Client.Local's buckets, writes and stopped connections (design document2026-10-10_review-of-local-store-and-writes.md):create_bucketanswersretention:as InfluxDB 2.7 does. A negative value is its 422,nilis no expiry, and any other non-integer is refused by name. It used to be stored, and the next write raised.- Creating a bucket that exists is
:okand keeps the bucket's retention, as over HTTP. It used to overwrite the retention. - A write racing
delete_databaseordelete_bucketno longer leaves points behind that a database later created under that name would show. On v3 the write creates the database again; on v2 it gets the bucket's 404. - Every operation on a stopped connection answers
Client.Local: the connection is stopped. It used to raise an ETSArgumentError. This also covers a connection whose starting process has exited. - A connection that
start/1did not return, such asClient.HTTP's keyword config, is refused by name (Client.Local: the connection is not one Client.Local.start/1 returned). It used to raise aFunctionClauseError. start/1raisesArgumentErrorfor an:orgthat is not a string, as for its other options. It used to be stored, andlist_bucketsraised.- A large payload with an early bad line no longer copies every line into each parsing chunk.
- A line refused on a partial v3 write types its fields in the line's order, up to the
refused one, as Core does. It used to go in name order, so
m b=1,a=2.0withaan integer leftbuntyped.
- Query results, health and streams (design document
2026-10-10_review-of-query-and-admin.md):Client.query_resultincludes the binary thatformat: :parquetreturns, which its spec did not allow.Client.HTTP.health/1returns a passing check for a 200 whose body is JSON but not an object (true,"OK"). It used to return the decoded value, not the map its spec promises.- A streamed SQL query splits only each new chunk into lines, so a long row sent in many
chunks is no longer re-scanned once per chunk. A blank CRLF line between rows is skipped
instead of raising a
:decodeStreamError. - The telemetry examples show the client module as
transport:, as the events carry it.
Client.Local.query_fluxanswers as InfluxDB 2.7 does for filters on a key a row lacks, for everyyield, and for string and boolean fields (design document2026-10-10_local-flux-pushdown-and-yields.md; every rule below was read from 2.7.12):filter(fn: (r) => r.nosuch != "a")returns every row when it is pushed to storage (the filters right afterrange, each pushed whole, split at their top-levelands:==/!=of any key,_valueagainst a literal,and/orof such), where a key the series lacks is the empty string for!=; afterlimit, a selector,yield, or a filter that was not pushed whole it is Flux, where it is null and keeps nothing. The old rule, "a key the row lacks never matches", was true only for the second. Flux'sand/orare no longer three-valued: the left side decides (null or falseis false,null and xis false). A literal of another type than the key matches nothing in storage; in Flux it is an error, and so is a time column in a comparison, so these are refused by name (Client.Local: comparing the string column h with a number ...), as are ordering against a boolean, ordering_valueagainst a string and a_valuecomparison on an unsigned field.- Every
yieldis a result of its own (its name in"result", tables numbered from 0 in each), and a last stage that is not ayieldis_result:yield(name: "a") |> sum()returns the rows before the sum asaand the sums as_result, where the double returned only the sums. InfluxDB sends the results in no fixed order; the double in pipeline order. A name used twice, or_resultbeside a last stage, is the engine's 400; ayieldwith no name is refused by name.rangemust be the first stage, else the engine's "cannot submit unbounded read". min()andmax()of a string or boolean field returned rows; InfluxDB answers a 500panic: unsupported for aggregate max: *reads.stringMultiShardArrayCursor(a positioned 400 afterlimit, a selector or a Flux filter), so they are refused by name.sum()andmean()of one keep the engine's 400unsupported input type for sum aggregate: stringwhere storage reads them (booleanwhen both kinds are there) and are refused by name where the engine's error holds a position or depends on which series storage dropped. A selector or aggregate that Flux runs emits its tables sorted by group key (_field, then the tags), where storage reads the series order.- The part
:pushdownof the Flux contract (17 tests) runs onClient.Localand on 2.7.
- Two more raises on caller input:
InfluxElixir.ConnectionSupervisor.start_link/1with a config that is not a keyword list holding an atom:namereturns{:error, {:invalid_options, config}}.InfluxElixir.write/3with a connection that is no connection (a host name, a number) returns{:error, {:invalid_connection, term}}, as the query functions do.
- Review of 71e2b8c, second half:
BatchWriter, the connection,Client.HTTP(design document2026-10-10_review-of-71e2b8c.md, "Second half"):BatchWriter.write/3andwrite_sync/3with a list of Points, iodata ornilraised aFunctionClauseErrorin the writer, which killed it and exited every caller. They take a Point, a list of Points, a binary or iodata; anything else is{:error, {:invalid_payload, payload}}and the writer keeps running. A Point is encoded in the caller.- The writer retried errors that can never succeed: a connection with no database retried
{:error, :no_database_specified}max_retriestimes, as did{:invalid_*, _},{:unencodable_body, _}and{:invalid_options, _}. One classifier decides, for the retry handler and the first attempt alike: a transport error ({:connection_error, _},:pool_timeout,:timeout), a 5xx, 408 and 429 are retried; every other error is discarded at once. The backoff (base * 2^attempt) was uncapped;:max_retry_delay_ms(default 30 000) caps it before the jitter. - The writer made each HTTP request inside its own callback, so
stats/1exited with:timeoutagainst a slow server,write/3andflush/2queued behind the request, and awrite/2that completed abatch_size: 1batch blocked its caller until the server answered. The requests are made by a monitored process (not linked), one at a time and in the order the batches were taken, and the writer handles the answer as a message.flush/2returns when the requests ahead of it, and its own first attempt, have finished;write_sync/3when its batch has been written or has failed for good;terminate/2waits for the request in flight, then writes the batches still to retry and the buffer as before.stats/2takes a timeout (default 5 s). The:hibernateafter every timer tick, which forced a full garbage collection each interval, is gone. batch_sizeand the backpressure bound (10 * batch_size) counted writes, not lines, so a write of many lines was one. They count lines.- A retry re-sent an untimed point, which the server stamps with its own clock: a server that
committed a write that then timed out stored the retry again, at a later time. A Point
without a
:timestampis stamped in the caller (see Changed). - A
BatchWriterstarted withconnection: :namecrashed on its first flush (Access.get(:name, :database)).Writer.write/3resolves a registered connection's name; one that is not registered is{:error, {:invalid_connection, name}}. InfluxElixir.write(conn, %Point{})was{:error, {:invalid_body, %Point{}}}. It takes a Point and a list of Points, encoded for the call's:precisionand the connection'sapi_version.ConnectionSupervisor: a client whoseinit_connection/1returned{:error, reason}raised aMatchErrorin the supervisor's init;start_link/1returns{:error, reason}. A supervisor that stopped (too many restarts, or any stop butremove_connection/1) left its name resolving to a dead connection and, forClient.Local, its store allocated: a first child,Registration, erases the name and releases the client's state whenever the supervisor stops. The call that reaches a connection in the moment before its pool has started is{:error, {:connection_error, :no_pool}}.- A failed connection start returned its whole config,
:tokenincluded, in the error and printed it in the logs of the failure. The child spec and the supervisor's argument carry the token opaque (it prints as#InfluxElixir.ConnectionSupervisor.Secret<...>); the connection's own:tokenstays a binary. The connection'sBatchWriteris started with the connection's name, so its child spec holds no config. Connection.finch_name/1ignored a connection's:finch_name; it returns it.Writer.write/3split every body into lines to count them for the telemetry metadata, with or without a handler. It counts newlines in the binary (point_countof a body with a blank line in the middle is one more than before, a trailing newline is still not a line).
- Documentation:
Query.Fluxdocuments that:orgdefaults to the connection's.Query.SQLStreamdocuments:timeoutand:pool_timeout.ConnectionSupervisorno longer speaks of several writers per connection.- The README's link to the testing guide works on GitHub.
Client.HTTPandFlight.Clientare listed in HexDocs' module groups.
- Review of 71e2b8c: Flight types, line-protocol escapes, Flight timeouts, raises (design
document
2026-10-10_review-of-71e2b8c.md; each verified against InfluxDB 3 Core 3.10.1 and InfluxDB 2.7.12):- Flight read every unsigned integer as signed (
UInt8200 was -56,UInt642^64-1 was -1): FlatBuffers leave out a field that holds its default, andInt.is_signeddefaults to false. AFloat16column was read as a double and dropped from the row; it is now read (subnormals too), and a Float16 or Float32 is the shortest decimal that reads back, as HTTP prints it (0.1, not0.10000000149011612). An infinity or a NaN isnilin the row (a present key), as HTTP'snull; aDate64is aDateTime, as over HTTP. Six fixtures were captured from Core (unsigned_ints,unsigned_casts,float16,float32,float_lists,dates). LineProtocol.encode/2takesapi_version: :v3 | :v2(default:v3). InfluxDB 2.7 never turns\\into one backslash in a name, so a tag valuea\bwritten for v3 was stored there asa\\b; with:v2a name's backslash is left as it is, and the characters 2.7 undoes are escaped per position. 2.7 cannot keep a measurement with a backslash before a comma, space,=or", nor a field key with one before a comma, space or=(a204and no query returns the points, or a parse error)::v2refuses them as{:invalid_measurement, name}and{:invalid_field_key, key}.BatchWriterencodes aPointfor its connection'sapi_version. NewConnection.api_version/1.LineProtocol.encode/2never raises: a term that is not aPoint, or a list that is not proper, is{:error, {:invalid_point, term}}, bad options are{:error, {:invalid_options, opts}}; a point withniltags has none, withnilfields is:empty_fields, and with tags or fields that are not maps is an invalid point.Point.new/3takesnilfields andniltags as empty.- Flight
:timeoutis a wall-clock bound on the call and its whole stream, run in a monitored process: a peer that accepts and stays silent is{:error, :timeout}(it held the caller for 5 s, or 60 s until the peer closed). The connection's:timeoutreaches the Flight transport throughClient.HTTP.query_sql/3. A failed Flight query is{:error, %{status: http_status, body: message}}as over HTTP (gRPC 3 is 400; the statuses 3 and 13 checked on Core, the rest the standard mapping; the differences are documented onInfluxElixir.query_sql/3). An empty token sends noauthorizationmetadata, and the ticket is encoded once. Client.HTTP: a Finch pool that is not running is{:error, {:connection_error, :no_pool}}(it raisedArgumentError: unknown registry; a streaming query raises theStreamErrorwith that reason when enumerated), and a keyword connection with neither:finch_namenor:nameis{:error, {:connection_error, :no_pool_name}}(it raisedKeyError). A query parameter Jason cannot write (an improper list, a tuple map key) is{:invalid_param, name, :unsupported_type}.InfluxElixir.query_sql/3,execute_sql/3,query_influxql/3andquery_flux/3with a connection that is neither a keyword list nor a map are{:error, {:invalid_connection, term}}(they raised in the telemetry span).format: nilis the default format on both query languages (InfluxQL sent"format": ""and got a 400; SQL sent none and failed to parse the answer).Client.Local(:v2): undoing a backslash that precedes no escaped character copied the rest of the text each time (200,000 of them took 1.0 s on a tag value, now 40 ms).
- Flight read every unsigned integer as signed (
Client.LocalSQL and InfluxQL: fifth review of the differential fuzz (design document2026-10-09_sql-influxql-fuzz-fifth-review.md; 20,688 statements compared with Core, none that matched before differs now):- SQL: the precedences of
||(as*),<<and>>(as^),&,|,^and the regular expression operators (as=) are the parser's, in one table that the right operand of every operator and the low bound of aBETWEENare read from ('a' = 'b' || - - ... 1refuses at 46 signs as Core does,1 BETWEEN 1 & 2 AND 3reads);CEIL,FLOOR,SUBSTRING,SUBSTR,OVERLAY,TRY_CAST,EXTRACT,POSITIONandTRIMare read, so that a nest in their arguments is the recursion error where Core gives it (it was skipped), and a syntax error in one is the parser's; the operand of anINSERT,UPDATEorDELETEis written without a pair of parentheses per sign (UPDATE ... SET v = - - ... 1refused at 25 signs, Core answers to 48); a text over 8 KB with a/*or a$is read whole, so that an unterminated comment after the window is the tokenizer's error; the pattern of aLIKEthat goes on with a comparison,INorBETWEEN, andCEIL(x, <not a number>), are refused by name where they answered in another way than Core. - SQL: a run of blanks in a clause is read in linear time (
SELECT 1+ 32,000 blanks took 3.6 s, aWHEREwith them 15.8 s). - InfluxQL: the quotient of two integers is an integer beside an unsigned number (
u + n / 1 = bisUInt64 = Boolean, its value the float cut to an integer: 502 of 1,120 comparisons differed); acrossAND/ORthe errors of building a comparison come first, then the firstUInt64 = Boolean, then the first division that waited (u + u / s = b OR u + u / s > 1);- + 'x'is the unary error;GROUP BY time(((1m)))is refused by name (it was a parse error Core does not give). - InfluxQL: a select item nested or chained deeper than Core answers is refused by name, like a
WHERE(Core dies onabs(abs(...))past 625, parentheses past 1,604, a sum past 800 terms,n + (n + ...)past 729,-(-(...))past 802 in the select list). - Refactors: one state map for a SQL read (
with_state/1), the time comparand onInfluxQLWalk,InfluxQLWalk.Optsnarrowed toctx,callsandstate, the kind:false_constant, the deadInfluxQLDepth.budget/0andmessage/0removed.
- SQL: the precedences of
Client.Local: three more differences from Core found while covering this round's code:- A chain ending at the bare
time(time > 5 < time) is Core's 500expected Expr. - A sign with no operand after a comparison (
n > - = 2) is Core'sinvalid conditional expression. - SQL
ANY,ALLandSOMEafter an operand are read as Core reads them, as an alias or a stray word. They were refused.
- A chain ending at the bare
Client.LocalInfluxQL: a condition nested or chained deeper than Core answers is refused by name (design document2026-10-09_influxql-fuzz-fourth-review.md): InfluxDB 3 Core 3.10.1 overflows its stack (the server dies) onabs(abs(...))past about 480 deep,(v + (v + ...))and(ok = (ok = ...))between 700 and 900, a sum ofvpast 820 terms,-(-(...))past 800 and groups that alternate (ok = (v > 0 AND ok = ...)) past 406; the double answered all of them, 800 and 8,000 deep.WHEREis now read once before anything else, as a path whose nodes cost what the measured depths say (a call 20, a sign 6, an arithmetic operator 10, a comparison 7,AND/OR7, a parenthesis 6), and one past 8,020 isClient.Local: unsupported InfluxQL (a condition nested or chained deeper than 8020 units, ...), status 400: 400 calls or 400 alternating groups, 802 added terms, 615(ok = ...)groups and 1,146ANDs still answer as Core does (the statements the contract pins, 400 groups and 800abs(1), are among them).Client.LocalSQL: the parser's recursion limit (design document2026-10-09_influxql-fuzz-fourth-review.md): Core's SQL parser stops at 50 levels of recursion; the double answered a statement nested 12,000 deep and was killed by 40 MB at 20,000. It now counts as the parser does (SQLSyntax): a statement isSQL error: RecursionLimitExceeded (current limit: 50), status 400, exactly where Core gives it:(v + (v + ...))of 24 operands answers, of 25 refuses;- - vandabs(abs(...))answer to 47 levels; an operator that binds tighter after another climbs one level deeper ((v + 2 * (...)): 15 levels). 47NOTs in a row end at the parser error Core gives (the limit is swallowed by the retry ofNOTas a name).CASE,CAST,NOT (...)nested past 20, and subqueries past 22, are refused by name (the engine's time grows with their depth, and its answer past the limit was not probed), as is a statement the double does not parse (a call it skips, a shape ofINSERT,UPDATE,DELETEit does not read whole) whose parentheses or prefix operators nest past 40. A long text is refused from its first 8 KB: a statement 20,000 deep is refused in milliseconds and little memory, inquery_sql,query_sql_stream,execute_sqlandcheck_sql.INSERT,UPDATEandDELETEare counted too (the values, the query, theSETvalues and theWHERE), at the depth Core gives:INSERT ... VALUES (coalesce(..., 1))answersDML not supportedto 47 levels and refuses at 48,UPDATE ... SET v = ...andDELETE ... WHERE ...answer to 48 and refuse at 49 ((v + (v + ...)): 23 and 24 for anINSERT, 24 and 25 for the others).UPDATE ... WHERE n div 1now gives the parser's own error, as Core does.Client.LocalInfluxQL: fourth review of the differential fuzz (design document2026-10-09_influxql-fuzz-fourth-review.md):- The errors of a comparison come in the order the planner builds them, calls and arithmetic
alike:
u + b > abs(s),(u + b) * abs(s) > 1,time + 1 > abs(s)and(u + 's') / abs(s) < 1.5are the coercion (they were the call's error),(u + 's') + abs(s) * 2the call. - A comparison with the bare time beside a bare operand is the error of the planner's split
(
time > n AND b:found symbol 'n::integer', ..., a boolean, a function, arithmetic of them, a chaintime > 0 < 1), not a 500invalid expr stack; what is not verified is refused by name. - An integer expression beside the quotient of an unsigned number and text (
n + (u / s) > 1,(u / s) < n,abs(n) / (u / s),(1 + 2) + (u / s)) iscannot use + between an integer and unsigned, an explicit cast is required; no null takes it away. A null does take away the error of a quotient across a comparison (u / 1h > nosuch,u / b > nosuch),abs()of a quotient included; of two such errors the engine's one is raised. - Arithmetic of the time that comes to a null (
n + b + time + 1 > b) keeps no point (it was the SQL engine's coercion error). - An unsigned division by zero fails the stream in the order the engine reads the points (series
by series, then by time), among the points the tags and times of the
WHEREkeep: it is refused where the zero is not last, and answered (t = 'b' and n / u > 1) where those comparisons leave it out. - Changed:
SQLExprreads an expression in time linear in its length (a statement nested 4,000 deep took 638 ms, now 214); one walker and one typer for the arithmetic of a condition (InfluxQLWalk,InfluxQLArithType;InfluxQLCallsremoved).
- The errors of a comparison come in the order the planner builds them, calls and arithmetic
alike:
Client.LocalInfluxQL: second review of the differential fuzz (design document2026-10-09_influxql-fuzz-second-review.md):- Arithmetic with a column the measurement lacks is typed as Core types it, by the types of the
two operands:
nosuch + 1h = ok,nosuch + time = ok,-nosuch + 'x' = ok,nosuch + ('x' + 'y') = ok,nosuch + nosuch2 + 'x' = okand the like keep the pointsokis false for (they answered[]). The coercion errors of either side of a comparison are raised in the order written:host + 'a' = nosuchand(w > 1) = host + sareDictionary(Int32, Utf8) + Utf8, the time beside a number or a string, an unsigned number beside text, two times (Cannot get result type for temporal operation). A null number beside an unsigned one isUInt64 = Booleanbeside a boolean. - A comparison chained with another (
a = b = c) is the first compared with the last, whatever the last is. - The retention policy of a three-part source is printed as Core prints it in the database
mismatch and in the 404 (
rv23/"x y",rv23/"select"; a backslash, a double quote and a line feed escaped), and so is a reserved word in the name of a column ("select"_k). Reserved words and empty quoted names may qualify a source (db.select.m,db."".m,rp.""); the last part may not be reserved;//is a regular expression that matches every measurement. - A quoted time that ends in a line feed (
'2023-10-01\n', the\nescape) is "not a valid timestamp", as it is for Core, in every form (it read as the time). (time > 0) = okand the like are Core'sTimestamp(ns) > Int64without thetype_coercionwrapper the SQL engine words it with.- A name of more than three parts in the select list (
SELECT a.b.c.d FROM m) is Core's parse error (the whole statement left unparsed; in the arguments of a call aFailurefrom the dot). - Integer arithmetic of constants wraps at 64 bits as Core's does (
n > 9223372036854775807 + 1isn >the lowest integer); arithmetic with an unsigned constant is refused by name. - The unterminated string right after the comma of a source list is the lexer's error.
- A column the measurement lacks beside the time in a condition in parentheses, and a duration
beside such a condition, are refused by name (the first answered
[], the second leaked the SQL). - Memory and time: a condition in parentheses nested 400 deep needs 0.8M words, not 6M (800 deep:
1.5M, not 20M); a sum of 3,000 calls takes 0.3 s, not 70 s; a list of more than 2,500 sources, a
statement of more than 262,144 bytes, and more than 20,000 points repeated over all the
measurements of a
FROMare refused by name before they are read (a list of 100,000 sources killed a process capped at 5M words). - Refactors: the type of arithmetic is
InfluxQLFold;InfluxQLNested.classify/3returns data thatInfluxQLWhererenders;Tokens.at_top_level/2,take_call/1,lacking/2andabsent_columns/3replace five depth walkers and five spellings of "a column the measurement lacks";InfluxQLError.refuse/1,InfluxQLSql.never/0andInfluxQLText.quoted_if_needed/1are written once each. - Constants and signs:
- A null divided by an integer is a null integer.
- The difference of two timestamps is refused by name. It answered
[]. - The lowest 64-bit integer behind signs is
Int64;- 9223372036854775808 tz('UTC')raised. - Signs outside parentheses keep the literal's type:
-(9223372036854775808)isUInt64, as in Core.
- Arithmetic with a column the measurement lacks is typed as Core types it, by the types of the
two operands:
Client.LocalInfluxQL: third review of the differential fuzz (design document2026-10-09_influxql-fuzz-third-review.md):- An integer written past the signed range is an unsigned constant everywhere it is typed:
9223372036854775808 = (n > 1),-(9223372036854775808) = ...,1 * 18446744073709551615 = ...areUInt64 = Booleanandt * 18446744073709551615 = ...the coercion error (they answered[]). Behind a minus sign the lowest signed integer staysInt64. - Arithmetic with a column the measurement lacks that stands alone, or beside a bare operand of
an
AND, is typed by its operands:nosuch * 'x' * uisBoolean * UInt64,-nosuch + utheBoolean AND UInt64of a bare unsigned operand,nosuch * 'x' + timeBoolean + Timestamp(ns)(they answered[]).abs(nosuch + 'x')is the planner's error for a boolean argument. - An unsigned number divided by a boolean, the time or a duration is the planner's comparison of
that operand with
UInt64at once (u / b = 1,u / time,18446744073709551615 / b), and text or the time divided by an unsigned number is the quotient too (time / u = b). The quotient of an unsigned number and text is null beside a null (-nosuch + u / s = b), null beside a float when the other side is no number, the outer operation's error beside text, and the error of the connective's second comparison wins over its division (t / u = 1 OR t / u = b). -(time) = bisInt64 * Timestamp(ns); the sum, product or quotient of two times is the plain temporal error, with notype_coercionwrapper;-(nosuch + 'x')is a null.- Only a comparison with the bare
timeon a side breaks the planner's stack beside a bare operand:time * 2 > 1 AND b,n * time = 1 AND bandtime + 1h > 0 AND nare the coercion or comparison errors (they wereinvalid expr stack),nosuch * time = 1 AND bis[]andnosuch * time * nosuch2 = 1 OR bkeeps the pointsbis true for.time + 1h < now()and-(time) = now()are "not implemented: now". A comparison with the time in arithmetic beside a bare operand that the SQL would raise on ((time) > 0 AND n) is refused by name (it answered[]). - An unsigned division by a zero the points make (
1 / u = 1,u / u = 1,n / u > 1), which fails Core's stream half way over three points or more, is refused by name. - Time:
abs(abs(...(v)...)) > 0nested 500 / 1,000 / 4,000 / 8,000 deep takes 0.17 / 0.05 / 0.23 / 0.43 s (3.6 s, killed, killed, not finished);(ok = (ok = ... (v > 1)))1,500 / 6,000 / 12,000 deep takes 0.24 / 1.8 / 6.0 s (1.2 s, 44.9 s, killed). - Refactors:
InfluxQLFoldis the only typer of arithmetic andFold.walk/2the one precedence walker (InfluxQLWhereArith's evaluator,InfluxQLTyped's constant folder and the calls' reader run handlers over it; 354 + 76 lines deleted);InfluxQLCallsreads the calls of a condition in one pass; the groups of a condition are read once; the deadconstantflag, the seven clauses ofFold.nulls/4(now a table) and the list of nulls written twice are gone. - Coverage pass over the typer, the calls and the planner (design document
2026-10-09_influxql-fuzz-third-review.md, "Coverage pass"):abs()with the wrong number of arguments names each by the type Core gives it (abs(u, 1) = 1isabs(UInt64, Int64),abs(nosuch, 1)Null,abs(1h, 1)Interval(MonthDayNano); they all readFloat64), and the coercion error of an argument's arithmetic comes before the call's own (abs(u + 's', 1) = 1);abs()of a duration is the planner's error for a duration; a bare operand that is computed (nosuch + 1,u + 's',abs(),now(),abs(now())) or that stands on a measurement that does not exist breaks the planner's stack beside a comparison of the time, as any other does (they answered rows, the call's error or "not implemented: now"); a parenthesis behind an operand in the arguments of a call (abs(1 (2)),abs(1h (1)),abs(true (1))) is the parser's failure at the parenthesis (it reached the SQL);abs()of a null as a bare operand (abs(nosuch + 1),abs(-s)) is refused by name (it answered[]). The dead leftovers of the refactor are gone: the unsigned/boolean clash ofInfluxQLWhereArithandInfluxQLTyped(InfluxQLNestedraises it first),Typed.mismatch/3, the:neveroutcome and the catch-all ofTyped.decide/2,:hugenumbers (the lexer refuses them), the missing-argument machinery of the walker (the parser refuses those statements) and the defaults oftyper/2andstandalone/4.
- An integer written past the signed range is an unsigned constant everywhere it is typed:
Added
InfluxElixir.write_batched/3: buffers a Point, a list of Points or line protocol on a connection'sBatchWriter(sync: truewaits for the batch,timeout:bounds the call). The writer was unreachable from the public API: the facade hadflush/2andstats/1only.InfluxElixir.write/3andWriter.write/3accept aPointand a list of Points.BatchWriteroption:max_retry_delay_ms(default 30 000),BatchWriter.stats/2andInfluxElixir.stats/2with a timeout, andBatchWriter.write/3accepting iodata and Points.
Changed
- A
Pointwritten throughBatchWriterwithout a:timestampis stamped with the time of thewrite/3call (aDateTime, written in the writer's:precision), where it used to be left for the server to stamp when the batch was sent. Points of one batch no longer share the server's time and are not stored a second time by a retry. Line protocol you encode yourself is sent as given. BatchWriter:batch_sizeand the backpressure bound count lines. A permanent error is discarded at the first attempt (see Fixed). Requests are made by a process of its own, so tests that write withbatch_sizereached and read at once mustflush/2(or write withwrite_sync/3) first: a write that completes a batch returns before the request.- The six manual emitters of
InfluxElixir.Telemetry(write_start/1,write_stop/2,write_exception/2,query_start/1,query_stop/2,query_exception/2) are deprecated in the documentation: the library never calls them andspan_write/2andspan_query/2emit the same events, paired. They are not removed. InfluxElixir.Config.base_url/1is the one place the request URL is built (Client.HTTPhad a private copy with its own defaults): a missing:schemeis:https, a missing:portis8086, and it no longer raises on a missing key.- Internal:
Client.HTTPis split intoClient.HTTP.Request(shared request helpers),Client.HTTP.BucketsandClient.HTTP.Stream(all@moduledoc false; its public functions are unchanged), its seven JSON POSTs and two DELETEs share one function each, andClient.Optionsholds the option, iodata and database reads that six modules repeated.
[0.1.43] - 2026-10-09
Fixed
Client.LocalInfluxQL: review of the differential fuzz (design document2026-10-09_influxql-fuzz-review.md): a parenthesised condition beside arithmetic (v > (w > 1) + 1,v > 1 + (w > 1),-(w > 1)) is the parse error Core gives (Nomat the operator,Failureafter+/-); a comparison with a condition in parentheses,trueor arithmetic of a missing column with text as an operand answers as Core does ((w > 1) = ok,true = (w > 1),'x' + v > (w > 1), chained comparisons) and its inner condition is typed;FROM m, m.is left over from the comma, qualified sources (rp.m,db.rp.m,db..m) name the database they run in (FROM m..xis Core's database-mismatch error), sources qualified differently arecan only perform queries on a single database; atz()zone other than'UTC'with a clause behind it is refused (Core looks the zone up first); a name that needs quoting is named with its\and"escaped ("x\"y"_k); equal select items are numbered past every name the list writes (v, v, v, v_1isv, v_2, v_3); a backslash in a quoted name or string is read as Core reads it (\\,\n) and any other escape is itsinvalid escape sequenceerror. Deeply nested parentheses are scanned in one pass (depth 1600 was killed at 5M words), and a measurement named inFROMmany times over more than 100,000 points is refused by name. The FROM grammar, the blank skipper, the math function list, the clause ranks and the nested-condition rules are written once each.Client.LocalInfluxQL: constants in a comparison:- A literal past what a float holds (
usage > 1 * <330 digits>.5) raised. It is now refused by name; Core answers no rows. - Two comparisons answered differently from Core and are now refused by name:
- a float field against an integer division (
usage > 85 / 2), which Core divides as floats; - a duration beside a condition in parentheses.
- a float field against an integer division (
- A literal past what a float holds (
Client.LocalInfluxQL: a differential fuzz against Core 3.10.1 (design document2026-10-09_influxql-differential-fuzz.md): a text with no statement keyword,SELECTandSHOWglued to a character, junk after the sources and between clauses, the list of sources (m-1,m, limit,m.), a measurement named twice inFROM(its points twice),ASCandDESCand::typeneed a keyword boundary,LIMITwith no number,GROUP BY time<c>(1m),|and^with no operand, a parenthesised condition compared with a column (v > (w > 1)),timeover a measurement that does not exist, an invalid time before a bare operand, a column the measurement lacks withfill(number), and the name of arithmetic over a quoted name are answered as Core answers; the rest is refused by name. ASELECTlist of 4000 items takes 0.28 s, not 1.5 s (equal names were numbered in quadratic time).- The rest of the client never raises on what a caller passes:
Client.HTTP: options that are not a keyword list are{:error, {:invalid_options, opts}}, a value a URL carries that is not text (a database, a precision, an org, a name to delete) is{:error, {:invalid_value, key, value}}, and a write body that is not iodata is{:error, {:invalid_body, body}}.- The facade,
Write.Writerandadd_connection/2read options that are not a keyword list without raising, and pass them to the client, which names them.write/3takes iodata everywhere. Local.query_sql_stream/3refuses options that are not a keyword list (an improper list raised;[1]ran).Flight.Client.query/3: a statement or database JSON cannot carry is{:unencodable_body, _}, and a token that is not a string is{:invalid_token, _}. A host that is not a host name is{:invalid_host, _}; a"host:port"is no longer called IPv6.bounded_connect/2runs unlinked, so an exit or a throw in the connect is{:connect_failed, _}too.Local.start/1:databases: nilis none listed, and options that are not a keyword list raise its namedArgumentError.- A precision that is not UTF-8 is written with U+FFFD in Local's 400, as Core writes it.
Client.LocalInfluxQL: every keyword glued to the next character, verified per keyword against Core 3.10.1:GROUP BY"host"andGROUP BY*answered rows; they are Core's 400s.LIMIT,OFFSET,SLIMIT,SOFFSET,WHERE,ORDER BY,ASandSHOW … ONagainst any non-word character give Core's error at Core's position.SELECT /v/FROM mand/v/AS xare answered./v/*was read as a comment.v::fieldFROMgives Core's type error.fill'(…)andORDER'raisedCaseClauseError; they are answered.- The glue character sets are defined once, in
InfluxQLText. - Conditions are scanned in linear time. A WHERE of 8,000
ANDs takes 1.8 s, not 4 s; 4,000 nested parentheses take 0.09 s, not 0.7 s. This includes the SQL WHERE scan and conjunct split.
- Every option that is not usable is a named error, never a raise:
query_influxqlwith a:databasethat is not a string raisedCaseClauseError(a regression of the previous fix). Options that are not a keyword list, a:precisionthat is no word, a:retentionor a v2 bucket name that is not UTF-8, and:permissionsthat are not a list now give a named error inClient.Local(Client.HTTPtoo, for:permissions).Local.check_sql/1of a non-string is a refusal.Local.write/3takes iodata, asClient.HTTPsends it.Client.HTTPreturns{:error, {:unencodable_body, _}}for an improper list or a map key JSON cannot write (Jason raised).Local.start/1raises a namedArgumentErrorfor database names that are not strings. Flight.Clientnever ends the caller: a bare IPv6 host (::1) still crashed it; any host with a colon is{:error, {:ipv6_unsupported, host}}, and a raise inside the connect is{:error, {:connect_failed, message}}instead of the linked task's crash.Flight.Reader: a null or struct field may cover the batch's rows when other fields carry data (a wide batch with a null column was refused); only a batch of such fields alone is bounded by cells.Client.LocalInfluxQL: keywords directly against the next character, as Core reads them:WHERE(…),WHERE+1 = 1,SELECT*FROM,SELECT(v)FROM,"v"FROMandFROM/re/are answered (they were refused or gave a wrongNombody).SHOW … WHERE(…)is[], as Core gives.LIMIT(1),LIMIT-1,GROUP BY(a)and their kin give Core'sNomerror from the keyword.tz(…)inside a condition gives Core's "only valid function calls" error at its position.- A statement that starts with a quote or
/gives Core'sNomat the first character. - A SHOW keyword against a quote gives Core's clause error, not "unterminated string
literal". The lexer error stays only where Core reads a name, a regex or an expression.
SHOW MEASUREMENTS WITH MEASUREMENT = /xraisedMatchError; it gives Core's "unterminated regex literal". - Subqueries, a number glued to
FROMandWHEREglued to a quote,*,=or)are refused by name. - A blank run is skipped once: 32,000 spaces tokenize in about 40 ms, not 1.4 s.
- The
~qblank rewrite tracks bracket classes ([]\s]). A construct it cannot rewrite (\Sin a class, POSIX classes,\Q…\E,\R,\h,\v,\p{Z}, thexmode) now fails the compile. That caught[\f\v]in the blank check, where PCRE's\valso matches\nand\r.
Client.LocalInfluxQL: one blank rule, keywords against odd characters,tzlast: InfluxQL blanks are a space, tab, CR or LF only; about 87 patterns also read\vand\fas blanks, soSHOW TAG\vKEYSandSHOW\vMEASUREMENTSgave another error than the engine's. A non-ASCII character right afterSELECT,FROMor another clause keyword is the engine's leftover from that keyword (it was a clause error).ANDorORwhere an operand is wanted is a name, as the engine reads it.tz('zone')ends the statement, so what follows it is left over (it was theSLIMIT405).NOW()isnow()in any case, and a(afternow()or a boolean is left over.- Values that are not text, and options that are not a keyword list, raised:
Client.Localraised on a statement, line protocol body, database, bucket or token name that is not a string (nil, a number, a map); each is now a 400 refusal by name (query_sql_stream/3raisesInfluxElixir.StreamErrorwith it).Client.HTTPraisedProtocol.UndefinedErroron SQL or a database JSON cannot encode (a tuple, a pid); it is{:error, {:unencodable_body, message}}.InfluxElixir.Config.validate/1of a list that is not a keyword list ([1, 2],[{"host", "x"}], an improper list) is a validation error. Flight.Readerbounds each field by its own kind: the bound for a batch of null-type columns counted rows, not cells, so 200 null columns of 8192 rows still went past 40 MB; a field with no buffers of its own (the null type, a struct) is now bounded by the batch's cells, and a field with data by its body. A field of a type the reader does not decode (run-end encoded) is that error, not a misleading row-count error.Flight.Clientand IPv6: a bracketed IPv6 host (the formConfigtakes) made the gRPC client raiseCaseClauseErrorand crash the caller; the gRPC client cannot connect to an IPv6 address at all (verified: Core answers on[::1]over HTTP), soquery/3returns{:error, {:ipv6_unsupported, host}}. The HTTP transport works over IPv6.Client.LocalInfluxQL: glued connectives, bind parameters, Unicode blanks:AND#n,OR@and other connectives glued to a character gaveinvalid conditional expression; they are the engine'sNomat the keyword.$nameand$1are read as operands (their parse errors are the engine's) and a condition that reads one is refused by name. A Unicode blank (WHERE n > 1<NBSP>) was trimmed and the rows answered; blanks are ASCII only, in one place, as the engine reads them. Errors and refusals are ordered by where they stand in the text, and a call error no longer loses to a group failure keyed at theWHERE. A sign at the end of a condition ((n > 1 +,time > now() is the engine'sParsing Failure.- Variance is Welford again: the compensated two-pass variance of the previous fix matched the engine less often than Welford, the engine's own accumulator (31 of 60 small groups exact against 49). Equal values still have a variance of exactly zero.
- A name that is not text raised: a non-binary database (
database: :name) raised inClient.HTTP, and a non-binary database, bucket or token name raised inClient.Local's admin functions, since the UTF-8 checks of the previous fix matched only binaries. Such names take the path they took before.
[0.1.42] - 2026-10-08
Fixed
Client.LocalInfluxQL: one order of parse errors, carriage returns, dotted names: a stray quote or comment no longer wins over an earlier parse error (a lexer error stands only where the parser reaches the token), a)that closes nothing wins over a later bad token,SELECT m. FROM mis "expected field", a carriage return right after a keyword fails as it does on Core, non-ASCII letters and digits are no identifier or number characters, and=~ /.*/is answered again. Tokenizing is linear (parse reductions at 1,600 terms: 5.7M to 1.4M). Refusals of select items name why.Client.LocalSQL: regex cost read once, IN pairs everywhere, the engine's rewrites, variance:s ~ '\x{4a}'raised (a second reader of the pattern disagreed with the first); the cost of a pattern is now summed by the one reader (SQLRegexCost, withSQLRegexGuardbeside it), 24% fewer reductions per check. AnINpair the engine folds to a constant is refused wherever aNULLoperand shows it (an aggregate's argument,GROUP BY, aHAVINGbeside the aggregates it names, a comparison, a test, a cast, anORof equalities), andtime, neverNULL, is not refused;time IS [NOT] NULLfolds to a constant as the engine does (24 cases of unsigned negation are answered). Patterns the engine rewrites before it runs them are refused by name: an anchored literal beside~*, a literal backslash,.*.format:of invalid UTF-8 or a non-string is a refusal, not aJason.EncodeError; aWITHoverlength(s)answers3, not{:int, 32, 3}; a refusal echoes the whole statement; the caret ofa{2,1}?spans the?. The variance of ordinary floats is the corrected two-pass with compensated sums (51% fewer reductions; the true value, which the engine's merged partitions scatter around, where store-order Welford drifted 30 times further). Refusal reasons name the statement or the clause they are about.Client.HTTPraised on text that is not UTF-8: SQL, InfluxQL or Flux text, a database name, a bucket name or a token name that is not UTF-8 raisedJason.EncodeErrorout ofquery_sql,execute_sql,query_influxql,query_flux,create_database,create_bucketandcreate_token. Nothing is sent: they return{:error, {:unencodable_body, message}}, andquery_sql_streamraisesInfluxElixir.StreamError.Flight.Readerallocated by a corrupt count: a record batch's row count or field length, or a list column's offsets, were trusted as read, so one corrupt byte of a frame could build a column of billions of rows (36 of 1,950 corrupted frames went past 400 MB). A count the batch's body cannot hold is now{:error, {:decode_error, _}}, and list offsets are clamped to the child array. No valid frame decodes differently.- Connection options a request cannot use are refused up front:
InfluxElixir.Config.validate/1accepted a:portor:flight_portabove 65535 and an empty:host, or one with a blank or control character; each failed later, at the first request, far from the option. They are now validation errors. A host that is a name, an IPv4 address, a bracketed IPv6 address or non-ASCII text is still accepted. ResponseParser.parse/2raised on a body it could not read: a CSV body that is not CSV (a proxy's HTML error page, a body cut off inside a quoted cell) raisedNimbleCSV.ParseError, and a JSON array or JSONL line holding something other than an object raisedFunctionClauseError, out ofquery_sql/query_influxql/query_flux. They are now{:error, {:csv_parse_error, message}}and{:error, {:unexpected_json, value}}; a streamed line that is not an object raisesInfluxElixir.StreamError(kind: :decode) as a line that is not JSON already did.BatchWriterdropped batches on 408 and 429: every 4xx was discarded as the batch's own fault, including 408 (request timeout) and 429 (too many requests), which ask the client to try later. A batch refused while the server was busy was lost. Both are now retried like a 5xx, with the same backoff and:max_retries; any other 4xx is still discarded.- Names and parameters that are not UTF-8:
Local.create_tokenraised on a token name that is not UTF-8,delete_databasequoted the bytes into its 404, andcreate_bucketaccepted such a name; every database, bucket and token name that is not UTF-8 is now refused by name. A query parameter whose name is not UTF-8 is{:invalid_param, name, :unsupported_key}for both clients (Client.HTTPcould not have written it as JSON). Client.LocalSQL: IN pairs under NOT, regex the crate reads differently, float aggregates in store order:NOT (i IN (NULL) AND i IN (2))andNOT (i IN (0) AND i IN (1, 2))kept three-valued logic where the engine folds the contradiction (256 rows for Core's 300); such pairs underNOT/ORare refused by name.\v, possessive quantifiers (a++a),(?m)or\zbeside text with a newline, whitespace inside a counted repetition and huge nested repetitions are refused (they answered PCRE's matches); a non-ASCII escape is the engine'sunrecognized escape sequence. The fixed scan order for float aggregates is gone: the engine's own floatsumdiffers from run to run, and the order cost 2.5 times the work.NULL = NULLover a CTE,OFFSETon a one-row aggregate andORDER BY NULLanswer as the engine does;LIKE ... ESCAPE '\'andLIKE 'w' || '%'are answered again. A deep call nest in anINSERTwas re-typed at every level (800 levels 28.6M to 9.7M reductions), and a long non-ASCII word is tokenized 5 times faster.Client.LocalInfluxQL: operators with no operand,fill()arguments, invalid UTF-8:WHERE f OR =~ /a/and(x AND )answered[]; they are the engine'sinvalid conditional expressionat the operator. A call's arguments in aWHEREare read as the engine reads them (fill(+)is itsNomfailure, not the call error), the leftmost parse error wins across clauses, andfill(-\f1)is an invalidFILLoption. InfluxQL and Flux text that is not valid UTF-8, and such a database name, raisedArgumentError; they are refused by name.- Tests: load-sensitive bounds, atoms, cost and clocks: three tests failed under heavy load on Finch's 5-second pool start and checkout and a 30-second await; they no longer depend on either. The atom test covers Flux, line protocol, admin names and query parameters. Every cost test has a lower bound and pins the refusal it measures; batch-writer refutations stop the writer first; telemetry no longer brackets the wall clock.
Client.LocalSQL: no raise on a stray character after a cast, pruning only by verified folds, unverified regex refused:n::int # araisedMatchError; it is refused by name.n IN (NULL, 1) AND n = 2was counted as a one-element list and answered[]where the engine reports a negation error; lists are counted as written,INlists nested one level right fold as the engine folds them, and NULL folds the double has not verified are refused by name. A regex construct the engine's crate reads differently from PCRE ([a&&b],(?>a),\b{start},\p{Greek},\wover non-ASCII text, ...) is refused by name; it answered PCRE's matches.s ~ 'v' || '1'took the text between the first and last quote as the pattern; it is refused.INTERVAL '+N days'and padded intervals are the engine's syntax error. A CTE no longer capturesiox.m; a CTE's negation is pruned under an empty outer query. Floatsum/avgandvar/stddevwere read in a scan order (tags, then time; withdrawn in the next review).SELECT *no longer counts the select list's columns over every row (+15% removed).Client.LocalInfluxQL:fill()behind or inside a condition,GROUP BYbeside aWHERE, bare operands beside constants, the sign of afill()number: afill(where an operand is wanted (WHERE fill(1),WHERE n > 1 AND fill(1)) is Core'sinvalid expression, the only valid function calls are ...at the call (it answered all rows, and aninvalid conditional expressionat the connective); afill()that follows a whole condition ends it, and what stands behind (fill(2),GROUP BY host,= 2, any word) is left over from where it starts; an unclosedfill(isinvalid FILL optionat the end of the parenthesis, and of afill()option and a later badLIMIT/ORDER BYoperand the leftmost is the error.fill(- 1)is minus one (a sign may stand apart from its digits).WHERE c GROUP BY * LIMIT x(and/re/) read the raw text of the clause it had blanked and answered aNomerror at theGROUP; it is theLIMITerror. A comparison of constants, and of a column the measurement lacks with a number, beside a bare operand no longer refuses the whole condition (Core answers[]; 616 of the 645 statements of the second corpus that 6e5b8fd had begun to refuse), a bare string beside the latter is refused by name (Core'sCannot infer common argument type), a string constant under=~/!~keeps no point, and(true)or a missing column beside atimecomparison is the 500invalid expr stack.fill(n)drops the rows no column holds a value in (f + nwhere one is missing), as Core does. The error of a clause is carried with its position (no more reading it back out of the body), and a second statement that does not parse is the error before the planner'sSLIMIT405. A 1 800-statement random corpus against Core: no wrong answer left that is not a refusal or Core's own clock.Client.LocalDML: linear tokenizing, no raise, refusals split by cause: the SQL tokenizer read each word with a regex over the rest of the text (which validates all of it as UTF-8 each time), so anUPDATE, aDELETEor anINSERT ... SELECTof a chain ofnwords cost the square ofn(1600 terms cost 7.3 times 400); it walks the word's bytes and costs 3.95 times. An arithmetic operator over two tags in an operand is the planner'sCannot coerce arithmetic expression ...again (it was refused by name; verified against Core in 2528 shapes), and a statement cut by a;inside a value the double cannot read (INSERT INTO m (f) VALUES ((CASE WHEN n THEN 1 ; END))) is refused by name instead of raising. The coarse refusals of aWHEREthat is no boolean, of an unknown function and of two select items with one name say what they stopped at.Client.LocalINSERT / UPDATE / DELETE: planned in linear time,ORDER BYin calls, the names of anINSERT ... SELECT, one table resolver: a chain of||was typed once for every prefix of it (100 terms: 279 ms in anINSERT, 315 ms in anUPDATE; 400 terms: 5 s); it is typed once per link, and the text a chain is planned as nests no deeper than one pair of parentheses. An array or map literal inVALUES(ARRAY[1,2],[1,2][1],{a: 1}) is one cell and not a parse error, anORDER BYinside a call (first_value(n ORDER BY time),count(* ORDER BY n)) is read, and a;in the middle of a statement gives the parser's own error for the statement that stops short (UPDATE m SET v = ; 1,INSERT INTO m (v) ; VALUES (1)). The items of anINSERT ... SELECTare compared by name: two items with one name are the planner'sProjections require unique expression names ...(literals, columns, aliases, operators, casts and the common calls are printed as the planner prints them, and an item whose name the double cannot print is refused by name), the errors of several items are the one error that holds them all (400 when each is the planner's own, else 500:SELECT $0, host,SELECT CAST(1 AS UUID), CAST(2 AS UUID)),SELECT *with no table is an error, a column of the table named as another item is ambiguous, an aggregate beside a column is refused, and the type and null-ness errors of an item come before the count of the items. ACASTto a timestamp, a||and a unary+find the type of their operand where they stand (a row of values that starts with anything but a name or a literal is the engine'sOnly identifiers and literals are supported in tuples, a type in the column list of a table alias that never closes is the parser's error at the type, a name the table lacks insideCAST(v AS TIMESTAMP)is found before the one beside it), the type of a cast is read before what it casts, aTRY_CASTtypes nothing it holds, an aggregate under a test is not typed, a placeholder has no type inside the operand of a||, a placeholder beside an operand that fails to type is refused, and a number above the largestInt64is aUInt64.SQLTableresolves the table of a statement and of a query (locate/1), lists the columns of the engine's own tables once (engine_columns/2) and words the not-found and compound-name errors once, soSELECT * FROM system.nosuchis the planner'stable 'public.system.nosuch' not found. A singleINSERTorDELETEis no longer tokenized and checked a second time before it is planned (anINSERTof 2000 rows: 108M reductions, 1.34 s, to 78M, 0.97 s). The contract asks the double for the system tables in either edition (catalog_system/0) and a real server only when it is a Core.Client.LocalInfluxQL: no atom from the text, the planner's order of errors, and the operands ofAND/OR:SELECT v FROM m SLIMIT xas the first statement of a VM raisedArgumentError(String.to_existing_atomon the clause word; the clause words are literals now, andSOFFSET xis worded as theSLIMITclause as on Core); of two bad operands the leftmost is the error, and the first clause out of the engine's order is the parse error at its start (... fill(null) SLIMIT 1 LIMIT 2 OFFSET 1). The errors of rewriting the statement (a constant alone in parentheses,n * (-3),mixing aggregate and non-aggregate columns, a transform of a field in aGROUP BY time) come before theWHERE's, a select list that reads no field is empty whatever theWHEREholds (SELECT count(host) ... WHERE host), and theWHERE's comparison that cannot be typed (UInt64 >= Boolean) comes before the select list's (cannot use / between an integer and unsigned). A field, constant or tag insideAND/ORis typed leaves first as the planner types it, for pairs, chains of three or more and groups (a pair with such an operand keeps no point; unsigned operands and two numbers are itsCannot infer common argument typeerror);fill(n)of a plain select fills the number columns a row lacks, of a selector over a string or boolean column is the engine'sno conversionerror; aggregates of a tag and of a boolean are the engine's coercion errors. The planner contract pins 432 statements of these (InfluxQLDefectCases).Client.LocalSQL: the order of the engine's errors, exact number folding, and refusals that say why: the stages at which Core finds a query's errors (plan build, placeholders, the analyzer'sWHERE, aggregate,HAVING, select andORDER BY, then the optimizer and the physical plan) are one ordered table (SQLStage) in place of float ranks; an unbound$nis found after the select list's planner errors and before the analyzer's; aHAVING's plan errors come before everyWHEREcoercion error; numbers fold pairwise with Core's exact types (Int64withUInt64isDecimal128(20, 0), that with aFloat64Decimal128(35, 15)), soavg/sum/stddevofcoalesce(tag, n)answer as Core does;IN/BETWEENwithNULLoperands,HAVING NOT y,-3::text,first_value(1e3 ...),BETWEENwith::casts, a negation under a relation the optimizer proves empty andcount(DISTINCT ...)names are Core's; a mid-statement;is the parser's error at the;,DELETE FROM (andINSERT INTO t ;are its errors, and=>is one token;FROM main,SET TIME ZONE,?and aDELETEof a subquery are refused by name. Typing a chain of N operators is linear again (i+i+..x 6400: 2.2 s to 0.63 s). The refusal tables pin each case's exact reason, and the ratchet compares it.Client.LocalSQL typed an expression in three ways and answered what Core refuses: one table (SQLExprType) now decides every type, with an exact memo keyed by the node;-(n < 1),-coalesce(s, 'x'),-CASE ...and a unary plus of a non-number are theNegation only supports ...400;abs(coalesce(n, 1)) + 's'namesInt64; aHAVINGthat reads a select alias types it (abs(y)ofmax(s)was anArgumentError;y + 1 > 1answered[]) and binds its placeholders (HAVING count(*) > $1answered[]), which the planner meets in its own order; anORDER BYname that is a select item's output is that item; the select list's coercion errors come after the planner's, the operands of a||fail as the planner's, and aWHEREthat is one typed non-boolean expression is refused first, as on Core;time IS [NOT] DISTINCT FROM '<timestamp>'compares the instants andIS DISTINCT FROM's right side is the rest of the expression;COALESCE,NULLIF,GREATESTandLEASTof a number and text are typed as the number; a leading;beforeINSERT/UPDATEno longer raises andUPDATEis read by an operand parser (aliases, case-sensitive targets, qualified field lists,absof a non-number);GRANT ROLE,DATABASE ROLE,TO SHARE/APPLICATIONare the engine's 405;SELECT DISTINCT ... HAVING ... ORDER BY upper(host)answers; aHAVINGof 900 terms costs 24 M reductions (951 M).Client.LocalSQL: the random-expression regressions and theUPDATEmodules' coverage: a tag beside a number (coalesce(host, 1)) isDictionary(Int32, Int64), anInt64with aUInt64inCOALESCE,NULLIF,GREATESTandLEASTa decimal computed as Core does, aCASEof text and a timestamp a timestamp; the errors of a predicate are found in the engine's order (theANDs,ORs, operators and calls left to right, theNOT,IN,LIKEandCASEcoercions after them);WHERE ok = sandWHERE u >= ok(two columns, one a boolean) were answered[]and are the planning error;NULL IN (true, 1)is the error;length(coalesce(s, 'a'))is named as Core names it;NOT 0underIS NULLno longer raises;INSERT ... VALUEScounts the rows against its column list;UPDATEreads a string as an alias, checks aLIKE's escape and no longer words a trailing dot as a name. TheUPDATE/INSERTmodules are covered to 100% by contract cases with Core's answers.Client.LocalInfluxQL kept no point for an unsigned field beside text (u + s = 1), where the engine's error isCannot coerce arithmetic expression UInt64 + Utf8;true / abs(time)raised. Column names,LIMIT/OFFSETper column and the duplicate-name error now come from one projection plan in the engine's order:time AS n, *keeps the time,usage AS host, top(n, host, 2)names the taghost_1, andtop()beside plain fields counts per column. A descendingtop()/bottom()tie goes to the later point, as on the engine.Client.LocalInfluxQL counted missing fields and compared mismatched kinds:count()of a field the measurement lacks is omitted (it was a0), still taking its name (count(nosuch), count(v)iscount_1); a comparison between kinds that do not compare (a number against a string, a boolean or a tag, strings and booleans in order, two tags, a regular expression against a number) keeps no point whatever the sides are (abs(1) != 'x',n / 0 = 'x',(s) != 0,abs(n) < true); an unsigned side against a boolean is the planner'sUInt64400;abs()words its errors as the planner does for a call that is the whole condition or part of one;top()andbottom()number the tag columns beside them as the engine does (top(v, 1), host GROUP BY hosthashostonce;top(v, host, 1) AS host_1 ... GROUP BY hostis theProjections require unique expression names400), answer the points with no value after those that have one when a field is beside them, and answer the schema error for an aliasedtimeor a field the measurement lacks;(s),+s,(b)and(host)are the column;*beside atimecolumn that takes a field's name numbers the later of the two; one typed arithmetic kernel serves the select list and theWHERE.Client.LocalInfluxQL aggregates oftimeread every point (0.1.41).max(time), count(s)is the latest point that has ans(as arefirst,last,min,modeandcount(distinct(time)), in a series, a bucket and beside plain columns or expressions); a series or bucket with no such point is not answered;fill(0)fills a time with the epoch;count()of a field no point holds is0beside another value, andcount(*)lists every field of the measurement. Arithmetic with a time, a boolean or a string (max(time) - min(time),v + true) is the engine'sincompatible operands400, andabs(min(time))answers nothing instead of a 500.Client.LocalInfluxQLWHEREanswered strings, divisions and calls the engine does not:'a' + 'a' = 'aa'ands + s = 'xx'concatenate, a string, boolean or tag under any other operator is null (s + 1,-b),/ 0is0(it closed the connection, or kept every point),abs()is computed and its misuse is the planner's 400, a sign before a string or boolean or a second one before a name is the parse error, a comment is echoed as written; every other function is refused by name (it answered[]).Client.LocalInfluxQL: a regex with an unknown flag inFROM, the select list,GROUP BYor a function argument is the planner's 400 (it was theWHERE500);SELECT *, *and*beside columns number their names;top(v, host, 1) ... GROUP BY hosthashost_1;AS 1,mean((v)),mean(v + 1)andtop(v, *::tag, 2)are the engine's errors;SELECT * ... GROUP BY /host/has only the grouped tags;SHOW TAG KEYS WHERE time != xover a measurement with no tag is the time error; an aggregate stamped with a bound that is not a whole microsecond (time > 1700000000000000000) answers the microsecondClient.HTTPreads from the engine.Client.LocalraisedArithmeticErroron InfluxQL transforms (0.1.41).derivative(v, 0s),elapsed(v, 0s)andmoving_average(v, 0)now give the engine's 400 ("duration argument must be positive", "moving_average window must be greater than 1"), andmoving_average(v, 1)no longer answers. A float overflow incumulative_sum,derivative,difference,moving_average,integral,medianorstddevis a null, and integerdifference/moving_averagewrap as the engine does.Client.LocalInfluxQL returned no rows where the engine answers (0.1.41):count(/re/),count(*::field),mode(*),mode(/re/),elapsed(*)andelapsedof a boolean field. An unknown function in a wildcard is now refused by name instead of answering[].Client.Localaccepted InfluxQL the engine rejects (0.1.41):host = /re/, a regex with flags,percentile(/re/, n),topof a tag, a bare constant as aSHOW … WHERE; and refused aSHOW TAG VALUESORon a missing column that 0.1.40 answered.Client.LocalSQL gave engine-shaped errors the engine does not give (0.1.41) forleft()/right(), aHAVINGon an alias, a keyword alias,1_000/1e/0b1,count(*,5 div 2,SELECT … INTO,USE, andSELECT 1; DELETE FROM; these now answer or give the engine's own error.selector_max(n, time) + 1,sum(NULL),avg(NULL)and an aggregate of a mistypedCASEgive the engine's planning error, and an expression overMIN(NULL)/MAX(NULL)is refused by name.- Queries against a database with a retention were up to 7× slower (0.1.41); they now cost about the same as any other database.
Client.LocalInfluxQL dropped amin(time)/max(time)column beside another aggregate, andWHERE - hostwas a planner error where the engine answers[](a tag under arithmetic is null).Client.LocalSQL readINTOandASused as column names as clauses, resolved a qualifiedHAVING/ORDER BYname (cpu.c) as a select alias, answeredleft(NULL, 1.5)where the engine errors, and raised onselector_max(...) || 'a'and on some malformed InfluxQL select lists. Numbers such as1Land0XFFare read as the engine reads them (one definition of a number token now serves every SQL reader).Client.Localprinted a token created on a whole second as….000Z; the engine prints…Zwith no fraction (it is to the millisecond otherwise).Client.LocalSQL aggregate queries scanned every point to list columns anORDER BYmight name, even with noORDER BY(count(*)cost 8.8×); they are back to their earlier cost.Client.LocalSQL raised on a selector under a text function (length(selector_first(s, time))) and answered selector shapes the engine rejects (selector_max(v, v), a selector inCASEor compared inHAVING); these are the engine's errors now.selector_first(host, time)returned no value for a tag.Client.LocalSQL refusedGRANT ALL,MERGEandNULL - time, which 0.1.41 answered as the engine does, and answeredsubstr(NULL, 'a'),NULL || 1,host || nandcount(distinct -1)differently from the engine; statements the engine does not run (CREATE SEQUENCE,DROP INDEX,create trigger t) now carry its exact wording.Client.LocalSQL gave||the precedence of+:'a' || 1 + 2answered"a3"; it binds like*, as on the engine, so that is the engine's coercion error.SELECT DISTINCT … HAVINGignored theHAVING.Client.LocalSQL read SQL words inUPDATEas columns (… SET n = CASE …gave "No field named case").UPDATE,GRANT,REVOKE,DENYandDESCnow answer only the shapes verified against the engine and refuse the rest by name;||,LIKE,IS DISTINCT FROMand aggregates of a computedNULLfollow the engine's typing.Client.Localraised on a table name of about 4,100 characters or aLIKEpattern past about 64,000, and an expression of hundreds of operators took seconds to type (900 terms: 5 s); these are refusals by name and a single typing pass. AHAVINGnaming a select alias several times read the table's columns once per name.mix testchose the wrong tests for an absolute or./path, a path outsidetest/,--only=v2, an--includeof an integration tag without a path, and a path after a switch it did not know (--no-compile test/x_test.exsran the whole suite), and an option's value that names a directory (--exclude lib) was taken for a path. The rule is nowmix/test_args.ex, with its own test: known switches and their values are skipped, and every other argument is a path when it is one.Client.LocalInfluxQL: ties, unsigned booleans, conditions that are no boolean, the order of errors and quotient windows (each verified against Core 3.10.1, pinned inInfluxQLOrderCases):top(v, host, n)andbottom(v, host, n)rank the points they chose by their own times, not by the times their series began at (v = 9at 5s ofb, 6s ofc, 8s ofa:top(v, host, 2)isbandc; descending,aandc); an unsigned field or a number made of one against a boolean field, in either order and with any operator, isCannot infer common argument type for comparison operation UInt64 = Booleanwith notype_coercionprefix (it wasInt64), a tag over an unsigned field (host > u) compares as text and keeps every point that has the unsigned value, and the error comes after the select list's and before theLIMIT's; a condition that is a number made of an unsigned field (-u,u + 1,abs(u)) isBoolean AND UInt64, and with atz()clause the planner's filter error (Cannot create filter with non-boolean predicate 'Int64(0)' returning Int64, the arithmetic ones refused by name); a comparison of thetimebeside an operand that is no boolean, a boolean field included, is the 500invalid expr stack; the operands of an expression that cannot be typed (-f + time, -0.0) are the error before the condition is split and beforefield must contain at least one variable, and the secondtop()is the error of the selectors that cannot be combined before a constant that follows it;fill(none)andfill(linear)of a grouping with nothing to aggregate are the fill error;LIMITandOFFSETof a column that is a quotient (sum(f) / count(f) ... GROUP BY time(1m)) do not count the buckets where it is null.- Contract and unit tests of the double's own tests: the cost test of a
HAVINGthat names an alias compares the cost of one more reference over a table of 1500 and of 6000 rows (a re-read of the columns per reference costs 3.6 times as much on the larger table; the correct code 0.8), the store-lock tests release or kill the holder only once the waiter is contending for the lock, the case-table test folds blanks beside operators and SQL comments into a statement's identity (not the regular expressions of InfluxQL), checks that a pin's spellings differ in what the pin says, and lists the SQL spellings still waiting to be taken out of the SQL tables, and themix testswitch list is checked against the switchesmix testdocuments.
Changed
- The integration one-liners pin
influxdb:3.10.1-core, the version the exact engine bodies were verified against, and start it with--wal-snapshot-size 100000: Core 3.10.1 panics ("timestamp wraparound") when it snapshots a point near the largest timestamp, which the contracts write, and its writes then hang until restart. With a 10 ms WAL flush a suite reached that snapshot within one run. - One server's integration suite is run with
mix test --only v3_core(orv2);--include integrationselects every server's. - Contract case tables report every mismatch; tables that accept a refusal by name pin exactly which cases are refused, so a regression to "refused" fails naming the case. Line-protocol errors, including the 10,000-line chunk payloads, are pinned once, in a contract run on both engines.
[0.1.41] - 2026-10-03
Added
Client.LocalInfluxQL: the functions and clauses dashboards send.GROUP BY *,/re/, fields and::type, with the engine's parse errors at their positions (GROUP BY time(,fill(previous, a trailing comma, ...);percentile,mode,top,bottom,integral,abs round floor ceil sqrt ln log pow, the transformsderivative,non_negative_derivative,difference,non_negative_difference,cumulative_sum,moving_averageandelapsed(of fields, and of aggregates in aGROUP BY time, with the engine's scan of the bucket before the range),F(*),F(/re/),*::field,*::tag,/re/columns,FROMwith several names or/re/,first/last/min/maxof booleans,/* */comments,tz('UTC'),SLIMIT/SOFFSET(the engine's 405) andSHOW MEASUREMENTS | TAG KEYS | FIELD KEYS | TAG VALUES | RETENTION POLICIESwithON,FROM,WITH,WHERE,LIMITandOFFSET. A look-around or atomic group in a regular expression is the engine's 500. A Grafana-style corpus of 167 statements went from 62 refusals of what the engine answers to 4.config :influx_elixir, :local_influxql_max_rows(default 2,000,000) sets the most rows of aGROUP BY timeseriesClient.Localwill hold.Client.LocalSQL: the expressions dashboards send. Aggregate arithmetic (sum(n) / count(n),max(x) - min(x)),HAVING,CASE,COALESCE,NULLIF,GREATEST,LEAST,lower,upper,length,substr,starts_with,sqrt,ln,log,pow(inWHEREtoo),IS [NOT] DISTINCT FROM,IS [NOT] TRUE, an alias withoutAS,SELECTwithoutFROM,information_schema.tables/columns/schemata,SHOW TABLES/COLUMNSandiox./public.iox.table names, with the engine's names, types and errors. A 162-query dashboard corpus went from 93 refusals of what the engine answers to 37; what stays refused is listed in the testing guide. Aggregates carry their result types, sosum(n) + hostis the engine's coercion error; aCASEwith a textWHENcompares as text;n == 1,SELECT ALL,FETCH FIRST,OFFSET n ROWS, backtick names,FROM (t)andFROM a, bare read as the engine reads them, and parser errors are positioned on the statement as written.Client.LocalInfluxQLGROUP BY time(every[, offset])withfill(). Every bucket of the range is answered (buckets start at multiples ofeveryfrom the epoch, shifted byoffset; the range is theWHEREbounds, from the first point of the series tonow()without them);fill(null),none,previous,linear(an integer truncated toward zero) and a number cast to the column's type.median,spread,stddevandcount(distinct(f))are answered, and arithmetic in the select list (usage + 1,-n,sum(n) / count(n),n::float,n::integer), with the engine's column names, types and planning errors.fill()without aGROUP BY,min/maxof strings and the engine's errors formean,sum,median,spreadandstddevof a string field are answered.- Time comparisons: quoted times with an offset (
'… +0000',+01:00,UTC), compound durations (1h30m), and+/-of quoted times, durations, integers andnow()(time > '…Z' - 1h30m,now() - 1, sub-second durations).
Changed
Client.Local's internals are organised by area.Client.Localis the facade; the work is in modules underclient/local/grouped assql/,influxql/,line_protocol/,flux/,store/,write/,admin/andshared/. They are hidden from the HexDocs sidebar (@moduledoc false); the published modules are grouped by role. Duration units, 64-bit limits and column type names each have one definition, and the InfluxQL modules have no runtime cycles.- Test support. One
InfluxElixir.TestServer(a black-hole and a test-answered listener) and shared helpers for polling, telemetry and token shapes replace copies in several test files; tests that wrote untimed points now write timestamps and compare whole rows. - Test suite layout. Each contract runs as per-part async modules
(
test/influx_elixir/client/contract_local/<profile>/,test/integration/contract_<profile>/):mix testtakes 8-10 s instead of 27-41 s, and a plainmix testno longer compiles the integration modules (INTEGRATION=1, or an explicittest/integrationpath, includes them). The integration one-liners start Core with--wal-flush-interval 10ms, since a write is answered when the WAL flushes: the Core suite takes 18 s instead of 8½ minutes.
Fixed
Client.Localdiscarded a v3 database'sretention:. On Core a database created withretention_period: "1h"accepts a point of any age (204, whateveraccept_partialsays) and hides expired data from SQL, InfluxQL andSHOW TAG VALUES, a 10-minute chunk of a table at a time: an expired point is shown for as long as the newest point of its chunk is. Tables and columns stay in the schema.SHOW RETENTION POLICIESprints the period (1h0m0s,168h0m0s,30s;0sfor none). The double returned every row and always0s; it now keeps the period in whole seconds and applies all of this. A period of"0"(or under a second) is a retention of zero, not none: every point before now is hidden, as on Core. Creating a database that exists keeps its retention. Verified against Core; Enterprise's was not. Seedocs/design/2026-10-03_local-database-retention.md.Client.LocalInfluxQL found no rows for anORnaming a column the measurement lacks (host = 'a' OR zone = 'z'); such a comparison is now false, as on the engine. Refusals no longer say "invalid statement" for statements the engine answers.Client.LocalSQL answered with error bodies the engine does not give for qualified table names,information_schema, an emptyWHERE, a trailingORDER BY, a non-booleanWHERE,LIKEontimeandGROUP BY (); it now answers, or gives the engine's parser and planner errors.- The Hex package carried the wrong files.
files:sat outsidepackage()inmix.exs, so Hex used its default list: 0.1.40 and earlier shippedpriv/plts/dialyzer.pltand.formatter.exsand left outusage-rules.mdandusage-rules/. The package now holdslib/, the README, LICENSE, CHANGELOG and the usage rules. Client.LocalInfluxQL refusedORwith an unsigned field (u > 5 OR i < 0,h = 'c' OR u > 5); the engine answers it. The comparisons that need the engine's unsigned rules are now a column of the point that the SQL reads, soAND,ORand parentheses combine them.Client.LocalInfluxQL regular expressions on string fields matched nothing.msg =~ /m/andmsg !~ /m0/now match string values; a backslash before a letter other thand w s D S W p P xis dropped, as the engine does (\bisb), and\\/no longer ends a regular expression.- 0.1.40 shipped a stray script,
lib/influx_elixir/client/local/tmp_edit.exs, in the package. It is removed. It was never compiled or loaded. Client.Localqueries were 30-100% slower in 0.1.40 than in 0.1.39 at 100k points (SELECT *, numericWHERE, every InfluxQL query). Each query scanned every point for column names anORDER BYposition might need, converted every result cell, and made a second pass for column types; InfluxQL lost itsSELECT *fast path. They are back within 10% of 0.1.39.Client.LocalSQL runs what the engine's optimizer leaves.- What the simplifier removes is never evaluated:
x AND false,x OR true, absorption (A AND (A OR B)),x = xandx IS NULLof a constant, and comparisons with aNULLliteral. - A
CASTof an integer column to an integer type takes part in the empty-range check (CAST(j AS INT) > 5 AND j < 3is the engine's interval error), as doescol = lit AND 1/0 = 1. - A decimal compared with a float past
1e20is the optimizer'sDecimal128error; an integer literal pastUInt64compares as a double. AND/ORwhose right operand fails for a row the left one leaves out: the engine's outcome depends on how the table is stored (a fresh write runs the operand over the whole batch unless the left side keeps under a fifth of it; a persisted table runs the cheaper columns' conjuncts first), so the double answers only what is the same in both (a tag conjunct that leaves the failing rows out, in either order; a guard that keeps no row; a row that reaches the failure) and refuses the rest by name. A refusal of the double or a NaN in an operand a row does not reach is no failure:total > 0 AND used / total > 0.5answers.- Unsigned arithmetic, and an integer column's with a float constant, is
not in the interval analysis (
u + 1 > 5 AND u < 3is[]);i / 0 = 1 AND i > 0is the division's interval error andi / 0 = 1 AND i < 5closes the connection. - A literal on the left of
IS NULL,BETWEEN,IN,LIKEis that constant;(n + 1) > 5and(n) IS NULLparse;(SELECT ...)is the query; statement parser errors print the engine's token (X'1f'for0x1f,B'1',1e5,@@x) and a bareUPDATEorGRANTisExpected: ..., found: EOF.
- What the simplifier removes is never evaluated:
- Smaller
Client.LocalSQL fixes:ORDER BY a + 1reads the output namea;trunc(x, n)wrapsnto Int32 andround(x, n)past Int32 closes the connection, as on the engine;FOO baris the parser'sExpected: an SQL statement;COMMIT,SET,PREPAREandEXECUTEareStatement not supported;SELECT *, iis refused by name. - InfluxQL in
Client.Localfollows the engine's planner.- An unsigned field makes arithmetic unsigned, so it wraps (
-u < 0is never true,j > ucastsj);SUMwraps at its type's range, and a floatSUMpast the double range isnullinstead of raising. notis a name; a column aliasedtimebecomestime_1;SELECT time FROM tis empty;TIMEis the time in any case.time = 'a',GROUP BY time,WHERE timeand constant select items get the engine's planner errors.- Parentheses in
WHERE, unclosed literals,=~without a regular expression and reserved words after+/-get the engine's positioned parse errors, after a;and inSHOW TAG VALUEStoo. - Two tags compared (
k = x) are false for every row.
- An unsigned field makes arithmetic unsigned, so it wraps (
[0.1.40] - 2026-10-02
Fixed
Client.Localcrashed on float overflow.f * 1e308 * 1e308, asumpast the double range and the like raisedArithmeticError; they now answernullas the engine does, and an infinity compares as a number inWHEREandORDER BY. A float divided by zero is no longer refused. An Int64sumpast the range wraps, as on the engine.Unsigned (
UInt64) fields inClient.LocalSQL were treated as Int64.u / 2gave2where the engine gives the decimal2.5000;-uanswered where the engine refuses the negation;u + nwrapped at the wrong width. Unsigned columns now follow the engine's decimal and wrap rules.More
Client.LocalSQL answers now match the engine:trunc;(v)namedv; an unaliased1e400namedFloat64(inf);ORDER BYpositions onSELECT *and their range errors; schema errors before atimetype error; atimebound at the last nanosecond. A qualified reference to a column both sides of aCROSS JOINhave is refused by name instead of the engine's "ambiguous" error the engine does not give.InfluxQL in
Client.Localaccepted reserved words.WHERE tag = 'a',SELECT name,GROUP BY keyand the rest of the engine's keywords are its positioned parse errors; a failing statement after;gives that statement's own error;LIMIT/OFFSETpast Int64 are the engine's range errors; a boolean field compared with a number, and a negative number compared with an unsigned field, answer as the engine does.A
Client.Locallock taken again by its holder hung forever. It now raises.CAST(x AS INTEGER)inClient.Localwas a 64-bit integer. On the engineINTEGER/INTis Int32,SMALLINTInt16 andTINYINTInt8, so arithmetic on them wraps at that width and a constant that does not fit is the optimizer's 500. Local now does the same, unwraps a cast compared with an integer literal inWHEREas the engine's optimizer does, and folds a failing constant cast or a negated minimum into the engine's 500 instead of closing the connection.FLOAT/REAL(Float32), unsigned andDECIMALcasts are refused by name.Client.Localrefused aggregates and expressions withoutAS.SELECT count(*) FROM tnow answers[%{"count(*)" => n}], and every unaliased item is named as the engine names it (avg(t.v),sum(t.v * Int64(2)),Int64(1)).Empty ranges in
Client.LocalSQL returned[]where the engine fails. Atimerange the top-levelANDleaves empty is the engine's 500 "provided filters on time column did not produce a valid set of boundaries"; an empty range on a numeric field is the engine's DataFusion internal error, in its three wordings. InfluxQL still answers[], as the engine does.More
Client.LocalSQL answers now match InfluxDB 3 Core:- "Valid fields are" lists columns qualified by the table, with the
select list first for
ORDER BYandGROUP BY; - quoted names in the select list are columns;
- integer literals past UInt64 are Float64,
1e400is null, and-(9223372036854775808)is the engine's negation error; absof the Int64 minimum closes the connection, as on the engine;- an Int64 divided by a UInt64 parameter is a four-place decimal;
- UTC aliases (
Zulu,UCT,Etc/GMT±N),EST/MST/HSTand the:60leap second are read in timestamps; NULL = timematches nothing, and"r""vt"prints asr"vt.
- "Valid fields are" lists columns qualified by the table, with the
select list first for
InfluxQL in
Client.Local:GROUP BY r, horders series by tag key, as the engine does; a doubled sign, an integer overflow, a lone.and a second statement after;give the engine's parse errors at its positions.Client.Locallocks backed off for seconds under contention. Token creation and the database limit used:global.trans; 16 concurrent creates took 4.4 s. They now use a lock key in the store that a waiter clears when its holder has died.Client.Localslowed to a crawl under concurrent writes. Eight writers of 2,500 lines to one series took 4.5 s, 32 took 20 s, and 50 timed out at 60 s. A payload's points now go into the store as one batched insert, its series keys are claimed by one all-or-nothinginsert_new, and the table haswrite_concurrency. The same writers now take 0.05 s, 0.2 s and 0.4 s. Duplicate merging and last-write-wins are unchanged.Parsing a large payload no longer spawns an unbounded process. The parse for 2,000 lines or more sized a process for the whole payload, which added about 1 GB for 200k lines (8.7 GB for eight at once), and it kept running after its caller died. Lines are now parsed 10k at a time, each chunk in a process that ends with the chunk: about 170 MB retained and 380-470 MB at peak for 200k lines, and the speed is unchanged.
InfluxDB 2 reads of a field with mixed types stopped at the wrong group in
Client.Local. The engine reads the earliest group's type up to the first later group of another type and drops that group and every group after it, per measurement and field, across all tag sets. Local also returned later groups of the first type. Verified on 2.7.delete_bucket/2onClient.Localleft the bucket's points and per-group schema behind. A bucket created again returned the old points, and a write of a new field type was refused with a 422.Client.Localaccepted a point older than a v2 bucket's retention. 2.7 drops it before any shard sees it and answers 422partial write: dropped N points outside retention policy of duration 2h0m0s - oldest point <series key> at <time> dropped because it violates a Retention Policy Lower Bound at <now - retention>, newest point ... dropped=N for database: <bucket id> for retention policy: autogen. A failing shard group's message replaces it; the other points are written. A v3 database'sretention:is still not applied.Client.LocalSQL matches InfluxDB 3 in more corners. Verified against Core.- A huge integer or Decimal param (beyond a double) is the engine's 400
number out of range, not a crash.params: nilis accepted, and an unusable key or shape is{:error, {:invalid_param, ...}}. - Int64 arithmetic wraps as the engine's does (
MAX + 1isMIN), andMIN / -1closes the connection. E'…'strings take the engine's backslash escapes.- A double-quoted name is always a column. A name that needs its quotes is printed as the engine prints it in "No field named".
- A
timestring Arrow cannot read is the optimizer's 500 (Error parsing timestamp from '…': …), not a 400 about integers. time = NULLand a niltimeparam match no rows.roundkeeps the sign of a zero result (round(-0.3)is-0.0).
- A huge integer or Decimal param (beyond a double) is the engine's 400
Client.LocalInfluxQL.- A series without the
GROUP BYtag comes last, as on the engine. - A number with an exponent, a trailing dot or a hex prefix is the engine's parse error.
- The parse error's position is right when a clause follows
WHERE.
- A series without the
Contract tests no longer assume what the engines leave open.
- Row order without
ORDER BYis not assumed. - When several InfluxDB 2 shard groups fail in one write, the engine reports one of them, and which one varies between identical writes. The contract accepts any of them, and the double reports the earliest.
- Row order without
HTTP timeout tests could flake under load. Each wait is now well under the timeout a wrong precedence would apply, and well over the one under test.
Client.Localread SQL text its tokenizer would refuse, and ignored what the engine ignores. Verified against Core 3.10.- An unterminated
'...',"...",/* ... */or$$...$$is the engine'sSQL error: TokenizerError(...)400 naming the line and column, not an answer. -- ...and/* ... */(which nest) are comments: a quote inside one no longer re-pairs the quotes and drops aWHERE.- A trailing
;is accepted, empty statements are nothing, and two statements are the engine's 405. $$...$$and$tag$...$tag$are string literals;$a$bis the tokenizer's error, as on the engine.- A literal that starts with a combining mark was compared as nothing.
- Identifiers in any script (
"fé",é AS é2,count("é")) are columns; only ASCII letters fold to lower case.
- An unterminated
Parameters are bound, not substituted into the text. A placeholder is a value wherever a value may stand (comparands,
INandBETWEENitems,LIKEand regex patterns, expression operands,LIMIT,OFFSET), typed as the engine types the request's JSON: a non-negative integer is aUInt64("time" > $awith0is the engine's type error, and an error body no longer carries a private-use character). A key spelled"$a"no longer binds$a, as on the engine.Client.Localanswers a boolean compared with another type as the engine does (v = $pwithtrueagainst an integer column,v IN (1, true),v BETWEEN 1 AND true), and reports the planner's first error in the engine's order: the select list's calls, operators and aggregates, thenWHERE, thenORDER BY, a negation last.A parameter that is a JSON object or array is the engine's 400 from
Client.Local, with the column where its parser stops; aDecimalthat is NaN or an infinity (which has no JSON number) and a value with no JSON form are{:error, {:invalid_param, name, reason}}from both clients instead of invalid JSON or a raise.Client.Localline protocol now splits lines the way both engines do. Verified against Core and 2.7.- A
"in a measurement, tag key or tag value is an ordinary byte. It used to join the line to the next, losing that line or rejecting the write; it now opens a string only as a field value. Lines are split with the engines' ownscanLinerules. - A quoted measurement keeps its quotes (
"m"is the table"m"). - Each engine's escape rules apply to measurements, tag keys and
values, and field keys. InfluxDB 2 never collapses
\\. - On InfluxDB 3, an error's
line_numbercounts only the lines that are not blank or comments, as on the engine. - On InfluxDB 2:
- field types are per shard group;
- a partial write is reported by one failing group: the engine's choice among several varies between identical writes (usually the earliest), and the double always reports the earliest;
- a measurement the engine accepts but never returns is accepted and never returned.
- Refused by name rather than answered differently:
- an infinite float (
1e999), which InfluxDB 3 stores as infinity; - a repeated tag key, which InfluxDB 3 accepts and then fails every query on.
- an infinite float (
- A
Flux in
Client.Localorders rows by nanosecond.first()andlast()were swapped for points less than a microsecond apart.sort()andgroup()are refused by name.InfluxDB 2 writes in
Client.Localwere quadratic in shard groups. 40k points in 40k hourly groups took 8.6 s; they now take about 0.2 s.
Changed
Client.Local's SQL executor is split into focused modules (SQLNumber,SQLCast,SQLEval,SQLAggregate,SQLCondition,SQLSort,SQLRow,SQLTyping,SQLPlan,SQLSchema,SQLGrouping,SQLJoin,SQLRange,SQLFold, with sharedSQLLimitsandSQLPredicates);SQLExecutorkeeps the pipeline.Tests compare whole results. About 150 partial map patterns, which ignore extra keys, became exact comparisons; numeric
==became===outside the contract modules too; the batch writer's backpressure test is driven by a listener the test controls instead of the clock.Client.Localstore API trimmed.Store.measurement?/3,Store.store_point/3andInfluxQL.where_sql/2are removed andStore.put_database/2is private;SQLExecutor.run/2isrun_influxql/2.Tests compare rows strictly.
%{"v" => 1} == %{"v" => 1.0}is true in Elixir, so contract assertions now use===; unique names carry the wall clock so runs on a persistent server never collide; real-server cleanup runs inafter; concurrency tests start their tasks on a barrier.Client.Local's SQL parser is split into focused modules:SQLParser(entry),SQLLexer,SQLMask,SQLLiteral,SQLTime,SQLExpr,SQLSelect,SQLWhere,SQLClauses,SQLLimitandSQLBind.sql_parser.exdrops from 3,027 lines to 694. InfluxQL and the line protocol share its quote masking and float rendering.Contract coverage.
- Every contract quote keeps its source location, so a failure points at the contract line.
- Tests where Local deliberately differs are tagged
:local_divergence. - The SQL contracts also run on the
:v3_enterpriseprofile. - Test databases and buckets on real servers have unique names and are cleaned up.
For tests: a parameter key written as
"$name"no longer binds. InfluxDB 3 reads a parameter's key without the$, and a"$name"key binds nothing there, so a query that passed againstClient.Localfailed in production. Writeparams: %{name: value}or%{"name" => value}.Client.Localparses line protocol 3 to 4 times faster. For 100k-line payloads:- bare lines: 332 → about 130 ms;
- three tags and four fields: 1752 → about 540 ms.
A large payload is parsed 10k lines at a time, each chunk in a short-lived process, so the caller's heap is left alone.
Tests.
- Fidelity tests that repeated the contract suite are gone, so each fact is pinned once and runs against Local and the real engines.
- Tests on internal functions are driven through the public API instead.
- The optional-dependency check walks each file's syntax tree.
test/supportcompiles without warnings.
[0.1.39] - 2026-10-01
Fixed
- The library failed to compile in a project without
decimal. It is an optional dependency, but the SQL parser matched%Decimal{}, which needs the struct at compile time (Decimal.__struct__/1 is undefined). It now matches%{__struct__: Decimal}, and a test keeps optional structs out oflib/. - A
Decimalquery parameter was compared as text over HTTP. Jason encodes a Decimal as a JSON string, soamount >= $pwithDecimal.new("1000.00")kept 500.0 ("500.0" >= "1000.00"), whileClient.Localcompared numbers.Client.HTTPnow sends Decimal parameters as JSON numbers, and both clients compare numbers. Client.Localgave wrong answers to SQL that InfluxDB 3 answers differently. Each case was verified against Core 3.10.- Quoting and parameters.
''inside a literal ('O''Brien') and inLIKEpatterns was not read as a quote.- A string parameter containing a quote was injected into the
query text (
"zzz' OR v > 0 OR name = 'q"returned every row). - Literals containing a comma, an operator or a keyword
(
IN ('Smith, John'),s = 'a>b','note limit 5') were refused or cut short. LIKE/ILIKEmatched bytes, not characters.
- Nulls and three-valued logic.
NOT IN (1, NULL)andBETWEEN … AND NULLreturned rows.first_valuedropped afalsevalue.- Ordered aggregates sorted a null ordering value first.
- Grouping and ordering.
GROUP BY timereturned one row.DATE_BINput negative timestamps in the bucket after.- A
DATE_BINin the select list that did not match theGROUP BYwas answered with the wrong buckets.
- Comparisons.
- A float compared with a string literal was rendered as
5.0e3instead of5000.0, which gave wrong rows. WHERE 1 = 1andWHERE truewere a schema error or refused.
- A float compared with a string literal was rendered as
- Plan-time type errors.
- Aggregates over strings, booleans, tags or
timecrashed or answered (median(s)was"y"). - Arithmetic over a string answered nulls.
- Each is now the engine's planning error, with the same wording, even when no row matches.
- Aggregates over strings, booleans, tags or
- Errors in the engine's words (the double now uses them):
- bare-integer
timecomparisons; AVG(time)and the othertimeaggregates;DISTINCT … ORDER BY;- negative or non-numeric
LIMIT/OFFSET; FIRST()/LAST();LIKEover a number;- CROSS JOIN ambiguity;
- ungrouped projections;
- unknown
format:values (with the column position); - boolean casts;
- a CTE column named
time; - integer division by zero, which closes the connection.
- bare-integer
- Crashes, now answered or refused by name:
DATE_BINwith a zero interval;roundwith a huge scale;- a CTE
timecolumn holding strings.
- Refused by name. A float divided by zero is infinity on the
engine, which compares as a number (
WHERE v / 0.0 > 1keeps every row). Elixir cannot hold it, so the double refuses by name instead of returning no rows. - Transport error shape. A
CASTthat cannot be performed now returns%Mint.TransportError{reason: :closed}, as over HTTP, not a bare:closed.
- Quoting and parameters.
Client.Localline protocol, InfluxQL and Flux now match the engines, verified against Core and 2.7.- Line protocol.
- Text after the timestamp (
m v=1 100 200) is refused, as arev=.5andv=5., with the engine's messages. - InfluxDB 2's parse errors are now its own words (
invalid field format,missing field value,invalid number, …), and every bad line is reported.
- Text after the timestamp (
- InfluxQL.
- A literal containing
intoorfill(is no longer refused. time > 0sand bare-integer nanosecond times are answered.- An aggregate's
timeis theWHERElower bound. LIMITandOFFSETcount per field.
- A literal containing
- Flux.
- A
rangefar in the future wraps as the engine's does instead of crashing. start >= stopis the engine's 400.
- A
- Buckets.
list_bucketsmaps carrytype,orgIDand shard group durations.
- Line protocol.
Client.Localconcurrency. A token deleted while it was being created could reappear, and concurrent first writes could exceed:v3_core's 5-database limit. Both now happen under a lock.
Changed
Client.LocalInfluxQLSELECTis about twice as fast at 100k points. Redundant passes and sorts are gone,SHOW MEASUREMENTSreads the column index, and a Flux filter on_measurementreads only those measurements.Tests. About 55
local_test.exstests that duplicated the contract suite or could not fail are gone. Others now assert exact results:- error bodies taken from the real servers;
ORDER BYchecked on data written out of order;DISTINCTchecked with real duplicates.
New contract modules for the SQL parser, the SQL executor, and InfluxQL/Flux/line protocol run against Local and the real engines.
[0.1.38] - 2026-10-01
Added
Client.Localevaluatesabs,round,floorandceilin SQL (issue #25), wherever an expression stands:WHERE, the select list,ORDER BY, and an aggregate's argument.WHERE abs(amount) >= $thresholdwas refused by name, and before 0.1.37 it was read as a column namedabs(amount), which returned no rows. The double matches InfluxDB 3, verified against Core:roundrounds half away from zero and takes an optional scale.round,floorandceilreturn floats;abskeeps the argument's type.- A null argument gives null.
- A wrong argument type or count is the planner's error, even when no row reaches the call. Its wording depends on the clause the call is in.
floororceilwith a scale is the engine's 405.
- A
WHEREoperand may be an expression inIS [NOT] NULLand[NOT] IN (...)too.
Changed
Breaking: tokens are created and deleted by name, on the endpoints InfluxDB 3 serves.
create_token(conn, description, opts)anddelete_token(conn, id)called/api/v3/configure/tokenand/api/v3/configure/token/{id}, which neither Core nor Enterprise serves. They never worked against a server.create_token(conn, name, opts)makes an admin token, on Core and Enterprise.- With
permissions: ["db:db1:read,write", ...](the CLI's--permissionform) it makes an Enterprise resource token. expiry_secs:sets an expiry.- The result carries
id,name,token,hash,created_atandexpiry. delete_token(conn, name)deletes by name.Client.Localanswers as the server does, on:v3_coretoo:- a taken name is a 409;
- an unknown name is a 404;
- deleting
_adminis a 405; - a bad
expiry_secsis the 400 with its column.
The admin token path is verified against Core. The resource token's request is the one the Enterprise CLI sends; its response is not verified, because Enterprise needs a license.
Fixed
Client.Localread a literal on the left of a comparison as a column name.WHERE 1 < fwas the engine's "No field named 1" schema error, for a query the engine answers. The comparison is now turned around.
[0.1.37] - 2026-10-01
Added
Client.Localanswers more of InfluxQL, as InfluxDB 3 does. These constructs were refused by name and are now answered, verified against Core:- regular expressions (
tag =~ /re/,!~); - relative time (
time > now() - 30m); - double-quoted identifiers;
SHOW TAG VALUES [FROM m] WITH KEY = | != | =~ | !~ | IN (...) [WHERE ...], with the engine's row per missing key and its default 24-hour window.
Arithmetic in the select list, several measurements in
FROM, sub-second durations, andLIMIT/OFFSETonSHOW TAG VALUESare still refused by name.- regular expressions (
Client.LocalSQL has the regular-expression operators~,!~,~*and!~*, with DataFusion's semantics (verified): unanchored, a null is unknown, a non-string column is the engine's planning error, and an invalid pattern is its 500.
Fixed
Client.Localanswered some InfluxQLWHEREclauses wrongly. It ran InfluxQL'sWHEREwith SQL's null rules, but InfluxQL reads a tag a point lacks as the empty string. Verified against Core:host != 'h1'dropped points withouthost; the engine keeps them.host = ''found nothing; the engine finds those points.host > 'h0'compared strings; ordering a tag is always false in InfluxQL.NOT, which InfluxQL does not have, was answered; the engine refuses it as a parse error.
Untimed lines of one write became separate points in
Client.Local. Both engines give every line of a write that has no timestamp the same time, the request's, so lines of one series are one point, with fields merged and the last write winning. The double stamped each line separately and kept them as separate rows. Tests that wrote several untimed points of one series and counted rows now write timestamps.BatchWriterstopped flushing on its timer after any other flush. A flush atbatch_size, an explicitflush/2, or awrite_sync/3cancelled the interval timer, and nothing restarted it. Points written afterwards sat in the buffer until the next full batch or shutdown. Every flush now restarts the interval.Killing one connection's supervisor took every connection down. The restart found the killed supervisor's Finch pool and writer still registered, failed with
:already_started, and the repeated failures stoppedInfluxElixir.Supervisor. The restart now waits for those names to be released.query_sql(..., format: :csv)lost rows of a one-column result. InfluxDB 3 writes a null or empty one-column row as"". The parser took that for a table separator, dropped the row and read the next one as a header. InfluxDB 3's CSV is now parsed as the single table it is.ResponseParser.parse/2takes:flux_csvfor InfluxDB 2's annotated CSV, whichquery_fluxuses.gzip: trueon a write sent an uncompressed body under a gzip header. The server refused it witherror decoding gzip stream.Writernow compresses whenevergzip: trueis given, and never whengzip: falseis.execute_sqlover HTTP droppedparams:. A$nameplaceholder was the server's 400No value found for placeholder.Client.Localbound it, so tests passed.database: nilover HTTP overrode the connection's default database and failed with:no_database_specified.Client.Localand the telemetry metadata already treated it as no database given.HTTP admin calls returned a bare
Jason.DecodeErrorfor a body that is not JSON. They now return{:json_parse_error, reason}, as queries do.list_databasesreturns{:unexpected_response, body}for JSON that is not a list, instead of raising.Client.Localread a write body by its bytes, not bygzip:. InfluxDB decompresses exactly when it is told to. Verified against InfluxDB 3 Core and 2.7:- A gzip body without
gzip: truewas stored by the double and refused by the server. - A plain body with
gzip: truewas stored by the double and refused by the server. - InfluxDB 3 refuses a body that is not UTF-8. The double stored it.
The double now does all three as the engines do, with the engines' own error bodies. InfluxDB 3's database retention error now carries the server's
at line 1 column Nsuffix.- A gzip body without
The
Admin.*moduledocs claimed a telemetry span that admin calls do not emit.Configcalled:tokenrequired, but it is optional.
Changed
- Line protocol encoding is about 6x faster. 10,000 tagged points now take 30 ms instead of 191 ms. The encoder no longer runs four string replacements on every name: it scans each name once and escapes only when needed, and it builds lines as iodata. The output is unchanged.
[0.1.36] - 2026-09-30
Fixed
Client.Localaccepted line-protocol whitespace that InfluxDB refuses, and refused some it accepts. Each case was verified against InfluxDB 3 Core and 2.7:- A line holding only a no-break space,
\v,\for\rwas skipped as blank. Both servers refuse it; only spaces and tabs make a line blank. - CRLF line endings. InfluxDB 3 answers
Could not parse entire line. Found trailing content: `\r`, and echoes the line without its\r. The double now gives that exact answer; it used to answerUnable to parse timestamp value. - InfluxDB 2 refuses a
\rafter a number, but stores a string field that a\rfollows, closing quote included (s="x"\risx"). The:v2profile used to refuse it. - A tab or
\rafter an invalid field value fails that field (No fields were provided, or trailing content for a later field), as on InfluxDB 3.
- A line holding only a no-break space,
Changed
Client.Localwrites are about 40% faster. A 100k-line write went from about 2.25 s to about 1.3 s. Three changes:- Leading whitespace and blank lines are now checked byte by byte, where a regex and a full-line trim ran on every line.
- Each column's type is confirmed once per write, instead of costing two ETS calls per column per point.
- The table-exists lookup that only a
timecolumn needs no longer runs for every point.
Client.Localmulti-keyORDER BYis 25–35% faster. Each row's sort-key values are now read once, not on every comparison. At 100k points,ORDER BY host, time DESCwent from 375 to about 270 ms andSELECT DISTINCT ON (host) ... ORDER BY host, time DESCfrom 572 to about 395 ms (medians of 7 runs). A single-key sort such asORDER BY timestill sorts in place, which is faster when the key is a field read. Ordering is unchanged, including full ties in write order, which a new test pins; the testing guide notes that the server's order for ties is arbitrary.
Fixed
Query.*andAdmin.*behaved differently from the facade.Query.SQL,Query.SQLStream,Query.InfluxQL,Query.Fluxand theAdmin.*modules called the client directly. So:- a connection name raised
FunctionClauseError(for exampleQuery.SQL.query(:my_conn, sql)orAdmin.Databases.list(:my_conn)), whereInfluxElixir.query_sql(:my_conn, sql)worked; - queries made through them emitted no telemetry span.
They now call the facade functions, so the two entry points behave identically.
- a connection name raised
Changed
Contract tests assert exact results. Fifteen contract tests checked only
rows != []before inspectinghd(rows), or summed values across buckets. One tag-escaping test accepted either of two answers. They now assert the exact rows the engine returns, verified on InfluxDB 3 Core and 2.7:- the four 2-minute
AVGbuckets (10.0, 25.0, 45.0, 60.0); - the single hourly
SUM,COUNTandMIN/MAXrow; "us,east"for an escaped comma in a tag;- all five streamed values.
Their fixtures now check their own writes, so a failed write can't surface later as a wrong answer. The telemetry error test pins the query error it expects.
- the four 2-minute
[0.1.35] - 2026-09-29
Fixed
Client.Localaccepted anyretention:for a v3 database. InfluxDB 3 takes a duration string and refuses anything else with 400serde json error: ... expected a duration. That includesretention: 3600, seconds as a v2 bucket takes them, so such a call passed in tests and failed in production. The double now applies the engine's grammar, verified on 53 values against Core: one or more<number><unit>parts, a fraction allowed, case-sensitive units, or a bare"0". It still stores no retention, so nothing expires.health/1onClient.Localanswered"version" => "local"on every profile. InfluxDB 3's/healthis a plainOK, whichClient.HTTPreports as%{"status" => "pass"}. The double now answers that shape on the v3 profiles, and InfluxDB 2's JSON shape on:v2. The contract asserts each exactly instead ofstatus in ["pass", "ok"].delete_bucket/2could delete another org's bucket. The lookup from a bucket name to its ID did not name the connection's org. When two orgs had a bucket of the same name, it took the first match: on InfluxDB 2.7 adev-influxconnection deleted the other org's bucket and left its own. The lookup andlist_buckets/1are now scoped to the connection's:org. A 404 for the org itself is passed through as the server's answer, not reported as "bucket not found".list_buckets/1returned at most 20 buckets. InfluxDB 2 pages the list, and only the first page was read, so an org with more buckets silently lost the rest. Every page is now read, 100 at a time.- Names with
&,+,#or=went to the wrong place. Query values were built withURI.encode/1, which leaves those characters alone. A write to bucketa&bwent to bucketa(into its data, if such a bucket existed),c+dwent toc d, ande#fwent toe. Query values are now form-encoded and path segments strictly encoded, for org, bucket and database names, precision, and bucket and token IDs. A v3db/rpname such asname/autogenwrites, queries and drops as before.
Changed
- The testing guide explains how to pin production's column types
(#24). Each
Client.Localstore starts with an empty schema, so a test's first write to a measurement decides its column types. A writer that sends the wrong type therefore defines the column in its own test but is refused in production. The guide now says so, with asetuprecipe that seeds one point with production's types. The double already refuses a conflicting type as InfluxDB 3 does, re-verified on every profile and on v0.1.33.
[0.1.34] - 2026-09-28
Fixed
BatchWriteroptions were not validated. Each of these was reproduced:batch_size: 0started a writer that refused every write as{:error, :buffer_full}.- A misspelt key such as
flush_interval:was silently ignored and its default used. batch_size: "10"started, then crashed the writer on its first write.
start_link/1now validates with NimbleOptions and returns{:error, %NimbleOptions.ValidationError{}}naming the option. Through a connection'sbatch_writer:config, that failsadd_connection/2(or application start). The moduledoc's option list is generated from the same schema, so it cannot drift from what is accepted.A connection that failed to start stayed registered.
ConnectionSupervisorregisters the connection and initialises the client before starting its children. When a child failed,add_connection/2returned the error but the name still resolved to a connection that was not running, andClient.Local's store stayed allocated. Both are now released, so a corrected retry under the same name starts. A name that is already running keeps its registration.
[0.1.33] - 2026-09-28
Added
Client.LocalrunsSELECT DISTINCT ON (...)(#23). The double refused everyDISTINCT ONquery. InfluxDB 3 (DataFusion) supports it, and it is the idiomatic "latest row per key" query (... ORDER BY k, time DESC). A consumer that rescued the refusal got "no rows" from its tests instead of exercising the read path. The double now keeps the first row per distinct key afterORDER BY, beforeLIMITandOFFSET, over plain or projected columns or*. It follows the engine's rules, verified against InfluxDB 3 Core:ORDER BYmust start with theONcolumns, in order (400);ORDER BYresolves against the table, not select aliases (500);- aggregates and
GROUP BYare refused (405); - an empty
ON ()is a 400; - a missing key column is the null key.
An expression in
ON, such asDATE_BIN(...), is refused by name.Writes can be all-or-nothing, and acknowledged before persistence. InfluxDB 3's
accept_partial=falseandno_sync=truewrite parameters had no way through the client;write/3now takesaccept_partial:andno_sync:(v3 only) andClient.HTTPsends them.Client.Localmodels both as verified: withaccept_partial: falsethe first bad line in line order rejects the payload with the engine's single-line"line protocol parsing error"body and stores nothing, not even schema;no_syncis accepted; a flag that is not a boolean is the engine's 400.
Changed
Breaking (tests):
Client.Localfollows SQL's identifier rules. DataFusion folds every unquoted identifier to lower case: columns, tables, aliases and CTE names. Only a double-quoted identifier is exact, and"..."is never a string. The double compared names case-sensitively and read"..."as a string literal, so it disagreed with the server both ways:SELECT Hoston a tagHostpassed against the double, but the server answersNo field named host.SELECT Kfailed against the double, but the server readsk.v AS Vanswered"V"instead of"v".WHERE k = "a"matched the stringainstead of comparing with a column.SELECT "Host"andAS "Mixed Case"were refused.
The new
Client.Local.SQLIdentifiersapplies the rules before parsing, verified against InfluxDB 3 Core. InfluxQL identifiers stay case-sensitive, as on the server.Client.Local:error messages now echo the query with its identifiers folded.Breaking (tests):
Client.Localhas no implicit"default"database. With neither:databasenor:databasesconfigured, the double wrote to and queried a"default"database, and listed it. The server has no such database.Client.HTTPreturns{:error, :no_database_specified}for the same call, so code that forgotdatabase:passed its tests and failed in production. The double now answers exactly as HTTP does:write/3,query_sql/3andexecute_sql/3return{:error, :no_database_specified}.query_sql_stream/3raisesStreamErrorof kind:no_database.query_influxql/3returns the engine's 400 (must specify a 'db' parameter...);SHOW DATABASESstill works.
Without
:database, the first of:databasesis now the default, as it already was inClient.HTTP. The Local connection map no longer carries:databases, a snapshot taken at start that went stale on every create and delete. Uselist_databases/1.Client.Local's ETS storage is its own module,Client.Local.Store. The key layout, the atomic insert rules, the duplicate-merge fast path and the deletion bookkeeping were spread throughlocal.exas raw:etspatterns (40 call sites); they now live in one 300-line module with a small API, andlocal.ex(1,583 → 1,430 lines) holds no:etscall. Behaviour is unchanged.
Fixed
A
Point'sDateTimetimestamp only worked at nanosecond precision. The encoder always wrote nanoseconds, so aBatchWriterwithwrite_opts: [precision: :second](or:millisecond,:microsecond) had every point refused by InfluxDB 3 withtimestamp, 1790596800123456000, out of range for precision: Second.LineProtocol.encode/2now takesprecision:and writes aDateTimein that unit, truncated toward the past.BatchWriterencodes with its ownwrite_optsprecision. An integer timestamp is still taken as given, since it is already in the caller's unit.Client.Localaccepted timestamps no server stores, and then crashed on the query. A timestamp is refused once scaled past a signed 64-bit count of nanoseconds, in each version's words (verified on Core and 2.7):- InfluxDB 3 answers
timestamp, N, out of range for precision: Unit, andUnable to parse timestamp valuefor anything past 64 bits. - InfluxDB 2 answers
time outside range -9223372036854775806 - 9223372036854775806, a range that excludes both int64 ends, and astrconv.ParseInterror past 64 bits.
The double used to store the scaled value, and a later
SELECTraised converting it to aDateTime. A test fixture that built timestamps past int64 by string concatenation relied on this and was fixed.- InfluxDB 3 answers
LineProtocol.encode/1produced lines no server accepts, or one both drop silently. Each case was verified on InfluxDB 3 Core and 2.7:- An integer outside 64 bits. Both reject the line.
- A name ending in a backslash. Both reject it, even though the encoder escaped the backslash.
- A measurement starting with
#. It is a comment line to both, so inside a batch it vanished while the other lines were stored. Escaping it as\#stores the backslash.
These are now refused with the existing
{:invalid_field_value, ...},{:invalid_measurement, ...},{:invalid_tag_key, ...},{:invalid_tag_value, ...}and{:invalid_field_key, ...}errors.BatchWriter.write/3returns the error to the caller.Client.Localaccepted tabs in line protocol that InfluxDB 3 refuses. InfluxDB 3 ends a name or value at an unescaped tab, but it separates sections only with spaces. So a tab in a measurement, tag, field key or after a value refuses the line, with a message that depends on where the tab stands. The double now gives each of those answers, as verified on InfluxDB 3 Core. A leading tab is whitespace, as on the engine; the double had kept it as part of the measurement name. A tab inside a quoted string is fine. InfluxDB 2 stores tabs, and so does the:v2profile.query_sql_stream/3could starve its connection pool. A stream runs its request in a producer process that holds a pool connection. There were two ways to lose it:- If the consuming process was killed mid-stream (
:kill, a brutal task shutdown), the producer waited forever for an acknowledgement, holding the connection. On a small pool, every later request was a:pool_timeout. - If the consumer stopped early (
Enum.take/2), cleanup killed the producer. Finch's pool drops the connection of an owner that dies without serving the requests already waiting for it, so a request queued at that moment timed out.
Both were reproduced on InfluxDB 3 Core with a one-connection pool. The producer now monitors its consumer, and it ends the request with
Finch.stream_while/5's{:halt, _}instead of dying, which checks the connection back in. Early-stop cleanup asks the producer to cancel, and kills it only if it has not stopped within a second (a server gone silent). Two integration tests fail against the previous client.- If the consuming process was killed mid-stream (
Flight.Client.query/3closes its gRPC channel when decoding raises. The channel was closed on every return path but not on a raise; the call now usestry/after.Enterprise
DELETEinClient.Localignored SQL's identifier rules. It is now read like aSELECT: unquoted names fold to lower case, and quoted ones are exact. Before,DELETE FROM "Cpu"looked for a measurement literally named"Cpu", quotes included, and itsWHEREcompared names case-sensitively.A
Client.Localtable ended with its last point. A table exists once its columns are in the catalog, as on the engine, so aSELECTafter an EnterpriseDELETEof every row answers[]. Before, it was a "table not found" error.Tests that proved little were tightened. Contract tests now check the engine's full error body for malformed line protocol and a created token's fields, where they used to check only for a non-empty body and a map. Two tests that duplicated stronger ones were removed. The runtime
add_connection/2test now writes and queries through the name, and checks the name is gone afterremove_connection/1.Client.Localignored InfluxDB 3's database rules. Each of the following was verified against Core:- Names. A name must start with an ASCII letter or digit and
contain only letters, digits,
_,-and at most one/(the<db>/<rp>form). The double accepted any name. It now returns the engine's 400, with the engine's message, in the engine's order. This applies tocreate_database/3, to a write that creates a database, and tostart/1, which raises. - Database limit. Core holds at most 5 databases; the
:v3_coreprofile now returns the engine's 422 for a sixth. - Missing database. A query against a database that does not
exist is the engine's 404,
{"error":"query error: database not found: <name>"}. That covers SQL of any kind, even unparseable, and InfluxQL after parsing. The double used to answer a table error, or rows forSELECT 1. _internal.list_databases/1andSHOW DATABASESinclude the engine's own_internal, and dropping it is the engine's 500.
- Names. A name must start with an ASCII letter or digit and
contain only letters, digits,
InfluxQL with
format: :csvinClient.Localaggregated strings. The inner SQL query inheritedformat:, so InfluxQL saw CSV strings before rendering them again. The format now applies once, to the InfluxQL result.Stale module examples. Three module examples no longer worked:
Write.Writerwrote with no database,Admin.Tokensused the:v3_coreprofile (tokens are Enterprise-only), andAdmin.Bucketsused:v3_core(buckets are v2). All three now run as written.A stopping
BatchWriterlost its buffer. Itsterminate/2flushed the buffer, but the writer did not trap exits. When a supervisor stopped it (application shutdown,remove_connection/1), the exit signal killed it beforeterminate/2could run, so every buffered line was lost. The tests usedGenServer.stop/1, which runsterminate/2regardless, so they never caught it. The writer now traps exits. On shutdown it writes any batch still being retried, then the buffer, once each and in the order they were written. It answers anywrite_sync/3caller waiting on those writes. The new:shutdownoption sets how long the supervisor waits (default5_000).An invalid
Pointcrashed theBatchWriter. The point was encoded inside the writer withencode!, so a point with no fields killed the process along with every other caller's buffered lines.write/3andwrite_sync/3now encode in the caller and return{:error, reason}, asLineProtocol.encode/1does.Backpressure lifted while a retry chain was still in flight. An explicit
flush/2orwrite_sync/3during a retry chain starts a second chain, but the writer tracked only one. The first chain to end cleared the marker, so writes flushed into new chains instead of being held to the10 × batch_sizebound. Each chain now keeps its own batch andwrite_sync/3caller until it ends.Client.Localignoredformat:. Over HTTP,format: :csvreturns every value as a string ("1.5","true"), but the double returned typed values. A test of CSV handling could pass against the double and fail in production. The double also returned rows for:parquetinstead of a binary, and rows for an unknown format where the engine answers 400. The newClient.Local.Formatanswers asClient.HTTPdoes, verified against InfluxDB 3 Core:- Floats render as the engine's CSV does:
1e15is"1000000000000000.0",1e16is"1e16",1.5e-5is"0.000015"and1e-6is"1e-6". Integers and booleans render as the engine's strings too. - A nested value gives the same connection error as the server's aborted body.
:parquetis refused by name.:xmlis the engine's 400.:prettyand:json_linesare{:unsupported_format, f}.
The same applies to
query_influxql/3.query_sql_stream/3ignoresformat:on both clients.- Floats render as the engine's CSV does:
An empty CSV cell was a
nilkey. A null column is absent from a JSON row, butResponseParserput an empty CSV cell into the row asnil. An empty cell is either a null or an empty string; CSV cannot tell them apart (verified on InfluxDB 3's CSV and on a v2 Fluxpivot). Empty cells are now left out, sorefute Map.has_key?/2holds for every format.Flux (HTTP) returned a newline inside a string as
\r\n. InfluxDB 2's CSV writer (Go'scsv.Writerin CRLF mode) rewrites every\nin a quoted value as\r\nand drops a bare\r(verified), so a storeds="l1\nl2"read back as"l1\r\nl2"overClient.HTTPwhileClient.Localreturned"l1\nl2".ResponseParserundoes the rewrite; both clients now return the value as stored (unless it contained a\rof its own, which the server has already dropped).transport: :flightsilently dropped every column of a type the reader did not know. Verified on InfluxDB 3:selector_*without a subscript (a Struct),array_agg(a List),time - LAG(time)(a Duration),concat/upperresults (Utf8View),CAST(... AS DATE),DECIMALandBYTEA, and literal lists and structs all came back as empty rows over Flight while HTTP returned them. The reader now walks Arrow's field nodes, buffers and variadic buffer counts depth-first and decodes each of these to exactly what HTTP returns; types it still does not decode (Interval, Time, Map, Union, ...) are an error naming the type and column instead of a vanished column. Tested against 13 recorded engine responses intest/fixtures/flightand live.Timestamps inside structs and lists were strings over HTTP.
ResponseParsernow coerces nested values, soselector_last(v, time)is%{"time" => %DateTime{}, "value" => v}on HTTP, Flight andClient.Local, which now also accepts a selector without['value' | 'time']and returns that struct, as the engine does.Client.Localrefused a line with repeated spaces between its sections (m v=1i 1) as "No fields were provided"; InfluxDB 3 accepts it (verified), and so does the double now.A schema error's
original_linewas the raw line. InfluxDB 3 reports the line as it parsed it — single spaces,v=2.0asv=2,1e3as1000, strings unquoted (verified); the double renders it the same way. Parse errors still show the raw line, as on the engine.Client.Localread three clocks. Untimed points and Fluxnow()usedSystem.system_time/1, SQLnow()usedSystem.os_time/1; the two differ by microseconds, so a point a test stamped with one clock could fall afternow()read from the other and drop out ofrange(start: -1h). The double now has one clock (Store.now_ns/0, the later of the two), and Flux's exclusivestopdefaults to a nanosecond after it, so a point written a moment ago is always in range, as on a real server.
[0.1.32] - 2026-09-25
Changed
Client.Localreads and writes are two to four times faster. Every read merged duplicate points (same measurement, tags and time), about three quarters of a query's cost at 100k points, although most measurements never hold a duplicate. A write now records each series and time with an atomicinsert_new; a second write of the same key marks the measurement, and reads merge only marked measurements. The line-protocol parser skips its escape-aware scans and unescaping when a token has no quote or backslash, and slices bytes instead of graphemes. Measured: a 50k-point write 1,962 → 697 ms, 200 small writes 40 → 16 ms,COUNTover 50k points 88 → 19 ms. Results are unchanged.
Fixed
Client.Local.execute_sql/3accepted statements InfluxDB 3 refuses. Every statement butDELETEreturned{:ok, %{"rows_affected" => 0}}, andDELETEon:v3_corereturned a bare:delete_not_supported. Verified against Core:DELETE,INSERT,UPDATEare 400Error during planning: DML not supported: Delete | Insert Into | Update;CREATE TABLE | VIEW | DATABASEandDROP TABLE | VIEWare 400... DDL not supported: CreateMemoryTable | CreateView | CreateCatalog | DropTable | DropView; anything else (ALTER,TRUNCATE) is 405This feature is not implemented: Unsupported SQL statement: <sql>; aSELECTreturns rows. The double now answers each of these as the engine does (its:v3_enterpriseDELETEis unchanged), andquery_sql/3gives a non-query the same answer, since the engine serves both from one endpoint. The contract test that should have caught this asserted{:error, _}andis_map(result); it and the other contract tests accepting any error now assert the engine's status and body.Client.HTTP.execute_sql/3returnedSELECTrows as raw JSON with string timestamps; they are now typed likequery_sql/3rows. The facade,Query.SQL.execute/3and the usage rules advertisedexecute_sqlfor "DELETE, INSERT INTO ... SELECT", which Core refuses; they now say what it accepts. Theexecute_sqlcallback returns{:ok, map() | [map()]}.Client.Localgot NULL wrong inWHERE,ORDER BYandDISTINCT. Verified against InfluxDB 3:WHERE NOT (rack = '1'),rack NOT IN (...)andv NOT BETWEEN ...returned rows where the column was null (the double's logic was two-valued);ORDER BYput a null first for strings, last for numbers and betweenfalseandtrue(term order) where the engine puts nulls last ascending and first descending, so aGROUP BYof a tag with missing values was mis-ordered too;SELECT DISTINCTdropped the all-null combination the engine returns as%{}. The evaluator now uses three-valued logic, sorting honours DataFusion's null placement andNULLS FIRST/NULLS LAST, andDISTINCTkeeps the null row.LIKE 'al\%%'matched nothing inClient.Local. The backslash escape was read as a literal backslash; it now makes the next character literal, as on the engine.Client.Localrefused valid SQL: a boolean column as a predicate (WHERE b,NOT b), the%operator and unary minus (-n). All three now work as on the engine (%takes the dividend's sign and works on floats); a non-boolean bare column is the engine's planning error.HAVINGis still refused by name.Client.Localmisparsed escaped backslashes. A string field value ending in an escaped backslash (s="ends\\",v=1i) swallowed the next field and failed the line; InfluxDB 3 storesends\. A measurement, tag key, tag value or field key ending in a backslash (m\\,t=a) was stored under a garbled name or refused with the wrong message; InfluxDB 3 refuses it with "Measurements, tag keys and values, and field keys may not end with a backslash". Both verified. Under the:v2profile a measurement keeps\\,as InfluxDB 2 does (verified), and tag and field names ending in a backslash are refused as it refuses them.- No release since 0.1.21 had a CHANGELOG heading (#22). The publish
job bumped the version and published without touching
CHANGELOG.md, so 0.1.22 through 0.1.31 were all released with their entries under[Unreleased]and a consumer could not tell which version changed what. Every entry is now under the release that first shipped it, worked out from the[Unreleased]block at each release tag (all 71 entries placed, their text unchanged). The publish job now writes## [x.y.z] - dateunder[Unreleased]before publishing, fails the release if the heading is missing, and commits it with the bump; a test fails CI if the version inmix.exshas no heading or the headings are out of order.
[0.1.31] - 2026-09-24
Fixed
query_sql_stream/3over HTTP returned timestamps as strings. Each streamed JSONL row was onlyJason.decoded, sotime(and everyDATE_BINalias orMAX(time)) was"2023-11-14T22:13:20"whilequery_sql/3andClient.Local's stream returned aDateTime; code that switched to streaming for a large result broke. Streamed rows now get the same coercion; verified identical toquery_sql/3on InfluxDB 3.Client.LocalrefusedGROUP BYandORDER BYreferences DataFusion accepts.GROUP BY bucket(a select alias — the usual form afterDATE_BIN(...) AS bucket),GROUP BY 1, 2,ORDER BY 2 DESCwere all "No field named" schema errors, andGROUP BY DATE_BIN(...), host— one row per bucket per host — was refused although the guide said it was supported. Positions and aliases are now resolved to the select items they name,DATE_BINcombines with grouping columns, and a position outside the select list is the engine's planning error; 14 of 14 comparison queries match InfluxDB 3.Client.Localskipped every Flux stage it did not understand. The double matched a few regexes anywhere in the query and ignored the rest, so|> mean(),last(),limit(),count(),aggregateWindow(),pivot()andgroup()all returned the raw rows;orbehaved likeand;!=,not,r._value > 2.0andrange(stop:)were ignored; a query withoutrange()or on a missing bucket returned rows or[]. Verified against InfluxDB 2.7, the newClient.Local.Fluxruns the pipeline in order —range,filterwith a real expression grammar and three-valued logic,first/last/min/max,mean/sum/count,limit,yield— numbers tables in the engine's series order, adds_start/_stop, and answers 24 of 24 comparison queries identically. Any other stage is a 400 naming it; norange()is the engine's unbounded-read 400; a missing bucket is its 404;mean()over strings is its 400. Two tests that asserted the old behaviour (all rows withoutrange(),[]for a missing bucket) now assert the engine's answers.Client.Localanswered InfluxQLSELECTas SQL. Verified against InfluxDB 3 over 50 statements, 28 of which differed: rows lacked"iox::measurement", a projection losttime,MEAN/COUNTwere refused, aggregates were not namedmean/count/sum, a lone selector did not return its point's time,LIMITdid not apply perGROUP BYseries, an unknown column or measurement was an error instead of{:ok, []},SHOW FIELD KEYSand bareSHOW TAG KEYSwere unsupported, andSHOW DATABASESlacked"deleted". The newClient.Local.InfluxQLmodule answers all 50 identically;GROUP BY time(...), regular expressions,fill()and other constructs it does not model are refused by name.Client.Localcomparedtimeat microsecond precision in SQL.ORDER BY timesorted the projectedDateTime, so points less than a microsecond apart came back in insertion order, and a literal such as'…:20.0000002Z'lost its last three digits. The engine uses the nanoseconds (verified); so does the double now, on everyORDER BY timeform (*, a projection, an alias oftime) and in every time literal.transport: :flightreturned null columns asnilkeys. Over HTTP, InfluxDB 3 leaves a null column out of the row, and the library documents that a null column is absent, notnil. The Flight reader put every schema column in every row, so the same query gave different maps per transport, and code matching onMap.has_key?/2or comparing rows broke when switching. Verified on 25,000 rows mixing tags, integer, unsigned, float, string and boolean fields and nulls across several record batches: the rows differed only innilkeys, and now they are identical. The reader's null tests assertedrow["v"] == nil, which passes whether the key is absent ornil; they now assert the key is absent.
[0.1.30] - 2026-09-23
Changed
Flight.Clientbuilds itsDoGetticket throughbuild_ticket/2instead of a second inline copy of the JSON. Admin tests assert what a call does (the bucket is listed with its rule, the database is gone) rather than:okalone.Connection.get/1reads:persistent_termwith a default instead of rescuingArgumentError.Writertests assert what a write does — every point of a gzipped payload is stored,precision:changes the stored time,:clientselects the client — instead of{:ok, :written}alone.ResponseParsertests every string cell for InfluxDB 3's zone-less timestamp shape before deciding whether to decode it; a one-clause binary pattern now screens out strings that cannot match before the regex runs (about 14 ns instead of 340 ns per ordinary string cell). Results are unchanged.BatchWritertests cover a chain that exhausts more than one retry (errors are counted per chain, not per attempt) andmax_retries: 0against a transport error.
Fixed
Admin.DatabasesandAdmin.Bucketsdocumented options that did not exist. The docs named:retention_periodand:retention_seconds;Client.HTTPreads:retention, so a consumer following the docs got the option silently ignored. The docs now name:retentionand its verified format: a duration string such as"30d"for InfluxDB 3 (an integer is a 400), seconds for InfluxDB 2 (1–3599 is a 500retention policy duration must be at least 1h0m0s;0is no expiry).Client.Local.delete_bucket/2returned:okfor a missing bucket, claiming to match "the idempotent delete semantics of the v2 API". InfluxDB 2 answers 404 (verified) andClient.HTTPreports it as{:error, %{status: 404, body: "bucket not found: <name>"}}; the double now does the same.create_bucket/3keeps:retention, refuses 1–3599 seconds as the engine does, andlist_buckets/1lists each bucket's"retentionRules"in the engine's shape.delete_database/2's 404 body is the engine'sthe requested resource was not found: <name>, and a v2 write to a missing bucket answers the engine's JSON 404 (bucket "<name>" not found) instead of a plain "database not found".Client.Localkept duplicate points as separate rows. InfluxDB 3 and 2.7 both treat a measurement's points with the same tag set and timestamp as one point — fields merge, the later write wins per field, the last of two such lines in a payload wins (verified) — and the double returned one row per write, so a fixture that rewrote a point saw two rows and doubled its aggregates. Points are still stored as written, one ETS insert each, and are merged on every read;DELETEremoves the merged point and counts it once.Client.Localcrashed on precision spellings the engine accepts.HTTP.write/3passesprecision:to InfluxDB 3 verbatim, which takesns | n | nanosecond | us | u | microsecond | ms | millisecond | s | second | auto(verified), and maps the long names onto InfluxDB 2'sns | us | ms | s. The double accepted only the four long atoms and raisedFunctionClauseErroron:ms,"ms","nanosecond"or:auto, so a write that works in production crashed in tests. It now accepts what each profile's server accepts, implementsautoat the engine's thresholds (|ts| below 5e9 seconds, 5e12 milliseconds, 5e15 microseconds, else nanoseconds; verified), and answers an unknown precision with the server's 400 body (serde error: unknown variant …on v3,invalid precision; valid precision units are ns, us, ms, and son v2).ConnectionSupervisorstarted a Finch pool the connection never used. With:finch_namepointing at an existing pool, a second idle pool was still started per connection, and the batch writer restarted with it underrest_for_one. No per-connection pool is started when:finch_nameis set.Connection.fetch!/1raised:persistent_term's bareArgumentErrorfor an unknown name; it now names the missing connection and says how to register one.- The manual telemetry emitters
write_stop/2,write_exception/2,query_stop/2andquery_exception/2emitted%{duration}only, while the spans (and the documented events) carrymonotonic_timetoo. They now emit the same measurements.write_stop/2's docs mentioned acompressed_bytesmetadata key that nothing emits (removed from the event docs on 2026-09-11); the leftover is gone. LineProtocol.encode/1emitted lines no server accepts. An empty tag value (host=), an empty tag key or field key, the reserved tag keytime, and a newline in a measurement, tag key, tag value or field key all encoded without complaint and failed at the server — and a newline splits the line, sotags: %{"host" => "a\nb"}stored a bogus measurementbon InfluxDB 3 (verified). A non-string tag value or an unsupported field value (nil, an atom) crashed the encoder with aFunctionClauseError. Each is now a tagged error fromencode/1({:invalid_tag_value, key, value},{:reserved_tag_key, "time"},{:invalid_field_value, key, value}, …); see "Validation" in the moduledoc.Client.Localworded atimefield on a new table as a column-type conflict. InfluxDB 3 says'time' is a reserved columnfor a tag or a field on a table that does not exist yet, and reports the column-type conflict withiox::column_type::timestamponly on an existing table. The double now does the same (the check moved from the parser into the store, which knows whether the table exists).
[0.1.29] - 2026-09-22
Fixed
Client.Local's:v2profile applied InfluxDB 3's write rules. Verified against InfluxDB 2.7, which differs on nearly every point: a field type conflict is HTTP 422 ("unprocessable entity", message ending indropped=N) with the other lines stored; a line that fails to parse rejects the whole payload with HTTP 400 ("code":"invalid",unable to parse '<line>': ...) and nothing is stored;timeas a field is dropped silently and as a tag is a 400; a tag and a field may share a name; an empty payload is accepted. The double now applies those rules under:v2and InfluxDB 3's under:v3_core/:v3_enterprise.
[0.1.28] - 2026-09-22
Fixed
Client.LocalrefusedLIMIT n OFFSET m, which InfluxDB 3 runs (#21). Verified against the engine:OFFSETskips rows beforeLIMITtakes them, in either order, on plain, projected, grouped andDISTINCTrows;OFFSET 0is a no-op, an offset past the end is an empty result, a negative offset is "OFFSET must be >=0" and a bare word is a schema error. All of that is now mirrored, so a paginated read can be tested against the double instead of re-implementing the offset in Elixir.
[0.1.27] - 2026-09-18
Fixed
Client.Localaccepted writes InfluxDB 3 rejects, and rejected one it accepts. Verified against the engine: a field written as an integer and later as a float (or tag then field, string then float, boolean then integer) is refused line by line with "invalid column type for column 'v', expected iox::column_type::field::integer, got iox::column_type::field::float";timeas a tag or field, a key used as both tag and field on one line, an integer outside int64 and an empty payload are refused; a rejected line drops only itself — the other lines are stored and the response is the partial-write JSON with one entry per bad line. The double accepted all of those (and stored every line), and refused a newline inside a quoted string value, which the engine keeps. It now keeps a per-measurement column schema ({:column, database, measurement, column}, fixed atomically by the first writer), applies a payload line by line, returns the engine's body, and drops the schema with the data when the database is deleted. Unsigned integers (7u) are accepted.Client.Local.delete_database/2kept the deleted database's points, so a re-created database was not empty. The points and schema go with it.
[0.1.26] - 2026-09-17
Changed
- SQL execution split out of
Client.Local. The 900-line executor — CTEs, joins, theWHEREevaluator, aggregates, casts, ordering, schema checks — isClient.Local.SQLExecutor, pure over the points it is handed through a fetch function;Client.Localkeeps storage, profiles and the InfluxQL and Flux paths. Public behaviour is unchanged. Client.Localchecks a query's column references against the first row before scanning every row's columns; the scan now runs only when a name is missing there, which is also when the error message needs the full list. Per-query fixed cost at 10k points drops from about 20 ms to under 5 ms; results are unchanged.Client.Local.SQLParsercuts a SELECT into its parts in one place. Eight regexes each found "the table after FROM" for their own dispatcher (star, column list, aggregate, DISTINCT, the clause check, the alias stripper, two helpers);split_select/1now does it once and the dispatchers work from its parts. No behaviour change; 71 lines fewer.
Fixed
Client.Localread a bare word insideIN (...)as a string.host IN (a, b)compared against"a"and"b"; on the engine the items are column references (v IN (1, other)works,host IN (a, b)is a schema error). Items are now parsed like every other comparand.Client.Localrefused a constant in a select list.0.0 AS volume(the #17 candle query's placeholder volume) was "unsupported column expression" in an aggregate and a schema error in a projection; the engine returns the constant on every row. Supported with an alias in every query shape; an unaliased constant is refused with the reason.DELETE ... WHERE a OR bcrashedClient.Local(:v3_enterprise) with aFunctionClauseError: the delete path still folded predicates with the helper from beforeWHEREbecame a boolean expression. It now evaluates the same expression treeSELECTdoes.CAST(col AS INTEGER)inWHEREwas rejected by 0.1.24 (#20) — and silently matched nothing in 0.1.23. The report is right that 0.1.24 refuses the orderbook depth query withClient.Local: unsupported WHERE clause: CAST(level AS INTEGER). It was never a working query on the double: 0.1.23 readCAST(level AS INTEGER)as a column named that and returned no rows for it, so tests passing against 0.1.23 were passing on an empty result.CAST(and DataFusion'scol::TYPEshorthand) now works wherever an expression is allowed —WHERE,BETWEEN,LIKE, projections, aggregates, arithmetic andORDER BY— with the engine's semantics, verified against InfluxDB 3 Core:INTEGER/INT/BIGINT,DOUBLE/FLOAT,VARCHAR/STRING/TEXT; text converts only when the whole string is a number, a float truncates to an integer, a number renders to text. A cast that cannot be performed ('abc'toINTEGER,timetoINTEGER) makes InfluxDB 3 Core drop the connection mid-response, whichClient.HTTPreports as{:error, {:connection_error, %Mint.TransportError{ reason: :closed}}}; the double reports{:error, {:connection_error, :closed}}.ORDER BYignored every term after the first inClient.Local.ORDER BY symbol DESC, levelsorted bysymbolonly. All terms apply, each with its own direction, and a term may be an expression (ORDER BY CAST(level AS INTEGER) DESC) on raw and projected rows.
[0.1.25] - 2026-09-15
Fixed
Client.LocalignoredGROUP BYon a plain projection andORDER BYon column-grouped aggregates, and sampled a row for an ungrouped column.SELECT host FROM p GROUP BY hostreturned every row;SELECT host, SUM(v) AS t FROM p GROUP BY host ORDER BY t DESCcame back in map order;SELECT host, MAX(v) FROM p(noGROUP BY) picked the first row's host. The engine returns one row per group, honours the ordering, and fails planning for the ungrouped column ("must appear in the GROUP BY clause or must be part of an aggregate function"); the double now does all three.Client.Localanswered queries that name a column no row has.SELECT nosuch,MAX(nosuch),WHERE nosuch = 1,GROUP BY nosuch,ORDER BY nosuchandDISTINCT nosuchare all the same 500 schema error on InfluxDB 3 ("No field named nosuch"); the double returned rows without the column, no rows, or unsorted rows depending on the clause. Every column reference in a query is now checked against the rows' columns (the check added forWHEREexpressions in 0.1.24 generalised), and an output alias remains a validORDER BYtarget.
[0.1.24] - 2026-09-15
Fixed
- An unbound
$placeholdermatched nothing inClient.Local; the engine fails planning ("No value found for placeholder with name $host"). The double now returns that error, so a missing binding cannot pass a test as an empty result. Client.Localrefusedmedian()andCROSS JOIN, which InfluxDB 3 runs (#19). Verified against InfluxDB 3 Core:medianreturns the middle value, or for an even count the mean of the two middle values in the column's type (two integers average with integer division: the median of 1 and 4 is 2), null over no rows, and is rejected overtime;FROM w CROSS JOIN refpairs every row with every row ofref. Both are supported, so the median-screened candle query in the issue now runs on the double with the same rows as the server. A column present on both sides of the join is refused as ambiguous, as the engine refuses the unqualified reference.Client.Localcompared a bare word inWHEREas a string.price <= med * 3comparedpricewith the text"med * 3"andhost = prodwith"prod"— both silently wrong. Either side of a comparison may now be an arithmetic expression over columns, a bare word is a column reference, and a column no row has is the engine's schema error ("No field named prod"), which is what production returns for a forgotten pair of quotes.- A
nilparam rendered as the wordnilinClient.Local; it now renders asNULL, which never matches, as the JSONnullJason sends over HTTP never matches.
[0.1.23] - 2026-09-15
Fixed
Client.Localreturned wrong rows forOR,NOT, parentheses and<>inWHERE, and forLIMIT 0. The clause splitter only knewAND:v > 3 OR v < 2was read as one predicate against the string"3 OR v < 2",NOT host = 'a'andv <> 1.0matched nothing, andLIMIT 0returned every row where the engine returns none.WHEREis now parsed as a boolean expression —ANDbinding tighter thanOR,NOT, parentheses, string literals opaque — and<>,[NOT] BETWEEN ... AND ...(includingtime) and[NOT] LIKE/ILIKEare supported with the engine's semantics (LIKEcase-sensitive,_one character,LIKEover a numeric column reproduces the engine's planning error).LIMIT 0returns no rows; a negative or non-numericLIMITis rejected as the engine rejects it. A malformed expression is rejected, never truncated.Client.Localcompared a string tag against a bare number by Erlang term order, sorack > 3matched every tag. The engine keeps the column as text and renders the literal (rack = 2matches"2";rack > 3does not match"10"); the double now does the same.Flight.Readerwalked empty FlatBuffer vectors at bogus indices.for i <- 0..(count - 1)withcount == 0is the descending range[0, -1]in Elixir, so a schema with no fields or a record batch with an empty buffers vector was read twice at invalid positions instead of not at all. The ranges now carry an explicit//1step, as the other comprehensions in the module already did.Client.Localrefused projected arithmetic and CTEs InfluxDB 3 runs (#18). Verified against InfluxDB 3 Core:SELECT (bid + ask) / 2 AS mid, time FROM qandWITH w AS (SELECT bid, time FROM q) SELECT DATE_BIN(INTERVAL '1 minute', w.time) AS time, MAX(w.bid) AS hi FROM w GROUP BY DATE_BIN(INTERVAL '1 minute', w.time)both return rows on the server and wereClient.Local:400s. The double now supports an arithmetic expression as a projected column (with an alias;ORDER BYmay name it), non-recursiveWITHCTEs executed in order (a later CTE or the finalSELECTreads an earlier one), table aliases (FROM q AS w,FROM q w) andalias.columnqualifiers in every clause.Client.Localsilently ignored everything after the table name.SELECT * FROM w CROSS JOIN qanswered fromwalone,... UNION SELECT ...tookUNIONas a table alias, andWHERE x IN (SELECT ...)compared against the string"SELECT ...". Joins, set operations, subqueries,HAVING,OFFSETand window functions are now rejected by name (Client.Local: unsupported SQL construct JOIN). Keywords are matched by the shape only a clause can have (OFFSET 1,OVER () and string literals are ignored, so a column namedoffsetoroverand a value such as'select from join'— both fine on the engine — still work.Client.Localreturned"time" => nilfor a row without a timestamp (a CTE that did not projecttime); the column is omitted, as everywhere else.
[0.1.22] - 2026-09-14
Changed
BatchWritertests no longer inspect GenServer state to check that configuration was stored; scheduling and jitter are asserted through the observable flush instead.Client.Localsplit into three modules. The 2,470-line module now owns storage, capability checks and query execution (1,450 lines); the SQL parser isClient.Local.SQLParserand the line-protocol parser isClient.Local.LineProtocolParser, both pure. Public behaviour is unchanged; the contract suites prove it.Client.Local.query_influxql/3matches eachSHOWpattern once.- Removed the unused internal
InfluxElixir.InfluxCasecase template fromtest/support/(never shipped; no test used it). Client.Local's line-protocol splitters accumulate tokens in binaries (runtime-optimised append) instead of one list cell per byte plus a reverse and join, cutting allocations on every write to the double.
Fixed
Client.Localacceptedtimecomparands InfluxDB rejects, and silently matched nothing for ones it accepts. Verified against InfluxDB 3 Core: a bare integer (time > 1700000000) or integer param fails planning on the server ("Cannot infer common argument type for comparison operation Timestamp(ns) > Int64") and an unparseable string fails execution, while the double returned{:ok, []}for both;now() - INTERVAL '2 minutes'runs on the server but was compared as the literal string, so it too returned{:ok, []}. The parser now accepts exactly the engine's forms — quoted ISO-8601 datetimes (zoned, zone-less, fractional), quoted dates,now()offset byINTERVALterms, evaluated at query time — and rejects the rest with aClient.Local:400 that names the engine's rule.DateTimeparams rendered as~U[...]inClient.Local. Jason sends them as ISO-8601 strings over HTTP; the double now rendersDateTime,NaiveDateTimeandDateparams the same way.SELECT DISTINCT ... ORDER BYwas ignored byClient.Local: rows came back ascending whatever the direction.ORDER BYon a selected column is honoured; on any other column it is rejected with DataFusion's own message.MAX(time)/MIN(time)returned an empty row fromClient.Local(the timestamp is not a field, so the aggregate saw only nulls). They now return theDateTime, as the engine does;AVG(time),SUM(time), the statistics overtimeand arithmetic ontimeare rejected as DataFusion rejects them.Client.LocalrefusedCOUNT(DISTINCT col)andWHERE col IS [NOT] NULL, both ordinary SQL the engine runs. Both are supported.- HTTP JSON left aliased timestamp columns as strings — see the entry
above for #16/#17; this sweep's contract tests cover
MAX(time)too. BatchWriterdocumented a backpressure it could never apply. The buffer emptied on every flush, so{:error, :buffer_full}was unreachable and the tests that "proved" it forged the GenServer state. Automatic flushes now wait for an in-flight retry chain instead of opening a new chain per batch against a failing server, the buffer is bounded at10 * batch_sizewhile a chain is in flight, and the deferred buffer is flushed when the chain ends. Awrite_sync/3caller is answered by its own chain's result (the caller's reference travels with the chain; before, a later chain could answer it).BatchWritertests no longer inject:retrymessages or read GenServer state: retries are exercised through a closed port (unit) and through a Finch pool checkout timeout that resolves before the backoff fires (integration, against the real server).Client.Localrejected valid InfluxDB 3 SQL (#16, #17). Verified against InfluxDB 3 Core:STDDEV/STDDEV_SAMP/STDDEV_POP/VAR/VAR_SAMP/VAR_POP, arithmetic inside an aggregate (SUM(value * value),AVG(bid + ask), integer operands dividing as integers),selector_first|last|min|max(field, time)['value' | 'time'],SELECT DISTINCT a, b, andORDER BYa projected alias (ORDER BY bucket DESC) all work on the server and were allClient.Local:400s in the double. All are now supported with the values the engine returns;VARIANCEstays rejected because DataFusion has no such function.Client.Localreturnednilcolumns the real engine omits. InfluxDB 3 leaves a null column out of the JSON row entirely (an empty group carries onlyCOUNT: 0; a sample statistic over one row has no key). The double now omits them too, sorefute Map.has_key?(row, "avg")means the same thing on both.- HTTP JSON responses left timestamp columns other than
timeas strings. ADATE_BIN(...) AS bucketalias orselector_*(...)['time']came back as"2023-11-14T22:12:00"over HTTP but as aDateTimeover Flight and fromClient.Local.Query.ResponseParsernow decodes InfluxDB 3's zone-less timestamp rendering under any column name (zoned RFC3339 strings are still decoded only undertime/_time/_start/_stop). mix docswarned that the README'sLICENSElink had no target; the licence is now an ExDoc extra.
Added
InfluxElixir.Client.Local.check_sql/1— parse a query without running it and get the sameClient.Local:errorquery_sql/3would, so a test canflunk/1with the reason instead of being silently excluded.- Testing guide: "Checking a Query Before Running It" and "Running Against a
Real InfluxDB" (the integration tier,
INFLUX_V3_CORE_HOST/_PORT, Docker one-liners), and the new aggregate, selector and null-omission semantics with the values recorded from the engine. CLAUDE.mddescribed a/docslayout (architecture/,api/,development/, per-directory READMEs, a design template) that did not exist. The READMEs and template now exist,docs/design/README.mdindexes every design document and carries the real-engine Docker one-liners, andCLAUDE.mddescribes the actual layout.- The shipped usage rules made false claims. They told consumers to
start a Finch pool themselves (the supervisor starts one per connection),
that booleans encode as
t/f(they aretrue/false), to pass params as a keyword list (a map), and that write errors carry:retryable/:non_retryableatoms (no such atoms exist; errors are%{status, body}or{:connection_error, reason}). All three rule files rewritten against the current code. Client.HTTPraised on keyword-listparams:. Jason cannot encode the tuples, soparams: [tag: "v"]crashed the HTTP client whileClient.Localaccepted it — exactly the shape the old usage rules recommended. Both clients now accept a map or a keyword list; the contract suite proves it against the real engine.Client.Locallost concurrent writes to the same database (#15). Points were stored as one list per measurement and every write read the list, prepended and wrote it back, so parallel writers overwrote each other's inserts while all reported{:ok, :written}(159 of 480 survived in the report). The ETS layout is now one object per point, database, bucket and token, so every mutation is a single atomic insert or delete. The same change removes the quadratic copy on bulk writes: 20,000 lines took 63 s and now take well under a second. Points scan in insertion order.- The testing guide's "Key Differences" still listed
first/lastas supported aggregates and omittedDISTINCT,GROUP BY <columns>,COUNT(*)and$paramsubstitution; corrected to the current parser. - README usage example could not work. It placed
{InfluxElixir, ...}in a supervision tree (the facade has nochild_spec/1; the library is an OTP application configured viaconfig :influx_elixir, :connections), put a scheme inhost:and used the nonexistentdefault_database:key, which HTTP config validation now rejects at startup. Rewritten with a working configuration, a write/query example, the v2 options and the shipped test helper. InfluxElixir.TestHelpernow ships in the package. It was documented (CLAUDE.md, usage rules, the original design) as a helper for consuming applications' test suites, but lived undertest/support/, which is only compiled in this repository's test environment — no consumer ever received it. It is now underlib/. The usage rules also named a nonexistentsetup_local/1; the function issetup_influx/1, which passes its options straight toClient.Local.start/1. Covered by its own test module.InfluxElixir.Admin.Healthdocumented an atom-keyed%{status: "pass"}result; both clients return string keys (%{"status" => "pass"}), as the 2026-03-13 integration plan already required.
[0.1.21] - 2026-09-11
Added
- Telemetry is actually emitted.
InfluxElixir.Telemetrydocumented[:influx_elixir, :write | :query, ...]events, but nothing in the library called it.Write.Writer.write/3(henceInfluxElixir.write/3and everyBatchWriterflush) now emits the write span withdatabase,bytesandpoint_count;InfluxElixir.query_sql/3,execute_sql/3,query_influxql/3andquery_flux/3emit the query span withdatabase,transport(the client module) and, for list results,row_count.:stopmetadata carriesresult: :ok | :error. BatchWriterandWrite.Writeraccept a:clientoption to write with a specific client module instead of the configured one.InfluxElixir.Configknows:timeout,:batch_writerand:finch_name.
Fixed
Client.HTTPnever set Finch'spool_timeout(#14), so every request waited at most Finch's default 5 s to check a connection out of the pool no matter how generous:timeoutwas, and against a slow multi-node endpoint failed with a transport:timeoutat five seconds. A:pool_timeoutoption now resolves like:timeout(per-call opt → connection → 5_000) and is passed on every request, including the streaming query. Verified against InfluxDB 3 Core with a size-1 pool held by a sleeping stream — which also showed that Finch raises on a checkout timeout rather than returning an error, so the exception used to escapequery_sql/3. It is now{:error, {:connection_error, :pool_timeout}}, and the streaming query raisesInfluxElixir.StreamErrorwithreason: :pool_timeout.- Flight and HTTP now return the same
timevalues.Flight.Readerdecoded Timestamp columns to raw integers while the HTTP path yieldsDateTime; the reader now reads the ArrowTimeUnitand converts.Query.ResponseParseralso treated InfluxDB 3's zone-less JSON timestamps ("2023-11-14T22:13:20.123456789") as opaque strings becauseDateTime.from_iso8601/1rejects them; they are now parsed as UTC. Verified against InfluxDB 3 Core: identical rows on both transports. transport: :flightwas documented but ignored by the facade,Query.SQLand the usage rules; every query went over HTTP.Client.HTTP.query_sql/3now dispatches toFlight.Clientwhentransport: :flightis given, using the connection's host/token, the resolved database, andflight_port(opt, connection, or 443).params:are rejected over Flight instead of being dropped. Verified against InfluxDB 3 Core's Flight endpoint.BatchWriterretried 4xx responses. The discard clause matched{:error, {:http_error, status}}, a shape no client produces, so a rejected batch (bad line protocol, unknown database) was retried with backoff untilmax_retriesran out. It now matches the clients'%{status: 4xx}and drops the batch on the first response. The retry path is covered by a real transport error against a closed port.ConnectionSupervisorhanded the batch writer the raw config instead of the initialised connection, so abatch_writer:underClient.Localcrashed on its first flush. Covered by a supervisor-level test.ConnectionSupervisorvalidates HTTP connection config. A typo such asdefault_database:(which the facade and application docs themselves used) was silently ignored; withClient.HTTPit now fails at startup with aNimbleOptions.ValidationError.Client.Localconfigs are not validated.
Changed
timeis aDateTimeon every client and transport.Client.LocalreturnedtimeandDATE_BINbuckets as ISO 8601 strings, while the HTTP path (once its zone-less parsing was fixed, see below) and Flight returnDateTime. All three now returnDateTimewith microsecond precision, so the contract suite asserts one instant across Local, HTTP and Flight. Code that compared Local'stimeto a string must use aDateTime(six-digit sigil orDateTime.compare/2).InfluxElixir.write/3goes throughWrite.Writer, so payloads over 1 KB are gzipped likeBatchWriterflushes already were.Flight.Readerdecodes fixed-width columns with binary comprehensions (one pass, no per-element slicing) instead of indexedbinary_part/3.Client.Local.query_flux/3returns the long row shape real Flux returns: one row per field with_field/_value,_measurement,_time(aDateTime), the tags,resultand a per-seriestableindex, ordered by table then time.filter(fn: (r) => r._field == "...")is honoured. The old wide rows (%{"_measurement", "<field>" => v, "time"}) could not exercise consumer Flux handling; verified against InfluxDB 2.7.Client.HTTP.query_flux/3requests#datatypeannotations so CSV cells come back typed (double,long,unsignedLong,boolean, RFC3339 →DateTime) instead of as strings.Query.ResponseParser.coerce_types/1also converts_time,_startand_stop.parse/2returns{:error, {:unexpected_json, term}}for a JSON scalar body instead of raisingCaseClauseError.
Added
api_version: :v2 | :v3connection option (InfluxElixir.Config). Required for InfluxDB 2.x: a v2 server answers200to the v3 write path without storing anything, so writes silently vanished and malformed line protocol or an unknown bucket reported success. With:v2the client usesPOST /api/v2/write?org=&bucket=&precision=ns|us|ms|s.
Fixed
Client.HTTP.create_bucket/3works against real InfluxDB v2. It sent"orgID": "", which v2 rejects (id must have a length of 16 bytes). The org ID is now resolved from the connection's:orgname (org_id:overrides). Creating a bucket that already exists is treated as success, matchingClient.Local.Client.HTTP.delete_bucket/2accepts a bucket name. v2 deletes by ID; the name is resolved viaGET /api/v2/buckets?name=, so the same call works againstClient.Local. A 16-hex argument is used as an ID directly.- Flux CSV parsing uses NimbleCSV. The hand-rolled splitter left
\ron every last cell and header, turned the blank line between tables into a row and the next table's header into data, and broke quoted cells containing commas. All were observed against InfluxDB 2.7. LineProtocolfloats no longer lose precision.{:decimals, 17}formatting wrote1.0e-20as0.0; the shortest round-trip form is used (1.0e-20,2.5e-7), which InfluxDB 3 Core accepts and reads back exactly.Telemetry.write_start/1andquery_start/1emit wall-clocksystem_time. They emittedSystem.monotonic_time/0under that key, an arbitrary offset that is useless as a timestamp. Amonotonic_timemeasurement is emitted alongside, matching:telemetry.span/3.Flight.Client.query/3closes the gRPC channel whenDoGetfails; it was only disconnected on success.Client.Local.stop/1no longer races the owner process's ETS cleanup. Called from anon_exitafter the test process had exited, the:ets.info/1guard could pass and:ets.delete/1then raiseArgumentError, failing the test intermittently.Flight.Readerrow assembly is linear in the batch's row count. Cells were read withEnum.at/2on the column lists for every row, which made decoding a record batch quadratic; columns are now tuples read withelem/2.
Changed
Client.HTTProutes every request through onerequest/7helper that maps the status to{:ok, response}/{:error, %{status, body}}/{:error, {:connection_error, reason}}, replacing fourteen copies of the same three-clausecase. No behavioural change.
[0.1.20] - 2026-09-10
Changed
Client.Localordered aggregates now use the InfluxDB v3 SQL spelling (#13).first_value(field ORDER BY col [ASC|DESC])andlast_value(field ORDER BY col [ASC|DESC])are parsed and executed, includingGROUP BY <columns>for "latest value per group" queries. The InfluxQL-styleFIRST(field, time)/LAST(field, time)the double previously accepted are rejected: InfluxDB v3 fails planning on them (Invalid function 'last'), so accepting them let a query pass tests and 400 in production. The rejection names the v3 spelling.first_value/last_valuewithout an innerORDER BYare also rejected — DataFusion returns an arbitrary group member in that case, which the double cannot reproduce. Verified against a live InfluxDB 3 Core; the shared contract suite now passes against the real engine (it previously failed on the twoFIRST/LASTtests).Client.Localparser rejections are prefixedClient.Local:so anunsupported column expressionerror reads as a limitation of the test double rather than of InfluxDB. Plain aggregates (AVG,SUM,COUNT,MIN,MAX) now reject a second argument, as the real engine does.Client.Localreports a missing table the way the real engine does.query_sql/3on an unknown measurement returned{:error, {:table_not_found, name}}whileClient.HTTPreturns{:error, %{status: 400, body: "Error during planning: table ... not found"}}, and the streaming path mapped it to a 404. Both now produce the 400 planning error, so consumer code that matches%{status: 400}can be exercised against the double. Code matching the old tuple must be updated.
Fixed
BatchWriternow honours its:databaseoption. The value was stored in state and never forwarded to the write, so every flush landed in the connection's default database. It is now the write target unless:write_optsnames a:databaseexplicitly.Client.Local(:v2profile) accepts writes to buckets created withcreate_bucket/3. Writes only checked thedatabases:seeded at start, so a bucket created through the API returned404 database not found.Client.Localno longer re-types quoted string literals (#12). A bound string param or quoted literal such as'08338636'was parsed back throughInteger.parse, dropping the leading zero and changing the type, soWHERE repcode = $rc/IN ($rc)over zero-padded identifiers never matched while real InfluxDB v3 matched correctly. Quoted literals are now strings; only bare literals are typed. Comparing a string literal against a numeric field compares the field's text rendering, which is what DataFusion does (amount >= '1000.00'is lexical and matches500.0on the real engine too), so that footgun now fails in tests the same way it fails in production.- Linear-time accumulation in
Client.LocalWHERE parsing andFlight.Readerbatch decoding. Both appended with++inside a reduce, which is quadratic in the number of clauses / record batches. Client.Localparam substitution is whole-placeholder and single-pass.$hwas previously replaced inside$hmin, and a substituted string value containing another placeholder's name could be re-substituted.
[0.1.19] - 2026-07-08
Fixed
InfluxElixir.Client.Local.query_sql_stream/3now mirrors the HTTP client's error semantics (#11).Client.Localis the documented drop-in test double forClient.HTTP, but it still returned an empty stream on a query error or an unsupported operation whileClient.HTTPraised — so consumer code that rescuesInfluxElixir.StreamError(to avoid treating an outage as "no data") could not be exercised against the test double. It now raisesInfluxElixir.StreamErroron enumeration for both cases, matchingClient.HTTP.
Added
- Tests covering the Local
:http_status/:unsupportedstream-error paths and lazy (deferred) raising.
[0.1.18] - 2026-07-08
Fixed
query_sql_stream/3(HTTP transport) now truly streams and no longer swallows errors (#10). Previously it usedFinch.request/3, which buffered the entire response body and eagerly decoded every JSONL line before yielding — giving zero memory benefit overquery_sql/3— and it halted to an empty list on non-2xx statuses, transport errors, and unresolved databases, so every failure class looked like "zero rows". It now consumes the response withFinch.stream/5, decoding JSONL line-by-line with back-pressure (constant memory), and raises anInfluxElixir.StreamErroron a missing database, a non-success HTTP status, or a transport error when the stream is enumerated.
Added
InfluxElixir.StreamErrorexception, raised while consuming a streaming query that cannot produce rows. Carries a:kind(:no_database | :http_status | :transport | :decode | :unsupported) plus:status/:body/:reasoncontext.InfluxElixir.StreamError.stream/1builds anEnumerable.t()that defers the raise to enumeration, shared by both client implementations.- Tests covering the HTTP
:no_database/:transportpaths (real Finch pool, no mocking) andStreamErrormessage construction.
[0.1.17] - 2026-06-30
Changed
- Loosened
decimalconstraint to~> 2.0 or ~> 3.0(#9). Unblocks downstream apps from upgrading pastdecimal 2.4.1(EEF-CVE-2026-32686) and from picking upecto ~> 3.14 → ash ~> 3.29chains. Surface used (Decimal.to_string/2,%Decimal{}pattern) is stable across 2 → 3. - Loosened
grpcconstraint to~> 0.11 or ~> 1.0(#9). Unblocks downstream apps from upgrading pastgrpc 0.11.5(5 CVEs including EEF-CVE-2026-48853). - Defaulted the Flight client to the Mint gRPC adapter so the library doesn't
pull in
:gun, which becameoptionalingrpc 1.0. Mint is already available viafinch. InfluxElixir.Supervisornow skips addingGRPC.Client.Supervisoras a child whengrpc 1.0+is present (1.0 auto-starts it via its ownApplication).
Fixed
LocalClientnow supportsCOUNT(*)as a scalar and DATE_BIN-bucketed aggregate.LocalClientWHERE-clause parser now returns a 400 error for unrecognised clauses (e.g.LIKE) instead of silently matching all rows.
Added
- Regression tests for
COUNT(*), explicit column-listSELECT,INoperator narrowing, write-timestamp preservation, and silent WHERE drop.
Earlier releases
- Initial project setup with module stubs
- CI pipeline with quality checks and auto-publish to Hex.pm