neptune-lambda-client 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ env:
9
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
10
+
11
+ jobs:
12
+ test:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ matrix:
16
+ node: [22, 24]
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: actions/setup-node@v4
20
+ with:
21
+ node-version: ${{ matrix.node }}
22
+ cache: npm
23
+ - run: npm ci
24
+ - run: npm test
package/README.md CHANGED
@@ -2,24 +2,31 @@
2
2
 
3
3
  ## Overview
4
4
  A very simple Gremlin client to robustly query AWS Neptune from AWS Lambda. The client will automatically
5
- reestablish a connection to the database if the web socket connection closes and will also automatically
6
- retry (5 times) when it encounters `ConcurrentModificationException` and `ReadOnlyViolationException` errors.
5
+ reestablish a connection to the database if the web socket connection closes and will also automatically
6
+ retry (up to 5 times, with exponential backoff and jitter) when it encounters `ConcurrentModificationException`
7
+ and `ReadOnlyViolationException` errors.
7
8
 
8
9
  ## Usage
9
10
 
10
- This client is instantiated with a factory function and exposes a single function called `query`. `query` accepts
11
- a single argument, which is a function that use the Gremlin `g` object.
11
+ This client is instantiated with a factory function and exposes a `query` function that accepts a single
12
+ argument: a function that uses the Gremlin `g` object. It also exposes a `close` function for graceful shutdown.
12
13
 
13
14
  ```js
14
- const gremlinClient = require('neptune-lambda-client');
15
+ import { create } from 'neptune-lambda-client';
15
16
 
16
- const {query} = gremlinClient.create({
17
- host: 'neptune-db-url',
18
- port: '8182',
19
- useIam: true
20
- });
17
+ const { query } = create('neptune-db-url', '8182', { useIam: true });
21
18
 
22
19
  async function getNode(id) {
23
20
  return query(async g => g.V(id).next().then(x => x.value));
24
21
  }
25
22
  ```
