From 3c61cd74a4a7dcb3670809d4fedd0773b8849317 Mon Sep 17 00:00:00 2001 From: Arthur Schreiber Date: Fri, 17 Jul 2026 11:29:29 +0000 Subject: [PATCH 1/2] perf: avoid stack trace capture in NotEnoughDataError `NotEnoughDataError` is thrown and caught as part of normal parsing control flow: every time a parser hits the end of the currently buffered data mid-value, it throws this error and the caller waits for the next chunk before retrying. Because it extended `Error`, every throw captured a stack trace that was never used, making each throw/catch cycle roughly 3x more expensive than necessary. Turn it into a plain class. All catch sites detect it via `instanceof NotEnoughDataError` and it never escapes the parsing internals, so behavior is unchanged. Also add test coverage for the throw/retry contract: direct tests for the read helpers' incomplete-data behavior, and byte-at-a-time parsing tests for the token stream and row parsers. Co-Authored-By: Claude Fable 5 --- src/token/helpers.ts | 11 +++-- test/unit/token/helpers-test.ts | 50 ++++++++++++++++++++ test/unit/token/row-token-parser-test.ts | 51 +++++++++++++++++++++ test/unit/token/token-stream-parser-test.ts | 12 +++++ 4 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 test/unit/token/helpers-test.ts diff --git a/src/token/helpers.ts b/src/token/helpers.ts index c5a1d7137..3ecaff575 100644 --- a/src/token/helpers.ts +++ b/src/token/helpers.ts @@ -8,12 +8,17 @@ export class Result { } } -export class NotEnoughDataError extends Error { +// This error is thrown and caught as part of normal parsing control flow: +// whenever a parser encounters an incomplete value, it throws this error, +// and the caller waits for more data to arrive before retrying. +// +// It deliberately does not extend `Error` - capturing a stack trace on +// every construction is expensive, and the stack is never used as this +// error never escapes the parsing internals. +export class NotEnoughDataError { byteCount: number; constructor(byteCount: number) { - super(); - this.byteCount = byteCount; } } diff --git a/test/unit/token/helpers-test.ts b/test/unit/token/helpers-test.ts new file mode 100644 index 000000000..e59685c55 --- /dev/null +++ b/test/unit/token/helpers-test.ts @@ -0,0 +1,50 @@ +import { assert } from 'chai'; + +import { NotEnoughDataError, readBigInt64LE, readUInt8, readUInt16LE, readUInt32LE, readUsVarChar } from '../../../src/token/helpers'; + +function assertThrowsNotEnoughDataError(fn: () => void, expectedByteCount: number) { + try { + fn(); + } catch (err) { + assert.instanceOf(err, NotEnoughDataError); + assert.strictEqual((err as NotEnoughDataError).byteCount, expectedByteCount); + return; + } + + assert.fail('expected a NotEnoughDataError to be thrown'); +} + +describe('NotEnoughDataError', function() { + it('should carry the number of bytes needed to retry the read', function() { + const err = new NotEnoughDataError(42); + + assert.strictEqual(err.byteCount, 42); + }); + + it('should be thrown by read helpers when the buffer is too short', function() { + const buf = Buffer.from([0x01, 0x02]); + + assertThrowsNotEnoughDataError(() => readUInt8(buf, 2), 3); + assertThrowsNotEnoughDataError(() => readUInt16LE(buf, 1), 3); + assertThrowsNotEnoughDataError(() => readUInt32LE(buf, 0), 4); + assertThrowsNotEnoughDataError(() => readBigInt64LE(buf, 0), 8); + assertThrowsNotEnoughDataError(() => readUInt32LE(buf, 1), 5); + }); + + it('should be thrown by variable length read helpers when the data is incomplete', function() { + // A `UsVarChar` with a length prefix of 3 characters, but only 2 characters of data. + const buf = Buffer.alloc(2 + 4); + buf.writeUInt16LE(3, 0); + buf.write('ab', 2, 'ucs2'); + + assertThrowsNotEnoughDataError(() => readUsVarChar(buf, 0), 2 + 6); + }); + + it('should not be thrown when the buffer holds enough data', function() { + const buf = Buffer.from([0x01, 0x02, 0x03, 0x04]); + + const result = readUInt32LE(buf, 0); + assert.strictEqual(result.value, 0x04030201); + assert.strictEqual(result.offset, 4); + }); +}); diff --git a/test/unit/token/row-token-parser-test.ts b/test/unit/token/row-token-parser-test.ts index 7fbb63f1e..f45c0e4b3 100644 --- a/test/unit/token/row-token-parser-test.ts +++ b/test/unit/token/row-token-parser-test.ts @@ -61,6 +61,57 @@ describe('Row Token Parser', function() { }); }); + describe('parsing a row delivered one byte at a time', function() { + it('should parse it correctly', async function() { + const debug = new Debug(); + const colMetadata: ColumnMetadata[] = [ + { + colName: 'col0', + userType: 0, + flags: 0, + precision: undefined, + scale: undefined, + dataLength: undefined, + schema: undefined, + udtInfo: undefined, + type: dataTypeByName.VarChar, + collation: new Collation(1033, 0, 0, 52) + }, + { + colName: 'col1', + userType: 0, + flags: 0, + precision: undefined, + scale: undefined, + dataLength: undefined, + schema: undefined, + udtInfo: undefined, + type: dataTypeByName.Int, + collation: undefined + } + ]; + + const buffer = new WritableTrackingBuffer(0, 'ascii'); + buffer.writeUInt8(0xd1); + buffer.writeUsVarchar('hello world'); + buffer.writeUInt32LE(1234); + + const chunks = Array.from(buffer.data, (byte) => Buffer.from([byte])); + + const parser = Parser.parseTokens(chunks, debug, options, colMetadata); + const result = await parser.next(); + assert.isFalse(result.done); + const token = result.value; + + assert.instanceOf(token, RowToken); + assert.strictEqual(token.columns.length, 2); + assert.strictEqual(token.columns[0].value, 'hello world'); + assert.strictEqual(token.columns[1].value, 1234); + + assert.isTrue((await parser.next()).done); + }); + }); + it('should parse int', async function() { const debug = new Debug(); const colMetadata: ColumnMetadata[] = [{ diff --git a/test/unit/token/token-stream-parser-test.ts b/test/unit/token/token-stream-parser-test.ts index 889f14276..2316cd079 100644 --- a/test/unit/token/token-stream-parser-test.ts +++ b/test/unit/token/token-stream-parser-test.ts @@ -55,4 +55,16 @@ describe('Token Stream Parser', () => { parser.on('end', done); }); + + it('should parse token delivered one byte at a time', function(done) { + const debug = new Debug({ token: true }); + const buffer = createDbChangeBuffer(); + + const chunks = Array.from(buffer, (byte) => Buffer.from([byte])); + + // Cast to Message since tests use a simplified input instead of full Message + const parser = new Parser(chunks as unknown as Message, debug, new TestDatabaseChangeHandler(), options); + + parser.on('end', done); + }); }); From d625032c179a42112f2c986c38a013cc9af0b98b Mon Sep 17 00:00:00 2001 From: Arthur Schreiber Date: Fri, 17 Jul 2026 11:46:48 +0000 Subject: [PATCH 2/2] refactor: mark NotEnoughDataError field as declare Matches the `Result` class above and avoids emitting a class field that is immediately overwritten in the constructor. No measurable performance difference on V8. Co-Authored-By: Claude Fable 5 --- src/token/helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/token/helpers.ts b/src/token/helpers.ts index 3ecaff575..869376368 100644 --- a/src/token/helpers.ts +++ b/src/token/helpers.ts @@ -16,7 +16,7 @@ export class Result { // every construction is expensive, and the stack is never used as this // error never escapes the parsing internals. export class NotEnoughDataError { - byteCount: number; + declare byteCount: number; constructor(byteCount: number) { this.byteCount = byteCount;