@milaboratories/pl-client 2.4.20 → 2.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milaboratories/pl-client",
3
- "version": "2.4.20",
3
+ "version": "2.5.0",
4
4
  "description": "New TS/JS client for Platform API",
5
5
  "types": "./dist/index.d.ts",
6
6
  "main": "./dist/index.js",
@@ -23,6 +23,7 @@
23
23
  "@protobuf-ts/runtime-rpc": "^2.9.4",
24
24
  "canonicalize": "^2.0.0",
25
25
  "denque": "^2.1.0",
26
+ "lru-cache": "^11.0.1",
26
27
  "https-proxy-agent": "^7.0.5",
27
28
  "cacheable-lookup": "^6.1.0",
28
29
  "long": "^5.2.3",
@@ -41,7 +42,7 @@
41
42
  "jest": "^29.7.0",
42
43
  "@jest/globals": "^29.7.0",
43
44
  "ts-jest": "^29.2.5",
44
- "@milaboratories/platforma-build-configs": "1.0.1"
45
+ "@milaboratories/platforma-build-configs": "1.0.2"
45
46
  },
46
47
  "scripts": {
47
48
  "type-check": "tsc --noEmit --composite false",
@@ -0,0 +1,9 @@
1
+ import { BasicResourceData, ResourceData } from './types';
2
+
3
+ export type ResourceDataCacheRecord = {
4
+ /** There is a slight chance of inconsistent data retrieval from tx if we allow later transactions to leak resource data into earlier transactions.
5
+ * This field allows to prevent this. */
6
+ cacheTxOpenTimestamp: number;
7
+ data: ResourceData | undefined;
8
+ readonly basicData: BasicResourceData;
9
+ };
@@ -20,6 +20,9 @@ import { PlDriver, PlDriverDefinition } from './driver';
20
20
  import { MaintenanceAPI_Ping_Response } from '../proto/github.com/milaboratory/pl/plapi/plapiproto/api';
21
21
  import * as tp from 'node:timers/promises';
22
22
  import { Dispatcher } from 'undici';
23
+ import { LRUCache } from 'lru-cache';
24
+ import { ResourceDataCacheRecord } from './cache';
25
+ import { DefaultFinalResourceDataPredicate, FinalResourceDataPredicate } from './final';
23
26
 
24
27
  export type TxOps = PlCallOps & {
25
28
  sync?: boolean;
@@ -56,17 +59,33 @@ export class PlClient {
56
59
 
57
60
  private _serverInfo: MaintenanceAPI_Ping_Response | undefined = undefined;
58
61
 
62
+ //
63
+ // Caching
64
+ //
65
+
66
+ /** This function determines whether resource data can be cached */
67
+ public readonly finalPredicate: FinalResourceDataPredicate;
68
+
69
+ /** Resource data cache, to minimize redundant data rereading from remote db */
70
+ private readonly resourceDataCache: LRUCache<ResourceId, ResourceDataCacheRecord>;
71
+
59
72
  private constructor(
60
73
  configOrAddress: PlClientConfig | string,
61
74
  auth: AuthOps,
62
75
  ops: {
63
76
  statusListener?: PlConnectionStatusListener;
77
+ finalPredicate?: FinalResourceDataPredicate;
64
78
  } = {}
65
79
  ) {
66
80
  this.ll = new LLPlClient(configOrAddress, { auth, ...ops });
67
81
  const conf = this.ll.conf;
68
82
  this.txDelay = conf.txDelay;
69
83
  this.forceSync = conf.forceSync;
84
+ this.finalPredicate = ops.finalPredicate ?? DefaultFinalResourceDataPredicate;
85
+ this.resourceDataCache = new LRUCache({
86
+ maxSize: conf.maxCacheBytes,
87
+ sizeCalculation: (v) => (v.basicData.data?.length ?? 0) + 64
88
+ });
70
89
  switch (conf.retryBackoffAlgorithm) {
71
90
  case 'exponential':
72
91
  this.defaultRetryOptions = {
@@ -199,7 +218,14 @@ export class PlClient {
199
218
  // opening low-level tx
200
219
  const llTx = this.ll.createTx(ops);
201
220
  // wrapping it into high-level tx (this also asynchronously sends initialization message)
202
- const tx = new PlTransaction(llTx, name, writable, clientRoot);
221
+ const tx = new PlTransaction(
222
+ llTx,
223
+ name,
224
+ writable,
225
+ clientRoot,
226
+ this.finalPredicate,
227
+ this.resourceDataCache
228
+ );
203
229
 
204
230
  let ok = false;
205
231
  let result: T | undefined = undefined;
@@ -45,6 +45,9 @@ export interface PlClientConfig {
45
45
  /** Last resort measure to solve complicated race conditions in pl. */
46
46
  forceSync: boolean;
47
47
 
48
+ /** Maximal number of bytes of resource state to cache */
49
+ maxCacheBytes: number;
50
+
48
51
  //
49
52
  // Retry
50
53
  //
@@ -54,23 +57,30 @@ export interface PlClientConfig {
54
57
  * (pl uses optimistic transaction model with regular retries in write transactions)
55
58
  * */
56
59
  retryBackoffAlgorithm: 'exponential' | 'linear';
60
+
57
61
  /** Maximal number of attempts in */
58
62
  retryMaxAttempts: number;
63
+
59
64
  /** Delay after first failed attempt, in ms. */
60
65
  retryInitialDelay: number;
66
+
61
67
  /** Each time delay will be multiplied by this number (1.5 means plus on 50% each attempt) */
62
68
  retryExponentialBackoffMultiplier: number;
69
+
63
70
  /** [used only for ] This value will be added to the delay from the previous step, in ms */
64
71
  retryLinearBackoffStep: number;
72
+
65
73
  /** Value from 0 to 1, determine level of randomness to introduce to the backoff delays sequence. (0 meaning no randomness) */
66
74
  retryJitter: number;
67
75
  }
68
76
 
69
- export const DEFAULT_REQUEST_TIMEOUT = 1000;
70
- export const DEFAULT_TX_TIMEOUT = 10_000;
77
+ export const DEFAULT_REQUEST_TIMEOUT = 2000;
78
+ export const DEFAULT_TX_TIMEOUT = 30_000;
71
79
  export const DEFAULT_TOKEN_TTL_SECONDS = 31 * 24 * 60 * 60;
72
80
  export const DEFAULT_AUTH_MAX_REFRESH = 12 * 24 * 60 * 60;
73
81
 
82
+ export const DEFAULT_MAX_CACHE_BYTES = 35_000_000; // 35 Mb
83
+
74
84
  export const DEFAULT_RETRY_BACKOFF_ALGORITHM = 'exponential';
75
85
  export const DEFAULT_RETRY_MAX_ATTEMPTS = 10;
76
86
  export const DEFAULT_RETRY_INITIAL_DELAY = 4; // 4 ms
@@ -110,6 +120,8 @@ export function plAddressToConfig(
110
120
  txDelay: 0,
111
121
  forceSync: false,
112
122
 
123
+ maxCacheBytes: DEFAULT_MAX_CACHE_BYTES,
124
+
113
125
  retryBackoffAlgorithm: DEFAULT_RETRY_BACKOFF_ALGORITHM,
114
126
  retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
115
127
  retryInitialDelay: DEFAULT_RETRY_INITIAL_DELAY,
@@ -149,6 +161,8 @@ export function plAddressToConfig(
149
161
  txDelay: parseInt(url.searchParams.get('tx-delay')) ?? 0,
150
162
  forceSync: Boolean(url.searchParams.get('force-sync')) ?? false,
151
163
 
164
+ maxCacheBytes: parseInt(url.searchParams.get('max-cache-bytes')) ?? DEFAULT_MAX_CACHE_BYTES,
165
+
152
166
  retryBackoffAlgorithm: (url.searchParams.get('retry-backoff-algorithm') ??
153
167
  DEFAULT_RETRY_BACKOFF_ALGORITHM) as any,
154
168
  retryMaxAttempts:
@@ -0,0 +1,84 @@
1
+ import { Optional } from 'utility-types';
2
+ import { BasicResourceData, isNotNullResourceId, isNullResourceId, ResourceData } from './types';
3
+
4
+ /**
5
+ * Function is used to guide multiple layers of caching in pl-client and derived pl-tree.
6
+ *
7
+ * This function defines expected resource-specific state mutation behaviour,
8
+ * if it returns true, system will expect that this data will never change as long as resource exist.
9
+ *
10
+ * If resource data contain information about fields, if should be taken into account, fields are undefined,
11
+ * "final" state should be calculated for "basic" part of resource data only.
12
+ */
13
+ export type FinalResourceDataPredicate = (
14
+ resourceData: Optional<ResourceData, 'fields'>
15
+ ) => boolean;
16
+
17
+ function readyOrDuplicateOrError(r: ResourceData | BasicResourceData): boolean {
18
+ return (
19
+ r.resourceReady || isNotNullResourceId(r.originalResourceId) || isNotNullResourceId(r.error)
20
+ );
21
+ }
22
+
23
+ function readyAndHasAllOutputsFilled(r: Optional<ResourceData, 'fields'>): boolean {
24
+ if (!readyOrDuplicateOrError(r)) return false;
25
+ if (!r.outputsLocked) return false;
26
+ if (r.fields === undefined) return true; // if fields are not provided basic resource state is not expected to change in the future
27
+ for (const f of r.fields)
28
+ if (isNullResourceId(f.error) && (isNullResourceId(f.value) || !f.valueIsFinal)) return false;
29
+ return true;
30
+ }
31
+
32
+ // solaly for logging
33
+ const unknownResourceTypeNames = new Set<string>();
34
+
35
+ /** Default implementation, defining behaviour for built-in resource types. */
36
+ export const DefaultFinalResourceDataPredicate: FinalResourceDataPredicate = (r): boolean => {
37
+ switch (r.type.name) {
38
+ case 'StdMap':
39
+ case 'std/map':
40
+ case 'EphStdMap':
41
+ case 'PFrame':
42
+ case 'BContext':
43
+ case 'BlockPackCustom':
44
+ case 'BinaryMap':
45
+ case 'BinaryValue':
46
+ case 'BlobMap':
47
+ case 'BResolveSingle':
48
+ case 'BResolveSingleNoResult':
49
+ case 'BQueryResult':
50
+ case 'TengoTemplate':
51
+ case 'TengoLib':
52
+ case 'SoftwareInfo':
53
+ case 'Dummy':
54
+ return readyOrDuplicateOrError(r);
55
+ case 'json/resourceError':
56
+ return r.type.version === '1';
57
+ case 'json/object':
58
+ case 'json/string':
59
+ case 'json/array':
60
+ case 'json/number':
61
+ case 'BContextEnd':
62
+ case 'Frontend/FromUrl':
63
+ case 'Frontend/FromFolder':
64
+ case 'BObjectSpec':
65
+ return true;
66
+ case 'UserProject':
67
+ return false;
68
+ default:
69
+ if (r.type.name.startsWith('Blob/')) return true;
70
+ else if (r.type.name.startsWith('BlobUpload/')) {
71
+ return readyAndHasAllOutputsFilled(r);
72
+ } else if (r.type.name.startsWith('PColumnData/')) {
73
+ return readyOrDuplicateOrError(r);
74
+ } else {
75
+ // Unknonw resource type detected
76
+ // Set used to log this message only once
77
+ if (!unknownResourceTypeNames.has(r.type.name)) {
78
+ console.log('UNKNOWN RESOURCE TYPE: ' + r.type.name);
79
+ unknownResourceTypeNames.add(r.type.name);
80
+ }
81
+ }
82
+ }
83
+ return false;
84
+ };
@@ -77,6 +77,13 @@ export class LLPlClient {
77
77
 
78
78
  grpcInterceptors.push(this.createErrorInterceptor());
79
79
 
80
+ //
81
+ // Leaving it here for now
82
+ // https://github.com/grpc/grpc-node/issues/2788
83
+ //
84
+ // We should implement message pooling algorithm to overcome hardcoded NO_DELAY behaviour
85
+ // of HTTP/2 and allow our small messages to batch together.
86
+ //
80
87
  const grpcOptions: GrpcOptions = {
81
88
  host: this.conf.hostAndPort,
82
89
  timeout: this.conf.defaultRequestTimeout,
@@ -84,7 +91,7 @@ export class LLPlClient {
84
91
  ? ChannelCredentials.createSsl()
85
92
  : ChannelCredentials.createInsecure(),
86
93
  clientOptions: {
87
- 'grpc.use_local_subchannel_pool': 1,
94
+ 'grpc.keepalive_time_ms': 30_000, // 30 seconds
88
95
  interceptors: grpcInterceptors
89
96
  }
90
97
  };
@@ -11,7 +11,9 @@ import {
11
11
  ResourceData,
12
12
  ResourceId,
13
13
  ResourceType,
14
- FutureFieldType
14
+ FutureFieldType,
15
+ isLocalResourceId,
16
+ extractBasicResourceData
15
17
  } from './types';
16
18
  import {
17
19
  ClientMessageRequest,
@@ -25,6 +27,9 @@ import { toBytes } from '../util/util';
25
27
  import { fieldTypeToProto, protoToField, protoToResource } from './type_conversion';
26
28
  import { notEmpty } from '@milaboratories/ts-helpers';
27
29
  import { isNotFoundError } from './errors';
30
+ import { FinalResourceDataPredicate } from './final';
31
+ import { LRUCache } from 'lru-cache';
32
+ import { ResourceDataCacheRecord } from './cache';
28
33
 
29
34
  /** Reference to resource, used only within transaction */
30
35
  export interface ResourceRef {
@@ -132,6 +137,9 @@ export class PlTransaction {
132
137
  private readonly globalTxId: Promise<bigint>;
133
138
  private readonly localTxId: number = PlTransaction.nextLocalTxId();
134
139
 
140
+ /** Used in caching */
141
+ private readonly txOpenTimestamp = Date.now();
142
+
135
143
  private localResourceIdCounter = 0;
136
144
 
137
145
  /** Store logical tx open / closed state to prevent invalid sequence of requests.
@@ -148,7 +156,9 @@ export class PlTransaction {
148
156
  private readonly ll: LLPlTransaction,
149
157
  public readonly name: string,
150
158
  public readonly writable: boolean,
151
- private readonly _clientRoot: OptionalResourceId
159
+ private readonly _clientRoot: OptionalResourceId,
160
+ private readonly finalPredicate: FinalResourceDataPredicate,
161
+ private readonly sharedResourceDataCache: LRUCache<ResourceId, ResourceDataCacheRecord>
152
162
  ) {
153
163
  // initiating transaction
154
164
  this.globalTxId = this.sendSingleAndParse(
@@ -436,23 +446,82 @@ export class PlTransaction {
436
446
  );
437
447
  }
438
448
 
449
+ /** This method may return stale resource state from cache if resource was removed */
439
450
  public async getResourceData(rId: AnyResourceRef, loadFields: true): Promise<ResourceData>;
451
+ /** This method may return stale resource state from cache if resource was removed */
440
452
  public async getResourceData(rId: AnyResourceRef, loadFields: false): Promise<BasicResourceData>;
453
+ /** This method may return stale resource state from cache if resource was removed */
441
454
  public async getResourceData(
442
455
  rId: AnyResourceRef,
443
456
  loadFields: boolean
444
457
  ): Promise<BasicResourceData | ResourceData>;
458
+ /** This method may return stale resource state from cache if ignoreCache == false if resource was removed */
445
459
  public async getResourceData(
446
460
  rId: AnyResourceRef,
447
- loadFields: boolean = true
461
+ loadFields: true,
462
+ ignoreCache: boolean
463
+ ): Promise<ResourceData>;
464
+ /** This method may return stale resource state from cache if ignoreCache == false if resource was removed */
465
+ public async getResourceData(
466
+ rId: AnyResourceRef,
467
+ loadFields: false,
468
+ ignoreCache: boolean
469
+ ): Promise<BasicResourceData>;
470
+ /** This method may return stale resource state from cache if ignoreCache == false if resource was removed */
471
+ public async getResourceData(
472
+ rId: AnyResourceRef,
473
+ loadFields: boolean,
474
+ ignoreCache: boolean
475
+ ): Promise<BasicResourceData | ResourceData>;
476
+ public async getResourceData(
477
+ rId: AnyResourceRef,
478
+ loadFields: boolean = true,
479
+ ignoreCache: boolean = false
448
480
  ): Promise<BasicResourceData | ResourceData> {
449
- return await this.sendSingleAndParse(
481
+ if (!ignoreCache && !isResourceRef(rId) && !isLocalResourceId(rId)) {
482
+ // checking if we can return result from cache
483
+ const fromCache = this.sharedResourceDataCache.get(rId);
484
+ if (fromCache && fromCache.cacheTxOpenTimestamp < this.txOpenTimestamp) {
485
+ if (!loadFields) return fromCache.basicData;
486
+ else if (fromCache.data) return fromCache.data;
487
+ }
488
+ }
489
+
490
+ const result = await this.sendSingleAndParse(
450
491
  {
451
492
  oneofKind: 'resourceGet',
452
493
  resourceGet: { resourceId: toResourceId(rId), loadFields: loadFields }
453
494
  },
454
495
  (r) => protoToResource(notEmpty(r.resourceGet.resource))
455
496
  );
497
+
498
+ // we will cache only final resource data states
499
+ // caching result even if we were ignore the cache
500
+ if (!isResourceRef(rId) && !isLocalResourceId(rId) && this.finalPredicate(result)) {
501
+ const fromCache = this.sharedResourceDataCache.get(rId);
502
+ if (fromCache) {
503
+ if (loadFields && !fromCache.data) {
504
+ fromCache.data = result;
505
+ // updating timestamp becuse we updated the record
506
+ fromCache.cacheTxOpenTimestamp = this.txOpenTimestamp;
507
+ }
508
+ } else {
509
+ if (loadFields)
510
+ this.sharedResourceDataCache.set(rId, {
511
+ basicData: extractBasicResourceData(result),
512
+ data: result,
513
+ cacheTxOpenTimestamp: this.txOpenTimestamp
514
+ });
515
+ else
516
+ this.sharedResourceDataCache.set(rId, {
517
+ basicData: extractBasicResourceData(result),
518
+ data: undefined,
519
+ cacheTxOpenTimestamp: this.txOpenTimestamp
520
+ });
521
+ }
522
+ }
523
+
524
+ return result;
456
525
  }
457
526
 
458
527
  public async getResourceDataIfExists(
@@ -471,7 +540,17 @@ export class PlTransaction {
471
540
  rId: AnyResourceRef,
472
541
  loadFields: boolean = true
473
542
  ): Promise<BasicResourceData | ResourceData | undefined> {
474
- return notFoundToUndefined(async () => await this.getResourceData(rId, loadFields));
543
+ // calling this mehtod will ignore cache, because user intention is to detect resource absence
544
+ // which cache will prevent
545
+ const result = await notFoundToUndefined(
546
+ async () => await this.getResourceData(rId, loadFields, true)
547
+ );
548
+
549
+ // cleaning cache record if resorce was removed from the db
550
+ if (result === undefined && !isResourceRef(rId) && !isLocalResourceId(rId))
551
+ this.sharedResourceDataCache.delete(rId);
552
+
553
+ return result;
475
554
  }
476
555
 
477
556
  /**
package/src/core/types.ts CHANGED
@@ -70,7 +70,7 @@ export function resourceTypesEqual(type1: ResourceType, type2: ResourceType): bo
70
70
  }
71
71
 
72
72
  /** Readonly fields here marks properties of resource that can't change according to pl's state machine. */
73
- export interface BasicResourceData {
73
+ export type BasicResourceData = {
74
74
  readonly id: ResourceId;
75
75
  readonly originalResourceId: OptionalResourceId;
76
76
 
@@ -88,30 +88,57 @@ export interface BasicResourceData {
88
88
  /** This value is derived from resource state by the server and can be used as
89
89
  * a robust criteria to determine resource is in final state. */
90
90
  readonly final: boolean;
91
+ };
92
+
93
+ export function extractBasicResourceData(rd: ResourceData): BasicResourceData {
94
+ const {
95
+ id,
96
+ originalResourceId,
97
+ kind,
98
+ type,
99
+ data,
100
+ error,
101
+ inputsLocked,
102
+ outputsLocked,
103
+ resourceReady,
104
+ final
105
+ } = rd;
106
+ return {
107
+ id,
108
+ originalResourceId,
109
+ kind,
110
+ type,
111
+ data,
112
+ error,
113
+ inputsLocked,
114
+ outputsLocked,
115
+ resourceReady,
116
+ final
117
+ };
91
118
  }
92
119
 
93
120
  export const jsonToData = (data: unknown) => Buffer.from(JSON.stringify(data));
94
121
 
95
122
  export const resDataToJson = (res: ResourceData) => JSON.parse(notEmpty(res.data).toString());
96
123
 
97
- export interface ResourceData extends BasicResourceData {
98
- fields: FieldData[];
99
- }
124
+ export type ResourceData = BasicResourceData & {
125
+ readonly fields: FieldData[];
126
+ };
100
127
 
101
128
  export function getField(r: ResourceData, name: string): FieldData {
102
129
  return notEmpty(r.fields.find((f) => f.name === name));
103
130
  }
104
131
 
105
- export interface FieldData {
106
- name: string;
107
- type: FieldType;
108
- status: FieldStatus;
109
- value: OptionalResourceId;
110
- error: OptionalResourceId;
132
+ export type FieldData = {
133
+ readonly name: string;
134
+ readonly type: FieldType;
135
+ readonly status: FieldStatus;
136
+ readonly value: OptionalResourceId;
137
+ readonly error: OptionalResourceId;
111
138
 
112
139
  /** True if value the fields points to is in final state. */
113
- valueIsFinal: boolean;
114
- }
140
+ readonly valueIsFinal: boolean;
141
+ };
115
142
 
116
143
  //
117
144
  // Local / Global ResourceId arithmetics