23
+
24
+ ## Known limitations
25
+
26
+ Per the [AWS Neptune Lambda guidance](https://docs.aws.amazon.com/neptune/latest/userguide/lambda-functions-examples.html#lambda-functions-examples-javascript),
27
+ if the underlying WebSocket is closed after the driver sends a request but before the response arrives,
28
+ the query resolves with `undefined` rather than throwing. Because this state cannot be turned into an
29
+ exception on the request/response path, we instead throw from the socket's `close` handler when the close
30
+ code is `1006` (abnormal closure) — this surfaces as an unhandled exception that fails the Lambda
31
+ invocation, so the client invoking the Lambda can retry. We recommend implementing retry logic on the
32
+ caller side as well as using the built-in retry in this library.
package/index.js CHANGED
@@ -1,10 +1,12 @@
1
- const aws4 = require('aws4');
2
- const gremlin = require('gremlin');
3
- const retry = require('async-retry');
1
+ import aws4 from 'aws4';
2
+ import gremlin from 'gremlin';
3
+ import retry from 'async-retry';
4
4
 
5
5
  const traversal = gremlin.process.AnonymousTraversalSource.traversal;
6
6
  const {driver: {DriverRemoteConnection}} = gremlin;
7
7
 
8
+ const RETRYABLE_ERRORS = ['ConcurrentModificationException', 'ReadOnlyViolationException'];
9
+
8
10
  function createHeaders(host, port, path, options) {
9
11
  if (!host || !port) {
10
12
  throw new Error('Host and port are required');
@@ -33,75 +35,86 @@ function createHeaders(host, port, path, options) {
33
35
  }).headers;
34
36
  }
35
37
 
36
- module.exports = {
37
- create: function(host, port, {useIam = true} = {useIam: true}) {
38
- let conn = null;
39
- let g = null;
40
-
41
- const path = "/gremlin"
42
- const url = `wss://${host}:${port}${path}`
38
+ export function create(host, port, {useIam = true, protocol = 'wss'} = {}) {
39
+ let conn = null;
40
+ let g = null;
43
41
 
44
- const createRemoteConnection = () => {
42
+ const path = "/gremlin"
43
+ const url = `${protocol}://${host}:${port}${path}`
45
44
 
46
- const c = new DriverRemoteConnection(
47
- url,
48
- {
49
- mimeType: 'application/vnd.gremlin-v2.0+json',
50
- pingEnabled: false,
51
- headers: useIam ? createHeaders(host, port, path, {}) : {}
52
- });
45
+ const createRemoteConnection = () => {
53
46
 
54
- c._client._connection.on('log', message => {
55
- console.info(`connection message - ${message}`);
47
+ const c = new DriverRemoteConnection(
48
+ url,
49
+ {
50
+ // Lambda freezes between invocations; heartbeats fired just before freeze
51
+ // time out after thaw, causing noisy disconnects. Our reconnect-on-error path
52
+ // handles dead connections, so skip the liveness pings.
53
+ pingEnabled: false,
54
+ headers: useIam ? createHeaders(host, port, path, {}) : {}
56
55
  });
57
56
 
58
- c._client._connection.on('close', (code, message) => {
59
- console.info(`close - ${code} ${message}`);
60
- if (code == 1006){
61
- console.error('Connection closed prematurely');
62
- throw new Error('Connection closed prematurely');
63
- }
64
- });
57
+ // gremlin opens the WebSocket eagerly on construction; swallow a potential
58
+ // orphan rejection here. The real error resurfaces on the next submit().
59
+ c.open().catch(() => {});
60
+
61
+ c._client._connection.on('log', message => {
62
+ console.info(`connection message - ${message}`);
63
+ });
65
64
 
66
- return c;
67
- };
68
-
69
- const createGraphTraversalSource = conn => {
70
- return traversal().withRemote(conn);
71
- };
72
-
73
- return {
74
- query: async f => {
75
- if (conn == null){
76
- console.info('Initializing connection')
77
- conn = createRemoteConnection();
78
- g = createGraphTraversalSource(conn);
79
- }
80
-
81
- return retry(async (bail, count) => {
82
- return f(g).catch(err => {
83
- if(count > 0) console.log('Retry attempt no: ' + count);
84
- if (err.message.startsWith('WebSocket is not open')){
85
- console.warn('Reopening connection');
86
- conn.close();
87
- conn = createRemoteConnection();
88
- g = createGraphTraversalSource(conn);
89
- throw err;
90
- } else if (err.message.includes('ConcurrentModificationException')){
91
- console.warn('Retrying query because of ConcurrentModificationException');
92
- throw err;
93
- } else if (err.message.includes('ReadOnlyViolationException')){
94
- console.warn('Retrying query because of ReadOnlyViolationException');
95
- throw err;
96
- } else {
97
- console.warn('Unrecoverable error: ' + err);
98
- return bail(err);
99
- }
100
- })
101
- }, {
102
- factor: 1,
103
- retries: 5
104
- });
65
+ c._client._connection.on('close', (code, message) => {
66
+ console.info(`close - ${code} ${message}`);
67
+ if (code == 1006){
68
+ console.error('Connection closed prematurely');
69
+ throw new Error('Connection closed prematurely');
70
+ }
71
+ });
72
+
73
+ return c;
74
+ };
75
+
76
+ const createGraphTraversalSource = conn => {
77
+ return traversal().withRemote(conn);
78
+ };
79
+
80
+ return {
81
+ query: async f => {
82
+ if (conn == null){
83
+ console.info('Initializing connection')
84
+ conn = createRemoteConnection();
85
+ g = createGraphTraversalSource(conn);
86
+ }
87
+
88
+ return retry(async (bail, count) => {
89
+ return f(g).catch(err => {
90
+ if(count > 0) console.log('Retry attempt no: ' + count);
91
+ if (err.message.startsWith('WebSocket is not open')){
92
+ console.warn('Reopening connection');
93
+ conn.close();
94
+ conn = createRemoteConnection();
95
+ g = createGraphTraversalSource(conn);
96
+ throw err;
97
+ }
98
+ if (RETRYABLE_ERRORS.some(name => err.message.includes(name))) {
99
+ console.warn('Retrying query: ' + err.message);
100
+ throw err;
101
+ }
102
+ console.warn('Unrecoverable error: ' + err);
103
+ return bail(err);
104
+ })
105
+ }, {
106
+ retries: 5,
107
+ factor: 2,
108
+ minTimeout: 1000,
109
+ maxTimeout: 10000,
110
+ randomize: true
111
+ });
112
+ },
113
+ close: async () => {
114
+ if (conn != null) {
115
+ await conn.close();
116
+ conn = null;
117
+ g = null;
105
118
  }
106
119
  }
107
120
  }
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "neptune-lambda-client",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "Gremlin client to robustly query AWS Neptune from AWS Lambda",
5
- "main": "index.js",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./index.js"
8
+ },
6
9
  "scripts": {
7
- "test": "mocha"
10
+ "test": "vitest run",
11
+ "test:watch": "vitest"
8
12
  },
9
13
  "repository": {
10
14
  "type": "git",
@@ -27,5 +31,9 @@
27
31
  "async-retry": "1.3.3",
28
32
  "aws4": "1.11.0",
29
33
  "gremlin": "3.5.2"
34
+ },
35
+ "devDependencies": {
36
+ "vitest": "^2.1.9",
37
+ "ws": "^8.18.0"
30
38
  }
31
39
  }
@@ -0,0 +1,32 @@
1
+ import { WebSocketServer } from 'ws';
2
+
3
+ export async function startFakeGremlin() {
4
+ const upgrades = [];
5
+ let resolveNextUpgrade;
6
+ let nextUpgrade = new Promise(resolve => { resolveNextUpgrade = resolve; });
7
+
8
+ const wss = new WebSocketServer({ port: 0, path: '/gremlin' });
9
+
10
+ wss.on('connection', (_socket, request) => {
11
+ const record = { headers: { ...request.headers }, url: request.url };
12
+ upgrades.push(record);
13
+ const resolve = resolveNextUpgrade;
14
+ nextUpgrade = new Promise(r => { resolveNextUpgrade = r; });
15
+ resolve(record);
16
+ });
17
+
18
+ await new Promise(resolve => wss.on('listening', resolve));
19
+ const { port } = wss.address();
20
+
21
+ return {
22
+ port,
23
+ upgrades,
24
+ waitForNextUpgrade: () => nextUpgrade,
25
+ close: () =>
26
+ new Promise(resolve => {
27
+ for (const client of wss.clients) client.close(1000, 'shutdown');
28
+ wss.close(() => resolve());
29
+ })
30
+ };
31
+ }
32
+
@@ -0,0 +1,147 @@
1
+ import { describe, it, expect, beforeEach, afterEach, onTestFinished, vi } from 'vitest';
2
+ import { create } from '../index.js';
3
+ import { startFakeGremlin } from './helpers/gremlin-server.js';
4
+
5
+ let server;
6
+
7
+ beforeEach(async () => {
8
+ server = await startFakeGremlin();
9
+ });
10
+
11
+ afterEach(async () => {
12
+ await server.close();
13
+ });
14
+
15
+ function createClient(opts = {}) {
16
+ const client = create('localhost', server.port, { useIam: false, protocol: 'ws', ...opts });
17
+ onTestFinished(() => client.close());
18
+ return client;
19
+ }
20
+
21
+ describe('create() / query()', () => {
22
+ it('resolves with the user function result on the happy path', async () => {
23
+ const client = createClient();
24
+ const result = await client.query(async () => 42);
25
+ expect(result).toBe(42);
26
+ });
27
+
28
+ describe('with faked retry timers', () => {
29
+ beforeEach(() => {
30
+ vi.useFakeTimers({ toFake: ['setTimeout'] });
31
+ });
32
+ afterEach(() => {
33
+ vi.useRealTimers();
34
+ });
35
+
36
+ it('retries on ConcurrentModificationException and eventually succeeds', async () => {
37
+ const client = createClient();
38
+ let calls = 0;
39
+ const p = client.query(async () => {
40
+ calls++;
41
+ if (calls < 3) throw new Error('ConcurrentModificationException');
42
+ return 'ok';
43
+ });
44
+ await vi.runAllTimersAsync();
45
+ expect(await p).toBe('ok');
46
+ expect(calls).toBe(3);
47
+ });
48
+
49
+ it('retries on ReadOnlyViolationException and eventually succeeds', async () => {
50
+ const client = createClient();
51
+ let calls = 0;
52
+ const p = client.query(async () => {
53
+ calls++;
54
+ if (calls < 2) throw new Error('ReadOnlyViolationException: stale primary');
55
+ return 'ok';
56
+ });
57
+ await vi.runAllTimersAsync();
58
+ expect(await p).toBe('ok');
59
+ expect(calls).toBe(2);
60
+ });
61
+
62
+ it('reconnects when the error is "WebSocket is not open"', async () => {
63
+ const client = createClient();
64
+ let calls = 0;
65
+ const p = client.query(async () => {
66
+ calls++;
67
+ if (calls === 1) throw new Error('WebSocket is not open: readyState 3 (CLOSED)');
68
+ return 'reconnected';
69
+ });
70
+ await vi.runAllTimersAsync();
71
+ expect(await p).toBe('reconnected');
72
+ expect(calls).toBe(2);
73
+
74
+ // The new WebSocket handshake runs on real I/O; wait for it before asserting.
75
+ vi.useRealTimers();
76
+ await vi.waitFor(() => expect(server.upgrades.length).toBeGreaterThanOrEqual(2), { timeout: 2000 });
77
+ });
78
+
79
+ it('gives up after the retry cap', async () => {
80
+ const client = createClient();
81
+ let calls = 0;
82
+ const p = client.query(async () => {
83
+ calls++;
84
+ throw new Error('ConcurrentModificationException always');
85
+ });
86
+ const assertion = expect(p).rejects.toThrow('ConcurrentModificationException');
87
+ await vi.runAllTimersAsync();
88
+ await assertion;
89
+ expect(calls).toBe(6);
90
+ });
91
+ });
92
+
93
+ it('does not retry on unrecoverable errors', async () => {
94
+ const client = createClient();
95
+ let calls = 0;
96
+ await expect(
97
+ client.query(async () => {
98
+ calls++;
99
+ throw new Error('some random failure');
100
+ })
101
+ ).rejects.toThrow('some random failure');
102
+ expect(calls).toBe(1);
103
+ });
104
+ });
105
+
106
+ describe('IAM signing', () => {
107
+ afterEach(() => {
108
+ vi.unstubAllEnvs();
109
+ });
110
+
111
+ it('sends a SigV4-signed Authorization header when useIam is true', async () => {
112
+ vi.stubEnv('AWS_ACCESS_KEY_ID', 'AKIATEST');
113
+ vi.stubEnv('AWS_SECRET_ACCESS_KEY', 'secret');
114
+ vi.stubEnv('AWS_REGION', 'eu-west-1');
115
+ vi.stubEnv('AWS_SESSION_TOKEN', undefined);
116
+
117
+ const client = createClient({ useIam: true });
118
+ const upgradePromise = server.waitForNextUpgrade();
119
+ await client.query(async () => 'done');
120
+ const upgrade = await upgradePromise;
121
+
122
+ expect(upgrade.headers.authorization).toMatch(/^AWS4-HMAC-SHA256/);
123
+ expect(upgrade.headers['x-amz-date']).toBeDefined();
124
+ });
125
+
126
+ it('sends no Authorization header when useIam is false', async () => {
127
+ const client = createClient();
128
+ const upgradePromise = server.waitForNextUpgrade();
129
+ await client.query(async () => 'done');
130
+ const upgrade = await upgradePromise;
131
+
132
+ expect(upgrade.headers.authorization).toBeUndefined();
133
+ expect(upgrade.headers['x-amz-date']).toBeUndefined();
134
+ });
135
+
136
+ it('rejects when useIam is true but AWS env vars are missing', async () => {
137
+ vi.stubEnv('AWS_ACCESS_KEY_ID', undefined);
138
+ vi.stubEnv('AWS_SECRET_ACCESS_KEY', undefined);
139
+ vi.stubEnv('AWS_REGION', undefined);
140
+ vi.stubEnv('AWS_SESSION_TOKEN', undefined);
141
+
142
+ const client = createClient({ useIam: true });
143
+ await expect(client.query(async () => 'done')).rejects.toThrow(
144
+ 'AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_REGION are required'
145
+ );
146
+ });
147
+ });