@miso.ai/server-sdk 0.6.0-beta.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.
@@ -0,0 +1,60 @@
1
+ import { log, stream } from '@miso.ai/server-commons';
2
+
3
+ const { formatDuration, formatBytes, formatTable } = log;
4
+
5
+ export default class LegacyProgressLogStream extends stream.LogUpdateStream {
6
+
7
+ constructor({
8
+ out = process.stdout,
9
+ err = process.stderr,
10
+ } = {}) {
11
+ super({
12
+ out,
13
+ err,
14
+ });
15
+ }
16
+
17
+ _renderError({ state: _, ...record }) {
18
+ return super._renderError(record);
19
+ }
20
+
21
+ _render({ state }) {
22
+ const { elapsed, pending, successful, failed, apiBps, sentBps } = state;
23
+
24
+ // sum pending requests
25
+ const pendingSums = sumMetrics(pending);
26
+
27
+ const table = formatTable([
28
+ ['', 'Requests', 'Records', 'Bytes', 'Server Time', 'Latency'],
29
+ ['Pending', pendingSums.requests, pendingSums.records, formatBytes(pendingSums.bytes), '--', '--'],
30
+ ['Successful', successful.requests, successful.records, formatBytes(successful.bytes), formatDuration(successful.took), formatDuration(successful.latency)],
31
+ ['Failed', failed.requests, failed.records, formatBytes(failed.bytes), formatDuration(failed.took), formatDuration(failed.latency)],
32
+ ]);
33
+
34
+ return `
35
+ Time elapsed: ${formatDuration(elapsed)}
36
+ API Speed: ${formatSpeed(apiBps)}
37
+ Client Speed: ${formatSpeed(sentBps)}
38
+
39
+ ${table}
40
+ `;
41
+ }
42
+
43
+ }
44
+
45
+ function sumMetrics(requests) {
46
+ return requests.reduce((acc, req) => {
47
+ acc.requests ++;
48
+ acc.records += req.records;
49
+ acc.bytes += req.bytes;
50
+ return acc;
51
+ }, {
52
+ requests: 0,
53
+ records: 0,
54
+ bytes: 0,
55
+ });
56
+ }
57
+
58
+ function formatSpeed(value) {
59
+ return isNaN(value) ? 'N/A' : `${formatBytes(value)}/s`;
60
+ }
@@ -0,0 +1,58 @@
1
+ import { Writable } from 'stream';
2
+ import { log } from '@miso.ai/server-commons';
3
+ import { FORMAT } from './constants.js';
4
+
5
+ export default class StandardLogStream extends Writable {
6
+
7
+ constructor({
8
+ level = log.INFO,
9
+ format = FORMAT.JSON,
10
+ out = process.stdout,
11
+ err = process.stderr,
12
+ } = {}) {
13
+ super({
14
+ objectMode: true,
15
+ });
16
+ if (log.LEVELS.indexOf(level) < 0) {
17
+ throw new Error(`Unrecognized log level: ${level}`);
18
+ }
19
+ this._level = level;
20
+ this._debug = log.reachesThreshold(log.DEBUG, level);
21
+ this._formatter = getFormatter(format);
22
+ this._out = out;
23
+ this._err = err;
24
+ }
25
+
26
+ _write(record, _, next) {
27
+ const { level } = record;
28
+ if (!log.reachesThreshold(level, this._level)) {
29
+ next();
30
+ return;
31
+ }
32
+ if (!this._debug) {
33
+ delete record.state;
34
+ }
35
+ (log.isError(level) ? this._err : this._out).write(this._formatter(record) + '\n');
36
+ next();
37
+ }
38
+
39
+ }
40
+
41
+ function getFormatter(format) {
42
+ switch (format) {
43
+ case FORMAT.TEXT:
44
+ return formatText;
45
+ case FORMAT.JSON:
46
+ return formatJson;
47
+ default:
48
+ throw new Error(`Unrecognized format: ${format}`);
49
+ }
50
+ }
51
+
52
+ function formatJson(record) {
53
+ return JSON.stringify(record);
54
+ }
55
+
56
+ function formatText({ level, timestamp, event, ...record }) {
57
+ return `[${new Date(timestamp).toISOString()}][${level.toUpperCase()}] ${event}, ${JSON.stringify(record)}`;
58
+ }
@@ -0,0 +1,66 @@
1
+ import { sink, trimObj } from '@miso.ai/server-commons';
2
+ import ServiceStats from './service-stats.js';
3
+
4
+ export default class ApiSink extends sink.BpsSink {
5
+
6
+ constructor(client, options) {
7
+ super(options);
8
+ this._client = client;
9
+ this._serviceStats = new ServiceStats();
10
+ }
11
+
12
+ _normalizeOptions({
13
+ params,
14
+ ...options
15
+ } = {}) {
16
+ return {
17
+ ...super._normalizeOptions(options),
18
+ ...trimObj({
19
+ params: this._normalizeParams(params),
20
+ }),
21
+ };
22
+ }
23
+
24
+ _normalizeParams(params) {
25
+ if (!params || params.length === 0) {
26
+ return undefined;
27
+ }
28
+ return params.reduce((acc, param) => {
29
+ const [key, value = '1'] = param.split('=');
30
+ acc[key] = value;
31
+ return acc;
32
+ }, {});
33
+ }
34
+
35
+ get serviceStats() {
36
+ return this._serviceStats.snapshot();
37
+ }
38
+
39
+ async _write(payload, { records, bytes }) {
40
+ let data;
41
+ try {
42
+ data = await this._execute(payload);
43
+ } catch(error) {
44
+ // not axios-handled error
45
+ if (!error.response) {
46
+ throw error;
47
+ }
48
+ data = error.response.data;
49
+ if (typeof data!== 'object') {
50
+ data = trimObj({ errors: true, cause: data });
51
+ }
52
+ }
53
+
54
+ // keep track of service stats on successful calls
55
+ if (!data.errors) {
56
+ this._serviceStats.track({ records, bytes, took: data.took });
57
+ }
58
+
59
+ return data;
60
+ }
61
+
62
+ async _execute(payload) {
63
+ throw new Error(`Unimplemented.`);
64
+ }
65
+
66
+ }
@@ -0,0 +1,26 @@
1
+ import ApiSink from './api-sink.js';
2
+
3
+ export default class DeleteSink extends ApiSink {
4
+
5
+ constructor(client, options) {
6
+ super(client, options);
7
+ }
8
+
9
+ _normalizeOptions({
10
+ ...options
11
+ } = {}) {
12
+ if (!options.type) {
13
+ throw new Error(`Type is required.`);
14
+ }
15
+ return {
16
+ ...super._normalizeOptions(options),
17
+ };
18
+ }
19
+
20
+ async _execute(payload) {
21
+ const { type, params } = this._options;
22
+ const response = await this._client._delete(type, payload, { params });
23
+ return response.data;
24
+ }
25
+
26
+ }
@@ -0,0 +1,111 @@
1
+ import { stream, buffer } from '@miso.ai/server-commons';
2
+ import version from '../version.js';
3
+ import DeleteSink from './delete-sink.js';
4
+ import DeletionStats from './deletion-state.js';
5
+
6
+ export default class DeleteStream extends stream.BufferedWriteStream {
7
+
8
+ constructor(client, type, {
9
+ name,
10
+ // super
11
+ objectMode,
12
+ heartbeatInterval,
13
+ // sink
14
+ recordsPerSecond,
15
+ // buffer
16
+ recordsPerRequest,
17
+ }) {
18
+ super({
19
+ name,
20
+ type,
21
+ version,
22
+ objectMode,
23
+ heartbeatInterval,
24
+ });
25
+ if (type !== 'products' && type !== 'users') {
26
+ throw new Error(`Unsupported type: ${type}`);
27
+ }
28
+
29
+ this._client = client;
30
+ this._type = type;
31
+
32
+ this._buffer = createBuffer(type, {
33
+ recordsPerRequest,
34
+ });
35
+
36
+ this._sink = new DeleteSink(client, {
37
+ type,
38
+ recordsPerSecond,
39
+ });
40
+
41
+ this._deletionStats = new DeletionStats();
42
+ }
43
+
44
+ get serviceStats() {
45
+ return this._sink.serviceStats;
46
+ }
47
+
48
+ get deletionStats() {
49
+ return this._deletionStats.snapshot();
50
+ }
51
+
52
+ _exportConfig() {
53
+ return {
54
+ client: this._client.options,
55
+ ...super._exportConfig(),
56
+ }
57
+ }
58
+
59
+ async _writeToSink(payload, request) {
60
+ const response = await super._writeToSink(payload, request);
61
+
62
+ this._deletionStats.track(request, response);
63
+
64
+ return response;
65
+ }
66
+
67
+ _output(message, args) {
68
+ const output = super._output(message, args);
69
+
70
+ // if upload fails, emit extracted payload at response event
71
+ if (message.event === 'response' && args.response && args.response.errors && args.payload) {
72
+ output.payload = JSON.parse(args.payload);
73
+ }
74
+
75
+ // TODO: we should find a way to place deletion stats as a main part in the events
76
+
77
+ // add upload stats
78
+ output.state = {
79
+ ...output.state,
80
+ stats: {
81
+ service: this.serviceStats,
82
+ deletion: this.deletionStats,
83
+ },
84
+ };
85
+
86
+ return output;
87
+ }
88
+
89
+ }
90
+
91
+ const DEFAULT_BUFFER_OPTIONS = Object.freeze({
92
+ suffix: ']}}',
93
+ separator: ',',
94
+ recordsLimit: 1000,
95
+ });
96
+
97
+ function createBuffer(type, {
98
+ recordsPerRequest,
99
+ }) {
100
+ const key = type === 'products' ? 'product_ids' : 'user_ids';
101
+ const options = {
102
+ ...DEFAULT_BUFFER_OPTIONS,
103
+ objectMode: true,
104
+ prefix: `{"data":{"${key}":[`,
105
+ transform: v => v.toString(),
106
+ };
107
+ if (recordsPerRequest) {
108
+ options.recordsLimit = recordsPerRequest;
109
+ }
110
+ return new buffer.JsonBuffer(options);
111
+ }
@@ -0,0 +1,32 @@
1
+ export default class DeletionStats {
2
+
3
+ constructor() {
4
+ this._records = 0;
5
+ this._deleted = 0;
6
+ this._notFound = 0;
7
+ }
8
+
9
+ get records() {
10
+ return this._records;
11
+ }
12
+
13
+ get deleted() {
14
+ return this._deleted;
15
+ }
16
+
17
+ get notFound() {
18
+ return this._notFound;
19
+ }
20
+
21
+ track(request, { data: response }) {
22
+ this._records += request.records;
23
+ this._deleted += (response.deleted && response.deleted.count) || 0;
24
+ this._notFound += (response.not_found && response.not_found.count) || 0;
25
+ }
26
+
27
+ snapshot() {
28
+ const { records, deleted, notFound } = this;
29
+ return Object.freeze({ records, deleted, notFound });
30
+ }
31
+
32
+ }
@@ -0,0 +1,43 @@
1
+ export default class ServiceStats {
2
+
3
+ constructor() {
4
+ this._requests = this._records = this._bytes = this._took = 0;
5
+ }
6
+
7
+ track({ records, bytes, took } = {}) {
8
+ if (isNaN(took) || took <= 0) {
9
+ return;
10
+ }
11
+ this._requests++;
12
+ this._bytes += records;
13
+ this._bytes += bytes;
14
+ this._took += took;
15
+ }
16
+
17
+ get requests() {
18
+ return this._requests;
19
+ }
20
+
21
+ get records() {
22
+ return this._records;
23
+ }
24
+
25
+ get bytes() {
26
+ return this._bytes;
27
+ }
28
+
29
+ get took() {
30
+ return this._took;
31
+ }
32
+
33
+ get bps() {
34
+ const { took, bytes } = this;
35
+ return took > 0 ? (bytes / took * 1000) : NaN;
36
+ }
37
+
38
+ snapshot() {
39
+ const { requests, records, bytes, took, bps } = this;
40
+ return Object.freeze({ requests, records, bytes, took, bps });
41
+ }
42
+
43
+ }
@@ -0,0 +1,34 @@
1
+ import { buffer } from '@miso.ai/server-commons';
2
+
3
+ const DEFAULT_OPTIONS = Object.freeze({
4
+ prefix: '{"data":[',
5
+ suffix: ']}',
6
+ separator: ',',
7
+ bytesLimit: 1024 * 1024,
8
+ });
9
+
10
+ export default function create(type, { objectMode, recordsPerRequest, bytesPerRequest } = {}) {
11
+ const options = {
12
+ ...DEFAULT_OPTIONS,
13
+ objectMode,
14
+ };
15
+ // TODO: validate RPR, BPR values
16
+ if (bytesPerRequest) {
17
+ options.bytesLimit = bytesPerRequest;
18
+ }
19
+
20
+ switch (type) {
21
+ case 'users':
22
+ case 'products':
23
+ options.recordsLimit = recordsPerRequest || 200;
24
+ return new buffer.JsonBuffer(options);
25
+ case 'interactions':
26
+ options.recordsLimit = recordsPerRequest || 1000;
27
+ return new buffer.JsonBuffer(options);
28
+ case 'experiment-events':
29
+ options.recordsLimit = 1;
30
+ return new buffer.JsonBuffer(options);
31
+ default:
32
+ throw new Error(`Unrecognized type: ${type}`);
33
+ }
34
+ }
@@ -0,0 +1,104 @@
1
+ import ApiSink from './api-sink.js';
2
+
3
+ class UploadSink extends ApiSink {
4
+
5
+ constructor(client, options) {
6
+ super(client, options);
7
+ }
8
+
9
+ _normalizeOptions({
10
+ async,
11
+ dryRun,
12
+ ...options
13
+ } = {}) {
14
+ if (!options.type) {
15
+ throw new Error(`Type is required.`);
16
+ }
17
+ return {
18
+ ...super._normalizeOptions(options),
19
+ async: !!async,
20
+ dryRun: !!dryRun,
21
+ };
22
+ }
23
+
24
+ async _execute(payload) {
25
+ const { type, async, dryRun, params } = this._options;
26
+ const response = await this._client.upload(type, payload, { async, dryRun, params });
27
+ return response.data;
28
+ }
29
+
30
+ }
31
+
32
+ class DataSetUploadSink extends UploadSink {
33
+
34
+ constructor(client, options) {
35
+ super(client, options);
36
+ this._stats.api = { count: 0, bytes: 0, took: 0 };
37
+ }
38
+
39
+ _normalizeOptions({
40
+ apiBpsRate,
41
+ apiBpsSampleThreshold = 1024 * 1024,
42
+ ...options
43
+ } = {}) {
44
+ return {
45
+ ...super._normalizeOptions(options),
46
+ apiBpsRate: this._normalizeApiBpsRate(apiBpsRate, options),
47
+ apiBpsSampleThreshold,
48
+ };
49
+ }
50
+
51
+ _normalizeApiBpsRate(apiBpsRate, options) {
52
+ if (options.type === 'interactions' || options.async) {
53
+ // in async mode, apiBps is meaningless
54
+ return false;
55
+ }
56
+ if (apiBpsRate === undefined || apiBpsRate === null) {
57
+ // default to 1 if absent
58
+ return 1;
59
+ }
60
+ if (apiBpsRate === false || (!isNaN(apiBpsRate) && apiBpsRate > 0)) {
61
+ // legal values
62
+ return apiBpsRate;
63
+ }
64
+ throw new Error(`Illegal apiBpsRate value: ${apiBpsRate}`);
65
+ }
66
+
67
+ _targetBps() {
68
+ const { bytesPerSecond: configuredBps, apiBpsRate, apiBpsSampleThreshold } = this._options;
69
+ if (apiBpsRate && this._stats.api.bytes < apiBpsSampleThreshold) {
70
+ // use configured BPS until we have enough data from API response
71
+ return configuredBps;
72
+ }
73
+ const apiBps = this._serviceStats.bps;
74
+ return !isNaN(apiBps) ? Math.max(apiBps * apiBpsRate, configuredBps) : configuredBps;
75
+ }
76
+
77
+ }
78
+
79
+ class ExperimentEventUploadSink extends UploadSink {
80
+
81
+ constructor(client, options) {
82
+ super(client, { ...options, type: 'experiment-events' });
83
+ }
84
+
85
+ async _execute(payload) {
86
+ const { experimentId } = this._options;
87
+ const response = await this._client.uploadExperimentEvent(experimentId, payload);
88
+ return response.data;
89
+ }
90
+
91
+ }
92
+
93
+ export default function create(client, type, options) {
94
+ switch (type) {
95
+ case 'users':
96
+ case 'products':
97
+ case 'interactions':
98
+ return new DataSetUploadSink(client, { ...options, type });
99
+ case 'experiment-events':
100
+ return new ExperimentEventUploadSink(client, options);
101
+ default:
102
+ throw new Error(`Unrecognized type: ${type}`);
103
+ }
104
+ }
@@ -0,0 +1,81 @@
1
+ import { stream } from '@miso.ai/server-commons';
2
+ import version from '../version.js';
3
+ import createSink from './upload-sink.js';
4
+ import createBuffer from './upload-buffer.js';
5
+
6
+ export default class UploadStream extends stream.BufferedWriteStream {
7
+
8
+ constructor(client, type, {
9
+ name,
10
+ // super
11
+ objectMode,
12
+ heartbeatInterval,
13
+ // sink
14
+ async,
15
+ dryRun,
16
+ params,
17
+ experimentId,
18
+ recordsPerSecond,
19
+ bytesPerSecond,
20
+ // buffer
21
+ recordsPerRequest,
22
+ bytesPerRequest,
23
+ } = {}) {
24
+ super({
25
+ name,
26
+ type,
27
+ version,
28
+ objectMode,
29
+ heartbeatInterval,
30
+ });
31
+
32
+ this._client = client;
33
+ this._type = type;
34
+
35
+ this._sink = createSink(client, type, {
36
+ async,
37
+ dryRun,
38
+ params,
39
+ experimentId,
40
+ recordsPerSecond,
41
+ bytesPerSecond,
42
+ });
43
+
44
+ this._buffer = createBuffer(type, {
45
+ objectMode,
46
+ recordsPerRequest,
47
+ bytesPerRequest,
48
+ });
49
+ }
50
+
51
+ get serviceStats() {
52
+ return this._sink.serviceStats;
53
+ }
54
+
55
+ _exportConfig() {
56
+ return {
57
+ client: this._client.options,
58
+ ...super._exportConfig(),
59
+ }
60
+ }
61
+
62
+ _output(message, args) {
63
+ const output = super._output(message, args);
64
+
65
+ // if upload fails, emit extracted payload at response event
66
+ if (message.event === 'response' && args.response && args.response.errors && args.payload) {
67
+ output.payload = JSON.parse(args.payload);
68
+ }
69
+
70
+ // add upload stats
71
+ output.state = {
72
+ ...output.state,
73
+ stats: {
74
+ service: this.serviceStats,
75
+ },
76
+ };
77
+
78
+ return output;
79
+ }
80
+
81
+ }