Skip to content

Commit 6e303c0

Browse files
authored
Introduce AbortSignal to Driver.executeQuery (#1199)
**⚠️ This API is released as preview.** This configuration property enables the cancellation of ongoing queries which was previous not possible when using `Driver.executeQuery`. The cancellation is done by closing the session used internally by the driver when execute the query. This means the cancellation is not safe and transaction might be commit apart of the execution being aborted. The cancellation might also triggers errors on the `Driver.executeQuery` execution. Example: ```javascript const abortController = new AbortController() // Some event which cancels the query process.on('SIGINT', () => abortController.abort() ) const result = await driver.executeQuery(query, params, { database: 'neo4j', signal: abortController.signal }) ``` **⚠️ This API is released as preview.**
1 parent 75d3040 commit 6e303c0

File tree

6 files changed

+215
-4
lines changed

6 files changed

+215
-4
lines changed

packages/core/src/driver.ts

+20-1
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ class QueryConfig<T = EagerResult> {
358358
resultTransformer?: ResultTransformer<T>
359359
transactionConfig?: TransactionConfig
360360
auth?: AuthToken
361+
signal?: AbortSignal
361362

362363
/**
363364
* @constructor
@@ -429,6 +430,23 @@ class QueryConfig<T = EagerResult> {
429430
* @see {@link driver}
430431
*/
431432
this.auth = undefined
433+
434+
/**
435+
* The {@link AbortSignal} for aborting query execution.
436+
*
437+
* When aborted, the signal triggers the result consumption cancelation and
438+
* transactions are reset. However, due to race conditions,
439+
* there is no guarantee the transaction will be rolled back.
440+
* Equivalent to {@link Session.close}
441+
*
442+
* **Warning**: This option is only available in runtime which supports AbortSignal.addEventListener.
443+
*
444+
* @since 5.22.0
445+
* @type {AbortSignal|undefined}
446+
* @experimental
447+
* @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
448+
*/
449+
this.signal = undefined
432450
}
433451
}
434452

@@ -595,7 +613,8 @@ class Driver {
595613
database: config.database,
596614
impersonatedUser: config.impersonatedUser,
597615
transactionConfig: config.transactionConfig,
598-
auth: config.auth
616+
auth: config.auth,
617+
signal: config.signal
599618
}, query, parameters)
600619
}
601620

packages/core/src/internal/query-executor.ts

+29
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ interface ExecutionConfig<T> {
3333
bookmarkManager?: BookmarkManager
3434
transactionConfig?: TransactionConfig
3535
auth?: AuthToken
36+
signal?: AbortSignal
3637
resultTransformer: (result: Result) => Promise<T>
3738
}
3839

@@ -49,6 +50,12 @@ export default class QueryExecutor {
4950
auth: config.auth
5051
})
5152

53+
const listenerHandle = installEventListenerWhenPossible(
54+
// Solving linter and types definitions issue
55+
config.signal as unknown as EventTarget,
56+
'abort',
57+
async () => await session.close())
58+
5259
// @ts-expect-error The method is private for external users
5360
session._configureTransactionExecutor(true, TELEMETRY_APIS.EXECUTE_QUERY)
5461

@@ -62,7 +69,29 @@ export default class QueryExecutor {
6269
return await config.resultTransformer(result)
6370
}, config.transactionConfig)
6471
} finally {
72+
listenerHandle.uninstall()
6573
await session.close()
6674
}
6775
}
6876
}
77+
78+
type Listener = (event: unknown) => unknown
79+
80+
interface EventTarget {
81+
addEventListener?: (type: string, listener: Listener) => unknown
82+
removeEventListener?: (type: string, listener: Listener) => unknown
83+
}
84+
85+
function installEventListenerWhenPossible (target: EventTarget | undefined, event: string, listener: () => unknown): { uninstall: () => void } {
86+
if (typeof target?.addEventListener === 'function') {
87+
target.addEventListener(event, listener)
88+
}
89+
90+
return {
91+
uninstall: () => {
92+
if (typeof target?.removeEventListener === 'function') {
93+
target.removeEventListener(event, listener)
94+
}
95+
}
96+
}
97+
}

