@miso.ai/server-sdk 0.6.0-beta.1 → 0.6.2-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,57 @@
1
+ import split2 from 'split2';
2
+ import { stream } from '@miso.ai/server-commons';
3
+ import { MisoClient } from '../src/index.js';
4
+
5
+ function build(yargs) {
6
+ return yargs
7
+ .option('output', {
8
+ alias: ['out', 'o'],
9
+ describe: 'Output mode',
10
+ choices: ['default', 'plus', 'minus'],
11
+ conflicts: ['plus', 'minus'],
12
+ })
13
+ .option('plus', {
14
+ alias: ['p'],
15
+ describe: 'Only show plus records (those are present in input and absent in Dojo)',
16
+ type: 'boolean',
17
+ conflicts: ['output', 'minus'],
18
+ })
19
+ .option('minus', {
20
+ alias: ['m'],
21
+ describe: 'Only show minus records (those are absent in input and present in Dojo)',
22
+ type: 'boolean',
23
+ conflicts: ['output', 'plus'],
24
+ });
25
+ };
26
+
27
+ const run = type => async ({
28
+ key,
29
+ server,
30
+ output,
31
+ plus,
32
+ minus,
33
+ }) => {
34
+ output = output || (plus ? 'plus' : minus ? 'minus' : undefined);
35
+
36
+ const client = new MisoClient({ key, server });
37
+ const misoIds = await client.ids(type);
38
+
39
+ const diffStream = new stream.DiffStream(misoIds, { output });
40
+ const outputStream = new stream.OutputStream({ objectMode: false });
41
+
42
+ await stream.pipeline(
43
+ process.stdin,
44
+ split2(),
45
+ diffStream,
46
+ outputStream,
47
+ );
48
+ };
49
+
50
+ export default function(type) {
51
+ return {
52
+ command: 'diff',
53
+ description: false,
54
+ builder: build,
55
+ handler: run(type),
56
+ };
57
+ }
package/cli/ids.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { Readable } from 'stream';
2
2
  import { stream } from '@miso.ai/server-commons';
3
3
  import { MisoClient } from '../src/index.js';
4
+ import diff from './ids-diff.js';
4
5
 
5
- function build(yargs) {
6
- return yargs;
7
- }
6
+ const build = type => yargs => {
7
+ return yargs
8
+ .command(diff(type));
9
+ };
8
10
 
