neptune-lambda-client 1.0.0 → 3.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.
- package/.claude/settings.local.json +8 -0
- package/.github/workflows/ci.yml +24 -0
- package/README.md +47 -10
- package/index.js +81 -66
- package/package.json +17 -5
- package/test/helpers/gremlin-container.js +14 -0
- package/test/helpers/gremlin-server.js +32 -0
- package/test/index.test.js +147 -0
- package/test/integration.test.js +155 -0
|
@@ -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,61 @@
|
|
|
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`
|
|
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.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
`gremlin` is a peer dependency — install it alongside the client so your application controls the
|
|
12
|
+
exact version that talks to your Neptune cluster:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm install neptune-lambda-client gremlin
|
|
16
|
+
```
|
|
7
17
|
|
|
8
18
|
## Usage
|
|
9
19
|
|
|
10
|
-
This client is instantiated with a factory function and exposes a
|
|
11
|
-
|
|
20
|
+
This client is instantiated with a factory function and exposes a `query` function that accepts a single
|
|
21
|
+
argument: a function that uses the Gremlin `g` object. It also exposes a `close` function for graceful shutdown.
|
|
12
22
|
|
|
13
23
|
```js
|
|
14
|
-
|
|
24
|
+
import { create } from 'neptune-lambda-client';
|
|
15
25
|
|
|
16
|
-
const {query} =
|
|
17
|
-
host: 'neptune-db-url',
|
|
18
|
-
port: '8182',
|
|
19
|
-
useIam: true
|
|
20
|
-
});
|
|
26
|
+
const { query } = create('neptune-db-url', '8182', { useIam: true });
|
|
21
27
|
|
|
22
28
|
async function getNode(id) {
|
|
23
29
|
return query(async g => g.V(id).next().then(x => x.value));
|
|
24
30
|
}
|
|
25
31
|
```
|
|
32
|
+
|
|
33
|
+
### Partition strategy
|
|
34
|
+
|
|
35
|
+
To scope every query through the client to a Gremlin
|
|
36
|
+
[`PartitionStrategy`](https://tinkerpop.apache.org/docs/current/reference/#partitionstrategy)
|
|
37
|
+
— useful for multi-tenant graphs and per-suite test isolation — pass a `partition`
|
|
38
|
+
option. Its fields are forwarded directly to `PartitionStrategy`:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
const { query } = create('neptune-db-url', '8182', {
|
|
42
|
+
useIam: true,
|
|
43
|
+
partition: {
|
|
44
|
+
partitionKey: '_partition',
|
|
45
|
+
writePartition: 'tenant-a',
|
|
46
|
+
readPartitions: ['tenant-a', 'shared'],
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The strategy is reapplied automatically when the client reconnects after a dropped
|
|
52
|
+
WebSocket, so partition isolation holds across the full Lambda lifetime.
|
|
53
|
+
|
|
54
|
+
## Known limitations
|
|
55
|
+
|
|
56
|
+
Per the [AWS Neptune Lambda guidance](https://docs.aws.amazon.com/neptune/latest/userguide/lambda-functions-examples.html#lambda-functions-examples-javascript),
|
|
57
|
+
if the underlying WebSocket is closed after the driver sends a request but before the response arrives,
|
|
58
|
+
the query resolves with `undefined` rather than throwing. Because this state cannot be turned into an
|
|
59
|
+
exception on the request/response path, we instead throw from the socket's `close` handler when the close
|
|
60
|
+
code is `1006` (abnormal closure) — this surfaces as an unhandled exception that fails the Lambda
|
|
61
|
+
invocation, so the client invoking the Lambda can retry. We recommend implementing retry logic on the
|
|
62
|
+
caller side as well as using the built-in retry in this library.
|
package/index.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import aws4 from 'aws4';
|
|
2
|
+
import gremlin from 'gremlin';
|
|
3
|
+
import { PartitionStrategy } from 'gremlin/lib/process/traversal-strategy.js';
|
|
4
|
+
import retry from 'async-retry';
|
|
4
5
|
|
|
5
6
|
const traversal = gremlin.process.AnonymousTraversalSource.traversal;
|
|
6
7
|
const {driver: {DriverRemoteConnection}} = gremlin;
|
|
7
8
|
|
|
9
|
+
const RETRYABLE_ERRORS = ['ConcurrentModificationException', 'ReadOnlyViolationException'];
|
|
10
|
+
|
|
8
11
|
function createHeaders(host, port, path, options) {
|
|
9
12
|
if (!host || !port) {
|
|
10
13
|
throw new Error('Host and port are required');
|
|
@@ -33,75 +36,87 @@ function createHeaders(host, port, path, options) {
|
|
|
33
36
|
}).headers;
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
let g = null;
|
|
40
|
-
|
|
41
|
-
const path = "/gremlin"
|
|
42
|
-
const url = `wss://${host}:${port}${path}`
|
|
39
|
+
export function create(host, port, {useIam = true, protocol = 'wss', partition} = {}) {
|
|
40
|
+
let conn = null;
|
|
41
|
+
let g = null;
|
|
43
42
|
|
|
44
|
-
|
|
43
|
+
const path = "/gremlin"
|
|
44
|
+
const url = `${protocol}://${host}:${port}${path}`
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
url,
|
|
48
|
-
{
|
|
49
|
-
mimeType: 'application/vnd.gremlin-v2.0+json',
|
|
50
|
-
pingEnabled: false,
|
|
51
|
-
headers: useIam ? createHeaders(host, port, path, {}) : {}
|
|
52
|
-
});
|
|
46
|
+
const createRemoteConnection = () => {
|
|
53
47
|
|
|
54
|
-
|
|
55
|
-
|
|
48
|
+
const c = new DriverRemoteConnection(
|
|
49
|
+
url,
|
|
50
|
+
{
|
|
51
|
+
// Lambda freezes between invocations; heartbeats fired just before freeze
|
|
52
|
+
// time out after thaw, causing noisy disconnects. Our reconnect-on-error path
|
|
53
|
+
// handles dead connections, so skip the liveness pings.
|
|
54
|
+
pingEnabled: false,
|
|
55
|
+
headers: useIam ? createHeaders(host, port, path, {}) : {}
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
// gremlin opens the WebSocket eagerly on construction; swallow a potential
|
|
59
|
+
// orphan rejection here. The real error resurfaces on the next submit().
|
|
60
|
+
c.open().catch(() => {});
|
|
61
|
+
|
|
62
|
+
c._client._connection.on('log', message => {
|
|
63
|
+
console.info(`connection message - ${message}`);
|
|
64
|
+
});
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
66
|
+
c._client._connection.on('close', (code, message) => {
|
|
67
|
+
console.info(`close - ${code} ${message}`);
|
|
68
|
+
if (code == 1006){
|
|
69
|
+
console.error('Connection closed prematurely');
|
|
70
|
+
throw new Error('Connection closed prematurely');
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return c;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const createGraphTraversalSource = conn => {
|
|
78
|
+
const g = traversal().withRemote(conn);
|
|
79
|
+
return partition ? g.withStrategies(new PartitionStrategy(partition)) : g;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
query: async f => {
|
|
84
|
+
if (conn == null){
|
|
85
|
+
console.info('Initializing connection')
|
|
86
|
+
conn = createRemoteConnection();
|
|
87
|
+
g = createGraphTraversalSource(conn);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return retry(async (bail, count) => {
|
|
91
|
+
return f(g).catch(err => {
|
|
92
|
+
if(count > 0) console.log('Retry attempt no: ' + count);
|
|
93
|
+
if (err.message.startsWith('WebSocket is not open')){
|
|
94
|
+
console.warn('Reopening connection');
|
|
95
|
+
conn.close();
|
|
96
|
+
conn = createRemoteConnection();
|
|
97
|
+
g = createGraphTraversalSource(conn);
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
if (RETRYABLE_ERRORS.some(name => err.message.includes(name))) {
|
|
101
|
+
console.warn('Retrying query: ' + err.message);
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
console.warn('Unrecoverable error: ' + err);
|
|
105
|
+
return bail(err);
|
|
106
|
+
})
|
|
107
|
+
}, {
|
|
108
|
+
retries: 5,
|
|
109
|
+
factor: 2,
|
|
110
|
+
minTimeout: 1000,
|
|
111
|
+
maxTimeout: 10000,
|
|
112
|
+
randomize: true
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
close: async () => {
|
|
116
|
+
if (conn != null) {
|
|
117
|
+
await conn.close();
|
|
118
|
+
conn = null;
|
|
119
|
+
g = null;
|
|
105
120
|
}
|
|
106
121
|
}
|
|
107
122
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neptune-lambda-client",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Gremlin client to robustly query AWS Neptune from AWS Lambda",
|
|
5
|
-
"
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.js"
|
|
8
|
+
},
|
|
6
9
|
"scripts": {
|
|
7
|
-
"test": "
|
|
10
|
+
"test": "vitest run",
|
|
11
|
+
"test:watch": "vitest"
|
|
8
12
|
},
|
|
9
13
|
"repository": {
|
|
10
14
|
"type": "git",
|
|
@@ -25,7 +29,15 @@
|
|
|
25
29
|
"homepage": "https://github.com/svozza/neptune-lambda-client#readme",
|
|
26
30
|
"dependencies": {
|
|
27
31
|
"async-retry": "1.3.3",
|
|
28
|
-
"aws4": "1.11.0"
|
|
29
|
-
|
|
32
|
+
"aws4": "1.11.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"gremlin": "^3.5.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"gremlin": "3.5.2",
|
|
39
|
+
"testcontainers": "^12.0.0",
|
|
40
|
+
"vitest": "^2.1.9",
|
|
41
|
+
"ws": "^8.18.0"
|
|
30
42
|
}
|
|
31
43
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { GenericContainer, Wait } from 'testcontainers';
|
|
2
|
+
|
|
3
|
+
export async function startGremlinServer() {
|
|
4
|
+
const container = await new GenericContainer('tinkerpop/gremlin-server:3.7.3')
|
|
5
|
+
.withExposedPorts(8182)
|
|
6
|
+
.withWaitStrategy(Wait.forLogMessage(/Channel started at port 8182/))
|
|
7
|
+
.start();
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
host: container.getHost(),
|
|
11
|
+
port: container.getMappedPort(8182),
|
|
12
|
+
stop: () => container.stop()
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { beforeAll, afterAll, describe, it, expect, onTestFinished } from 'vitest';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { create } from '../index.js';
|
|
4
|
+
import { startGremlinServer } from './helpers/gremlin-container.js';
|
|
5
|
+
|
|
6
|
+
let server;
|
|
7
|
+
|
|
8
|
+
beforeAll(async () => {
|
|
9
|
+
server = await startGremlinServer();
|
|
10
|
+
}, 120_000);
|
|
11
|
+
|
|
12
|
+
afterAll(async () => {
|
|
13
|
+
await server?.stop();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const KEY = '_partition';
|
|
17
|
+
|
|
18
|
+
function mkClient(write, reads) {
|
|
19
|
+
const client = create(server.host, String(server.port), {
|
|
20
|
+
useIam: false,
|
|
21
|
+
protocol: 'ws',
|
|
22
|
+
partition: { partitionKey: KEY, writePartition: write, readPartitions: reads }
|
|
23
|
+
});
|
|
24
|
+
onTestFinished(() => client.close());
|
|
25
|
+
return client;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('partition strategy', () => {
|
|
29
|
+
it('isolates writes to writePartition and gates reads on readPartitions', async () => {
|
|
30
|
+
const a = `a-${randomUUID()}`;
|
|
31
|
+
const b = `b-${randomUUID()}`;
|
|
32
|
+
const name = `iso-${randomUUID()}`;
|
|
33
|
+
|
|
34
|
+
const writerA = mkClient(a, [a]);
|
|
35
|
+
const readerA = mkClient(a, [a]);
|
|
36
|
+
const readerB = mkClient(b, [b]);
|
|
37
|
+
|
|
38
|
+
await writerA.query(g => g.addV('Widget').property('name', name).next());
|
|
39
|
+
|
|
40
|
+
const fromB = await readerB.query(g => g.V().has('Widget', 'name', name).toList());
|
|
41
|
+
expect(fromB).toEqual([]);
|
|
42
|
+
|
|
43
|
+
const fromA = await readerA.query(g => g.V().has('Widget', 'name', name).toList());
|
|
44
|
+
expect(fromA.length).toBe(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('lets readPartitions span multiple partitions', async () => {
|
|
48
|
+
const a = `a-${randomUUID()}`;
|
|
49
|
+
const shared = `shared-${randomUUID()}`;
|
|
50
|
+
const nameA = `multi-a-${randomUUID()}`;
|
|
51
|
+
const nameShared = `multi-s-${randomUUID()}`;
|
|
52
|
+
|
|
53
|
+
const writerA = mkClient(a, [a]);
|
|
54
|
+
const writerShared = mkClient(shared, [shared]);
|
|
55
|
+
const readerBoth = mkClient(a, [a, shared]);
|
|
56
|
+
|
|
57
|
+
await writerA.query(g => g.addV('Widget').property('name', nameA).next());
|
|
58
|
+
await writerShared.query(g => g.addV('Widget').property('name', nameShared).next());
|
|
59
|
+
|
|
60
|
+
const fromA = await readerBoth.query(g => g.V().has('Widget', 'name', nameA).toList());
|
|
61
|
+
const fromShared = await readerBoth.query(g => g.V().has('Widget', 'name', nameShared).toList());
|
|
62
|
+
expect(fromA.length).toBe(1);
|
|
63
|
+
expect(fromShared.length).toBe(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('preserves the partition strategy across a reconnect', async () => {
|
|
67
|
+
const a = `a-${randomUUID()}`;
|
|
68
|
+
const b = `b-${randomUUID()}`;
|
|
69
|
+
const name = `reconnect-${randomUUID()}`;
|
|
70
|
+
|
|
71
|
+
const writerA = mkClient(a, [a]);
|
|
72
|
+
const readerA = mkClient(a, [a]);
|
|
73
|
+
const readerB = mkClient(b, [b]);
|
|
74
|
+
|
|
75
|
+
let calls = 0;
|
|
76
|
+
await writerA.query(g => {
|
|
77
|
+
calls++;
|
|
78
|
+
if (calls === 1) {
|
|
79
|
+
// Triggers index.js's reconnect path: close + rebuild g via
|
|
80
|
+
// createGraphTraversalSource. If the strategy is dropped on
|
|
81
|
+
// rebuild, the write below leaks into other partitions.
|
|
82
|
+
throw new Error('WebSocket is not open: readyState 3 (CLOSED)');
|
|
83
|
+
}
|
|
84
|
+
return g.addV('Widget').property('name', name).next();
|
|
85
|
+
});
|
|
86
|
+
expect(calls).toBe(2);
|
|
87
|
+
|
|
88
|
+
const fromB = await readerB.query(g => g.V().has('Widget', 'name', name).toList());
|
|
89
|
+
expect(fromB).toEqual([]);
|
|
90
|
+
|
|
91
|
+
const fromA = await readerA.query(g => g.V().has('Widget', 'name', name).toList());
|
|
92
|
+
expect(fromA.length).toBe(1);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('end-to-end query', () => {
|
|
97
|
+
function plainClient() {
|
|
98
|
+
const client = create(server.host, String(server.port), { useIam: false, protocol: 'ws' });
|
|
99
|
+
onTestFinished(() => client.close());
|
|
100
|
+
return client;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
it('round-trips a vertex through bytecode + GraphSON', async () => {
|
|
104
|
+
const client = plainClient();
|
|
105
|
+
const name = `e2e-${randomUUID()}`;
|
|
106
|
+
|
|
107
|
+
await client.query(g => g.addV('Widget').property('name', name).property('count', 7).next());
|
|
108
|
+
|
|
109
|
+
const fetched = await client.query(g =>
|
|
110
|
+
g.V().has('Widget', 'name', name).valueMap(true).toList()
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(fetched.length).toBe(1);
|
|
114
|
+
const v = fetched[0];
|
|
115
|
+
expect(v.get('name')).toEqual([name]);
|
|
116
|
+
expect(v.get('count')).toEqual([7]);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('connection lifecycle', () => {
|
|
121
|
+
it('reuses one connection across multiple queries, then rebuilds after close()', async () => {
|
|
122
|
+
const client = create(server.host, String(server.port), { useIam: false, protocol: 'ws' });
|
|
123
|
+
onTestFinished(() => client.close());
|
|
124
|
+
|
|
125
|
+
for (let i = 0; i < 3; i++) {
|
|
126
|
+
const out = await client.query(g => g.inject(i).next());
|
|
127
|
+
expect(out.value).toBe(i);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await client.close();
|
|
131
|
+
await client.close();
|
|
132
|
+
|
|
133
|
+
const out = await client.query(g => g.inject('after-reopen').next());
|
|
134
|
+
expect(out.value).toBe('after-reopen');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('server-side errors', () => {
|
|
139
|
+
it('bails immediately on a malformed traversal (no retry)', async () => {
|
|
140
|
+
const client = create(server.host, String(server.port), { useIam: false, protocol: 'ws' });
|
|
141
|
+
onTestFinished(() => client.close());
|
|
142
|
+
|
|
143
|
+
let calls = 0;
|
|
144
|
+
await expect(
|
|
145
|
+
client.query(g => {
|
|
146
|
+
calls++;
|
|
147
|
+
// addE() without from/to context is invalid at execution time
|
|
148
|
+
// on the server; the resulting error doesn't match any
|
|
149
|
+
// RETRYABLE_ERRORS entry, so we expect a single attempt.
|
|
150
|
+
return g.addE('orphan').next();
|
|
151
|
+
})
|
|
152
|
+
).rejects.toThrow();
|
|
153
|
+
expect(calls).toBe(1);
|
|
154
|
+
});
|
|
155
|
+
});
|