entangld 2.7.1 → 2.7.2
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/API.md +42 -0
- package/README.md +54 -1
- package/lib/Datastore.js +40 -5
- package/lib/error.js +12 -0
- package/package.json +1 -1
- package/test/abort.js +160 -0
package/API.md
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
<dt><a href="#EntangldTimeoutError">EntangldTimeoutError</a> ⇐ <code><a href="#EntangldError">EntangldError</a></code></dt>
|
|
13
13
|
<dd><p>Error for a request which got no reply in time.</p>
|
|
14
14
|
</dd>
|
|
15
|
+
<dt><a href="#EntangldAbortError">EntangldAbortError</a> ⇐ <code><a href="#EntangldError">EntangldError</a></code></dt>
|
|
16
|
+
<dd><p>Default reason for requests ended by abort().</p>
|
|
17
|
+
</dd>
|
|
15
18
|
<dt><a href="#RPCError">RPCError</a></dt>
|
|
16
19
|
<dd><p>Remote Error class for Entangld.</p>
|
|
17
20
|
</dd>
|
|
@@ -58,6 +61,7 @@ Synchronized Event Store
|
|
|
58
61
|
* [.namespace()](#Entangld+namespace) ⇒ <code>string</code>
|
|
59
62
|
* [.attach(namespace, obj)](#Entangld+attach)
|
|
60
63
|
* [.detach([namespace], [obj])](#Entangld+detach) ⇒ <code>boolean</code>
|
|
64
|
+
* [.abort([reason])](#Entangld+abort) ⇒ <code>number</code>
|
|
61
65
|
* [.transmit(func)](#Entangld+transmit)
|
|
62
66
|
* [.receive(msg, obj)](#Entangld+receive)
|
|
63
67
|
* [.push(path, data, [limit])](#Entangld+push)
|
|
@@ -136,6 +140,37 @@ one parameter.
|
|
|
136
140
|
| [namespace] | <code>string</code> | the namespace. |
|
|
137
141
|
| [obj] | <code>object</code> | the store object. |
|
|
138
142
|
|
|
143
|
+
<a name="Entangld+abort"></a>
|
|
144
|
+
|
|
145
|
+
### entangld.abort([reason]) ⇒ <code>number</code>
|
|
146
|
+
Abort every request waiting on a reply from an attached store
|
|
147
|
+
|
|
148
|
+
Each one rejects with reason, and its time limit and signal listener
|
|
149
|
+
are cleared, so nothing is left to hold the process open. A reply
|
|
150
|
+
which arrives later is ignored. Attachments, subscriptions, local
|
|
151
|
+
functions which are still running and requests made afterwards are
|
|
152
|
+
not affected, so this is safe to call more than once.
|
|
153
|
+
|
|
154
|
+
Use it when no reply can arrive any more, for example when a process
|
|
155
|
+
is shutting down or its link has closed. detach() doesn't do this: a
|
|
156
|
+
request doesn't belong to a store, and its reply can still arrive if
|
|
157
|
+
the same store is attached again.
|
|
158
|
+
|
|
159
|
+
Future work: abort(reason, obj), to abort only the requests sent to
|
|
160
|
+
one store, for example when one of several sockets closes. Requests
|
|
161
|
+
are kept by uuid alone (see get()), and a reply is matched by its
|
|
162
|
+
uuid whichever store it came from, so each request would first need
|
|
163
|
+
to record the obj it was sent to. That obj must be the one passed to
|
|
164
|
+
attach(), not its namespace, since a namespace can be detached and
|
|
165
|
+
attached to another store while a request waits. See the README.
|
|
166
|
+
|
|
167
|
+
**Kind**: instance method of [<code>Entangld</code>](#Entangld)
|
|
168
|
+
**Returns**: <code>number</code> - how many requests were aborted.
|
|
169
|
+
|
|
170
|
+
| Param | Type | Description |
|
|
171
|
+
| --- | --- | --- |
|
|
172
|
+
| [reason] | <code>\*</code> | what the requests reject with. Defaults to an EntangldAbortError. |
|
|
173
|
+
|
|
139
174
|
<a name="Entangld+transmit"></a>
|
|
140
175
|
|
|
141
176
|
### entangld.transmit(func)
|
|
@@ -411,6 +446,13 @@ Error class for Entangld.
|
|
|
411
446
|
## EntangldTimeoutError ⇐ [<code>EntangldError</code>](#EntangldError)
|
|
412
447
|
Error for a request which got no reply in time.
|
|
413
448
|
|
|
449
|
+
**Kind**: global class
|
|
450
|
+
**Extends**: [<code>EntangldError</code>](#EntangldError)
|
|
451
|
+
<a name="EntangldAbortError"></a>
|
|
452
|
+
|
|
453
|
+
## EntangldAbortError ⇐ [<code>EntangldError</code>](#EntangldError)
|
|
454
|
+
Default reason for requests ended by abort().
|
|
455
|
+
|
|
414
456
|
**Kind**: global class
|
|
415
457
|
**Extends**: [<code>EntangldError</code>](#EntangldError)
|
|
416
458
|
<a name="RPCError"></a>
|
package/README.md
CHANGED
|
@@ -225,6 +225,30 @@ try {
|
|
|
225
225
|
child.set("job.progress", 100); // Nobody is told
|
|
226
226
|
```
|
|
227
227
|
|
|
228
|
+
### Aborting waiting requests
|
|
229
|
+
|
|
230
|
+
When no reply can arrive any more, for example when a process is shutting down or its link has closed, `abort()` ends
|
|
231
|
+
every request waiting on an attached store. Each one rejects, with an `EntangldAbortError` or the reason you pass, and
|
|
232
|
+
its time limit is cleared. A pending `timeout_ms` would otherwise keep the process running until it fires.
|
|
233
|
+
|
|
234
|
+
```js
|
|
235
|
+
const { EntangldAbortError } = Entangld.error;
|
|
236
|
+
|
|
237
|
+
const request = parent.get("child.job.slow", undefined, { timeout_ms: 30000 });
|
|
238
|
+
|
|
239
|
+
parent.abort(); // Returns how many requests it aborted
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
await request;
|
|
243
|
+
} catch (err) {
|
|
244
|
+
console.log(err instanceof EntangldAbortError); // true
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`abort()` changes nothing else. Attachments, subscriptions, local functions which are still running and later requests
|
|
249
|
+
carry on, so it is safe to call more than once. `detach()` doesn't abort requests, since a reply can still arrive if the
|
|
250
|
+
same store is attached again.
|
|
251
|
+
|
|
228
252
|
### Over sockets
|
|
229
253
|
|
|
230
254
|
Each side of a socket gets its own store. The socket stands in for the store on the other side.
|
|
@@ -298,12 +322,41 @@ Over a socket that rarely matters, but it can for local stores holding values wh
|
|
|
298
322
|
|
|
299
323
|
## Known limitations
|
|
300
324
|
|
|
301
|
-
- `detach()` doesn't end subscriptions from or through that store, and doesn't touch requests waiting on it.
|
|
325
|
+
- `detach()` doesn't end subscriptions from or through that store, and doesn't touch requests waiting on it. Use
|
|
326
|
+
`abort()` to end waiting requests.
|
|
327
|
+
- `abort()` ends the requests waiting on every attached store. It can't yet end only one store's requests.
|
|
302
328
|
- Querying a parent can't include the contents of attached stores.
|
|
303
329
|
- Deref mode turns values into JSON and back, which is inefficient and loses values like `Map`.
|
|
304
330
|
- Some unit tests depend on the state left by earlier tests.
|
|
305
331
|
- `unsubscribe_tree()` needs a test that it throws when it can't remove every subscription.
|
|
306
332
|
|
|
333
|
+
## Future work
|
|
334
|
+
|
|
335
|
+
### Aborting one store's requests: `abort(reason, obj)`
|
|
336
|
+
|
|
337
|
+
A store with several attached stores, like a server with a socket per client, may want to end only the requests sent
|
|
338
|
+
over one link when that link closes, and leave the rest waiting. `abort(reason)` can't do that, because a request
|
|
339
|
+
doesn't know which store it was sent to:
|
|
340
|
+
|
|
341
|
+
- Requests are kept by uuid alone, as `_requests[uuid] = { resolve, reject }`.
|
|
342
|
+
- A reply is matched by its uuid, whichever store it came from.
|
|
343
|
+
|
|
344
|
+
What it would need:
|
|
345
|
+
|
|
346
|
+
1. **Record the store with each request.** `get()` would save the `obj` it transmitted to alongside `resolve` and
|
|
347
|
+
`reject`. It must be the `obj` passed to `attach()`, not the namespace. A namespace can be detached and attached to
|
|
348
|
+
a different store while a request waits, and the request still belongs to the store it was sent to.
|
|
349
|
+
2. **Filter in `abort()`.** With an `obj`, reject only that store's requests. Without one, reject them all, as now. An
|
|
350
|
+
`obj` which was never attached aborts nothing, rather than throwing, since it may have been detached already.
|
|
351
|
+
3. **Decide what a reply from another store means.** Once requests know their store, `receive()` could check that a
|
|
352
|
+
reply came from it. That is stricter than today, and could break setups where replies arrive through a different
|
|
353
|
+
object than the one requests go out on, so it should be its own change.
|
|
354
|
+
4. **Tests:** only the named store's requests reject; others still resolve; a request sent before its namespace was
|
|
355
|
+
re-attached to another store belongs to the old store; and the call with no `obj` is unchanged.
|
|
356
|
+
|
|
357
|
+
Even then, the trigger for it would be the link closing, not `detach()`. A detach and re-attach over the same link can
|
|
358
|
+
still deliver the reply.
|
|
359
|
+
|
|
307
360
|
## Development
|
|
308
361
|
|
|
309
362
|
```sh
|
package/lib/Datastore.js
CHANGED
|
@@ -19,7 +19,7 @@ function check_signal(signal) {
|
|
|
19
19
|
|
|
20
20
|
const Entangld_Message = require("./Message.js");
|
|
21
21
|
const Subscription = require("./Subscription.js");
|
|
22
|
-
const { EntangldError, EntangldTimeoutError, RPCError } = require("./error.js");
|
|
22
|
+
const { EntangldError, EntangldTimeoutError, EntangldAbortError, RPCError } = require("./error.js");
|
|
23
23
|
const {
|
|
24
24
|
is_beneath,
|
|
25
25
|
extract_from_path,
|
|
@@ -163,6 +163,41 @@ class Entangld extends EventEmitter {
|
|
|
163
163
|
&& this._namespaces.delete(obj);
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Abort every request waiting on a reply from an attached store
|
|
168
|
+
*
|
|
169
|
+
* Each one rejects with reason, and its time limit and signal listener
|
|
170
|
+
* are cleared, so nothing is left to hold the process open. A reply
|
|
171
|
+
* which arrives later is ignored. Attachments, subscriptions, local
|
|
172
|
+
* functions which are still running and requests made afterwards are
|
|
173
|
+
* not affected, so this is safe to call more than once.
|
|
174
|
+
*
|
|
175
|
+
* Use it when no reply can arrive any more, for example when a process
|
|
176
|
+
* is shutting down or its link has closed. detach() doesn't do this: a
|
|
177
|
+
* request doesn't belong to a store, and its reply can still arrive if
|
|
178
|
+
* the same store is attached again.
|
|
179
|
+
*
|
|
180
|
+
* Future work: abort(reason, obj), to abort only the requests sent to
|
|
181
|
+
* one store, for example when one of several sockets closes. Requests
|
|
182
|
+
* are kept by uuid alone (see get()), and a reply is matched by its
|
|
183
|
+
* uuid whichever store it came from, so each request would first need
|
|
184
|
+
* to record the obj it was sent to. That obj must be the one passed to
|
|
185
|
+
* attach(), not its namespace, since a namespace can be detached and
|
|
186
|
+
* attached to another store while a request waits. See the README.
|
|
187
|
+
*
|
|
188
|
+
* @param {*} [reason] what the requests reject with. Defaults to an
|
|
189
|
+
* EntangldAbortError.
|
|
190
|
+
* @return {number} how many requests were aborted.
|
|
191
|
+
*/
|
|
192
|
+
abort(reason = new EntangldAbortError()) {
|
|
193
|
+
const requests = Object.values(this._requests);
|
|
194
|
+
this._requests = {};
|
|
195
|
+
|
|
196
|
+
for (const request of requests) request.reject(reason);
|
|
197
|
+
|
|
198
|
+
return requests.length;
|
|
199
|
+
}
|
|
200
|
+
|
|
166
201
|
/**
|
|
167
202
|
* Transmit
|
|
168
203
|
*
|
|
@@ -209,8 +244,8 @@ class Entangld extends EventEmitter {
|
|
|
209
244
|
|
|
210
245
|
} else if (msg.type == "value") {
|
|
211
246
|
// Incoming value reply
|
|
212
|
-
let
|
|
213
|
-
if (
|
|
247
|
+
let request = this._requests[msg.uuid];
|
|
248
|
+
if (request) request.resolve(msg.value);
|
|
214
249
|
|
|
215
250
|
//Clean up request
|
|
216
251
|
delete this._requests[msg.uuid];
|
|
@@ -579,8 +614,8 @@ class Entangld extends EventEmitter {
|
|
|
579
614
|
// Request the data from the remote store
|
|
580
615
|
const msg = Entangld_Message.get(tree, params);
|
|
581
616
|
|
|
582
|
-
const reply = new Promise((
|
|
583
|
-
this._requests[msg.uuid] =
|
|
617
|
+
const reply = new Promise((resolve, reject) => {
|
|
618
|
+
this._requests[msg.uuid] = { resolve, reject };
|
|
584
619
|
this._transmit(msg, obj);
|
|
585
620
|
});
|
|
586
621
|
|
package/lib/error.js
CHANGED
|
@@ -26,6 +26,17 @@ class EntangldTimeoutError extends EntangldError {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Default reason for requests ended by abort().
|
|
31
|
+
*
|
|
32
|
+
* @extends EntangldError
|
|
33
|
+
*/
|
|
34
|
+
class EntangldAbortError extends EntangldError {
|
|
35
|
+
constructor(message = "Request aborted") {
|
|
36
|
+
super(message);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
/**
|
|
30
41
|
* Remote Error class for Entangld.
|
|
31
42
|
*/
|
|
@@ -54,5 +65,6 @@ class RPCError extends Error {
|
|
|
54
65
|
module.exports = exports = {
|
|
55
66
|
EntangldError,
|
|
56
67
|
EntangldTimeoutError,
|
|
68
|
+
EntangldAbortError,
|
|
57
69
|
RPCError,
|
|
58
70
|
};
|
package/package.json
CHANGED
package/test/abort.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
const assert = require("assert");
|
|
2
|
+
const { spawn } = require("child_process");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const { getEventListeners } = require("events");
|
|
5
|
+
const Entangld = require("../lib/Datastore.js");
|
|
6
|
+
const { EntangldError, EntangldAbortError } = require("../lib/error.js");
|
|
7
|
+
|
|
8
|
+
// Resolve to the error a promise rejects with, or fail if it resolves
|
|
9
|
+
function rejection(promise) {
|
|
10
|
+
return promise.then(
|
|
11
|
+
(val) => assert.fail(`Expected a rejection, got ${val}`),
|
|
12
|
+
(err) => err
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Attach store b to store a. Messages from a are delivered only if allow(msg)
|
|
17
|
+
function linked_stores(allow = () => true) {
|
|
18
|
+
const a = new Entangld();
|
|
19
|
+
const b = new Entangld();
|
|
20
|
+
|
|
21
|
+
a.transmit((msg, store) => {
|
|
22
|
+
if (allow(msg)) store.receive(msg, a);
|
|
23
|
+
});
|
|
24
|
+
b.transmit((msg, store) => store.receive(msg, b));
|
|
25
|
+
|
|
26
|
+
a.attach("b", b);
|
|
27
|
+
b.attach("a", a);
|
|
28
|
+
|
|
29
|
+
return { a, b };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("abort", () => {
|
|
33
|
+
|
|
34
|
+
it("rejects every waiting request with the reason", async () => {
|
|
35
|
+
const { a } = linked_stores(() => false);
|
|
36
|
+
const reason = new Error("stopping");
|
|
37
|
+
|
|
38
|
+
const first = a.get("b.one");
|
|
39
|
+
const second = a.call_rpc("b.two", [ 1 ], { timeout_ms: 60000 });
|
|
40
|
+
|
|
41
|
+
assert.strictEqual(a.abort(reason), 2);
|
|
42
|
+
assert.strictEqual(await rejection(first), reason);
|
|
43
|
+
assert.strictEqual(await rejection(second), reason);
|
|
44
|
+
assert.strictEqual(Object.keys(a._requests).length, 0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("rejects with an EntangldAbortError by default", async () => {
|
|
48
|
+
const { a } = linked_stores(() => false);
|
|
49
|
+
|
|
50
|
+
const request = a.get("b.value");
|
|
51
|
+
a.abort();
|
|
52
|
+
|
|
53
|
+
const err = await rejection(request);
|
|
54
|
+
assert.ok(err instanceof EntangldAbortError);
|
|
55
|
+
assert.ok(err instanceof EntangldError);
|
|
56
|
+
assert.strictEqual(err.name, "EntangldAbortError");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("clears the time limit and the signal listener", async () => {
|
|
60
|
+
const { a } = linked_stores(() => false);
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
|
|
63
|
+
const request = a.get("b.value", undefined, { timeout_ms: 60000, signal: controller.signal });
|
|
64
|
+
assert.strictEqual(getEventListeners(controller.signal, "abort").length, 1);
|
|
65
|
+
|
|
66
|
+
a.abort();
|
|
67
|
+
await rejection(request);
|
|
68
|
+
|
|
69
|
+
assert.strictEqual(getEventListeners(controller.signal, "abort").length, 0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("lets the process exit while a request with a time limit waits", function () {
|
|
73
|
+
this.timeout(10000);
|
|
74
|
+
|
|
75
|
+
// The request's timer would hold the process open for a minute
|
|
76
|
+
const script = `
|
|
77
|
+
const Entangld = require(${JSON.stringify(path.join(__dirname, "../lib/Datastore.js"))});
|
|
78
|
+
const a = new Entangld();
|
|
79
|
+
a.transmit(() => { });
|
|
80
|
+
a.attach("b", {});
|
|
81
|
+
a.get("b.value", undefined, { timeout_ms: 60000 }).catch(() => { });
|
|
82
|
+
setTimeout(() => a.abort(), 10);
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const started = Date.now();
|
|
87
|
+
const child = spawn(process.execPath, [ "-e", script ], { stdio: "inherit" });
|
|
88
|
+
const timer = setTimeout(() => {
|
|
89
|
+
child.kill();
|
|
90
|
+
reject(new Error("The process was still running after 5 s"));
|
|
91
|
+
}, 5000);
|
|
92
|
+
|
|
93
|
+
child.on("exit", (code) => {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
try {
|
|
96
|
+
assert.strictEqual(code, 0);
|
|
97
|
+
assert.ok(Date.now() - started < 5000);
|
|
98
|
+
resolve();
|
|
99
|
+
} catch (err) {
|
|
100
|
+
reject(err);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("ignores a reply which arrives after aborting", async () => {
|
|
107
|
+
const { a, b } = linked_stores(() => false);
|
|
108
|
+
const sent = [];
|
|
109
|
+
a.transmit((msg) => sent.push(msg));
|
|
110
|
+
|
|
111
|
+
const request = a.get("b.value");
|
|
112
|
+
a.abort();
|
|
113
|
+
await rejection(request);
|
|
114
|
+
|
|
115
|
+
assert.doesNotThrow(() => {
|
|
116
|
+
a.receive({ type: "value", uuid: sent[0].uuid, value: 1 }, b);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("leaves later requests, attachments and subscriptions alone", async () => {
|
|
121
|
+
const { a, b } = linked_stores();
|
|
122
|
+
const seen = [];
|
|
123
|
+
|
|
124
|
+
await a.subscribe("b.value", (path, value) => seen.push(value));
|
|
125
|
+
b.set("value", 1);
|
|
126
|
+
|
|
127
|
+
assert.strictEqual(a.abort(), 0);
|
|
128
|
+
assert.strictEqual(a.abort(), 0);
|
|
129
|
+
|
|
130
|
+
b.set("value", 2);
|
|
131
|
+
assert.deepStrictEqual(seen, [ 1, 2 ]);
|
|
132
|
+
assert.deepStrictEqual(a.namespaces, [ "b" ]);
|
|
133
|
+
assert.strictEqual(await a.get("b.value"), 2);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("doesn't end local functions which are running", async () => {
|
|
137
|
+
const store = new Entangld();
|
|
138
|
+
let finish;
|
|
139
|
+
store.set("slow", () => new Promise((resolve) => { finish = resolve; }));
|
|
140
|
+
|
|
141
|
+
const request = store.get("slow", undefined, { timeout_ms: 60000 });
|
|
142
|
+
assert.strictEqual(store.abort(), 0);
|
|
143
|
+
|
|
144
|
+
finish(5);
|
|
145
|
+
assert.strictEqual(await request, 5);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("isn't done by detach(), so a reply after attaching again still arrives", async () => {
|
|
149
|
+
const { a, b } = linked_stores(() => false);
|
|
150
|
+
const sent = [];
|
|
151
|
+
a.transmit((msg) => sent.push(msg));
|
|
152
|
+
|
|
153
|
+
const request = a.get("b.value");
|
|
154
|
+
a.detach("b");
|
|
155
|
+
a.attach("b", b);
|
|
156
|
+
|
|
157
|
+
a.receive({ type: "value", uuid: sent[0].uuid, value: 7 }, b);
|
|
158
|
+
assert.strictEqual(await request, 7);
|
|
159
|
+
});
|
|
160
|
+
});
|