Skip to content

v1.0.12

Choose a tag to compare

@brunolau brunolau released this 29 Sep 19:27

v1.0.12

A performance release. It targets the whole slowdown since 1.0.5, not only what 1.0.11 changed. Results, SQL (except the QueryBatch envelope), errors and types are unchanged; the upgrade notes list every observable difference, each checked.

Measured with bench/versions (8 interleaved rounds, Node 26.8.1, PostgreSQL 18.3; full report in bench/versions/results/1.0.5-vs-1.0.10-1.0.11-1.0.12.md).

against end to end in linkgress build
1.0.11 −2.3 % −16 % −6.3 %
1.0.5 (1.0.11 was +5.3 / +38 / +30 %) +2.7 % +16 % +21 %
1.0.5 with MockRowCache on (1.0.11 was +5.8 / +48 / +66 %) +2.8 % +22 % +55 %

The columns mean:

  • end to end: the query against PostgreSQL;
  • in linkgress: its statements answered from memory with the recorded rows;
  • build: zero rows, so building and dispatching only.

Nothing is slower end to end than in 1.0.11. The largest gains are:

scenario end to end in linkgress
a navigation row projected whole −34 % −72 %
collections −2.6 … −5.2 % −18 … −43 %
batched expressions −11 %
count() −2.4 % −57 % (build −56 %)

Where the time had gone, 1.0.5 → 1.0.11, release by release:

  • 1.0.7: build +16 %, in linkgress +19 %. Whole-row navigations, per-row collection setup, selectMany, and a spread of small build passes.
  • 1.0.9: count() / exists() evaluating the projection (count-where build 6.9 → 16.7 µs).
  • 1.0.11: the batch transport.
  • 1.0.6 and 1.0.9: import time (+21 %, +11 %).

Upgrade notes

No API changes, no new options, no SQL changes outside the QueryBatch envelope.

Results and semantics that change

  • A batched value's pg type parser is called with a receiver.
    • Each column's parser is now resolved once and called as a method of that column's read, which is how node-postgres calls it standalone. 1.0.11 called it without a receiver.
    • A custom parser that reads this now gives the same batched value as standalone, where 1.0.11 differed.
    • Parsers that don't use this, the defaults included, are unaffected.
  • Placeholder renumbering on multi-megabyte literals.
    • renumberPlaceholders() (QueryBatch branches, MutationBatch / CTE legs, insertWithChildren children) used a regular expression that failed on huge quoted literals:
      • V8 threw RangeError: Maximum call stack size exceeded at 20,000,000 characters;
      • Bun / JavaScriptCore silently stopped renumbering after 5,000,000.
    • The token scan that replaces it renumbers correctly at every size and is otherwise identical: 50,000 generated statements in the tests and 400,000 in an independent fuzz.

QueryBatch: which texts the server still sends

  • Ten types are now rebuilt client-side. For date, time, timetz, timestamp, timestamptz, interval, money, bytea, point and circle, the value's text is rebuilt client-side from its row_to_json form. The server no longer sends it. The rebuilt text is parsed with the same parser as before, including a client's own text-passthrough parsers for 1114 / 1184 / 1082.
    • The rebuilt text is byte-identical to the text the server sent in 1.0.11. An independent check on PostgreSQL 18.3 covered 2,449 edge values × 36 session settings, including:
      • 16 time zones, among them local-mean-time and 20-minute offsets;
      • four IntervalStyles;
      • bytea_output = escape;
      • extra_float_digits;
      • eight lc_monetary locales.
    • Batched values were identical to 1.0.11 on pg, postgres.js, PGlite, Bun (binary and text) and the in-memory engine.
  • The server still sends texts for:
    • int8 / numeric;
    • arrays;
    • values of user-defined types (OID ≥ 16384, domains included);
    • the client's own custom-parsed types other than the ten;
    • date / timestamp / timestamptz while the session's DateStyle is not ISO. A per-statement InitPlan checks current_setting('DateStyle') LIKE 'ISO%' once.
  • A client that parses json (OID 114) itself has the server send every text, as in 1.0.11 (the same text types, no DateStyle check), and nothing is rebuilt. An example is a reviver that turns ISO strings into Dates. The batch envelope is json, so such a parser runs on the rows first and could hand back a Date where a text was to be rebuilt.
  • Catalog domains: 1.0.11 parity, and a known gap. A domain the catalog defines (OID < 16384) still has no text sent, so a batch keeps its value's JSON form, as 1.0.11 did. Standalone reads it as its base type. Only information_schema.time_stamp has a rebuilt base type. Closing that gap is a separate fidelity fix.

Performance

QueryBatch: temporal, bytea, money and geometric texts rebuilt from the row

1.0.11 made a batched value read exactly as the same query reads standalone on the same client. To do that, it sent each such value's text (concat(v), per row) next to its row_to_json form. For the ten types above, that text can be recovered from the JSON form:

  • time, timetz, interval, money, bytea, point and circle: the JSON form carries the type's output text;
  • date, timestamp and timestamptz: the JSON form is ISO whatever the DateStyle. Under an ISO DateStyle it differs from the text only by the T and by a timestamptz's whole-hour offset (+01:00 → +01).

