perf: decode ASCII varchar values without iconv#1755
Conversation
Decoding a `varchar` or `char` value goes through `iconv.decode`, which performs a codec lookup and allocates a fresh decoder object and an intermediate buffer on every call. For the common case - pure ASCII data in an ASCII compatible codepage - the value can be decoded directly via the native `latin1` conversion instead. Whether a codepage decodes the 7-bit ASCII range identically to ASCII is determined once per codepage by probing `iconv.decode` with all 128 ASCII bytes, so the fast path is safe for any collation, including multi-byte codepages like CP932. Values containing non-ASCII bytes (checked via `buffer.isAscii`) continue to go through `iconv` unchanged. Decoding a short ASCII value becomes ~4.5x faster, improving row throughput on varchar-heavy result sets by ~50% in end-to-end benchmarks against a real server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review:
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #1755 +/- ##
==========================================
+ Coverage 80.84% 80.88% +0.03%
==========================================
Files 90 90
Lines 4913 4902 -11
Branches 924 928 +4
==========================================
- Hits 3972 3965 -7
+ Misses 645 640 -5
- Partials 296 297 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The set of codepages that can reach `readChars` is closed - they all come from the collation tables - so a hardcoded list is simpler than probing `iconv` at runtime. A codepage missing from the list only loses the fast path, decoding still works correctly via `iconv`. A new test verifies that every codepage in the collation tables decodes the 7-bit ASCII range identically to ASCII, so a future collation addition that violates the list's assumption fails the suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review: perf: decode ASCII varchar values without iconvNice, well-documented optimization with strong test/benchmark backing. A few notes: Code quality & correctness
Test coverage
Performance
Other
Overall this is a solid, low-risk, well-verified performance improvement. The only substantive suggestion is strengthening the codepage-list-sync test as noted above; everything else is minor style polish. |
`iconv.decode` creates a fresh decoder object on every call. Keep one decoder per codepage and reuse it instead. This is safe because a decoder that has fully consumed its input via `write` + `end` is back in its initial state, and decoding a value is fully synchronous. Speeds up decoding of non-ASCII values (the ones not covered by the ASCII fast path) by ~15% for single-byte codepages, ~20% for multi-byte codepages, and ~4x for UTF-8 collations, where creating the underlying `StringDecoder` dominates the per-value cost. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReviewNice change — well-motivated, clearly benchmarked, and the correctness story (ASCII-transparency probed against Correctness / robustnessReused decoders aren't cleaned up on a throw ( const result = decoder.write(data);
const trailer = decoder.end();
In practice iconv-lite's decoders are designed to be lenient (replacement chars rather than throwing) so this may never trigger, but given the blast radius (cross-connection data corruption in a DB driver) it seems worth defending against explicitly, e.g.: try {
const result = decoder.write(data);
const trailer = decoder.end();
return trailer ? result + trailer : result;
} catch (err) {
decodersByCodepage.delete(codepage); // drop the possibly-corrupted decoder
throw err;
}Minor / type-safety
Also trivial: Test coverageThe added tests are thorough (ASCII fast path across codepage kinds, non-ASCII single/multi-byte fallback, decoder state isolation across sequential values, the UTF-16BE non-ASCII-transparent gate case, empty/ Performance / securityBenchmarks are credible and the approach (cheap Overall this is solid, well-tested work — the decoder-reuse exception-safety point is the only thing I'd want addressed before merging. |
UTF-8 values can be decoded via the native `Buffer#toString` instead of going through `iconv`, with an explicit strip of a leading byte order mark to preserve `iconv.decode`'s behavior. This makes decoding a non-ASCII UTF-8 value ~2.4x faster. This also fixes a bug in the previous commit: `iconv` wraps decoders for BOM aware encodings in a wrapper that only strips the BOM once per decoder lifetime, so a reused UTF-8 decoder stripped the BOM only from the first decoded value. UTF-8 no longer goes through the decoder cache, and the cache now refuses to reuse decoders for any BOM aware encoding, falling back to per-value `iconv.decode` for those. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReviewNice, well-documented performance PR. I read through OverviewAdds a three-tier fast path for decoding
Strengths
Suggestions (minor, non-blocking)
SecurityNo concerns — this only changes how already-received bytes are decoded into JS strings; it doesn't change trust boundaries, and the ASCII/BOM guards are conservative (fast path only taken when provably safe, falling back to the existing Correctness / RiskThe module-level Overall this is a well-reasoned, well-tested optimization with good safety guards (per-value ASCII check + per-codepage allowlist + exclusion of BOM-aware codecs from caching) and convincing before/after numbers. Nice work. |
A leading U+FEFF in a `varchar` value under a UTF-8 collation is part of the stored data, not an encoding marker: SQL Server counts it in `LEN`, `nvarchar` values preserve it, and so do other SQL Server drivers. Stripping it - inherited from `iconv.decode`'s document-oriented default - silently dropped a character. Decode UTF-8 values natively without the BOM strip, and create the cached decoders for other codepages with `stripBOM: false`, which also removes the only stateful decoder layer, making every cached decoder safe to reuse. The PLP (`varchar(max)` / return value) decode paths now share the same `decodeChars` helper, so they gain the same BOM behavior and the native UTF-8 / reused-decoder performance. Internal UTF-8 fallback encodings are standardized on the `'utf-8'` spelling produced by the collation tables. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReviewNicely scoped, well-documented performance change with strong differential-fuzz and unit-test backing. A few notes below, nothing blocking. OverviewReplaces the Code quality
Potential issues / things worth double-checking
PerformanceFast-path gating ( SecurityNo new attack surface — Test coverageStrong overall — new tests cover ASCII/non-ASCII/SBCS/DBCS/UTF-8 (incl. astral plane), malformed UTF-8 matched against iconv's own output, empty/ |
Summary
Decoding a
varchar/charvalue currently always goes throughiconv.decode, which canonicalizes the encoding name, looks up the codec, allocates a fresh decoder object, allocates an intermediate buffer of twice the input size, and finally converts that to a string. This PR reworks that path:1. ASCII fast path. For the overwhelmingly common case — pure ASCII data — iconv is skipped entirely and the bytes are decoded with the native
latin1conversion. Two guards keep this safe for every collation:isAscii(data)(fromnode:buffer, native) — only pure 7-bit data takes the fast path.readCharsis closed — they all come from the collation tables incollation.ts— and every one of them is ASCII-transparent. A codepage missing from the list only loses the fast path; decoding still works correctly viaiconv. A test verifies the ASCII-transparency of every collation-table codepage againsticonvitself, so a future collation addition that violates this assumption fails the suite.2. Native UTF-8 decoding. Non-ASCII UTF-8 values (SQL 2019+
_UTF8collations) are decoded with the nativeBuffer#toString('utf8')instead of iconv.3. Decoder reuse for the remaining iconv path. Non-ASCII values in the single- and multi-byte codepages keep one decoder per codepage (via
iconv.getDecoder(codepage, { stripBOM: false })) instead of lettingiconv.decodecreate a fresh one per value. This is safe because decoding a value is fully synchronous and these decoders are back in their initial state afterwrite+end— SBCS decoders are stateless, DBCS decoders explicitly reset their state inend(), andstripBOM: falseavoids iconv's BOM wrapper, the only decoder layer that carries state across values.4. The PLP paths share the same helper.
varchar(max)values (row, NBC row, and return-value parsers) previously callediconv.decodedirectly; they now go through the samedecodeCharshelper and get the same performance and semantics.Behavior change: leading byte order marks are preserved
Current tedious silently strips a leading U+FEFF from
varcharvalues under UTF-8 collations — an accident oficonv.decode's document-orientedstripBOM: truedefault. That character is part of the stored data, not an encoding marker:NCHAR(0xFEFF) + N'hello'into avarchar(100) ... _UTF8column reportsLEN = 6,DATALENGTH = 8(verified against SQL Server 2025).nvarcharcolumn, but with the first character silently deleted from thevarchar(UTF8)column.UTF8Encoding.GetString) and mssql-jdbc (new String(bytes, UTF_8)) both preserve it.This PR stops stripping it, making
varchar(UTF8)consistent withnvarcharand with other drivers. This is a user-visible behavior change for values that genuinely start with U+FEFF (e.g. data bulk-loaded from BOM-prefixed files) — they now round-trip losslessly.Internal UTF-8 fallback encodings are standardized on the
'utf-8'spelling produced by the collation tables (previously a mix of'utf8'and'utf-8').Performance
readValue(~2.4x).SELECTs of 2,000-row result sets against a real SQL Server, median of 5 runs, verified in both execution orders):varcharcolumns): ~405k → ~600k rows/sec (+45–55%)varcharcolumn out of 8): ~243k → ~284k rows/sec (+17%)Correctness
iconv.decode(data, cp, { stripBOM: false })) — pinning that nativetoString('utf8')matches iconv's replacement-character behavior exactly, including for malformed input.varchar(max)PLP paths); malformed UTF-8 matching iconv's behavior; decoder state isolation (a value ending in a truncated Shift-JIS sequence followed by a clean value); a codepage where ASCII bytes do not decode as ASCII (UTF-16BE); empty values;NULLs;charvalues; and an almost-ASCII value.Notes
StripBOMWrapperonly strips a BOM once per decoder lifetime. Investigating that led to the realization that the stripping itself was unintended behavior, resolved as described above.🤖 Generated with Claude Code