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 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 resolve = this._requests[msg.uuid];
213
- if (resolve) resolve(msg.value);
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((res) => {
583
- this._requests[msg.uuid] = res;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entangld",
3
- "version": "2.7.1",
3
+ "version": "2.7.2",
4
4
  "description": "Synchronized key-value stores with RPCs and events",
5
5
  "main": "index.js",
6
6
  "browser": {
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
+ });