Client side:

  • Each column's parser is resolved once per branch, not once per value (DatabaseClient.typedTextParser, internal). A client with its own parseTypedText keeps getting it called.
  • A rebuilt text is joined into a flat string. A V8 rope made the drivers' date regexes about 40 % slower.
  • One text is rebuilt per run of equal JSON forms, while every row still gets its own parsed value.

Measured against 1.0.11, end to end:

scenario end to end in linkgress
batch-expression −11 %
batch-plain −5.8 %
batch-collection-max −3.4 % +10 %

batch-collection-max is slower in linkgress because each distinct value is now rebuilt in JS rather than sent by the server.

Profiled: batch-expression's statement on PostgreSQL went from 0.72 to 0.53 ms (1.0.10: 0.40), and its payload from 51 to 36.5 KB.

A navigation row projected whole: paths split once, rows folded by one plan

Since 1.0.7, a navigation row projected whole ({ author: p.user }) renders as one __nested__<path> column per value. Each row's fold re-split every alias, which took about 2/3 of linkgress's time on those rows.

  • The paths are now split once per result set.
  • The fold is compiled once from the first row's keys and replayed on every row with the same keys in the same order. The fold decides which objects to create and where each value goes.
  • Any other row falls back to the unchanged key-by-key rebuild. So does any key set a plan could fold differently from that rebuild: a segment every object has, such as constructor, or a value written where a path goes through.

Measured against 1.0.11: end to end 2.71 → 1.82 ms (−34 %), in linkgress 1.11 ms → 317 µs (−72 %), on 500 rows. 1.0.5 took 59 µs in linkgress; it returned the row as JSON, without its mappers. This was the first experiment of the 1.0.8 report.

Collections: item-read setup once per result set

A collection's item-read setup was rebuilt for every parent row, and for a nested collection for every item that holds one. The setup covers its fields' mappers, a scan of the target table's column cache, the described aliases, the nested collections' infos, and the literal and nested-object flags. It is now built once per collection of a result set (collectionItemsReader / nestedCollectionValueReader); the per-item loop is unchanged.

Measured against 1.0.11:

scenario in linkgress end to end
coll-nested-lateral / -cte −43 % −4.5 % (-cte)
coll-large-cte −39 % −5.2 %
coll-aggregates-lateral / -cte −35 % / −29 % −2.6 % (-lateral)
coll-cte / -lateral −24 % / −25 % −3.7 %
coll-temptable −18 %

In linkgress, every collection scenario is now at or below 1.0.5. This was the second experiment of the 1.0.8 report.

count() / exists(): no evaluation of a table's select-all projection

Since 1.0.9, count(), exists() and futureCount() evaluate the query's projection on a mock row, to refuse a projection that would miscount.

  • db.t.where(…) / orderBy(…) / with(…) project the table's select-all row. It holds only columns and cannot miscount, but evaluating it still built a mock row, its prototype and the select-all row.
  • Those selectors are now marked and skipped. Any other projection is checked as before, and the statements are unchanged.

Measured against 1.0.11:

scenario build in linkgress
count-where −56 % (17.8 → 7.9 µs; 1.0.5: ~7 µs) −57 %
batch-plain −26 %
transaction-reads −16 % −15 %

Row loop: plain column reads inline again

transformResults() read every value through readField(), a switch over every kind of read that V8 does not inline. 1.0.5 read its columns inline. Two reads now happen in the loop again, and in a nested object's loop: a column as the driver read it, and a column or expression through its mapper. Every other read still goes through readField().

Measured against 1.0.11, in linkgress: entity-1k −12 %, projection-20k −7.2 %. projection-20k's build is +1.3 µs (+7.5 %), which is repaid while reading its rows.

Select-all rows built through the mock row's column getters

A select-all row copied each column as row[column] through a prototype. With MockRowCache off, that prototype is new for every mock row, so each read is a keyed load V8 cannot cache. The prototype now also carries each column's getter, in a non-enumerable, symbol-keyed map, and the row is built by calling those directly. The FieldRefs and the row are the same.

Measured against 1.0.11, build: filter-order-limit −5.4 %, pk-lookup −3.8 %, entity-1k −7.3 %.

Placeholder renumbering by token scan

renumberPlaceholders() replaced a regular expression's matches with a callback that ran for every quoted identifier, which cost 8–10 % of a batch's build. It now scans token starts with indexOf and copies the text between them. The output is the replace's exactly:

  • an unclosed literal or comment stays plain text;
  • the greedy / backtracking end of a literal with doubled quotes behaves as before.

Profiled: 3.5 → 0.25 µs per branch.