packages/core/test/driver.test.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,8 @@ describe('Driver', () => {
473473
key: 'value'
474474
}
475475
}
476+
const aAbortController = new AbortController()
477+
476478
async function aTransformer (result: Result): Promise<string> {
477479
const summary = await result.summary()
478480
return summary.database.name ?? 'no-db-set'
@@ -488,7 +490,8 @@ describe('Driver', () => {
488490
['config.bookmarkManager=null', 'q', {}, { bookmarkManager: null }, extendsDefaultWith({ bookmarkManager: undefined })],
489491
['config.bookmarkManager set to non-null/empty', 'q', {}, { bookmarkManager: theBookmarkManager }, extendsDefaultWith({ bookmarkManager: theBookmarkManager })],
490492
['config.resultTransformer set', 'q', {}, { resultTransformer: aTransformer }, extendsDefaultWith({ resultTransformer: aTransformer })],
491-
['config.transactionConfig set', 'q', {}, { transactionConfig: aTransactionConfig }, extendsDefaultWith({ transactionConfig: aTransactionConfig })]
493+
['config.transactionConfig set', 'q', {}, { transactionConfig: aTransactionConfig }, extendsDefaultWith({ transactionConfig: aTransactionConfig })],
494+
['config.signal set', 'q', {}, { signal: aAbortController.signal }, extendsDefaultWith({ signal: aAbortController.signal })]
492495
])('should handle the params for %s', async (_, query, params, config, buildExpectedConfig) => {
493496
const spiedExecute = jest.spyOn(queryExecutor, 'execute')
494497

packages/core/test/internal/query-executor.test.ts

+113-1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ describe('QueryExecutor', () => {
3333
}
3434
}
3535

36+
const aAbortController = new AbortController()
37+
3638
it.each([
3739
['bookmarkManager set', { bookmarkManager: aBookmarkManager }, { bookmarkManager: aBookmarkManager }],
3840
['bookmarkManager undefined', { bookmarkManager: undefined }, { bookmarkManager: undefined }],
@@ -41,7 +43,10 @@ describe('QueryExecutor', () => {
4143
['impersonatedUser set', { impersonatedUser: 'anUser' }, { impersonatedUser: 'anUser' }],
4244
['impersonatedUser undefined', { impersonatedUser: undefined }, { impersonatedUser: undefined }],
4345
['auth set', { auth: { scheme: 'none', credentials: '' } }, { auth: { scheme: 'none', credentials: '' } }],
44-
['auth undefined', { auth: undefined }, { auth: undefined }]
46+
['auth undefined', { auth: undefined }, { auth: undefined }],
47+
['signal set', { signal: aAbortController.signal }, { }],
48+
['signal set signal', { signal: {} as unknown as AbortSignal }, { }],
49+
['signal undefined', { signal: undefined }, { }]
4550
])('should redirect % to the session creation', async (_, executorConfig, expectConfig) => {
4651
const { queryExecutor, createSession } = createExecutor()
4752

@@ -208,6 +213,56 @@ describe('QueryExecutor', () => {
208213
expect(errorGot).toBe(closeError)
209214
}
210215
})
216+
217+
whenAbortSignalIsEventTarget(() => {
218+
it('should configure listener and remove at end', async () => {
219+
const { queryExecutor, sessionsCreated } = createExecutor()
220+
const controller = new AbortController()
221+
const signal = controller.signal
222+
// @ts-expect-error
223+
const addListenerSpy = jest.spyOn(signal, 'addEventListener')
224+
// @ts-expect-error
225+
const removerListenerSpy = jest.spyOn(signal, 'removeEventListener')
226+
227+
const promise = queryExecutor.execute({ ...baseConfig, signal }, 'query')
228+
229+
expect(addListenerSpy).toHaveBeenCalled()
230+
expect(removerListenerSpy).not.toHaveBeenCalled()
231+
232+
await promise
233+
234+
expect(removerListenerSpy).toHaveBeenCalled()
235+
236+
// Default expectations
237+
expect(sessionsCreated.length).toBe(1)
238+
const [{ spyOnExecuteRead }] = sessionsCreated
239+
expect(spyOnExecuteRead).toHaveBeenCalled()
240+
})
241+
242+
it('should close session when abort', async () => {
243+
const { queryExecutor, sessionsCreated } = createExecutor()
244+
const controller = new AbortController()
245+
const signal = controller.signal
246+
// @ts-expect-error
247+
const removerListenerSpy = jest.spyOn(signal, 'removeEventListener')
248+
249+
const promise = queryExecutor.execute({ ...baseConfig, signal }, 'query')
250+
251+
controller.abort()
252+
253+
// Expect to close session
254+
expect(sessionsCreated[0].session.close).toHaveBeenCalled()
255+
256+
await promise
257+
258+
expect(removerListenerSpy).toHaveBeenCalled()
259+
260+
// Default expectations
261+
expect(sessionsCreated.length).toBe(1)
262+
const [{ spyOnExecuteRead }] = sessionsCreated
263+
expect(spyOnExecuteRead).toHaveBeenCalled()
264+
})
265+
})
211266
})
212267

213268
describe('when routing="WRITE"', () => {
@@ -364,6 +419,56 @@ describe('QueryExecutor', () => {
364419
expect(errorGot).toBe(closeError)
365420
}
366421
})
422+
423+
whenAbortSignalIsEventTarget(() => {
424+
it('should configure listener and remove at end', async () => {
425+
const { queryExecutor, sessionsCreated } = createExecutor()
426+
const controller = new AbortController()
427+
const signal = controller.signal
428+
// @ts-expect-error
429+
const addListenerSpy = jest.spyOn(signal, 'addEventListener')
430+
// @ts-expect-error
431+
const removerListenerSpy = jest.spyOn(signal, 'removeEventListener')
432+
433+
const promise = queryExecutor.execute({ ...baseConfig, signal }, 'query')
434+
435+
expect(addListenerSpy).toHaveBeenCalled()
436+
expect(removerListenerSpy).not.toHaveBeenCalled()
437+
438+
await promise
439+
440+
expect(removerListenerSpy).toHaveBeenCalled()
441+
442+
// Default expectations
443+
expect(sessionsCreated.length).toBe(1)
444+
const [{ spyOnExecuteWrite }] = sessionsCreated
445+
expect(spyOnExecuteWrite).toHaveBeenCalled()
446+
})
447+
448+
it('should close session when abort', async () => {
449+
const { queryExecutor, sessionsCreated } = createExecutor()
450+
const controller = new AbortController()
451+
const signal = controller.signal
452+
// @ts-expect-error
453+
const removerListenerSpy = jest.spyOn(signal, 'removeEventListener')
454+
455+
const promise = queryExecutor.execute({ ...baseConfig, signal }, 'query')
456+
457+
controller.abort()
458+
459+
// Expect to close session
460+
expect(sessionsCreated[0].session.close).toHaveBeenCalled()
461+
462+
await promise
463+
464+
expect(removerListenerSpy).toHaveBeenCalled()
465+
466+
// Default expectations
467+
expect(sessionsCreated.length).toBe(1)
468+
const [{ spyOnExecuteWrite }] = sessionsCreated
469+
expect(spyOnExecuteWrite).toHaveBeenCalled()
470+
})
471+
})
367472
})
368473

369474
function createExecutor ({
@@ -455,3 +560,10 @@ describe('QueryExecutor', () => {
455560
}
456561
}
457562
})
563+
564+
function whenAbortSignalIsEventTarget (fn: () => unknown): void {
565+
// @ts-expect-error AbortSignal doesn't implements EventTarget on this TS Config.
566+
if (typeof AbortSignal.prototype.addEventListener === 'function') {
567+
describe('when abort signal is event target', fn)
568+
}
569+
}

packages/neo4j-driver-deno/lib/core/driver.ts

+20-1
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ class QueryConfig<T = EagerResult> {
358358
resultTransformer?: ResultTransformer<T>
359359
transactionConfig?: TransactionConfig
360360
auth?: AuthToken
361+
signal?: AbortSignal
361362

362363
/**
363364
* @constructor
@@ -429,6 +430,23 @@ class QueryConfig<T = EagerResult> {
429430
* @see {@link driver}
430431
*/
431432
this.auth = undefined
433+
434+
/**
435+
* The {@link AbortSignal} for aborting query execution.
436+
*
437+
* When aborted, the signal triggers the result consumption cancelation and
438+
* transactions are reset. However, due to race conditions,
439+
* there is no guarantee the transaction will be rolled back.
440+
* Equivalent to {@link Session.close}
441+
*
442+
* **Warning**: This option is only available in runtime which supports AbortSignal.addEventListener.
443+
*
444+
* @since 5.22.0
445+
* @type {AbortSignal|undefined}
446+
* @experimental
447+
* @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
448+
*/
449+
this.signal = undefined
432450
}
433451
}
434452

@@ -595,7 +613,8 @@ class Driver {
595613
database: config.database,
596614
impersonatedUser: config.impersonatedUser,
597615
transactionConfig: config.transactionConfig,
598-
auth: config.auth
616+
auth: config.auth,
617+
signal: config.signal
599618
}, query, parameters)
600619
}
601620

