Skip to content

Commit 97ff4dc

Browse files
authored
Optimize usage of Intl API to speed up response parsing with many datetime objects (#1174)
## Background Lately, I've been upgrading a big business application that was using the latest 4.x version of the neo4j driver to use the latest 5.x version (we still use neo4j 4.x, but they are compatible [according to docs](https://neo4j.com/developer/kb/neo4j-supported-versions/#_neo4j_database_enterprise_edition_4)). The upgrade itself was very smooth, but while testing everything afterwards, we noticed that (almost) all of our requests to the backend took considerably longer to finish (~2x). After doing some investigation (mainly by using [clinic flamegraphs](https://clinicjs.org/flame/)) I noticed that there was a considerable increase in the time spent parsing the raw neo4j responses in the driver. Looking at it in more detail revealed that most of the increase stems from one particular codepath, namely from calls to [`getTimeInZoneId`](https://github.com/neo4j/neo4j-javascript-driver/blob/5.0/packages/bolt-connection/src/bolt/bolt-protocol-v5x0.utc.transformer.js#L160). Looking at it almost immediately revealed the culprit, which is how the `Intl` API is used there. It seems that a new `Intl.DateTimeFormat` object is created for each date time returned in the response. The `Intl` API is notoriously slow afaik, hence we should reduce the usage of those APIs to an absolute minimum in hot code paths, such as response parsing. Also, since the application I was upgrading is basically doing nothing else than managing timestamps at its core, it made sense that we noticed the performance degradation in such a severe way. ## Changes in this MR I decided to try out to cache the `DateTimeFormat` to prevent intializing the formatter for a given time zone more than once, and it seems to have helped quite a lot (in our case the "big" requests got a speedup of 60-70%). I also checked for other usages of `Intl` in the code base, but luckily only found one other place, where it's used to check the validity of a given timezone string. I added caching there as well, though I'm not entirely sure if this is a case of premature optimization, since we personally didn't run into performance issues where this particular method was involved. I'll leave this up to you guys to decide if we should include those changes in this MR as well, or revert them.
1 parent 7545b38 commit 97ff4dc

File tree

5 files changed

+110
-30
lines changed

5 files changed

+110
-30
lines changed

packages/bolt-connection/src/bolt/bolt-protocol-v5x0.utc.transformer.js

+23-11
Original file line numberDiff line numberDiff line change
@@ -157,18 +157,30 @@ function getOffsetFromZoneId (timeZoneId, epochSecond, nanosecond) {
157157
return offset
158158
}
159159

160+
const dateTimeFormatCache = new Map()
161+
162+
function getDateTimeFormatForZoneId (timeZoneId) {
163+
if (!dateTimeFormatCache.has(timeZoneId)) {
164+
const formatter = new Intl.DateTimeFormat('en-US', {
165+
timeZone: timeZoneId,
166+
year: 'numeric',
167+
month: 'numeric',
168+
day: 'numeric',
169+
hour: 'numeric',
170+
minute: 'numeric',
171+
second: 'numeric',
172+
hour12: false,
173+
era: 'narrow'
174+
})
175+
176+
dateTimeFormatCache.set(timeZoneId, formatter)
177+
}
178+
179+
return dateTimeFormatCache.get(timeZoneId)
180+
}
181+
160182
function getTimeInZoneId (timeZoneId, epochSecond, nano) {
161-
const formatter = new Intl.DateTimeFormat('en-US', {
162-
timeZone: timeZoneId,
163-
year: 'numeric',
164-
month: 'numeric',
165-
day: 'numeric',
166-
hour: 'numeric',
167-
minute: 'numeric',
168-
second: 'numeric',
169-
hour12: false,
170-
era: 'narrow'
171-
})
183+
const formatter = getDateTimeFormatForZoneId(timeZoneId)
172184

173185
const utc = int(epochSecond)
174186
.multiply(1000)

packages/core/src/internal/temporal-util.ts

+19-4
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717

1818
import Integer, { int, isInt } from '../integer'
19-
import { newError } from '../error'
19+
import { Neo4jError, newError } from '../error'
2020
import { assertNumberOrInteger } from './util'
2121
import { NumberOrInteger } from '../graph-types'
2222

@@ -428,13 +428,28 @@ export function assertValidNanosecond (
428428
)
429429
}
430430

431+
const timeZoneValidityCache = new Map<string, boolean>()
432+
const newInvalidZoneIdError = (zoneId: string, fieldName: string): Neo4jError => newError(
433+
`${fieldName} is expected to be a valid ZoneId but was: "${zoneId}"`
434+
)
435+
431436
export function assertValidZoneId (fieldName: string, zoneId: string): void {
437+
const cachedResult = timeZoneValidityCache.get(zoneId)
438+
439+
if (cachedResult === true) {
440+
return
441+
}
442+
443+
if (cachedResult === false) {
444+
throw newInvalidZoneIdError(zoneId, fieldName)
445+
}
446+
432447
try {
433448
Intl.DateTimeFormat(undefined, { timeZone: zoneId })
449+
timeZoneValidityCache.set(zoneId, true)
434450
} catch (e) {
435-
throw newError(
436-
`${fieldName} is expected to be a valid ZoneId but was: "${zoneId}"`
437-
)
451+
timeZoneValidityCache.set(zoneId, false)
452+
throw newInvalidZoneIdError(zoneId, fieldName)
438453
}
439454
}
440455

packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x0.utc.transformer.js

+23-11
Original file line numberDiff line numberDiff line change
@@ -157,18 +157,30 @@ function getOffsetFromZoneId (timeZoneId, epochSecond, nanosecond) {
157157
return offset
158158
}
159159

160+
const dateTimeFormatCache = new Map()
161+
162+
function getDateTimeFormatForZoneId (timeZoneId) {
163+
if (!dateTimeFormatCache.has(timeZoneId)) {
164+
const formatter = new Intl.DateTimeFormat('en-US', {
165+
timeZone: timeZoneId,
166+
year: 'numeric',
167+
month: 'numeric',
168+
day: 'numeric',
169+
hour: 'numeric',
170+
minute: 'numeric',
171+
second: 'numeric',
172+
hour12: false,
173+
era: 'narrow'
174+
})
175+
176+
dateTimeFormatCache.set(timeZoneId, formatter)
177+
}
178+
179+
return dateTimeFormatCache.get(timeZoneId)
180+
}
181+
160182
function getTimeInZoneId (timeZoneId, epochSecond, nano) {
161-
const formatter = new Intl.DateTimeFormat('en-US', {
162-
timeZone: timeZoneId,
163-
year: 'numeric',
164-
month: 'numeric',
165-
day: 'numeric',
166-
hour: 'numeric',
167-
minute: 'numeric',
168-
second: 'numeric',
169-
hour12: false,
170-
era: 'narrow'
171-
})
183+
const formatter = getDateTimeFormatForZoneId(timeZoneId)
172184

173185
const utc = int(epochSecond)
174186
.multiply(1000)

packages/neo4j-driver-deno/lib/core/internal/temporal-util.ts

+19-4
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717

1818
import Integer, { int, isInt } from '../integer.ts'
19-
import { newError } from '../error.ts'
19+
import { Neo4jError, newError } from '../error.ts'
2020
import { assertNumberOrInteger } from './util.ts'
2121
import { NumberOrInteger } from '../graph-types.ts'
2222

@@ -428,13 +428,28 @@ export function assertValidNanosecond (
428428
)
429429
}
430430

431+
const timeZoneValidityCache = new Map<string, boolean>()
432+
const newInvalidZoneIdError = (zoneId: string, fieldName: string): Neo4jError => newError(
433+
`${fieldName} is expected to be a valid ZoneId but was: "${zoneId}"`
434+
)
435+
431436
export function assertValidZoneId (fieldName: string, zoneId: string): void {
437+
const cachedResult = timeZoneValidityCache.get(zoneId)
438+
439+
if (cachedResult === true) {
440+
return
441+
}
442+
443+
if (cachedResult === false) {
444+
throw newInvalidZoneIdError(zoneId, fieldName)
445+
}
446+
432447
try {
433448
Intl.DateTimeFormat(undefined, { timeZone: zoneId })
449+
timeZoneValidityCache.set(zoneId, true)
434450
} catch (e) {
435-
throw newError(
436-
`${fieldName} is expected to be a valid ZoneId but was: "${zoneId}"`
437-
)
451+
timeZoneValidityCache.set(zoneId, false)
452+
throw newInvalidZoneIdError(zoneId, fieldName)
438453
}
439454
}
440455

runTests.sh

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
npm install -g gulp typescript jest
2+
3+
npm ci
4+
npm run build -- --no-private
5+
6+
if [ -n "$2" ]; then
7+
export NEOCTRL_ARGS="$2"
8+
fi
9+
10+
trap "npm run stop-neo4j" EXIT
11+
12+
npm run start-neo4j
13+
14+
if [ $? -ne 0 ]; then
15+
echo "Unable to start neo4j"
16+
exit 1
17+
fi
18+
19+
npm test -- --no-private
20+
21+
if [ $? -eq 0 ]; then
22+
echo "Exit with code 0"
23+
else
24+
echo "Exit with code 1"
25+
exit 1
26+
fi

0 commit comments

Comments
 (0)