Smaller items

  • customParsedTypeOids() (pg): a parser's source text is classified once per function (WeakMap), not on every call: 1.6 → 1.0 µs per call. Every batched query's build asks for it.
  • utf8ByteLength() (selectMany aliases): counts by UTF-16 code unit instead of allocating a string per code point. A surrogate pair counts 4 bytes and a lone surrogate 3, as before.
  • Lazy module loads: node:crypto (view definition markers) and node:readline (the schema manager's prompts) load on first use.
    • A process that only imports the package saves ~2.4 ms.
    • A context whose model has views loads node:crypto when it first computes a marker. Its startup is unchanged, and the measured context creation is +2.2 ms against −2.5 ms at import.

Tests

The release adds 34 tests.

  • The JSON-form value matrix, on every driver. For each of the ten types, at least 2,000 generated values plus edge cases:

    • BC dates, years past 9999, ±infinity, 24:00:00, every fraction length;
    • half-hour, quarter-hour and local-mean-time offsets, and DST;
    • 9 time zones, the 4 IntervalStyles, both bytea_output values and the ISO DateStyles.

    textOfJsonForm(to_json(v)) must equal both concat(v) and v::text.

  • Every client parser. typedTextParser must give each driver's parseTypedText result:

    • on pg, postgres.js, PGlite, Bun (binary, text, datesAsStrings) and the transactional wrapper;
    • for a client whose own parseTypedText is overridden on a subclass or on the instance.
  • Batched equals standalone.

    • Under three non-ISO DateStyles (SQL DMY, Postgres MDY, German) and an ISO one, including a client that returns the driver's text. On Bun under a non-ISO style, the test pins the batched value as the server's text instead (see Known, unchanged).
    • For a client with its own reviving json parser, on pg, postgres.js and PGlite, dateTrunc and a raw sql timestamptz expression included. The pg test also pins that the server is asked for every text, as in 1.0.11.
  • A catalog domain (information_schema.time_stamp): pinned as 1.0.11 parity plus the known gap.

  • Other pins:

    • the transport unit;
    • the fold plan against the per-key rebuild;
    • count() / exists() skipping the select-all projection, with unchanged statements;
    • the select-all getters with MockRowCache off and on;
    • renumberPlaceholders against the old regex (edge cases and 50,000 generated statements);
    • utf8ByteLength against a code-point count on 20,000 strings.

Known, unchanged

  • The rest of 1.0.7's build-tier rise. It is a spread of 0.2–1 µs passes: the navigation alias plan, the scope and outer-ref passes, and orderKey's Object.create(ref) (~0.9 µs). They weigh more with MockRowCache on, where fresh prototypes no longer dominate a build: +55 % against 1.0.5 there, a few µs per query.
  • A navigation row projected whole still takes ~5× 1.0.5's time in linkgress. That is 1.0.7's projection design: one column per value, read through its mapper.
  • Batched expressions are +89 % end to end against 1.0.5 (1.0.11: +116 %). This is the price of 1.0.11's client-faithful transport.
  • selectMany count(): +85 % in linkgress against 1.0.5 (1.0.11: +80 %). It grew in 1.0.7 and in 1.0.11.
  • Bun under a non-ISO DateStyle, as in 1.0.11.
    • Standalone, Bun's SQL client decodes a non-ISO date / timestamp text itself: it reads DD/MM as MM/DD, and a zone abbreviation (CET) as an invalid Date.
    • Bun exposes no parsers, and linkgress's Bun parsers reproduce only its ISO decoding. So a batched value under such a style is the text the server sent, as on 1.0.11.
  • A nested alias through an inherited name, as in 1.0.11. An alias like __nested__constructor__x, from a projection { constructor: { x: col } }, writes onto a builtin (Object.x), and the row loses the field.
  • MockRowCache stays off by default, and its off-mode semantics are unchanged (pinned by a test).
    • Off, every mock row gets a fresh prototype. That makes V8's inline caches miss throughout a query's build: fresh prototypes are 70–85 % of the build tier.
    • With the cache on, builds are 3–7× cheaper (a primary-key lookup ~15 µs → ~3.5 µs).

Verification

The full matrix ran on the final tree, one suite after another:

suite result duration
npm run type-check clean 3 s
npm run type-check:tests clean 8 s
in-memory engine (--memory) 5,257 passed / 0 failed, 210/210 files 19.5 s
PostgreSQL, node-postgres 5,257 / 0 64.0 s
parity (memory against PostgreSQL) identical outcomes for all 210 files and 5,257 tests 76.5 s
PostgreSQL, postgres.js 5,256 / 0, 1 skipped 63.0 s
PostgreSQL, Bun SQL 5,257 / 0 78.5 s
PostgreSQL, Bun SQL text mode (prepare: false) 5,257 / 0 78.2 s
PGlite 5,216 / 0, 43 skipped 37.3 s

1.0.11's counts were 5,223 (5,222 + 1 on postgres.js; 5,185 + 40 on PGlite). PGlite skips 3 of the 34 new tests, because they need a client that reaches the server.

An independent review of the series found two result changes, both fixed before this release:

  • a client that parses json itself got its reviver's Dates for batched temporal values;
  • information_schema.time_stamp was parsed.

A consumer application's full test suite ran on the final build with text-passthrough timestamp parsers. It gave the same results as on 1.0.11. Its SQL differed only in the QueryBatch envelopes (295 statements), whose inner statements are byte-identical, with equal execution counts.