packages/neo4j-driver-deno/lib/core/internal/query-executor.ts

+29
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ interface ExecutionConfig<T> {
3333
bookmarkManager?: BookmarkManager
3434
transactionConfig?: TransactionConfig
3535
auth?: AuthToken
36+
signal?: AbortSignal
3637
resultTransformer: (result: Result) => Promise<T>
3738
}
3839

@@ -49,6 +50,12 @@ export default class QueryExecutor {
4950
auth: config.auth
5051
})
5152

53+
const listenerHandle = installEventListenerWhenPossible(
54+
// Solving linter and types definitions issue
55+
config.signal as unknown as EventTarget,
56+
'abort',
57+
async () => await session.close())
58+
5259
// @ts-expect-error The method is private for external users
5360
session._configureTransactionExecutor(true, TELEMETRY_APIS.EXECUTE_QUERY)
5461

@@ -62,7 +69,29 @@ export default class QueryExecutor {
6269
return await config.resultTransformer(result)
6370
}, config.transactionConfig)
6471
} finally {
72+
listenerHandle.uninstall()
6573
await session.close()
6674
}
6775
}
6876
}
77+
78+
type Listener = (event: unknown) => unknown
79+
80+
interface EventTarget {
81+
addEventListener?: (type: string, listener: Listener) => unknown
82+
removeEventListener?: (type: string, listener: Listener) => unknown
83+
}
84+
85+
function installEventListenerWhenPossible (target: EventTarget | undefined, event: string, listener: () => unknown): { uninstall: () => void } {
86+
if (typeof target?.addEventListener === 'function') {
87+
target.addEventListener(event, listener)
88+
}
89+
90+
return {
91+
uninstall: () => {
92+
if (typeof target?.removeEventListener === 'function') {
93+
target.removeEventListener(event, listener)
94+
}
95+
}
96+
}
97+
}

0 commit comments

Comments
 (0)