@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,410 @@
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
+ }
package/src/version.js ADDED
@@ -0,0 +1 @@
1
+ export default '0.6.0-beta.0';