comq 0.16.1 → 0.16.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/.github/dependabot.yml +5 -0
- package/package.json +1 -1
- package/readme.md +7 -1
- package/source/.io/ReplyPipe.js +57 -14
- package/source/.io/ReplyStream.js +64 -10
- package/source/.io/const.js +9 -1
- package/source/.io/createReplyEmitter.js +84 -9
- package/source/attributes/lazy.js +65 -25
- package/source/attributes/lazy.test.js +39 -0
- package/source/attributes/recall.js +17 -0
- package/source/attributes/recall.test.js +43 -0
- package/source/channel.js +22 -7
- package/source/connection.js +16 -8
- package/source/io.js +35 -22
- package/source/pipeline.js +12 -4
- package/source/shards/connection.js +4 -0
- package/test/ReplyPipe.test.js +170 -0
- package/test/ReplyStream.test.js +82 -4
- package/test/channel.test.js +58 -0
- package/test/connection.mock.js +3 -1
- package/test/connection.test.js +34 -2
- package/test/io.diagnostics.test.js +11 -0
- package/test/memory.test.js +273 -0
- package/test/pipeline.test.js +21 -0
- package/test/shards/connection.test.js +15 -0
- package/types/connection.d.ts +2 -0
- package/types/io.d.ts +14 -2
package/.github/dependabot.yml
CHANGED
|
@@ -14,3 +14,8 @@ updates:
|
|
|
14
14
|
target-branch: "dev"
|
|
15
15
|
labels:
|
|
16
16
|
- "Bump"
|
|
17
|
+
ignore:
|
|
18
|
+
# a newer preset needs conventional-changelog-writer@9, which the
|
|
19
|
+
# semantic-release plugins do not use yet; the release fails to render its notes
|
|
20
|
+
- dependency-name: "conventional-changelog-conventionalcommits"
|
|
21
|
+
update-types: ["version-update:semver-major"]
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -255,6 +255,11 @@ These intervals are not configurable.
|
|
|
255
255
|
|
|
256
256
|
An "end stream" message is sent to the `replyTo` queue when the Reply stream is finished.
|
|
257
257
|
|
|
258
|
+
When the Consumer falls behind, a "pause" message is sent to the Producer's control queue,
|
|
259
|
+
and the Producer stops pulling values from the iterator until a "resume" message follows.
|
|
260
|
+
The Producer announces its support of these messages in the confirmation message,
|
|
261
|
+
so they are never sent to a Producer that would not understand them.
|
|
262
|
+
|
|
258
263
|
See also [Reply stream shutdown](#reply-stream-shutdown).
|
|
259
264
|
|
|
260
265
|
### Loss of tail
|
|
@@ -278,7 +283,8 @@ At the same time, there is no guarantee that the stream will be transmitted to t
|
|
|
278
283
|
|
|
279
284
|
> When using the [Sharded connection](#sharded-connection), the order of yielded values is maintained through buffering.
|
|
280
285
|
> However, there is a scenario in which some of the yielded values may be lost if a broker crashes.
|
|
281
|
-
> In this case, the Reply stream will be destroyed once the buffer's maximum size is exceeded
|
|
286
|
+
> In this case, the Reply stream will be destroyed once the buffer's maximum size is exceeded
|
|
287
|
+
> (1000 values or 16 MiB of encoded messages).
|
|
282
288
|
> Also, buffered control messages can result in [stream idling](#stream-control).
|
|
283
289
|
|
|
284
290
|
## Encoding
|
package/source/.io/ReplyPipe.js
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { EventEmitter } = require('node:events')
|
|
4
|
-
const {
|
|
4
|
+
const { Promex } = require('promex')
|
|
5
|
+
const { control, FLOW_HEADER, HEARTBEAT_INTERVAL } = require('./const')
|
|
5
6
|
|
|
6
7
|
/** @typedef {(message: any, properties?: comq.amqp.options.Publish) => Promise<void>} Reply */
|
|
7
8
|
|
|
8
9
|
class ReplyPipe extends EventEmitter {
|
|
9
10
|
#index = -1
|
|
10
11
|
#interrupted = false
|
|
12
|
+
#closed = false
|
|
11
13
|
#heartbeatInterval = global['COMQ_TESTING_HEARTBEAT_INTERVAL'] || HEARTBEAT_INTERVAL
|
|
12
14
|
|
|
13
15
|
/** @type {ReturnType<setInterval> | null} */
|
|
14
16
|
#interval = null
|
|
15
17
|
|
|
18
|
+
/** Closed while the consumer has asked for a pause. */
|
|
19
|
+
#gate = null
|
|
20
|
+
|
|
16
21
|
/** @type {string} */
|
|
17
22
|
#replyTo
|
|
18
23
|
|
|
@@ -51,7 +56,8 @@ class ReplyPipe extends EventEmitter {
|
|
|
51
56
|
|
|
52
57
|
this.#properties = {
|
|
53
58
|
chunk: { correlationId, ...CHUNK },
|
|
54
|
-
control: { correlationId, replyTo: feedback.queue, ...CONTROL }
|
|
59
|
+
control: { correlationId, replyTo: feedback.queue, ...CONTROL },
|
|
60
|
+
ok: { correlationId, replyTo: feedback.queue, ...CONTROL, headers: FLOW }
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
channel.diagnose('return', this.#onReturn)
|
|
@@ -59,11 +65,13 @@ class ReplyPipe extends EventEmitter {
|
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
async pipe () {
|
|
62
|
-
await this.#transmit(control.ok, this.#properties.
|
|
68
|
+
await this.#transmit(control.ok, this.#properties.ok)
|
|
69
|
+
|
|
70
|
+
if (this.#closed) return
|
|
63
71
|
|
|
64
|
-
this.#stream.on('data', this.#onData)
|
|
65
|
-
this.#stream.on('close', this.#close)
|
|
66
72
|
this.#heartbeat()
|
|
73
|
+
|
|
74
|
+
void this.#pump()
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
destroy () {
|
|
@@ -71,11 +79,35 @@ class ReplyPipe extends EventEmitter {
|
|
|
71
79
|
this.#stream.destroy()
|
|
72
80
|
}
|
|
73
81
|
|
|
82
|
+
/**
|
|
83
|
+
* The source is pulled rather than listened to: a value is asked for once the
|
|
84
|
+
* previous one has been handed to the channel, so a paused channel or a paused
|
|
85
|
+
* consumer holds the source back instead of piling its output up here.
|
|
86
|
+
*/
|
|
87
|
+
async #pump () {
|
|
88
|
+
try {
|
|
89
|
+
for await (const chunk of this.#stream) {
|
|
90
|
+
if (this.#gate !== null) await this.#gate
|
|
91
|
+
if (this.#closed) break
|
|
92
|
+
|
|
93
|
+
await this.#transmit(chunk, this.#properties.chunk)
|
|
94
|
+
|
|
95
|
+
if (this.#closed) break
|
|
96
|
+
|
|
97
|
+
this.#heartbeat()
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// the source has been destroyed, by this pipe or by whoever made it
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this.#close()
|
|
104
|
+
}
|
|
105
|
+
|
|
74
106
|
async #transmit (data, properties) {
|
|
75
107
|
this.#index++
|
|
76
108
|
|
|
77
|
-
const
|
|
78
|
-
|
|
109
|
+
const headers = { ...properties.headers, index: this.#index }
|
|
110
|
+
const ok = await this.#reply(data, { ...properties, headers })
|
|
79
111
|
|
|
80
112
|
if (!ok) this.#interrupt()
|
|
81
113
|
}
|
|
@@ -97,12 +129,21 @@ class ReplyPipe extends EventEmitter {
|
|
|
97
129
|
#clear () {
|
|
98
130
|
clearInterval(this.#interval)
|
|
99
131
|
|
|
100
|
-
this.#stream.removeAllListeners()
|
|
101
132
|
this.#channel.forget('return', this.#onReturn)
|
|
102
133
|
this.#feedback.off(this.#properties.control.correlationId, this.#control)
|
|
134
|
+
this.#resume()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#resume () {
|
|
138
|
+
this.#gate?.resolve()
|
|
139
|
+
this.#gate = null
|
|
103
140
|
}
|
|
104
141
|
|
|
105
142
|
#close = () => {
|
|
143
|
+
if (this.#closed) return
|
|
144
|
+
|
|
145
|
+
this.#closed = true
|
|
146
|
+
|
|
106
147
|
this.emit('close')
|
|
107
148
|
this.#clear()
|
|
108
149
|
|
|
@@ -111,12 +152,6 @@ class ReplyPipe extends EventEmitter {
|
|
|
111
152
|
}
|
|
112
153
|
}
|
|
113
154
|
|
|
114
|
-
#onData = async (chunk) => {
|
|
115
|
-
await this.#transmit(chunk, this.#properties.chunk)
|
|
116
|
-
|
|
117
|
-
this.#heartbeat()
|
|
118
|
-
}
|
|
119
|
-
|
|
120
155
|
#onReturn = (message) => {
|
|
121
156
|
if (message.fields.routingKey === this.#replyTo)
|
|
122
157
|
this.#interrupt()
|
|
@@ -127,6 +162,12 @@ class ReplyPipe extends EventEmitter {
|
|
|
127
162
|
case control.end:
|
|
128
163
|
this.#interrupt()
|
|
129
164
|
break
|
|
165
|
+
case control.pause:
|
|
166
|
+
this.#gate ??= new Promex()
|
|
167
|
+
break
|
|
168
|
+
case control.resume:
|
|
169
|
+
this.#resume()
|
|
170
|
+
break
|
|
130
171
|
default:
|
|
131
172
|
throw new Error(`Unknown control message: ${message}`)
|
|
132
173
|
}
|
|
@@ -155,4 +196,6 @@ const CHUNK = { mandatory: true }
|
|
|
155
196
|
/** @type {comq.amqp.options.Publish} */
|
|
156
197
|
const CONTROL = { type: 'control', mandatory: true }
|
|
157
198
|
|
|
199
|
+
const FLOW = { [FLOW_HEADER]: true }
|
|
200
|
+
|
|
158
201
|
exports.ReplyPipe = ReplyPipe
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { Readable } = require('node:stream')
|
|
4
4
|
const { Promex } = require('promex')
|
|
5
|
-
const { IDLE_INTERVAL, control } = require('./const')
|
|
5
|
+
const { IDLE_INTERVAL, FLOW_HEADER, control } = require('./const')
|
|
6
6
|
|
|
7
7
|
class ReplyStream extends Readable {
|
|
8
8
|
confirmation = new Promex()
|
|
@@ -16,6 +16,7 @@ class ReplyStream extends Readable {
|
|
|
16
16
|
/** @type {string} */
|
|
17
17
|
#correlationId
|
|
18
18
|
|
|
19
|
+
/** The confirmation message, addressing the control queue of the producer. */
|
|
19
20
|
#control
|
|
20
21
|
|
|
21
22
|
#reply
|
|
@@ -26,9 +27,19 @@ class ReplyStream extends Readable {
|
|
|
26
27
|
|
|
27
28
|
#buffered = 0
|
|
28
29
|
|
|
30
|
+
#bufferedBytes = 0
|
|
31
|
+
|
|
29
32
|
#maxBufferSize
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
#maxBufferBytes
|
|
35
|
+
|
|
36
|
+
/** Whether the producer honours `pause` and `resume`. */
|
|
37
|
+
#flow = false
|
|
38
|
+
|
|
39
|
+
/** Whether the producer has been asked to pause. */
|
|
40
|
+
#throttled = false
|
|
41
|
+
|
|
42
|
+
/** @type {Map<number, { payload: unknown, properties: comq.amqp.Properties, size: number }>} */
|
|
32
43
|
#queue = new Map()
|
|
33
44
|
|
|
34
45
|
/**
|
|
@@ -42,6 +53,7 @@ class ReplyStream extends Readable {
|
|
|
42
53
|
this.#correlationId = request.properties.correlationId
|
|
43
54
|
this.#idleInterval = global['COMQ_TESTING_IDLE_INTERVAL'] || IDLE_INTERVAL
|
|
44
55
|
this.#maxBufferSize = global['COMQ_TESTING_MAX_BUFFER_SIZE'] || MAX_BUFFER_SIZE
|
|
56
|
+
this.#maxBufferBytes = global['COMQ_TESTING_MAX_BUFFER_BYTES'] || MAX_BUFFER_BYTES
|
|
45
57
|
this.#reply = reply
|
|
46
58
|
|
|
47
59
|
this.confirmation.catch(noop) // it is not awaited until the stream is handed over
|
|
@@ -65,15 +77,25 @@ class ReplyStream extends Readable {
|
|
|
65
77
|
super._destroy(error, callback)
|
|
66
78
|
}
|
|
67
79
|
|
|
68
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Called once the consumer has room again.
|
|
82
|
+
*/
|
|
83
|
+
_read (_) {
|
|
84
|
+
if (!this.#throttled) return
|
|
85
|
+
|
|
86
|
+
this.#throttled = false
|
|
87
|
+
|
|
88
|
+
void this.#reply(this.#control, control.resume)
|
|
89
|
+
}
|
|
69
90
|
|
|
70
91
|
/**
|
|
71
92
|
* @param {unknown} payload
|
|
72
93
|
* @param {comq.amqp.Properties} properties
|
|
94
|
+
* @param {number} [size] of the encoded payload
|
|
73
95
|
*/
|
|
74
|
-
arrange (payload, properties) {
|
|
96
|
+
arrange (payload, properties, size = 0) {
|
|
75
97
|
if (properties.headers.index !== this.#index) {
|
|
76
|
-
this._buffer(payload, properties)
|
|
98
|
+
this._buffer(payload, properties, size)
|
|
77
99
|
|
|
78
100
|
return
|
|
79
101
|
}
|
|
@@ -85,6 +107,7 @@ class ReplyStream extends Readable {
|
|
|
85
107
|
|
|
86
108
|
while ((message = this.#queue.get(this.#index))) {
|
|
87
109
|
this.#queue.delete(this.#index)
|
|
110
|
+
this.#bufferedBytes -= message.size
|
|
88
111
|
this._add(message.payload, message.properties)
|
|
89
112
|
}
|
|
90
113
|
|
|
@@ -104,18 +127,45 @@ class ReplyStream extends Readable {
|
|
|
104
127
|
if (properties.type === 'control')
|
|
105
128
|
this._control(payload, properties)
|
|
106
129
|
else if (!this.push(payload))
|
|
107
|
-
this.
|
|
130
|
+
this._throttle()
|
|
108
131
|
}
|
|
109
132
|
|
|
110
|
-
|
|
111
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Values arriving out of order are held until the gap is filled. The hold is
|
|
135
|
+
* bounded by their number and, roughly, by their size: the size is that of
|
|
136
|
+
* the encoded message, which is what is known of a decoded value.
|
|
137
|
+
*
|
|
138
|
+
* @param {unknown} payload
|
|
139
|
+
* @param {comq.amqp.Properties} properties
|
|
140
|
+
* @param {number} size
|
|
141
|
+
* @private
|
|
142
|
+
*/
|
|
143
|
+
_buffer (payload, properties, size) {
|
|
144
|
+
if (this.#buffered > this.#maxBufferSize || this.#bufferedBytes + size > this.#maxBufferBytes) {
|
|
112
145
|
this.destroy()
|
|
113
146
|
|
|
114
147
|
return
|
|
115
148
|
}
|
|
116
149
|
|
|
117
150
|
this.#buffered++
|
|
118
|
-
this.#
|
|
151
|
+
this.#bufferedBytes += size
|
|
152
|
+
this.#queue.set(properties.headers.index, { payload, properties, size })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The consumer is behind. Values in flight keep coming, and a producer that
|
|
157
|
+
* knows nothing of `pause` keeps going, in which case they pile up here rather
|
|
158
|
+
* than get lost: dropping the listener would leave the stream waiting for a
|
|
159
|
+
* value that has already passed.
|
|
160
|
+
*
|
|
161
|
+
* @private
|
|
162
|
+
*/
|
|
163
|
+
_throttle () {
|
|
164
|
+
if (this.#throttled || !this.#flow) return
|
|
165
|
+
|
|
166
|
+
this.#throttled = true
|
|
167
|
+
|
|
168
|
+
void this.#reply(this.#control, control.pause)
|
|
119
169
|
}
|
|
120
170
|
|
|
121
171
|
/**
|
|
@@ -127,6 +177,7 @@ class ReplyStream extends Readable {
|
|
|
127
177
|
switch (message) {
|
|
128
178
|
case control.ok:
|
|
129
179
|
this.#control = { properties }
|
|
180
|
+
this.#flow = properties.headers?.[FLOW_HEADER] === true
|
|
130
181
|
this.confirmation.resolve()
|
|
131
182
|
break
|
|
132
183
|
case control.heartbeat:
|
|
@@ -147,12 +198,15 @@ class ReplyStream extends Readable {
|
|
|
147
198
|
|
|
148
199
|
_clear () {
|
|
149
200
|
clearTimeout(this.#timeout)
|
|
150
|
-
this.#emitter.
|
|
201
|
+
this.#emitter.off(this.#correlationId)
|
|
151
202
|
}
|
|
152
203
|
}
|
|
153
204
|
|
|
154
205
|
const MAX_BUFFER_SIZE = 1000
|
|
155
206
|
|
|
207
|
+
/** @type {number} */
|
|
208
|
+
const MAX_BUFFER_BYTES = 16 * 1024 * 1024
|
|
209
|
+
|
|
156
210
|
const UNCONFIRMED = 'Reply stream has been destroyed before confirmation'
|
|
157
211
|
|
|
158
212
|
function noop () {}
|
package/source/.io/const.js
CHANGED
|
@@ -3,8 +3,16 @@
|
|
|
3
3
|
exports.control = {
|
|
4
4
|
ok: 'ok',
|
|
5
5
|
heartbeat: 'heartbeat',
|
|
6
|
-
end: 'end'
|
|
6
|
+
end: 'end',
|
|
7
|
+
pause: 'pause',
|
|
8
|
+
resume: 'resume'
|
|
7
9
|
}
|
|
8
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Set on the confirmation message by a producer that honours `pause` and
|
|
13
|
+
* `resume`, so that a consumer never sends them to one that does not.
|
|
14
|
+
*/
|
|
15
|
+
exports.FLOW_HEADER = 'x-flow'
|
|
16
|
+
|
|
9
17
|
exports.HEARTBEAT_INTERVAL = 5_000
|
|
10
18
|
exports.IDLE_INTERVAL = 12_000
|
|
@@ -1,22 +1,97 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { randomBytes } = require('node:crypto')
|
|
4
|
-
const { EventEmitter } = require('node:events')
|
|
5
4
|
const { concat } = require('./concat')
|
|
6
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Routes the replies arriving on a queue to whoever is waiting for them.
|
|
8
|
+
*
|
|
9
|
+
* A Map keyed by correlation identifier rather than an EventEmitter: with
|
|
10
|
+
* thousands of requests in flight the emitter's event table degrades into a
|
|
11
|
+
* dictionary, and every `once` costs a wrapper.
|
|
12
|
+
*
|
|
13
|
+
* @implements {comq.ReplyEmitter}
|
|
14
|
+
*/
|
|
15
|
+
class ReplyEmitter {
|
|
16
|
+
/** @type {string} */
|
|
17
|
+
queue
|
|
18
|
+
|
|
19
|
+
/** @type {string} */
|
|
20
|
+
#prefix
|
|
21
|
+
|
|
22
|
+
/** @type {number} */
|
|
23
|
+
#sequence = 0
|
|
24
|
+
|
|
25
|
+
/** @type {Map<string, comq.ReplyHandler>} */
|
|
26
|
+
#handlers = new Map()
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} label
|
|
30
|
+
*/
|
|
31
|
+
constructor (label) {
|
|
32
|
+
const id = randomBytes(8).toString('hex')
|
|
33
|
+
|
|
34
|
+
this.queue = concat(label, id)
|
|
35
|
+
this.#prefix = id
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A correlation identifier that is unique across processes as well: the
|
|
40
|
+
* control queue of a producer sees the requests of every consumer, and tells
|
|
41
|
+
* them apart by nothing else. Random bytes per request would cost more than
|
|
42
|
+
* everything else on the way to the broker put together.
|
|
43
|
+
*
|
|
44
|
+
* @return {string}
|
|
45
|
+
*/
|
|
46
|
+
next () {
|
|
47
|
+
return this.#prefix + (++this.#sequence).toString(36)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} id
|
|
52
|
+
* @param {comq.ReplyHandler} handler
|
|
53
|
+
*/
|
|
54
|
+
on (id, handler) {
|
|
55
|
+
this.#handlers.set(id, handler)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {string} id
|
|
60
|
+
* @param {comq.ReplyHandler} [handler] the one to remove, or whichever is there
|
|
61
|
+
*/
|
|
62
|
+
off (id, handler) {
|
|
63
|
+
if (handler === undefined || this.#handlers.get(id) === handler) this.#handlers.delete(id)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {string} id
|
|
68
|
+
* @param {any} payload
|
|
69
|
+
* @param {comq.amqp.Properties} properties
|
|
70
|
+
* @param {number} [size] of the encoded payload
|
|
71
|
+
* @return {boolean} whether anyone was waiting
|
|
72
|
+
*/
|
|
73
|
+
emit (id, payload, properties, size) {
|
|
74
|
+
const handler = this.#handlers.get(id)
|
|
75
|
+
|
|
76
|
+
if (handler === undefined) return false
|
|
77
|
+
|
|
78
|
+
handler(payload, properties, size)
|
|
79
|
+
|
|
80
|
+
return true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** How many replies are being waited for. */
|
|
84
|
+
get pending () {
|
|
85
|
+
return this.#handlers.size
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
7
89
|
/**
|
|
8
90
|
* @param {string} label
|
|
9
91
|
* @returns {comq.ReplyEmitter}
|
|
10
92
|
*/
|
|
11
93
|
function createReplyEmitter (label) {
|
|
12
|
-
|
|
13
|
-
const queue = concat(label, id)
|
|
14
|
-
const emitter = new EventEmitter()
|
|
15
|
-
|
|
16
|
-
emitter.setMaxListeners(0)
|
|
17
|
-
emitter.queue = queue
|
|
18
|
-
|
|
19
|
-
return /** @type {comq.ReplyEmitter} */ emitter
|
|
94
|
+
return new ReplyEmitter(label)
|
|
20
95
|
}
|
|
21
96
|
|
|
22
97
|
exports.createReplyEmitter = createReplyEmitter
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Runs the initializers once per distinct set of arguments before the method.
|
|
5
|
+
*
|
|
6
|
+
* The record of what has been initialized is kept per context, so that a context
|
|
7
|
+
* that is dropped takes its record with it, and it is looked up by argument rather
|
|
8
|
+
* than scanned — a channel publishing to a thousand queues asks for the thousandth
|
|
9
|
+
* as fast as for the first.
|
|
10
|
+
*
|
|
11
|
+
* @param {object} context
|
|
12
|
+
* @param {Function | Function[]} initializers
|
|
13
|
+
* @param {(...args: unknown[]) => Promise<unknown>} method
|
|
14
|
+
*/
|
|
3
15
|
function lazy (context, initializers, method) {
|
|
4
|
-
if (context[LOCK] === undefined) context[LOCK] = Symbol('methods locking key')
|
|
5
|
-
|
|
6
16
|
if (!Array.isArray(initializers)) initializers = [initializers]
|
|
7
17
|
|
|
8
18
|
return async function (...args) {
|
|
@@ -24,7 +34,7 @@ async function call (context, initializers, args) {
|
|
|
24
34
|
let override
|
|
25
35
|
|
|
26
36
|
for (const init of initializers) {
|
|
27
|
-
const result = await
|
|
37
|
+
const result = await lock(context, init, args)
|
|
28
38
|
|
|
29
39
|
if (result !== undefined) override = result
|
|
30
40
|
}
|
|
@@ -33,46 +43,76 @@ async function call (context, initializers, args) {
|
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
/**
|
|
46
|
+
* An initializer expecting N arguments is run once per distinct N leading
|
|
47
|
+
* arguments, which are the keys of a trie of Maps N levels deep.
|
|
48
|
+
*
|
|
36
49
|
* @param {object} context
|
|
37
|
-
* @param {
|
|
38
|
-
* @param {
|
|
39
|
-
* @returns {Promise}
|
|
50
|
+
* @param {(...args: unknown[]) => Promise<unknown>} init
|
|
51
|
+
* @param {unknown[]} args
|
|
52
|
+
* @returns {Promise<unknown>}
|
|
40
53
|
*/
|
|
41
|
-
function
|
|
42
|
-
const
|
|
43
|
-
|
|
54
|
+
function lock (context, init, args) {
|
|
55
|
+
const arity = init.length
|
|
56
|
+
|
|
57
|
+
let node = table(context, init)
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < arity - 1; i++) node = branch(node, args[i])
|
|
60
|
+
|
|
61
|
+
const key = arity === 0 ? NONE : args[arity - 1]
|
|
62
|
+
const found = node.get(key)
|
|
63
|
+
|
|
64
|
+
if (found !== undefined) return found
|
|
65
|
+
|
|
66
|
+
const promise = init.apply(context, args.slice(0, arity))
|
|
67
|
+
|
|
68
|
+
node.set(key, promise)
|
|
44
69
|
|
|
45
|
-
return
|
|
70
|
+
return promise
|
|
46
71
|
}
|
|
47
72
|
|
|
48
73
|
/**
|
|
49
74
|
* @param {object} context
|
|
50
|
-
* @param {
|
|
51
|
-
* @
|
|
52
|
-
* @param {Symbol} key
|
|
53
|
-
* @returns {Promise<unknown>}
|
|
75
|
+
* @param {Function} init
|
|
76
|
+
* @return {Map<unknown, unknown>}
|
|
54
77
|
*/
|
|
55
|
-
function
|
|
56
|
-
|
|
78
|
+
function table (context, init) {
|
|
79
|
+
let tables = TABLES.get(context)
|
|
57
80
|
|
|
58
|
-
|
|
59
|
-
|
|
81
|
+
if (tables === undefined) {
|
|
82
|
+
tables = new Map()
|
|
83
|
+
TABLES.set(context, tables)
|
|
84
|
+
}
|
|
60
85
|
|
|
61
|
-
|
|
86
|
+
return branch(tables, init)
|
|
87
|
+
}
|
|
62
88
|
|
|
63
|
-
|
|
64
|
-
|
|
89
|
+
/**
|
|
90
|
+
* @param {Map<unknown, unknown>} node
|
|
91
|
+
* @param {unknown} key
|
|
92
|
+
* @return {Map<unknown, unknown>}
|
|
93
|
+
*/
|
|
94
|
+
function branch (node, key) {
|
|
95
|
+
let next = node.get(key)
|
|
65
96
|
|
|
66
|
-
|
|
97
|
+
if (next === undefined) {
|
|
98
|
+
next = new Map()
|
|
99
|
+
node.set(key, next)
|
|
100
|
+
}
|
|
67
101
|
|
|
68
|
-
return
|
|
102
|
+
return /** @type {Map<unknown, unknown>} */ next
|
|
69
103
|
}
|
|
70
104
|
|
|
105
|
+
/**
|
|
106
|
+
* @param {object} context
|
|
107
|
+
*/
|
|
71
108
|
function reset (context) {
|
|
72
|
-
context
|
|
109
|
+
TABLES.delete(context)
|
|
73
110
|
}
|
|
74
111
|
|
|
75
|
-
|
|
112
|
+
/** @type {WeakMap<object, Map<Function, Map<unknown, unknown>>>} */
|
|
113
|
+
const TABLES = new WeakMap()
|
|
114
|
+
|
|
115
|
+
const NONE = Symbol('no arguments')
|
|
76
116
|
|
|
77
117
|
lazy.reset = reset
|
|
78
118
|
|
|
@@ -297,3 +297,42 @@ describe('reset', () => {
|
|
|
297
297
|
expect(initialize2).toHaveBeenCalledTimes(2)
|
|
298
298
|
})
|
|
299
299
|
})
|
|
300
|
+
|
|
301
|
+
class Exposed {
|
|
302
|
+
do = lazy(this, this.initialize, async () => undefined)
|
|
303
|
+
|
|
304
|
+
async initialize (_) {}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// an initializer is a method shared by every instance of its class, so a record
|
|
308
|
+
// kept on it would outlive the instances it was made for
|
|
309
|
+
it('should keep no record on the initializer or the context', async () => {
|
|
310
|
+
const instance = new Exposed()
|
|
311
|
+
|
|
312
|
+
await instance.do(generate())
|
|
313
|
+
lazy.reset(instance)
|
|
314
|
+
await instance.do(generate())
|
|
315
|
+
|
|
316
|
+
expect(Object.getOwnPropertySymbols(Exposed.prototype.initialize)).toHaveLength(0)
|
|
317
|
+
expect(Object.getOwnPropertySymbols(instance)).toHaveLength(0)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('should tell arguments apart by identity', async () => {
|
|
321
|
+
let calls = 0
|
|
322
|
+
|
|
323
|
+
class Counted {
|
|
324
|
+
do = lazy(this, this.initialize, async () => undefined)
|
|
325
|
+
|
|
326
|
+
async initialize (_) {
|
|
327
|
+
calls++
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const instance = new Counted()
|
|
332
|
+
|
|
333
|
+
await instance.do(undefined)
|
|
334
|
+
await instance.do('undefined')
|
|
335
|
+
await instance.do(undefined)
|
|
336
|
+
|
|
337
|
+
expect(calls).toStrictEqual(2)
|
|
338
|
+
})
|
|
@@ -36,7 +36,24 @@ async function replay (context) {
|
|
|
36
36
|
await Promise.all(promises)
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Forgets the recorded calls, and the arguments they hold on to.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} context
|
|
43
|
+
*/
|
|
44
|
+
function reset (context) {
|
|
45
|
+
const methods = context[METHODS]
|
|
46
|
+
|
|
47
|
+
if (methods === undefined) return
|
|
48
|
+
|
|
49
|
+
for (const method of methods) method[CALLS] = undefined
|
|
50
|
+
|
|
51
|
+
context[METHODS] = undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
const METHODS = Symbol('context methods')
|
|
40
55
|
const CALLS = Symbol('method calls')
|
|
41
56
|
|
|
57
|
+
recall.reset = reset
|
|
58
|
+
|
|
42
59
|
exports.recall = recall
|