neptune-lambda-client 2.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/README.md +30 -0
- package/index.js +4 -2
- package/package.json +7 -3
- package/test/helpers/gremlin-container.js +14 -0
- package/test/integration.test.js +155 -0
package/README.md
CHANGED
|
@@ -6,6 +6,15 @@ reestablish a connection to the database if the web socket connection closes and
|
|
|
6
6
|
retry (up to 5 times, with exponential backoff and jitter) when it encounters `ConcurrentModificationException`
|
|
7
7
|
and `ReadOnlyViolationException` errors.
|
|
8
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
|
+
```
|
|
17
|
+
|
|
9
18
|
## Usage
|
|
10
19
|
|
|
11
20
|
This client is instantiated with a factory function and exposes a `query` function that accepts a single
|
|
@@ -21,6 +30,27 @@ async function getNode(id) {
|
|
|
21
30
|
}
|
|
22
31
|
```
|
|
23
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
|
+
|
|
24
54
|
## Known limitations
|
|
25
55
|
|
|
26
56
|
Per the [AWS Neptune Lambda guidance](https://docs.aws.amazon.com/neptune/latest/userguide/lambda-functions-examples.html#lambda-functions-examples-javascript),
|
package/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import aws4 from 'aws4';
|
|
2
2
|
import gremlin from 'gremlin';
|
|
3
|
+
import { PartitionStrategy } from 'gremlin/lib/process/traversal-strategy.js';
|
|
3
4
|
import retry from 'async-retry';
|
|
4
5
|
|
|
5
6
|
const traversal = gremlin.process.AnonymousTraversalSource.traversal;
|
|
@@ -35,7 +36,7 @@ function createHeaders(host, port, path, options) {
|
|
|
35
36
|
}).headers;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
export function create(host, port, {useIam = true, protocol = 'wss'} = {}) {
|
|
39
|
+
export function create(host, port, {useIam = true, protocol = 'wss', partition} = {}) {
|
|
39
40
|
let conn = null;
|
|
40
41
|
let g = null;
|
|
41
42
|
|
|
@@ -74,7 +75,8 @@ export function create(host, port, {useIam = true, protocol = 'wss'} = {}) {
|
|
|
74
75
|
};
|
|
75
76
|
|
|
76
77
|
const createGraphTraversalSource = conn => {
|
|
77
|
-
|
|
78
|
+
const g = traversal().withRemote(conn);
|
|
79
|
+
return partition ? g.withStrategies(new PartitionStrategy(partition)) : g;
|
|
78
80
|
};
|
|
79
81
|
|
|
80
82
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
6
|
"exports": {
|
|
@@ -29,10 +29,14 @@
|
|
|
29
29
|
"homepage": "https://github.com/svozza/neptune-lambda-client#readme",
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"async-retry": "1.3.3",
|
|
32
|
-
"aws4": "1.11.0"
|
|
33
|
-
|
|
32
|
+
"aws4": "1.11.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"gremlin": "^3.5.0"
|
|
34
36
|
},
|
|
35
37
|
"devDependencies": {
|
|
38
|
+
"gremlin": "3.5.2",
|
|
39
|
+
"testcontainers": "^12.0.0",
|
|
36
40
|
"vitest": "^2.1.9",
|
|
37
41
|
"ws": "^8.18.0"
|
|
38
42
|
}
|
|
@@ -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,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
|
+
});
|