9
11
  const run = type => async ({
10
12
  key,
@@ -26,7 +28,7 @@ export default function(type) {
26
28
  return {
27
29
  command: 'ids',
28
30
  description: false,
29
- builder: build,
31
+ builder: build(type),
30
32
  handler: run(type),
31
33
  };
32
34
  }
package/cli/transform.js CHANGED
@@ -32,7 +32,22 @@ async function run({ file }) {
32
32
 
33
33
  async function getTransformStream(loc) {
34
34
  const mod = await import(join(PWD, loc));
35
- return mod.default ? new mod.default() : new Transform({ objectMode: mod.objectMode !== false, ...mod });
35
+ return mod.default ? new mod.default() : new Transform(normalizeOptions(mod));
36
+ }
37
+
38
+ function normalizeOptions({
39
+ objectMode,
40
+ readableObjectMode,
41
+ writableObjectMode,
42
+ ...options
43
+ } = {}) {
44
+ readableObjectMode = readableObjectMode !== undefined ? readableObjectMode : objectMode !== undefined ? objectMode : true;
45
+ writableObjectMode = writableObjectMode !== undefined ? writableObjectMode : objectMode !== undefined ? objectMode : true;
46
+ return {
47
+ readableObjectMode,
48
+ writableObjectMode,
49
+ ...options
50
+ };
36
51
  }
37
52
 
38
53
  export default {
package/cli/upload.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import split2 from 'split2';
2
2
  import { log, stream } from '@miso.ai/server-commons';
3
- import { MisoClient, logger } from '../src/index.js';
3
+ import { MisoClient, logger, normalize } from '../src/index.js';
4
4
 
5
5
  function build(yargs) {
6
6
  return yargs
@@ -12,6 +12,10 @@ function build(yargs) {
12
12
  alias: ['dry'],
13
13
  describe: 'Dry run mode',
14
14
  })
15
+ .option('lenient', {
16
+ describe: 'Accept some enient record schema',
17
+ type: 'boolean',
18
+ })
15
19
  .option('records-per-request', {
16
20
  alias: ['rpr'],
17
21
  describe: 'How many records to send in a request',
@@ -37,11 +41,6 @@ function build(yargs) {
37
41
  alias: ['name'],
38
42
  describe: 'Stream name that shows up in log messages',
39
43
  })
40
- .option('legacy', {
41
- type: 'boolean',
42
- default: false,
43
- })
44
- .hide('legacy')
45
44
  .option('log-level', {
46
45
  describe: 'Log level',
47
46
  })
@@ -56,6 +55,7 @@ const run = type => async ({
56
55
  param: params,
57
56
  async,
58
57
  ['dry-run']: dryRun,
58
+ lenient,
59
59
  ['records-per-request']: recordsPerRequest,
60
60
  ['bytes-per-request']: bytesPerRequest,
61
61
  ['bytes-per-second']: bytesPerSecond,
@@ -63,7 +63,6 @@ const run = type => async ({
63
63
  debug,
64
64
  progress,
65
65
  ['stream-name']: name,
66
- legacy,
67
66
  ['log-level']: loglevel,
68
67
  ['log-format']: logFormat,
69
68
  }) => {
@@ -73,8 +72,10 @@ const run = type => async ({
73
72
 
74
73
  const client = new MisoClient({ key, server });
75
74
 
75
+ const uploadStreamObjectMode = lenient;
76
+
76
77
  const uploadStream = client.createUploadStream(type, {
77
- legacy,
78
+ objectMode: uploadStreamObjectMode,
78
79
  name,
79
80
  async,
80
81
  dryRun,
@@ -85,19 +86,29 @@ const run = type => async ({
85
86
  bytesPerRequest,
86
87
  bytesPerSecond,
87
88
  experimentId,
89
+ extra: {
90
+ lenient,
91
+ },
88
92
  });
89
93
 
90
94
  const logStream = logger.createLogStream({
91
95
  api: 'upload',
92
96
  type,
93
- legacy,
94
97
  level: loglevel,
95
98
  format: logFormat,
96
99
  });
97
100
 
101
+ // standard: stdin -> split2 -> upload -> log
102
+ // lenient: stdin -> split2 -> parse -> normalize -> upload -> log
103
+ // notice that the output of split2 are strings, while input/output of normalize are objects
104
+
98
105
  await stream.pipeline(
99
106
  process.stdin,
100
107
  split2(),
108
+ ...(lenient ? [
109
+ stream.parse(),
110
+ new normalize.Stream(type),
111
+ ] : []),
101
112
  uploadStream,
102
113
  logStream,
103
114
  );
package/package.json CHANGED
@@ -16,11 +16,11 @@
16
16
  "simonpai <simon.pai@askmiso.com>"
17
17
  ],
18
18
  "dependencies": {
19
- "@miso.ai/server-commons": "0.6.0-beta.1",
19
+ "@miso.ai/server-commons": "0.6.2-beta.0",
20
20
  "axios": "^0.27.2",
21
21
  "dotenv": "^16.0.1",
22
22
  "split2": "^4.1.0",
23
23
  "yargs": "^17.5.1"
24
24
  },
25
- "version": "0.6.0-beta.1"
25
+ "version": "0.6.2-beta.0"
26
26
  }
@@ -0,0 +1,62 @@
1
+ import axios from 'axios';
2
+ import { upload, batchDelete, buildUrl } from './helpers.js';
3
+ import UploadStream from '../stream/upload.js';
4
+ import DeleteStream from '../stream/delete.js';
5
+
6
+ export class Queries {
7
+
8
+ constructor(client, group) {
9
+ this._client = client;
10
+ this._group = group;
11
+ }
12
+
13
+ async _run(path, payload, options) {
14
+ // TODO: options
15
+ const url = buildUrl(this._client, `${this._group}/${path}`);
16
+ return (await axios.post(url, payload)).data.data;
17
+ }
18
+
19
+ }
20
+
21
+ export class Writable {
22
+
23
+ constructor(client, type) {
24
+ this._client = client;
25
+ this._type = type;
26
+ }
27
+
28
+ async upload(records, options) {
29
+ return (await upload(this._client, this._type, records, options)).data;
30
+ }
31
+
32
+ uploadStream(options = {}) {
33
+ return new UploadStream(this._client, this._type, options);
34
+ }
35
+
36
+ }
37
+
38
+ export class Entities extends Writable {
39
+
40
+ constructor(client, type) {
41
+ super(client, type);
42
+ }
43
+
44
+ async get(id) {
45
+ const url = buildUrl(this._client, `${this._type}/${id}`);
46
+ return (await axios.get(url)).data.data;
47
+ }
48
+
49
+ async ids() {
50
+ const url = buildUrl(this._client, `${this._type}/_ids`);
51
+ return (await axios.get(url)).data.data.ids;
52
+ }
53
+
54
+ async delete(ids, options = {}) {
55
+ return batchDelete(this._client, this._type, ids, options);
56
+ }
57
+
58
+ deleteStream(options = {}) {
59
+ return new DeleteStream(this._client, this._type, options);
60
+ }
61
+
62
+ }
@@ -0,0 +1,20 @@
1
+ import axios from 'axios';
2
+ import { buildUrl } from './helpers.js';
3
+
4
+ export default class Experiments {
5
+
6
+ constructor(client) {
7
+ this._client = client;
8
+ }
9
+
10
+ async uploadEvent(experimentId, record) {
11
+ // TODO: support non-string record
12
+ const url = buildUrl(this._client, `experiments/${experimentId}/events`);
13
+ // TODO: make content type header global
14
+ const headers = { 'Content-Type': 'application/json' };
15
+ const response = await axios.post(url, record, { headers });
16
+ // 200 response body does not have .data layer
17
+ return response.data ? response : { data: response };
18
+ }
19
+
20
+ }
@@ -0,0 +1,63 @@
1
+ import { asArray } from '@miso.ai/server-commons';
2
+ import axios from 'axios';
3
+ import { Buffer } from 'buffer';
4
+
5
+ export async function upload(client, type, records, options = {}) {
6
+ const url = buildUrl(client, type, options);
7
+ const payload = buildUploadPayload(records);
8
+ return axios.post(url, payload);
9
+ }
10
+
11
+ export async function batchDelete(client, type, ids, options = {}) {
12
+ const url = buildUrl(client, `${type}/_delete`, options);
13
+ const payload = buildBatchDeletePayload(type, ids);
14
+ // TODO: organize axios
15
+ const { data } = await axios.post(url, payload, {
16
+ headers: {
17
+ 'Content-Type': 'application/json',
18
+ },
19
+ });
20
+ return data;
21
+ }
22
+
23
+ export function buildUrl(client, path, { async, dryRun, params: extraParams } = {}) {
24
+ let { server, key } = client._options;
25
+ let params = `?api_key=${key}`;
26
+ if (dryRun) {
27
+ params += '&dry_run=1';
28
+ } else if (async) {
29
+ params += '&async=1';
30
+ }
31
+ if (extraParams) {
32
+ for (const key in extraParams) {
33
+ // TODO: deal with encodeURIComponent
34
+ params += `&${key}=${extraParams[key]}`;
35
+ }
36
+ }
37
+ return `${server}/v1/${path}${params}`;
38
+ }
39
+
40
+ export function buildUploadPayload(records) {
41
+ return typeof records === 'string' ? records :
42
+ Buffer.isBuffer(records) ? records.toString() :
43
+ { data: Array.isArray(records)? records : [records] };
44
+ }
45
+
46
+ export function buildBatchDeletePayload(type, ids) {
47
+ if (type !== 'products' && type !== 'users' && type !== 'interactions') {
48
+ throw new Error(`Unsupported type: ${type}`);
49
+ }
50
+ if ((typeof ids === 'string' && ids[0] === '{') || typeof ids === 'object' && !Array.isArray(ids)) {
51
+ return ids;
52
+ }
53
+ ids = asArray(ids);
54
+ if (ids.length === 0) {
55
+ return { data: {} };
56
+ }
57
+ // interactions are deleted by user_ids
58
+ return {
59
+ data: {
60
+ [type === 'products' ? 'product_ids' : 'user_ids']: ids,
61
+ },
62
+ };
63
+ }
@@ -0,0 +1,19 @@
1
+ import Products from './products.js';
2
+ import Users from './users.js';
3
+ import Interactions from './interactions.js';
4
+ import Experiments from './experiments.js';
5
+ import Search from './search.js';
6
+ import Recommendation from './recommendation.js';
7
+
8
+ export default class Api {
9
+
10
+ constructor(client) {
11
+ this.products = new Products(client);
12
+ this.users = new Users(client);
13
+ this.interactions = new Interactions(client);
14
+ this.experiments = new Experiments(client);
15
+ this.search = new Search(client);
16
+ this.recommendation = new Recommendation(client);
17
+ }
18
+
19
+ }
@@ -0,0 +1,11 @@
1
+ import { Writable } from './base.js';
2
+
3
+ export default class Interactions extends Writable {
4
+
5
+ constructor(client) {
6
+ super(client, 'interactions');
7
+ }
8
+
9
+ // TODO: delete (by user_ids)
10
+
11
+ }
@@ -0,0 +1,9 @@
1
+ import { Entities } from './base.js';
2
+
3
+ export default class Products extends Entities {
4
+
5
+ constructor(client) {
6
+ super(client, 'products');
7
+ }
8
+
9
+ }
@@ -0,0 +1,33 @@
1
+ import { Queries } from './base.js';
2
+
3
+ export default class Recommendation extends Queries {
4
+
5
+ constructor(client) {
6
+ super(client, 'recommendation');
7
+ }
8
+
9
+ async userToProducts(payload, options) {
10
+ return this._run('user_to_products', payload, options);
11
+ }
12
+
13
+ async userToCategories(payload, options) {
14
+ return this._run('user_to_categories', payload, options);
15
+ }
16
+
17
+ async userToAttributes(payload, options) {
18
+ return this._run('user_to_attributes', payload, options);
19
+ }
20
+
21
+ async userToTrending(payload, options) {
22
+ return this._run('user_to_trending', payload, options);
23
+ }
24
+
25
+ async userToHistory(payload, options) {
26
+ return this._run('user_to_history', payload, options);
27
+ }
28
+
29
+ async productToProducts(payload, options) {
30
+ return this._run('product_to_products', payload, options);
31
+ }
32
+
33
+ }
@@ -0,0 +1,21 @@
1
+ import { Queries } from './base.js';
2
+
3
+ export default class Search extends Queries {
4
+
5
+ constructor(client) {
6
+ super(client, 'search');
7
+ }
8
+
9
+ async search(payload, options = {}) {
10
+ return this._run('search', payload, options);
11
+ }
12
+
13
+ async autocomplete(payload, options = {}) {
14
+ return this._run('autocomplete', payload, options);
15
+ }
16
+
17
+ async multipleGet(payload, options = {}) {
18
+ return this._run('mget', payload, options);
19
+ }
20
+
21
+ }
@@ -0,0 +1,9 @@
1
+ import { Entities } from './base.js';
2
+
3
+ export default class Users extends Entities {
4
+
5
+ constructor(client) {
6
+ super(client, 'users');
7
+ }
8
+
9
+ }
package/src/client.js CHANGED
@@ -3,9 +3,9 @@ import { asArray } from '@miso.ai/server-commons';
3
3
  import { Buffer } from 'buffer';
4
4
  import axios from 'axios';
5
5
  import version from './version.js';
6
- import LegacyUploadStream from './stream/upload.legacy.js';
7
6
  import UploadStream from './stream/upload.js';
8
7
  import DeleteStream from './stream/delete.js';
8
+ import Api from './api/index.js';
9
9
 
10
10
  export default class MisoClient {
11
11
 
@@ -14,6 +14,7 @@ export default class MisoClient {
14
14
  constructor(options) {
15
15
  this._options = normalizeOptions(options);
16
16
  this.version = version;
17
+ this.api = new Api(this);
17
18
  }
18
19
 
19
20
  async upload(type, records, options) {
@@ -65,11 +66,6 @@ export default class MisoClient {
65
66
  }
66
67
 
67
68
  createUploadStream(type, options) {
68
- if (options.legacy) {
69
- options.heartbeat = options.heartbeatInvertal;
70
- delete options.heartbeatInvertal;
71
- return new LegacyUploadStream(this, type, options);
72
- }
73
69
  return new UploadStream(this, type, options);
74
70
  }
75
71
 
package/src/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { default as MisoClient } from './client.js';
2
+ export * as normalize from './normalize/index.js';
2
3
  export * as logger from './logger/index.js';
@@ -24,22 +24,55 @@ export default class ApiProgressLogStream extends stream.LogUpdateStream {
24
24
 
25
25
  _sections({ config, state }) {
26
26
  return [
27
- this._configTable(config),
27
+ this._configTableCached(config),
28
28
  this._statusTable(state),
29
29
  this._timeStatsTable(state),
30
30
  this._dataStatsTable(state),
31
31
  ];
32
32
  }
33
33
 
34
- _configTable(config) {
35
- const { name = '(anonymous)', client = {} } = config || {};
34
+ _configTableCached(config) {
35
+ return this._configTableString || (this._configTableString = this._configTable(config));
36
+ }
37
+
38
+ _configTable(config = {}) {
39
+ const { name, id, client = {}, experimentId } = config;
40
+ const rows = [];
41
+ if (experimentId) {
42
+ rows.push(['Exp. ID:', experimentId]);
43
+ }
44
+ const props = this._configProps(config);
45
+ if (props.length) {
46
+ rows.push(['Config:', props.join(', ')]);
47
+ }
36
48
  return formatTable([
37
- ['Job:', `${name}`],
49
+ ['Job:', `${name || id}`],
50
+ ...rows,
38
51
  ['Server:', `${client.server || '(default)'}`],
39
52
  ['API Key:', `${client.keyMasked}`],
40
53
  ]);
41
54
  }
42
55
 
56
+ _configProps(config = {}) {
57
+ const { sink = {}, extra = {} } = config;
58
+ const { dryRun, async, params } = sink;
59
+ const { lenient } = extra;
60
+ const props = [];
61
+ if (dryRun) {
62
+ props.push('dry-run');
63
+ }
64
+ if (async) {
65
+ props.push('async');
66
+ }
67
+ if (lenient) {
68
+ props.push('lenient');
69
+ }
70
+ if (params && Object.keys(params).length) {
71
+ props.push(`params = ${JSON.stringify(params)}`);
72
+ }
73
+ return props;
74
+ }
75
+
43
76
  _statusTable(state) {
44
77
  const { bps, stats } = state;
45
78
  const serviceBps = stats && stats.service && stats.service.bps;
@@ -1,6 +1,5 @@
1
1
  import { FORMAT } from './constants.js';
2
2
  import StandardLogStream from './standard.js';
3
- import LegacyProgressLogStream from './progress.legacy.js';
4
3
  import ApiProgressLogStream from './api-progress.js';
5
4
  import DeleteProgressLogStream from './delete-progress.js';
6
5
 
@@ -8,7 +7,6 @@ export * from './constants.js';
8
7
 
9
8
  export function createLogStream({
10
9
  api,
11
- legacy,
12
10
  level,
13
11
  format,
14
12
  out,
@@ -18,9 +16,6 @@ export function createLogStream({
18
16
  case FORMAT.PROGRESS:
19
17
  switch (api) {
20
18
  case 'upload':
21
- if (legacy) {
22
- return new LegacyProgressLogStream({ out, err });
23
- }
24
19
  return new ApiProgressLogStream({ out, err });
25
20
  case 'delete':
26
21
  return new DeleteProgressLogStream({ out, err });
@@ -0,0 +1,74 @@
1
+ import { Transform } from 'stream';
2
+ import { trimObj } from '@miso.ai/server-commons';
3
+ import * as shim from './shim.js';
4
+
5
+ export function products({
6
+ cover_image,
7
+ url,
8
+ ...record
9
+ }) {
10
+ // category -> assume single, categories -> assume multiple
11
+ return trimObj({
12
+ cover_image: shim.url(cover_image),
13
+ url: shim.url(url),
14
+ ...record,
15
+ });
16
+ }
17
+
18
+ export function users({
19
+ ...record
20
+ }) {
21
+ return trimObj({
22
+ ...record,
23
+ });
24
+ }
25
+
26
+ export function interactions({
27
+ ...record
28
+ }) {
29
+ return trimObj({
30
+ ...record,
31
+ });
32
+ }
33
+
34
+ export function experimentEvents({
35
+ ...record
36
+ }) {
37
+ return trimObj({
38
+ ...record,
39
+ });
40
+ }
41
+
42
+ function getTransformFunction(type) {
43
+ switch (type) {
44
+ case 'products':
45
+ return products;
46
+ case 'users':
47
+ return users;
48
+ case 'interactions':
49
+ return interactions;
50
+ case 'experiment-events':
51
+ return experimentEvents;
52
+ default:
53
+ throw new Error(`Unrecognized record type: ${type}`);
54
+ }
55
+ }
56
+
57
+ export class Stream extends Transform {
58
+
59
+ constructor(type) {
60
+ super({
61
+ objectMode: true,
62
+ });
63
+ this._transformFn = getTransformFunction(type);
64
+ }
65
+
66
+ async _transform(record, _, next) {
67
+ try {
68
+ next(undefined, this._transformFn(record));
69
+ } catch (error) {
70
+ next(error);
71
+ }
72
+ }
73
+
74
+ }
@@ -0,0 +1,72 @@
1
+ import { asArray } from '@miso.ai/server-commons';
2
+
3
+ export const number = _applyGeneralPass(_number);
4
+ export const string = _applyGeneralPass(_string);
5
+ export const array = _applyGeneralPass(_array);
6
+ export const doubleArray = _applyGeneralPass(_doubleArray);
7
+ export const time = _applyGeneralPass(_time);
8
+ export const url = _applyGeneralPass(_url);
9
+
10
+ function _number(value) {
11
+ value = Number(value);
12
+ if (isNaN(value)) {
13
+ throw new Error();
14
+ }
15
+ return value;
16
+ }
17
+
18
+ function _string(value) {
19
+ // TODO
20
+ return value;
21
+ }
22
+
23
+ function _array(value) {
24
+ value = asArray(value).filter(isNotBlank);
25
+ return value.length ? value : undefined;
26
+ }
27
+
28
+ function _doubleArray(value) {
29
+ // TODO
30
+ value;
31
+ }
32
+
33
+ function _time(value) {
34
+ // TODO: how to handle timezone
35
+ return value;
36
+ }
37
+
38
+ function _url(value) {
39
+ // blank safe
40
+ if (isBlank(value) || typeof value !== 'string') {
41
+ return undefined;
42
+ }
43
+ // a wild guess on whether the URL has been encoded
44
+ if (!value.includes('%')) {
45
+ value = encodeURI(value);
46
+ }
47
+ // bounce on invalid URL
48
+ new URL(value);
49
+
50
+ return value;
51
+ }
52
+
53
+ function isNotBlank(value) {
54
+ return !isBlank(value);
55
+ }
56
+
57
+ function isBlank(value) {
58
+ return value === undefined || value === null || value === '';
59
+ }
60
+
61
+ function _applyGeneralPass(fn) {
62
+ return value => {
63
+ if (isBlank(value)) {
64
+ return undefined;
65
+ }
66
+ try {
67
+ return fn(value);
68
+ } catch(_) {
69
+ return value;
70
+ }
71
+ };
72
+ }
@@ -46,7 +46,7 @@ export default class ApiSink extends sink.BpsSink {
46
46
  throw error;
47
47
  }
48
48
  data = error.response.data;
49
- if (typeof data!== 'object') {
49
+ if (typeof data !== 'object') {
50
50
  data = trimObj({ errors: true, cause: data });
51
51
  }
52
52
  }
@@ -1,4 +1,5 @@
1
1
  import ApiSink from './api-sink.js';
2
+ import { batchDelete } from '../api/helpers.js';
2
3
 
3
4
  export default class DeleteSink extends ApiSink {
4
5
 
@@ -19,8 +20,7 @@ export default class DeleteSink extends ApiSink {
19
20
 
20
21
  async _execute(payload) {
21
22
  const { type, params } = this._options;
22
- const response = await this._client._delete(type, payload, { params });
23
- return response.data;
23
+ return batchDelete(this._client, type, payload, { params });
24
24
  }
25
25
 
26
26
  }
@@ -1,7 +1,7 @@
1
1
  import { stream, buffer } from '@miso.ai/server-commons';
2
2
  import version from '../version.js';
3
3
  import DeleteSink from './delete-sink.js';
4
- import DeletionStats from './deletion-state.js';
4
+ import DeletionStats from './deletion-stats.js';
5
5
 
6
6
  export default class DeleteStream extends stream.BufferedWriteStream {
7
7
 
@@ -1,4 +1,5 @@
1
1
  import ApiSink from './api-sink.js';
2
+ import { upload } from '../api/helpers.js';
2
3
 
3
4
  class UploadSink extends ApiSink {
4
5
 
@@ -23,7 +24,7 @@ class UploadSink extends ApiSink {
23
24
 
24
25
  async _execute(payload) {
25
26
  const { type, async, dryRun, params } = this._options;
26
- const response = await this._client.upload(type, payload, { async, dryRun, params });
27
+ const response = await upload(this._client, type, payload, { async, dryRun, params });
27
28
  return response.data;
28
29
  }
29
30
 
@@ -84,7 +85,7 @@ class ExperimentEventUploadSink extends UploadSink {
84
85
 
85
86
  async _execute(payload) {
86
87
  const { experimentId } = this._options;
87
- const response = await this._client.uploadExperimentEvent(experimentId, payload);
88
+ const response = await this._client.api.experiments.uploadEvent(experimentId, payload);
88
89
  return response.data;
89
90
  }
90
91
 
@@ -20,6 +20,7 @@ export default class UploadStream extends stream.BufferedWriteStream {
20
20
  // buffer
21
21
  recordsPerRequest,
22
22
  bytesPerRequest,
23
+ extra,
23
24
  } = {}) {
24
25
  super({
25
26
  name,
@@ -27,6 +28,7 @@ export default class UploadStream extends stream.BufferedWriteStream {
27
28
  version,
28
29
  objectMode,
29
30
  heartbeatInterval,
31
+ extra,
30
32
  });
31
33
 
32
34
  this._client = client;
package/src/version.js CHANGED
@@ -1 +1 @@
1
- export default '0.6.0-beta.1';
1
+ export default '0.6.2-beta.0';
@@ -1,60 +0,0 @@
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
- }
@@ -1,410 +0,0 @@
1
- import { Transform } from 'stream';
2
- import { trimObj, log } from '@miso.ai/server-commons';
3
- import version from '../version.js';
4
-
5
- function getDefaultRecordsPerRequest(type) {
6
- switch (type) {
7
- case 'interactions':
8
- return 1000;
9
- default:
10
- return 200;
11
- }
12
- }
13
-
14
- function normalizeParams(params = []) {
15
- return params.reduce((acc, param) => {
16
- const [key, value = '1'] = param.split('=');
17
- acc[key] = value;
18
- return acc;
19
- }, {});
20
- }
21
-
22
- const PAYLOAD_PREFIX = '{"data":[';
23
- const PAYLOAD_SUFFIX = ']}';
24
- const PAYLOAD_OVERHEAD_BYTES = (PAYLOAD_PREFIX.length + PAYLOAD_SUFFIX.length) * 2;
25
-
26
- const MIN_HREATBEAT = 100;
27
-
28
- const requestPromises = new WeakMap();
29
-
30
- export default class LegacyUploadStream extends Transform {
31
-
32
- constructor(client, type, {
33
- name,
34
- objectMode,
35
- async,
36
- dryRun,
37
- params,
38
- heartbeat,
39
- recordsPerRequest,
40
- bytesPerRequest,
41
- bytesPerSecond,
42
- experimentId,
43
- } = {}) {
44
- super({
45
- readableObjectMode: true,
46
- writableObjectMode: objectMode,
47
- });
48
- this._client = client;
49
- this._type = type;
50
- this._options = {
51
- name,
52
- objectMode: !!objectMode,
53
- async: !!async,
54
- dryRun: !!dryRun,
55
- params: normalizeParams(params),
56
- heartbeat,
57
- recordsPerRequest: recordsPerRequest || getDefaultRecordsPerRequest(type),
58
- bytesPerRequest: bytesPerRequest || 1024 * 1024,
59
- bytesPerSecond: bytesPerSecond || 4 * 1024 * 1024,
60
- experimentId,
61
- };
62
- if (heartbeat !== undefined && (typeof heartbeat !== 'number') || heartbeat < MIN_HREATBEAT) {
63
- throw new Error(`Heartbeat must be a number at least ${MIN_HREATBEAT}: ${heartbeat}`);
64
- }
65
- this._singleRecordPerRequest = false;
66
-
67
- switch (type) {
68
- case 'experiment-events':
69
- if (!experimentId) {
70
- throw new Error(`Experiment id is required for experiment APIs`);
71
- }
72
- this._singleRecordPerRequest = true;
73
- }
74
-
75
- this._state = new State();
76
- this._resetBuffer();
77
- // log functions
78
- for (const level of log.LEVELS) {
79
- this[`_${level}`] = this._log.bind(this, level);
80
- }
81
- }
82
-
83
- _construct(done) {
84
- const { config } = this;
85
- this._info('construct', { config });
86
- const heartbeat = this._options.heartbeat;
87
- if (heartbeat) {
88
- this._heartbeatIntervalId = setInterval(this._heartbeat.bind(this), heartbeat);
89
- }
90
- done();
91
- }
92
-
93
- _transform(record, _, next) {
94
- this._pushStartEventIfNecessary();
95
- const { objectMode, bytesPerRequest } = this._options;
96
- const str = objectMode ? JSON.stringify(record) : record;
97
- const newSize = str.length * 2;
98
-
99
- if (this._singleRecordPerRequest) {
100
- this._buffer.content = str;
101
- this._buffer.bytes = newSize;
102
- this._buffer.records = 1;
103
- this._dispatch();
104
-
105
- } else {
106
- if (this._buffer.records && this._buffer.bytes + newSize >= bytesPerRequest) {
107
- // flush previous records if this record is large enough to exceed BPR threshold
108
- this._dispatch();
109
- }
110
- if (this._buffer.records > 0) {
111
- this._buffer.content += ',';
112
- }
113
- this._buffer.content += str;
114
- this._buffer.bytes += newSize;
115
- this._buffer.records++;
116
-
117
- this._dispatchIfNecessary();
118
- }
119
-
120
- const restTime = this._state.restTime(this._getBpsLimit());
121
- if (restTime > 0) {
122
- this._debug('rest', { restTime });
123
- setTimeout(next, restTime);
124
- } else if (this._state._pending.length > 15) {
125
- // TODO: figure out best strategy on this
126
- // release event loop for downstream
127
- setTimeout(next);
128
- } else {
129
- next();
130
- }
131
- }
132
-
133
- _log(level, event, args = {}) {
134
- this.push(trimObj({
135
- name: this._options.name,
136
- level,
137
- event,
138
- timestamp: Date.now(),
139
- ...args,
140
- state: this.state,
141
- }));
142
- }
143
-
144
- _heartbeat() {
145
- this._log(log.DEBUG, 'heartbeat');
146
- }
147
-
148
- async _flush(done) {
149
- this._pushStartEventIfNecessary();
150
- this._dispatch();
151
- await Promise.all(this._state.pending.map(r => requestPromises.get(r)));
152
- const { successful, failed } = this.state;
153
-
154
- if (this._heartbeatIntervalId) {
155
- clearInterval(this._heartbeatIntervalId);
156
- delete this._heartbeatIntervalId;
157
- }
158
-
159
- this._info('end', { successful, failed });
160
- done();
161
- }
162
-
163
- get state() {
164
- return this._state.export();
165
- }
166
-
167
- get config() {
168
- return Object.freeze({
169
- version,
170
- type: this._type,
171
- ...this._client.options,
172
- ...this._options,
173
- });
174
- }
175
-
176
- // helper //
177
- _pushStartEventIfNecessary() {
178
- if (this._state.next.request === 0 && this._buffer.records === 0) {
179
- this._info('start');
180
- }
181
- }
182
-
183
- _dispatchIfNecessary() {
184
- const { records, bytes } = this._buffer;
185
- const { recordsPerRequest, bytesPerRequest } = this._options;
186
- if (records > 0 && (records >= recordsPerRequest || bytes >= bytesPerRequest)) {
187
- this._dispatch();
188
- }
189
- }
190
-
191
- _dispatch() {
192
- const singleRecord = this._singleRecordPerRequest;
193
- const { records, bytes, content } = this._resetBuffer();
194
- if (records === 0) {
195
- return;
196
- }
197
- const request = this._state.createRequest(records, bytes);
198
-
199
- let requestResolve;
200
- requestPromises.set(request, new Promise(r => {
201
- requestResolve = r;
202
- }));
203
-
204
- this._debug('request', { request });
205
-
206
- this._state.open(request);
207
-
208
- const payload = singleRecord ? content : content + PAYLOAD_SUFFIX;
209
-
210
- (async () => {
211
- let response;
212
- try {
213
- response = await this._upload(payload);
214
- } catch(error) {
215
- response = !error.response ? trimObj({ errors: true, cause: error.message }) :
216
- typeof error.response.data !== 'object' ? trimObj({ errors: true, cause: error.response.data }) :
217
- error.response.data;
218
- }
219
- response.timestamp = Date.now();
220
- response.took = response.took || 0; // TODO: ad-hoc
221
-
222
- requestResolve();
223
- this._state.close(request, response);
224
-
225
- const failed = response.errors;
226
-
227
- (failed ? this._error : this._debug)('response', { request, response, payload: failed ? JSON.parse(payload) : undefined });
228
- this._info('upload', {
229
- result: failed ? 'failed' : 'successful',
230
- index: request.index,
231
- records: request.records,
232
- bytes: request.bytes,
233
- took: response.took,
234
- latency: response.timestamp - request.timestamp - response.took
235
- });
236
- })();
237
- }
238
-
239
- async _upload(payload) {
240
- switch (this._type) {
241
- case 'experiment-events':
242
- const { experimentId } = this._options;
243
- return (await this._client.uploadExperimentEvent(experimentId, payload)).data;
244
- default:
245
- const { async, dryRun, params } = this._options;
246
- const response = await this._client.upload(this._type, payload, { async, dryRun, params });
247
- return response.data;
248
- }
249
- }
250
-
251
- _resetBuffer() {
252
- const buffer = { ...this._buffer };
253
- this._buffer = {
254
- records: 0,
255
- bytes: PAYLOAD_OVERHEAD_BYTES,
256
- content: PAYLOAD_PREFIX,
257
- };
258
- return buffer;
259
- }
260
-
261
- /**
262
- * Note that when API is effectively in async mode, apiBps will be overestimated, but there is no harm to respect it.
263
- */
264
- _getBpsLimit() {
265
- const { bytesPerSecond } = this._options;
266
- const { pending, apiBps, completed } = this.state;
267
- // TODO: threshold to switch to real API BPS should be based on bytes, not requests
268
- // respect API BPS only when
269
- // 1. has pending requests
270
- // 2. has enouch data points from completed requests
271
- return pending.length > 0 && completed.requests > 9 && !isNaN(apiBps) && apiBps < bytesPerSecond ? apiBps : bytesPerSecond;
272
- }
273
-
274
- }
275
-
276
- class State {
277
-
278
- constructor() {
279
- this._start = Date.now();
280
- this._next = Object.freeze({
281
- request: 0,
282
- record: 0,
283
- });
284
- this._pending = [];
285
- this._successful = {
286
- requests: 0,
287
- records: 0,
288
- bytes: 0,
289
- took: 0,
290
- latency: 0,
291
- };
292
- this._failed = {
293
- requests: 0,
294
- records: 0,
295
- bytes: 0,
296
- took: 0,
297
- latency: 0,
298
- };
299
- }
300
-
301
- get start() {
302
- return this._start;
303
- }
304
-
305
- get next() {
306
- return this._next;
307
- }
308
-
309
- get pending() {
310
- return Object.freeze(this._pending.slice());
311
- }
312
-
313
- get successful() {
314
- return Object.freeze({ ...this._successful });
315
- }
316
-
317
- get failed() {
318
- return Object.freeze({ ...this._failed });
319
- }
320
-
321
- elapsed(time) {
322
- return (time || Date.now()) - this.start;
323
- }
324
-
325
- createRequest(records, bytes) {
326
- const { next } = this;
327
- const request = Object.freeze({
328
- index: next.request,
329
- recordOffset: next.record,
330
- records,
331
- bytes,
332
- timestamp: Date.now(),
333
- });
334
- this._next = Object.freeze({
335
- request: next.request + 1,
336
- record: next.record + records,
337
- });
338
- return request;
339
- }
340
-
341
- get sent() {
342
- const { _pending, completed } = this;
343
- return Object.freeze(_pending.reduce((acc, request) => {
344
- acc.requests++;
345
- acc.records += request.records;
346
- acc.bytes += request.bytes;
347
- return acc;
348
- }, {
349
- requests: completed.requests,
350
- records: completed.records,
351
- bytes: completed.bytes,
352
- }));
353
- }
354
-
355
- get completed() {
356
- const { _successful, _failed } = this;
357
- return Object.freeze({
358
- requests: _successful.requests + _failed.requests,
359
- records: _successful.records + _failed.records,
360
- bytes: _successful.bytes + _failed.bytes,
361
- took: _successful.took + _failed.took,
362
- latency: _successful.latency + _failed.latency,
363
- });
364
- }
365
-
366
- sentBps(timestamp) {
367
- const { sent } = this;
368
- const elapsed = this.elapsed(timestamp);
369
- return elapsed > 0 ? sent.bytes / elapsed * 1000 : NaN;
370
- }
371
-
372
- get apiBps() {
373
- const { _successful, _failed } = this;
374
- const took = _successful.took + _failed.took;
375
- const bytes = _successful.bytes + _failed.bytes;
376
- return took > 0 ? bytes / took * 1000 : NaN;
377
- }
378
-
379
- restTime(bps) {
380
- const elapsed = this.elapsed();
381
- const { sent } = this;
382
- return Math.max(0, sent.bytes / bps * 1000 - elapsed) * 1.05;
383
- }
384
-
385
- export(timestamp) {
386
- const { next, pending, successful, failed, sent, completed, apiBps } = this;
387
- timestamp = timestamp || Date.now();
388
- return Object.freeze({
389
- next, pending, sent, successful, failed, completed,
390
- elapsed: this.elapsed(timestamp),
391
- apiBps,
392
- sentBps: this.sentBps(timestamp),
393
- });
394
- }
395
-
396
- open(request) {
397
- this._pending.push(request);
398
- }
399
-
400
- close(request, response) {
401
- this._pending = this._pending.filter(r => r.index !== request.index);
402
- const category = response.errors ? this._failed : this._successful;
403
- category.requests++;
404
- category.records += request.records;
405
- category.bytes += request.bytes;
406
- category.took += response.took;
407
- category.latency += response.timestamp - request.timestamp - response.took;
408
- }
409
-
410
- }