@miso.ai/server-commons 0.6.6-beta.17 → 0.6.6-beta.18
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/package.json +1 -1
- package/src/channel/buffer.js +115 -0
- package/src/channel/channel.js +105 -0
- package/src/channel/component.js +71 -0
- package/src/channel/constants.js +6 -0
- package/src/channel/downgrade.js +27 -0
- package/src/channel/events.js +117 -0
- package/src/channel/index.js +8 -0
- package/src/channel/sink-gate.js +39 -0
- package/src/channel/sink.js +103 -0
- package/src/channel/time.js +105 -0
- package/src/channel/transform.js +83 -0
- package/src/channel/upgrade.js +99 -0
- package/src/channel/write.js +154 -0
- package/src/index.js +1 -0
- package/src/stream/buffered-write.js +9 -1
- package/src/stream/easy-transform.js +66 -0
- package/src/stream/index.js +1 -0
- package/src/stream/misc.js +7 -2
- package/test/channel.test.js +268 -0
- package/test/easy-transform.js +64 -0
package/package.json
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
function defaultSerialize({ payload }) {
|
|
2
|
+
return typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function normalizeOptions({
|
|
6
|
+
payloadPrefix = '[',
|
|
7
|
+
payloadSuffix = ']',
|
|
8
|
+
payloadDelimiter = ',',
|
|
9
|
+
serialize = defaultSerialize,
|
|
10
|
+
recordCap,
|
|
11
|
+
byteCap,
|
|
12
|
+
} = {}) {
|
|
13
|
+
if (!recordCap && !byteCap) {
|
|
14
|
+
throw new Error('At least one of recordCap or byteCap must be specified.');
|
|
15
|
+
}
|
|
16
|
+
if (typeof serialize !== 'function') {
|
|
17
|
+
throw new Error('serialize must be a function.');
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
payloadPrefix,
|
|
21
|
+
payloadSuffix,
|
|
22
|
+
payloadDelimiter,
|
|
23
|
+
serialize,
|
|
24
|
+
recordCap,
|
|
25
|
+
byteCap,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default class WriteChannelBuffer {
|
|
30
|
+
|
|
31
|
+
constructor(options) {
|
|
32
|
+
this._options = options = normalizeOptions(options);
|
|
33
|
+
this._affixSize = (options.payloadPrefix.length + options.payloadSuffix.length) * 2;
|
|
34
|
+
this._clear();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_clear() {
|
|
38
|
+
this._records = 0;
|
|
39
|
+
this._bytes = this._affixSize;
|
|
40
|
+
this._payload = this._options.payloadPrefix;
|
|
41
|
+
this._data = [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Returns true if the buffer is empty.
|
|
46
|
+
*/
|
|
47
|
+
get empty() {
|
|
48
|
+
return this._records === 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Returns true if the buffer is full.
|
|
53
|
+
*/
|
|
54
|
+
get full() {
|
|
55
|
+
const { recordCap, byteCap } = this._options;
|
|
56
|
+
return (recordCap !== undefined && this._records >= recordCap) ||
|
|
57
|
+
(byteCap !== undefined && this._bytes >= byteCap);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Take a data event and push it into the buffer.
|
|
62
|
+
* Returns an array of requests if the buffer is full, or an empty array otherwise.
|
|
63
|
+
* @returns An array of requests.
|
|
64
|
+
*/
|
|
65
|
+
push(event) {
|
|
66
|
+
let dispatches = [];
|
|
67
|
+
const serializedPayload = this._options.serialize(event);
|
|
68
|
+
const contentSize = serializedPayload.length * 2;
|
|
69
|
+
const { empty } = this;
|
|
70
|
+
|
|
71
|
+
if (!empty) {
|
|
72
|
+
const delimiterSize = this._options.payloadDelimiter.length * 2;
|
|
73
|
+
if (this._bytes + delimiterSize + contentSize > this._options.byteCap) {
|
|
74
|
+
// if buffer is not empty and exceeds bytes limit after pushing, then flush right now
|
|
75
|
+
dispatches = this.flush();
|
|
76
|
+
} else {
|
|
77
|
+
// non-empty, not flushed
|
|
78
|
+
this._bytes += delimiterSize;
|
|
79
|
+
this._payload += this._options.payloadDelimiter;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this._records++;
|
|
84
|
+
this._bytes += contentSize;
|
|
85
|
+
this._payload += serializedPayload;
|
|
86
|
+
this._data.push(event);
|
|
87
|
+
|
|
88
|
+
// it's possible that the new record is so large that the buffer is instantly full even after a flush
|
|
89
|
+
// in this case, the buffer generates two dispatches from the push
|
|
90
|
+
if (this.full) {
|
|
91
|
+
dispatches = [...dispatches, ...this.flush()];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return dispatches;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Flush the buffer, which returns an array of requests (which can be empty) and reset the buffer to empty state.
|
|
99
|
+
* @returns An array of requests.
|
|
100
|
+
*/
|
|
101
|
+
flush() {
|
|
102
|
+
if (this.empty) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
const records = this._records;
|
|
106
|
+
const bytes = this._bytes;
|
|
107
|
+
const payload = this._payload + this._options.payloadSuffix;
|
|
108
|
+
const data = this._data;
|
|
109
|
+
this._clear();
|
|
110
|
+
return [{ payload, records, bytes, data }];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
destroy() {}
|
|
114
|
+
|
|
115
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { ChannelBase } from './component.js';
|
|
2
|
+
import { LOG_LEVEL, ChannelOutput, createStartEvent, createEndEvent } from './events.js';
|
|
3
|
+
|
|
4
|
+
export default class Channel extends ChannelBase {
|
|
5
|
+
|
|
6
|
+
constructor({ transform, flush, ...options } = {}) {
|
|
7
|
+
super(options);
|
|
8
|
+
|
|
9
|
+
if (transform) {
|
|
10
|
+
if (typeof transform !== 'function') {
|
|
11
|
+
throw new Error('Transform must be a function');
|
|
12
|
+
}
|
|
13
|
+
this._runCustomTransform = transform.bind(this);
|
|
14
|
+
}
|
|
15
|
+
if (flush) {
|
|
16
|
+
if (typeof flush !== 'function') {
|
|
17
|
+
throw new Error('Flush must be a function');
|
|
18
|
+
}
|
|
19
|
+
this._runCustomFlush = flush.bind(this);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
this._upstream = {};
|
|
23
|
+
this.out = new ChannelOutput(this);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// customization //
|
|
27
|
+
async _runCustomTransform(event) {
|
|
28
|
+
this.out.pass(event);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async _runCustomFlush() {}
|
|
32
|
+
|
|
33
|
+
async _runFlush() {
|
|
34
|
+
await this._runCustomFlush();
|
|
35
|
+
// clear heartbeat if necessary
|
|
36
|
+
// TODO
|
|
37
|
+
if (!this._upstream.start) {
|
|
38
|
+
// not seen start event from upstream, so the upstream is empty
|
|
39
|
+
throw new Error('Start event not received from upstream');
|
|
40
|
+
}
|
|
41
|
+
if (!this._upstream.end) {
|
|
42
|
+
// not seen end event from upstream, warn
|
|
43
|
+
this.out.log(LOG_LEVEL.WARNING, 'End event not received from upstream');
|
|
44
|
+
}
|
|
45
|
+
// write my own end event
|
|
46
|
+
this.out.write(this._createEndEvent(this._upstream.end));
|
|
47
|
+
|
|
48
|
+
await super._runFlush();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_readUpstreamStartEvent(event) {
|
|
52
|
+
// take the heartbeat setting
|
|
53
|
+
// TODO
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
// internal //
|
|
59
|
+
async _runTransform(event) {
|
|
60
|
+
// expect the start event from upstream
|
|
61
|
+
if (!this._upstream.start) {
|
|
62
|
+
if (event.type !== 'start') {
|
|
63
|
+
throw new Error(`The first event received from upstream must be a start event: ${JSON.stringify(event)}`);
|
|
64
|
+
}
|
|
65
|
+
this._upstream.start = event;
|
|
66
|
+
this.out.write(this._createStartEvent(event));
|
|
67
|
+
this._readUpstreamStartEvent(event);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
switch (event.type) {
|
|
71
|
+
case 'start':
|
|
72
|
+
throw new Error(`Received a second start event from upstream: ${JSON.stringify(event)}`);
|
|
73
|
+
case 'end':
|
|
74
|
+
if (this._upstream.end) {
|
|
75
|
+
// seen end event from upstream, warn
|
|
76
|
+
this.out.log(LOG_LEVEL.WARNING, `Received a second end event from upstream: ${JSON.stringify(event)}`);
|
|
77
|
+
} else {
|
|
78
|
+
this._upstream.end = event;
|
|
79
|
+
}
|
|
80
|
+
break;
|
|
81
|
+
default:
|
|
82
|
+
await this._runCustomTransform(event);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_writePulseEvent() {
|
|
87
|
+
try {
|
|
88
|
+
this.out.write({
|
|
89
|
+
type: 'pulse',
|
|
90
|
+
...this.pulse,
|
|
91
|
+
});
|
|
92
|
+
} catch (error) {
|
|
93
|
+
this.out.log(LOG_LEVEL.ERROR, `Failed to write pulse event: ${error.message}`, { error });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
_createStartEvent(event) {
|
|
98
|
+
return createStartEvent(this, event);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_createEndEvent(event) {
|
|
102
|
+
return createEndEvent(this, event);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import EasyTransform from '../stream/easy-transform.js';
|
|
2
|
+
import { trimObj } from '../object.js';
|
|
3
|
+
import { stackEventInfo } from './events.js';
|
|
4
|
+
|
|
5
|
+
// TODO: camelCase to kebab-case on options keys
|
|
6
|
+
function exportOptions(options) {
|
|
7
|
+
return trimObj({ ...options });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class ChannelComponent {
|
|
11
|
+
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
this._options = this._normalizeOptions(options);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
_normalizeOptions({
|
|
17
|
+
...options
|
|
18
|
+
}) {
|
|
19
|
+
return trimObj({
|
|
20
|
+
...options,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get options() {
|
|
25
|
+
return exportOptions(this._options);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// TODO: by mixin
|
|
31
|
+
|
|
32
|
+
export class ChannelBase extends EasyTransform {
|
|
33
|
+
|
|
34
|
+
constructor({ name, ...options } = {}) {
|
|
35
|
+
super({ ...options, objectMode: true });
|
|
36
|
+
this._options = this._normalizeOptions(options);
|
|
37
|
+
this._name = name || this.constructor.name;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_normalizeOptions({
|
|
41
|
+
objectMode,
|
|
42
|
+
writableObjectMode,
|
|
43
|
+
readableObjectMode,
|
|
44
|
+
...options
|
|
45
|
+
}) {
|
|
46
|
+
return trimObj({
|
|
47
|
+
...options,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get name() {
|
|
52
|
+
return this._name;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get options() {
|
|
56
|
+
return exportOptions(this._options);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get pulse() {
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get result() {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_stackEventInfo(event, field, info) {
|
|
68
|
+
return stackEventInfo(this, event, field, info);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import EasyTransform from '../stream/easy-transform.js';
|
|
2
|
+
|
|
3
|
+
export default class DowngradeChannel extends EasyTransform {
|
|
4
|
+
|
|
5
|
+
constructor({ objectMode, ...options } = {}) {
|
|
6
|
+
super({
|
|
7
|
+
...options,
|
|
8
|
+
writableObjectMode: true,
|
|
9
|
+
readableObjectMode: objectMode,
|
|
10
|
+
});
|
|
11
|
+
this._objectMode = objectMode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async _runTransform({ type, payload }) {
|
|
15
|
+
switch (type) {
|
|
16
|
+
case 'data':
|
|
17
|
+
payload !== undefined && this._pushBuffer(this._format(payload));
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_format(payload) {
|
|
23
|
+
// TODO: review this
|
|
24
|
+
return (this._objectMode || typeof payload === 'string') ? payload : JSON.stringify(payload);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { trimObj } from '../object.js';
|
|
2
|
+
|
|
3
|
+
export const LOG_LEVEL = {
|
|
4
|
+
DEBUG: 'debug',
|
|
5
|
+
INFO: 'info',
|
|
6
|
+
WARNING: 'warning',
|
|
7
|
+
ERROR: 'error',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function createStartEvent(channel, event = { type: 'start' }) {
|
|
11
|
+
return stackEventInfo(channel, event, 'options', channel.options);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createEndEvent(channel, event = { type: 'end' }) {
|
|
15
|
+
return stackEventInfo(channel, event, 'results', channel.result);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function stackEventInfo(channel, event = {}, field, info) {
|
|
19
|
+
info = normalizeChannelInfo({
|
|
20
|
+
...info,
|
|
21
|
+
channel: channel.name,
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
...event,
|
|
25
|
+
[field]: [...(event[field] || []), info],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function writeChannelInfo(channel, array) {
|
|
30
|
+
if (array === undefined) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
return array.map(info => normalizeChannelInfo({
|
|
34
|
+
...info,
|
|
35
|
+
channel: channel.name,
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function normalizeChannelInfo({ channel, timestamp, ...info }) {
|
|
40
|
+
return trimObj({
|
|
41
|
+
channel,
|
|
42
|
+
...info,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function normalizeEvent({
|
|
47
|
+
type,
|
|
48
|
+
channel,
|
|
49
|
+
index,
|
|
50
|
+
timestamp,
|
|
51
|
+
...rest
|
|
52
|
+
} = {}) {
|
|
53
|
+
// re-order properties
|
|
54
|
+
return trimObj({
|
|
55
|
+
type,
|
|
56
|
+
channel,
|
|
57
|
+
index,
|
|
58
|
+
...rest,
|
|
59
|
+
timestamp,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function validateEvent(event) {
|
|
64
|
+
switch (event.type) {
|
|
65
|
+
case 'data':
|
|
66
|
+
if (!event.id) {
|
|
67
|
+
throw new Error('Id is required for data events');
|
|
68
|
+
}
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function generateDefaultSinkResponse({ records, data }) {
|
|
74
|
+
// default implementation: assume all successful
|
|
75
|
+
return {
|
|
76
|
+
writes: 1,
|
|
77
|
+
successful: {
|
|
78
|
+
records,
|
|
79
|
+
data,
|
|
80
|
+
},
|
|
81
|
+
failed: {
|
|
82
|
+
records: 0,
|
|
83
|
+
data: [],
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class ChannelOutput {
|
|
89
|
+
|
|
90
|
+
constructor(channel) {
|
|
91
|
+
this._channel = channel;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
pass(event) {
|
|
95
|
+
this._channel._pushBuffer(normalizeEvent(event));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
write({ timestamp = Date.now(), ...rest }) {
|
|
99
|
+
const event = {
|
|
100
|
+
...rest,
|
|
101
|
+
channel: this._channel.name,
|
|
102
|
+
timestamp,
|
|
103
|
+
};
|
|
104
|
+
validateEvent(event);
|
|
105
|
+
this._channel._pushBuffer(normalizeEvent(event));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
log(level, message, data) {
|
|
109
|
+
this.write({
|
|
110
|
+
type: 'log',
|
|
111
|
+
logLevel: level,
|
|
112
|
+
message,
|
|
113
|
+
...data,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { default as Channel } from './channel.js';
|
|
2
|
+
export { default as WriteChannel } from './write.js';
|
|
3
|
+
export { default as WriteChannelBuffer } from './buffer.js';
|
|
4
|
+
export { default as WriteChannelSink } from './sink.js';
|
|
5
|
+
export { default as WriteChannelSinkGate } from './sink-gate.js';
|
|
6
|
+
export { default as UpgradeChannel } from './upgrade.js';
|
|
7
|
+
export { default as DowngradeChannel } from './downgrade.js';
|
|
8
|
+
export { default as TransformChannel } from './transform.js';
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export default class WriteChannelSinkGate {
|
|
2
|
+
|
|
3
|
+
constructor(options = {}) {
|
|
4
|
+
this._options = options;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
blockedTime(sinkState, now = Date.now()) {
|
|
8
|
+
const { start, started } = sinkState;
|
|
9
|
+
if (start === undefined) {
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
const elapsed = now - start;
|
|
13
|
+
const targetBps = this._targetBps(now);
|
|
14
|
+
const targetRps = this._targetRps(now);
|
|
15
|
+
const targetWps = this._targetWps(now);
|
|
16
|
+
|
|
17
|
+
const shallElapsed = Math.max(started.records / targetRps, started.bytes / targetBps, started.writes / targetWps) * 1000;
|
|
18
|
+
|
|
19
|
+
const blockedTime = shallElapsed - elapsed;
|
|
20
|
+
if (blockedTime <= 1000) {
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
// TODO: review this
|
|
24
|
+
return Math.ceil(blockedTime);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_targetBps(timestamp) {
|
|
28
|
+
return this._options.bytesPerSecond;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_targetRps(timestamp) {
|
|
32
|
+
return this._options.recordsPerSecond;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_targetWps(timestamp) {
|
|
36
|
+
return this._options.writesPerSecond;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { trimObj } from '../object.js';
|
|
2
|
+
import Resolution from '../resolution.js';
|
|
3
|
+
import { ChannelComponent } from './component.js';
|
|
4
|
+
import { generateDefaultSinkResponse } from './events.js';
|
|
5
|
+
|
|
6
|
+
function clone(obj) {
|
|
7
|
+
return Object.freeze(trimObj({ ...obj }));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default class WriteChannelSink extends ChannelComponent {
|
|
11
|
+
|
|
12
|
+
constructor({ write, ...options } = {}) {
|
|
13
|
+
super(options);
|
|
14
|
+
if (write) {
|
|
15
|
+
if (typeof write !== 'function') {
|
|
16
|
+
throw new Error('write must be a function.');
|
|
17
|
+
}
|
|
18
|
+
this._write = write.bind(this);
|
|
19
|
+
}
|
|
20
|
+
this._state = {
|
|
21
|
+
start: undefined,
|
|
22
|
+
started: { index: -1, writes: 0, records: 0, bytes: 0 },
|
|
23
|
+
finished: { index: -1, writes: 0, records: 0, bytes: 0, successful: 0, failed: 0 },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get state() {
|
|
28
|
+
const { start, started, finished } = this._state;
|
|
29
|
+
return clone({
|
|
30
|
+
start,
|
|
31
|
+
started: clone(started),
|
|
32
|
+
finished: clone(finished),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async write(request) {
|
|
37
|
+
if (this._finishedRes) {
|
|
38
|
+
this._finishedRes.reject(new Error(`More data written after finished call`));
|
|
39
|
+
this._finishedRes = undefined;
|
|
40
|
+
}
|
|
41
|
+
const { records, bytes } = request;
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const { started, finished } = this._state;
|
|
44
|
+
if (started.writes === 0) {
|
|
45
|
+
this._state.start = now;
|
|
46
|
+
}
|
|
47
|
+
started.index++;
|
|
48
|
+
started.writes++;
|
|
49
|
+
started.records += records;
|
|
50
|
+
started.bytes += bytes;
|
|
51
|
+
|
|
52
|
+
const { index } = started;
|
|
53
|
+
const response = await this._write(request);
|
|
54
|
+
|
|
55
|
+
// sanity check
|
|
56
|
+
if (!response || !response.successful || !response.failed) {
|
|
57
|
+
throw new Error(`Invalid response: ${JSON.stringify(response)}`);
|
|
58
|
+
}
|
|
59
|
+
if (response.successful.records + response.failed.records !== records) {
|
|
60
|
+
throw new Error(`Invalid response: successful records (${response.successful.records}) + failed records (${response.failed.records}) !== records (${records})`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// a _write call may use more than 1 write
|
|
64
|
+
const writes = response.writes || 1;
|
|
65
|
+
if (writes > 1) {
|
|
66
|
+
started.writes += writes - 1; // patch the number
|
|
67
|
+
}
|
|
68
|
+
finished.index = index;
|
|
69
|
+
finished.writes += writes;
|
|
70
|
+
finished.records += records;
|
|
71
|
+
finished.bytes += bytes;
|
|
72
|
+
finished.successful += response.successful.records;
|
|
73
|
+
finished.failed += response.failed.records;
|
|
74
|
+
|
|
75
|
+
if (this._finishedRes && finished.writes === started.writes) {
|
|
76
|
+
this._finishedRes.resolve();
|
|
77
|
+
this._finishedRes = undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return response;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async _write(event) {
|
|
84
|
+
// default implementation: do nothing but assume all successful
|
|
85
|
+
return generateDefaultSinkResponse(event);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get finished() {
|
|
89
|
+
const { started, finished } = this._state;
|
|
90
|
+
if (started.writes === finished.writes) {
|
|
91
|
+
return Promise.resolve();
|
|
92
|
+
}
|
|
93
|
+
return (this._finishedRes || (this._finishedRes = new Resolution())).promise;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
destroy() {
|
|
97
|
+
if (this._finishedRes) {
|
|
98
|
+
this._finishedRes.reject(new Error(`Stream is destroyed.`));
|
|
99
|
+
this._finishedRes = undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { delay } from '../async.js';
|
|
2
|
+
|
|
3
|
+
export default class TimeTracker {
|
|
4
|
+
|
|
5
|
+
constructor(timestamp = Date.now()) {
|
|
6
|
+
Object.defineProperty(this, 'constructedAt', { value: timestamp });
|
|
7
|
+
this._firstWriteAt = undefined;
|
|
8
|
+
this._write = 0;
|
|
9
|
+
this._pausedAt = undefined;
|
|
10
|
+
this._willResumeAt = undefined;
|
|
11
|
+
this._paused = 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// operation //
|
|
15
|
+
setFirstWrite(timestamp = Date.now()) {
|
|
16
|
+
if (this._firstWriteAt !== undefined) {
|
|
17
|
+
throw new Error(`First write time already set: ${this._firstWriteAt}`);
|
|
18
|
+
}
|
|
19
|
+
this._firstWriteAt = timestamp;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
addWrite(duration) {
|
|
23
|
+
this._write += duration;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async pause(pauseTime, pausedAt = Date.now()) {
|
|
27
|
+
if (pauseTime <= 0) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (this._firstWriteAt === undefined || this._pausedAt !== undefined) {
|
|
31
|
+
throw new Error(`Cannot pause() before first write or when paused.`);
|
|
32
|
+
}
|
|
33
|
+
this._pausedAt = pausedAt;
|
|
34
|
+
this._willResumeAt = pausedAt + pauseTime;
|
|
35
|
+
await delay(pauseTime);
|
|
36
|
+
this.paused && this.resume();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
resume(resumeAt = Date.now()) {
|
|
40
|
+
if (this._firstWriteAt === undefined || this._pausedAt === undefined) {
|
|
41
|
+
throw new Error(`Cannot resume() before first write or when not paused.`);
|
|
42
|
+
}
|
|
43
|
+
this._paused += resumeAt - this._pausedAt;
|
|
44
|
+
this._pausedAt = this._willResumeAt = undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// stats //
|
|
48
|
+
get paused() {
|
|
49
|
+
return !!this._pausedAt;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get firstWriteAt() {
|
|
53
|
+
return this._firstWriteAt;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get pausedAt() {
|
|
57
|
+
return this._pausedAt;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get willResumeAt() {
|
|
61
|
+
return this._willResumeAt;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get write() {
|
|
65
|
+
return this._write;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
total(timestamp) {
|
|
69
|
+
return timestamp - this.constructedAt;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
waiting(timestamp) {
|
|
73
|
+
return (this._firstWriteAt !== undefined ? this._firstWriteAt : timestamp) - this.constructedAt;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
elapsed(timestamp) {
|
|
77
|
+
return this._firstWriteAt === undefined ? 0 : timestamp - this._firstWriteAt;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
paused(timestamp) {
|
|
81
|
+
return this._paused + (this._pausedAt !== undefined ? timestamp - this._pausedAt : 0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
running(timestamp) {
|
|
85
|
+
return this.elapsed(timestamp) - this.paused(timestamp);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
snapshot(timestamp = Date.now()) {
|
|
89
|
+
const { constructedAt, firstWriteAt, pausedAt, willResumeAt, write } = this;
|
|
90
|
+
return Object.freeze(trimObj({
|
|
91
|
+
currentTime: timestamp,
|
|
92
|
+
constructedAt,
|
|
93
|
+
firstWriteAt,
|
|
94
|
+
pausedAt,
|
|
95
|
+
willResumeAt,
|
|
96
|
+
write,
|
|
97
|
+
total: this.total(timestamp),
|
|
98
|
+
waiting: this.waiting(timestamp),
|
|
99
|
+
elapsed: this.elapsed(timestamp),
|
|
100
|
+
paused: this.paused(timestamp),
|
|
101
|
+
running: this.running(timestamp),
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
}
|