@milaboratories/pl-drivers 1.2.16

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.
Files changed (117) hide show
  1. package/README.md +18 -0
  2. package/dist/clients/download.d.ts +30 -0
  3. package/dist/clients/download.d.ts.map +1 -0
  4. package/dist/clients/helpers.d.ts +14 -0
  5. package/dist/clients/helpers.d.ts.map +1 -0
  6. package/dist/clients/logs.d.ts +26 -0
  7. package/dist/clients/logs.d.ts.map +1 -0
  8. package/dist/clients/ls_api.d.ts +13 -0
  9. package/dist/clients/ls_api.d.ts.map +1 -0
  10. package/dist/clients/progress.d.ts +25 -0
  11. package/dist/clients/progress.d.ts.map +1 -0
  12. package/dist/clients/upload.d.ts +38 -0
  13. package/dist/clients/upload.d.ts.map +1 -0
  14. package/dist/drivers/download_and_logs_blob.d.ts +106 -0
  15. package/dist/drivers/download_and_logs_blob.d.ts.map +1 -0
  16. package/dist/drivers/download_url.d.ts +70 -0
  17. package/dist/drivers/download_url.d.ts.map +1 -0
  18. package/dist/drivers/helpers/files_cache.d.ts +28 -0
  19. package/dist/drivers/helpers/files_cache.d.ts.map +1 -0
  20. package/dist/drivers/helpers/helpers.d.ts +34 -0
  21. package/dist/drivers/helpers/helpers.d.ts.map +1 -0
  22. package/dist/drivers/helpers/ls_list_entry.d.ts +49 -0
  23. package/dist/drivers/helpers/ls_list_entry.d.ts.map +1 -0
  24. package/dist/drivers/helpers/ls_storage_entry.d.ts +25 -0
  25. package/dist/drivers/helpers/ls_storage_entry.d.ts.map +1 -0
  26. package/dist/drivers/helpers/polling_ops.d.ts +8 -0
  27. package/dist/drivers/helpers/polling_ops.d.ts.map +1 -0
  28. package/dist/drivers/helpers/test_helpers.d.ts +2 -0
  29. package/dist/drivers/helpers/test_helpers.d.ts.map +1 -0
  30. package/dist/drivers/logs.d.ts +29 -0
  31. package/dist/drivers/logs.d.ts.map +1 -0
  32. package/dist/drivers/logs_stream.d.ts +50 -0
  33. package/dist/drivers/logs_stream.d.ts.map +1 -0
  34. package/dist/drivers/ls.d.ts +30 -0
  35. package/dist/drivers/ls.d.ts.map +1 -0
  36. package/dist/drivers/upload.d.ts +87 -0
  37. package/dist/drivers/upload.d.ts.map +1 -0
  38. package/dist/helpers/download.d.ts +15 -0
  39. package/dist/helpers/download.d.ts.map +1 -0
  40. package/dist/index.cjs +2 -0
  41. package/dist/index.cjs.map +1 -0
  42. package/dist/index.d.ts +15 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +4627 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/proto/github.com/milaboratory/pl/controllers/shared/grpc/downloadapi/protocol.client.d.ts +36 -0
  47. package/dist/proto/github.com/milaboratory/pl/controllers/shared/grpc/downloadapi/protocol.client.d.ts.map +1 -0
  48. package/dist/proto/github.com/milaboratory/pl/controllers/shared/grpc/downloadapi/protocol.d.ts +103 -0
  49. package/dist/proto/github.com/milaboratory/pl/controllers/shared/grpc/downloadapi/protocol.d.ts.map +1 -0
  50. package/dist/proto/github.com/milaboratory/pl/controllers/shared/grpc/lsapi/protocol.client.d.ts +42 -0
  51. package/dist/proto/github.com/milaboratory/pl/controllers/shared/grpc/lsapi/protocol.client.d.ts.map +1 -0
  52. package/dist/proto/github.com/milaboratory/pl/controllers/shared/grpc/lsapi/protocol.d.ts +165 -0
  53. package/dist/proto/github.com/milaboratory/pl/controllers/shared/grpc/lsapi/protocol.d.ts.map +1 -0
  54. package/dist/proto/github.com/milaboratory/pl/controllers/shared/grpc/progressapi/protocol.client.d.ts +44 -0
  55. package/dist/proto/github.com/milaboratory/pl/controllers/shared/grpc/progressapi/protocol.client.d.ts.map +1 -0
  56. package/dist/proto/github.com/milaboratory/pl/controllers/shared/grpc/progressapi/protocol.d.ts +171 -0
  57. package/dist/proto/github.com/milaboratory/pl/controllers/shared/grpc/progressapi/protocol.d.ts.map +1 -0
  58. package/dist/proto/github.com/milaboratory/pl/controllers/shared/grpc/streamingapi/protocol.client.d.ts +122 -0
  59. package/dist/proto/github.com/milaboratory/pl/controllers/shared/grpc/streamingapi/protocol.client.d.ts.map +1 -0
  60. package/dist/proto/github.com/milaboratory/pl/controllers/shared/grpc/streamingapi/protocol.d.ts +315 -0
  61. package/dist/proto/github.com/milaboratory/pl/controllers/shared/grpc/streamingapi/protocol.d.ts.map +1 -0
  62. package/dist/proto/github.com/milaboratory/pl/controllers/shared/grpc/uploadapi/protocol.client.d.ts +98 -0
  63. package/dist/proto/github.com/milaboratory/pl/controllers/shared/grpc/uploadapi/protocol.client.d.ts.map +1 -0
  64. package/dist/proto/github.com/milaboratory/pl/controllers/shared/grpc/uploadapi/protocol.d.ts +337 -0
  65. package/dist/proto/github.com/milaboratory/pl/controllers/shared/grpc/uploadapi/protocol.d.ts.map +1 -0
  66. package/dist/proto/google/api/http.d.ts +451 -0
  67. package/dist/proto/google/api/http.d.ts.map +1 -0
  68. package/dist/proto/google/protobuf/descriptor.d.ts +1646 -0
  69. package/dist/proto/google/protobuf/descriptor.d.ts.map +1 -0
  70. package/dist/proto/google/protobuf/duration.d.ts +106 -0
  71. package/dist/proto/google/protobuf/duration.d.ts.map +1 -0
  72. package/dist/proto/google/protobuf/timestamp.d.ts +151 -0
  73. package/dist/proto/google/protobuf/timestamp.d.ts.map +1 -0
  74. package/package.json +47 -0
  75. package/src/clients/download.test.ts +45 -0
  76. package/src/clients/download.ts +106 -0
  77. package/src/clients/helpers.ts +84 -0
  78. package/src/clients/logs.ts +68 -0
  79. package/src/clients/ls_api.ts +34 -0
  80. package/src/clients/progress.ts +86 -0
  81. package/src/clients/upload.test.ts +30 -0
  82. package/src/clients/upload.ts +199 -0
  83. package/src/drivers/download_and_logs_blob.ts +801 -0
  84. package/src/drivers/download_blob.test.ts +223 -0
  85. package/src/drivers/download_url.test.ts +90 -0
  86. package/src/drivers/download_url.ts +314 -0
  87. package/src/drivers/helpers/files_cache.test.ts +79 -0
  88. package/src/drivers/helpers/files_cache.ts +74 -0
  89. package/src/drivers/helpers/helpers.ts +136 -0
  90. package/src/drivers/helpers/ls_list_entry.test.ts +57 -0
  91. package/src/drivers/helpers/ls_list_entry.ts +152 -0
  92. package/src/drivers/helpers/ls_storage_entry.ts +135 -0
  93. package/src/drivers/helpers/polling_ops.ts +7 -0
  94. package/src/drivers/helpers/test_helpers.ts +5 -0
  95. package/src/drivers/logs.test.ts +337 -0
  96. package/src/drivers/logs.ts +214 -0
  97. package/src/drivers/logs_stream.ts +399 -0
  98. package/src/drivers/ls.test.ts +90 -0
  99. package/src/drivers/ls.ts +147 -0
  100. package/src/drivers/upload.test.ts +454 -0
  101. package/src/drivers/upload.ts +499 -0
  102. package/src/helpers/download.ts +43 -0
  103. package/src/index.ts +15 -0
  104. package/src/proto/github.com/milaboratory/pl/controllers/shared/grpc/downloadapi/protocol.client.ts +60 -0
  105. package/src/proto/github.com/milaboratory/pl/controllers/shared/grpc/downloadapi/protocol.ts +442 -0
  106. package/src/proto/github.com/milaboratory/pl/controllers/shared/grpc/lsapi/protocol.client.ts +63 -0
  107. package/src/proto/github.com/milaboratory/pl/controllers/shared/grpc/lsapi/protocol.ts +503 -0
  108. package/src/proto/github.com/milaboratory/pl/controllers/shared/grpc/progressapi/protocol.client.ts +84 -0
  109. package/src/proto/github.com/milaboratory/pl/controllers/shared/grpc/progressapi/protocol.ts +697 -0
  110. package/src/proto/github.com/milaboratory/pl/controllers/shared/grpc/streamingapi/protocol.client.ts +212 -0
  111. package/src/proto/github.com/milaboratory/pl/controllers/shared/grpc/streamingapi/protocol.ts +1036 -0
  112. package/src/proto/github.com/milaboratory/pl/controllers/shared/grpc/uploadapi/protocol.client.ts +170 -0
  113. package/src/proto/github.com/milaboratory/pl/controllers/shared/grpc/uploadapi/protocol.ts +1201 -0
  114. package/src/proto/google/api/http.ts +838 -0
  115. package/src/proto/google/protobuf/descriptor.ts +5173 -0
  116. package/src/proto/google/protobuf/duration.ts +272 -0
  117. package/src/proto/google/protobuf/timestamp.ts +354 -0
@@ -0,0 +1,223 @@
1
+ import * as fsp from 'node:fs/promises';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import {
5
+ ConsoleLoggerAdapter,
6
+ HmacSha256Signer
7
+ } from '@milaboratories/ts-helpers';
8
+ import {
9
+ PlClient,
10
+ PlTransaction,
11
+ TestHelpers,
12
+ jsonToData,
13
+ FieldRef,
14
+ poll,
15
+ PollTxAccessor,
16
+ BasicResourceData,
17
+ FieldId
18
+ } from '@milaboratories/pl-client';
19
+ import { scheduler } from 'node:timers/promises';
20
+ import { createDownloadClient, createLogsClient } from '../clients/helpers';
21
+ import {
22
+ DownloadDriver,
23
+ OnDemandBlobResourceSnapshot
24
+ } from './download_and_logs_blob';
25
+
26
+ const fileName = 'answer_to_the_ultimate_question.txt';
27
+
28
+ test('should download a blob and read its content', async () => {
29
+ await TestHelpers.withTempRoot(async (client) => {
30
+ const logger = new ConsoleLoggerAdapter();
31
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'test-download-1-'));
32
+
33
+ const driver = new DownloadDriver(
34
+ logger,
35
+ createDownloadClient(logger, client),
36
+ createLogsClient(client, logger),
37
+ dir,
38
+ new HmacSha256Signer(HmacSha256Signer.generateSecret()),
39
+ { cacheSoftSizeBytes: 700 * 1024, nConcurrentDownloads: 10 }
40
+ );
41
+ const downloadable = await makeDownloadableBlobFromAssets(client, fileName);
42
+
43
+ const c = driver.getDownloadedBlob(downloadable);
44
+
45
+ const blob = await c.getValue();
46
+ expect(blob).toBeUndefined();
47
+
48
+ await c.awaitChange();
49
+
50
+ const blob2 = await c.getValue();
51
+ expect(blob2).toBeDefined();
52
+ expect(blob2!.size).toBe(3);
53
+ expect((await driver.getContent(blob2!.handle))?.toString()).toBe('42\n');
54
+ });
55
+ });
56
+
57
+ test('should get on demand blob without downloading a blob', async () => {
58
+ await TestHelpers.withTempRoot(async (client) => {
59
+ const logger = new ConsoleLoggerAdapter();
60
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'test-download-2-'));
61
+ const driver = new DownloadDriver(
62
+ logger,
63
+ createDownloadClient(logger, client),
64
+ createLogsClient(client, logger),
65
+ dir,
66
+ new HmacSha256Signer(HmacSha256Signer.generateSecret()),
67
+ { cacheSoftSizeBytes: 700 * 1024, nConcurrentDownloads: 10 }
68
+ );
69
+
70
+ const downloadable = await makeDownloadableBlobFromAssets(client, fileName);
71
+ const c = driver.getOnDemandBlob(downloadable);
72
+
73
+ const blob = await c.getValue();
74
+ expect(blob).toBeDefined();
75
+ expect(blob.size).toEqual(3);
76
+
77
+ const content = await driver.getContent(blob!.handle);
78
+ expect(content?.toString()).toStrictEqual('42\n');
79
+ });
80
+ });
81
+
82
+ test('should get undefined when releasing a blob from a small cache and the blob was deleted.', async () => {
83
+ await TestHelpers.withTempRoot(async (client) => {
84
+ const logger = new ConsoleLoggerAdapter();
85
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'test-download-3-'));
86
+ const driver = new DownloadDriver(
87
+ logger,
88
+ createDownloadClient(logger, client),
89
+ createLogsClient(client, logger),
90
+ dir,
91
+ new HmacSha256Signer(HmacSha256Signer.generateSecret()),
92
+ { cacheSoftSizeBytes: 1, nConcurrentDownloads: 10 }
93
+ );
94
+ const downloadable = await makeDownloadableBlobFromAssets(client, fileName);
95
+
96
+ const c = driver.getDownloadedBlob(downloadable);
97
+
98
+ const blob = await c.getValue();
99
+ expect(blob).toBeUndefined();
100
+
101
+ await c.awaitChange();
102
+
103
+ const blob2 = await c.getValue();
104
+ expect(blob2).toBeDefined();
105
+ expect(blob2!.size).toBe(3);
106
+ expect((await driver.getContent(blob2!.handle))?.toString()).toBe('42\n');
107
+
108
+ // The blob is removed from a cache since the size is too big.
109
+ c.resetState();
110
+ await scheduler.wait(100);
111
+
112
+ const c2 = driver.getDownloadedBlob(downloadable);
113
+
114
+ const noBlob = await c2.getValue();
115
+ expect(noBlob).toBeUndefined();
116
+ });
117
+ });
118
+
119
+ test('should get the blob when releasing a blob, but a cache is big enough and it keeps a file on the local drive.', async () => {
120
+ await TestHelpers.withTempRoot(async (client) => {
121
+ const logger = new ConsoleLoggerAdapter();
122
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'test-download-4-'));
123
+ const driver = new DownloadDriver(
124
+ logger,
125
+ createDownloadClient(logger, client),
126
+ createLogsClient(client, logger),
127
+ dir,
128
+ new HmacSha256Signer(HmacSha256Signer.generateSecret()),
129
+ { cacheSoftSizeBytes: 700 * 1024, nConcurrentDownloads: 10 }
130
+ );
131
+ const downloadable = await makeDownloadableBlobFromAssets(client, fileName);
132
+
133
+ const c = driver.getDownloadedBlob(downloadable);
134
+
135
+ const blob = await c.getValue();
136
+ expect(blob).toBeUndefined();
137
+
138
+ await c.awaitChange();
139
+
140
+ const blob2 = await c.getValue();
141
+ expect(blob2).toBeDefined();
142
+ expect(blob2!.size).toBe(3);
143
+ expect((await driver.getContent(blob2!.handle))?.toString()).toBe('42\n');
144
+
145
+ // The blob is removed from a cache since the size is too big.
146
+ c.resetState();
147
+ await scheduler.wait(100);
148
+
149
+ const c2 = driver.getDownloadedBlob(downloadable);
150
+
151
+ const blob3 = await c2.getValue();
152
+ expect(blob3).toBeDefined();
153
+ expect(blob3!.size).toBe(3);
154
+ expect((await driver.getContent(blob3!.handle))?.toString()).toBe('42\n');
155
+ });
156
+ });
157
+
158
+ async function makeDownloadableBlobFromAssets(
159
+ client: PlClient,
160
+ fileName: string
161
+ ) {
162
+ await client.withWriteTx(
163
+ 'MakeAssetDownloadable',
164
+ async (tx: PlTransaction) => {
165
+ const importSettings = jsonToData({
166
+ path: fileName,
167
+ storageId: 'library'
168
+ });
169
+ const importer = tx.createStruct(
170
+ { name: 'BlobImportInternal', version: '1' },
171
+ importSettings
172
+ );
173
+ const importerBlob: FieldRef = {
174
+ resourceId: importer,
175
+ fieldName: 'blob'
176
+ };
177
+
178
+ const download = tx.createStruct({
179
+ name: 'BlobDownload',
180
+ version: '2'
181
+ });
182
+ const downloadBlob: FieldRef = {
183
+ resourceId: download,
184
+ fieldName: 'blob'
185
+ };
186
+ const downloadDownloadable: FieldRef = {
187
+ resourceId: download,
188
+ fieldName: 'downloadable'
189
+ };
190
+
191
+ const dynamicId: FieldId = {
192
+ resourceId: client.clientRoot,
193
+ fieldName: 'result'
194
+ };
195
+
196
+ tx.setField(downloadBlob, importerBlob);
197
+ tx.createField(dynamicId, 'Dynamic', downloadDownloadable);
198
+ await tx.commit();
199
+ }
200
+ );
201
+
202
+ const [download, kv] = await poll(client, async (tx: PollTxAccessor) => {
203
+ const root = await tx.get(client.clientRoot);
204
+ const download = await root.get('result');
205
+
206
+ return [
207
+ download.data,
208
+ await download.getKValueObj<{ sizeBytes: string }>('ctl/file/blobInfo')
209
+ ];
210
+ });
211
+
212
+ return {
213
+ id: download.id,
214
+ type: download.type,
215
+ data: undefined,
216
+ fields: undefined,
217
+ kv: {
218
+ 'ctl/file/blobInfo': {
219
+ sizeBytes: Number(kv.sizeBytes)
220
+ }
221
+ }
222
+ } as OnDemandBlobResourceSnapshot;
223
+ }
@@ -0,0 +1,90 @@
1
+ import { TestHelpers } from '@milaboratories/pl-client';
2
+ import { ConsoleLoggerAdapter } from '@milaboratories/ts-helpers';
3
+ import * as os from 'node:os';
4
+ import { text } from 'node:stream/consumers';
5
+ import { Readable } from 'node:stream';
6
+ import * as fs from 'node:fs';
7
+ import * as fsp from 'node:fs/promises';
8
+ import * as path from 'node:path';
9
+ import { DownloadUrlDriver } from './download_url';
10
+
11
+ test('should download a tar archive and extracts its content and then deleted', async () => {
12
+ await TestHelpers.withTempRoot(async (client) => {
13
+ const logger = new ConsoleLoggerAdapter();
14
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'test1-'));
15
+ const driver = new DownloadUrlDriver(logger, client.httpDispatcher, dir);
16
+
17
+ const url = new URL(
18
+ 'https://block.registry.platforma.bio/releases/v1/milaboratory/enter-numbers/0.4.1/frontend.tgz'
19
+ );
20
+
21
+ const c = driver.getPath(url);
22
+
23
+ const path1 = await c.getValue();
24
+ expect(path1).toBeUndefined();
25
+
26
+ await c.awaitChange();
27
+
28
+ const path2 = await c.getValue();
29
+ expect(path2).not.toBeUndefined();
30
+ expect(path2?.error).toBeUndefined();
31
+ expect(path2?.path).not.toBeUndefined();
32
+
33
+ console.log('frontend saved to dir: ', path2);
34
+ const indexJs = fs.createReadStream(path.join(path2!.path!, 'index.js'));
35
+ const indexJsCode = await text(Readable.toWeb(indexJs));
36
+ expect(indexJsCode).toContain('use strict');
37
+
38
+ c.resetState();
39
+ });
40
+ });
41
+
42
+ test('should show a error when 403 status code', async () => {
43
+ try {
44
+ await TestHelpers.withTempRoot(async (client) => {
45
+ const logger = new ConsoleLoggerAdapter();
46
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'test1-'));
47
+ const driver = new DownloadUrlDriver(logger, client.httpDispatcher, dir);
48
+
49
+ const url = new URL(
50
+ 'https://block.registry.platforma.bio/releases/v1/milaboratory/NOT_FOUND'
51
+ );
52
+
53
+ const c = driver.getPath(url);
54
+
55
+ const path1 = await c.getValue();
56
+ expect(path1).toBeUndefined();
57
+
58
+ await c.awaitChange();
59
+
60
+ const path2 = await c.getValue();
61
+ expect(path2).not.toBeUndefined();
62
+ expect(path2?.error).not.toBeUndefined();
63
+ });
64
+ } catch (e) {
65
+ console.log('HERE: ', e);
66
+ }
67
+ });
68
+
69
+ test('should abort a downloading process when we reset a state of a computable', async () => {
70
+ await TestHelpers.withTempRoot(async (client) => {
71
+ const logger = new ConsoleLoggerAdapter();
72
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'test2-'));
73
+ const driver = new DownloadUrlDriver(logger, client.httpDispatcher, dir);
74
+
75
+ const url = new URL(
76
+ 'https://block.registry.platforma.bio/releases/v1/milaboratory/enter-numbers/0.4.1/frontend.tgz'
77
+ );
78
+
79
+ const c = driver.getPath(url);
80
+
81
+ const path1 = await c.getValue();
82
+ expect(path1).toBeUndefined();
83
+
84
+ c.resetState();
85
+ await c.awaitChange();
86
+
87
+ const path2 = await c.getValue();
88
+ expect(path2).toBeUndefined();
89
+ });
90
+ });
@@ -0,0 +1,314 @@
1
+ import {
2
+ CallersCounter,
3
+ MiLogger,
4
+ TaskProcessor,
5
+ notEmpty
6
+ } from '@milaboratories/ts-helpers';
7
+ import * as fsp from 'node:fs/promises';
8
+ import * as path from 'node:path';
9
+ import { Writable, Transform } from 'node:stream';
10
+ import {
11
+ ChangeSource,
12
+ Computable,
13
+ ComputableCtx,
14
+ Watcher
15
+ } from '@milaboratories/computable';
16
+ import { randomUUID, createHash } from 'node:crypto';
17
+ import * as zlib from 'node:zlib';
18
+ import * as tar from 'tar-fs';
19
+ import { FilesCache } from './helpers/files_cache';
20
+ import { Dispatcher } from 'undici';
21
+ import { DownloadHelper, NetworkError400 } from '../helpers/download';
22
+
23
+ export interface DownloadUrlSyncReader {
24
+ /** Returns a Computable that (when the time will come)
25
+ * downloads an archive from an URL,
26
+ * extracts it to the local dir and returns a path to that dir. */
27
+ getPath(url: URL): Computable<PathResult | undefined>;
28
+ }
29
+
30
+ export interface PathResult {
31
+ /** Path to the downloadable blob, might be undefined when the error happened. */
32
+ path?: string;
33
+ /** Error that happened when the archive were downloaded. */
34
+ error?: string;
35
+ }
36
+
37
+ export type DownloadUrlDriverOps = {
38
+ cacheSoftSizeBytes: number;
39
+ withGunzip: boolean;
40
+ nConcurrentDownloads: number;
41
+ };
42
+
43
+ /** Downloads .tar or .tar.gz archives by given URLs
44
+ * and extracts them into saveDir. */
45
+ export class DownloadUrlDriver implements DownloadUrlSyncReader {
46
+ private readonly downloadHelper: DownloadHelper;
47
+
48
+ private urlToDownload: Map<string, Download> = new Map();
49
+ private downloadQueue: TaskProcessor;
50
+
51
+ /** Writes and removes files to a hard drive and holds a counter for every
52
+ * file that should be kept. */
53
+ private cache: FilesCache<Download>;
54
+
55
+ constructor(
56
+ private readonly logger: MiLogger,
57
+ httpClient: Dispatcher,
58
+ private readonly saveDir: string,
59
+ private readonly opts: DownloadUrlDriverOps = {
60
+ cacheSoftSizeBytes: 50 * 1024 * 1024,
61
+ withGunzip: true,
62
+ nConcurrentDownloads: 50
63
+ }
64
+ ) {
65
+ this.downloadQueue = new TaskProcessor(
66
+ this.logger,
67
+ this.opts.nConcurrentDownloads
68
+ );
69
+ this.cache = new FilesCache(this.opts.cacheSoftSizeBytes);
70
+ this.downloadHelper = new DownloadHelper(httpClient);
71
+ }
72
+
73
+ /** Use to get a path result inside a computable context */
74
+ getPath(url: URL, ctx: ComputableCtx): PathResult | undefined;
75
+
76
+ /** Returns a Computable that do the work */
77
+ getPath(url: URL): Computable<PathResult | undefined>;
78
+
79
+ getPath(
80
+ url: URL,
81
+ ctx?: ComputableCtx
82
+ ): Computable<PathResult | undefined> | PathResult | undefined {
83
+ // wrap result as computable, if we were not given an existing computable context
84
+ if (ctx === undefined) return Computable.make((c) => this.getPath(url, c));
85
+
86
+ const callerId = randomUUID();
87
+
88
+ // read as ~ golang's defer
89
+ ctx.addOnDestroy(() => this.releasePath(url, callerId));
90
+
91
+ const result = this.getPathNoCtx(url, ctx.watcher, callerId);
92
+ if (result?.path === undefined)
93
+ ctx.markUnstable(
94
+ `a path to the downloaded and untared archive might be undefined. The current result: ${result}`
95
+ );
96
+
97
+ return result;
98
+ }
99
+
100
+ getPathNoCtx(url: URL, w: Watcher, callerId: string) {
101
+ const key = url.toString();
102
+ const task = this.urlToDownload.get(key);
103
+
104
+ if (task != undefined) {
105
+ task.attach(w, callerId);
106
+ return task.getPath();
107
+ }
108
+
109
+ const newTask = this.setNewTask(w, url, callerId);
110
+ this.downloadQueue.push({
111
+ fn: async () => this.downloadUrl(newTask, callerId),
112
+ recoverableErrorPredicate: (e) => true
113
+ });
114
+
115
+ return newTask.getPath();
116
+ }
117
+
118
+ /** Downloads and extracts a tar archive if it wasn't downloaded yet. */
119
+ async downloadUrl(task: Download, callerId: string) {
120
+ await task.download(this.downloadHelper, this.opts.withGunzip);
121
+ // Might be undefined if a error happened
122
+ if (task.getPath()?.path != undefined) this.cache.addCache(task, callerId);
123
+ }
124
+
125
+ /** Removes a directory and aborts a downloading task when all callers
126
+ * are not interested in it. */
127
+ async releasePath(url: URL, callerId: string): Promise<void> {
128
+ const key = url.toString();
129
+ const task = this.urlToDownload.get(key);
130
+ if (task == undefined) return;
131
+
132
+ if (this.cache.existsFile(task.path)) {
133
+ const toDelete = this.cache.removeFile(task.path, callerId);
134
+
135
+ await Promise.all(
136
+ toDelete.map(async (task) => {
137
+ await rmRFDir(task.path);
138
+ this.cache.removeCache(task);
139
+
140
+ this.removeTask(
141
+ task,
142
+ `the task ${JSON.stringify(task)} was removed` +
143
+ `from cache along with ${JSON.stringify(toDelete)}`
144
+ );
145
+ })
146
+ );
147
+ } else {
148
+ // The task is still in a downloading queue.
149
+ const deleted = task.counter.dec(callerId);
150
+ if (deleted)
151
+ this.removeTask(
152
+ task,
153
+ `the task ${JSON.stringify(task)} was removed from cache`
154
+ );
155
+ }
156
+ }
157
+
158
+ /** Removes all files from a hard drive. */
159
+ async releaseAll() {
160
+ this.downloadQueue.stop();
161
+
162
+ await Promise.all(
163
+ Array.from(this.urlToDownload.entries()).map(async ([id, task]) => {
164
+ await rmRFDir(task.path);
165
+ this.cache.removeCache(task);
166
+
167
+ this.removeTask(
168
+ task,
169
+ `the task ${task} was released when the driver was closed`
170
+ );
171
+ })
172
+ );
173
+ }
174
+
175
+ private setNewTask(w: Watcher, url: URL, callerId: string) {
176
+ const result = new Download(this.getFilePath(url), url);
177
+ result.attach(w, callerId);
178
+ this.urlToDownload.set(url.toString(), result);
179
+
180
+ return result;
181
+ }
182
+
183
+ private removeTask(task: Download, reason: string) {
184
+ task.abort(reason);
185
+ task.change.markChanged();
186
+ this.urlToDownload.delete(task.url.toString());
187
+ }
188
+
189
+ private getFilePath(url: URL): string {
190
+ const sha256 = createHash('sha256').update(url.toString()).digest('hex');
191
+ return path.join(this.saveDir, sha256);
192
+ }
193
+ }
194
+
195
+ class Download {
196
+ readonly counter = new CallersCounter();
197
+ readonly change = new ChangeSource();
198
+ readonly signalCtl = new AbortController();
199
+ error: string | undefined;
200
+ done = false;
201
+ sizeBytes = 0;
202
+
203
+ constructor(
204
+ readonly path: string,
205
+ readonly url: URL
206
+ ) {}
207
+
208
+ attach(w: Watcher, callerId: string) {
209
+ this.counter.inc(callerId);
210
+ if (!this.done) this.change.attachWatcher(w);
211
+ }
212
+
213
+ async download(clientDownload: DownloadHelper, withGunzip: boolean) {
214
+ try {
215
+ const sizeBytes = await this.downloadAndUntar(
216
+ clientDownload,
217
+ withGunzip,
218
+ this.signalCtl.signal
219
+ );
220
+ this.setDone(sizeBytes);
221
+ } catch (e: any) {
222
+ if (e instanceof URLAborted || e instanceof NetworkError400) {
223
+ this.setError(e);
224
+ // Just in case we were half-way extracting an archive.
225
+ await rmRFDir(this.path);
226
+ return;
227
+ }
228
+
229
+ throw e;
230
+ }
231
+ }
232
+
233
+ private async downloadAndUntar(
234
+ clientDownload: DownloadHelper,
235
+ withGunzip: boolean,
236
+ signal: AbortSignal
237
+ ): Promise<number> {
238
+ if (await fileExists(this.path)) {
239
+ return await dirSize(this.path);
240
+ }
241
+
242
+ const resp = await clientDownload.downloadRemoteFile(
243
+ this.url.toString(),
244
+ {},
245
+ signal
246
+ );
247
+ let content = resp.content;
248
+
249
+ if (withGunzip) {
250
+ const gunzip = Transform.toWeb(zlib.createGunzip());
251
+ content = content.pipeThrough(gunzip, { signal });
252
+ }
253
+ const untar = Writable.toWeb(tar.extract(this.path));
254
+ await content.pipeTo(untar, { signal });
255
+
256
+ return resp.size;
257
+ }
258
+
259
+ getPath(): PathResult | undefined {
260
+ if (this.done) return { path: notEmpty(this.path) };
261
+
262
+ if (this.error) return { error: this.error };
263
+
264
+ return undefined;
265
+ }
266
+
267
+ private setDone(sizeBytes: number) {
268
+ this.done = true;
269
+ this.sizeBytes = sizeBytes;
270
+ this.change.markChanged();
271
+ }
272
+
273
+ abort(reason: string) {
274
+ this.signalCtl.abort(new URLAborted(reason));
275
+ }
276
+
277
+ private setError(e: any) {
278
+ this.error = String(e);
279
+ this.change.markChanged();
280
+ }
281
+ }
282
+
283
+ class URLAborted extends Error {}
284
+
285
+ async function fileExists(path: string): Promise<boolean> {
286
+ try {
287
+ await fsp.access(path);
288
+ return true;
289
+ } catch {
290
+ return false;
291
+ }
292
+ }
293
+
294
+ /** Gets a directory size by calculating sizes recursively. */
295
+ async function dirSize(dir: string): Promise<number> {
296
+ const files = await fsp.readdir(dir, { withFileTypes: true });
297
+ const sizes = await Promise.all(
298
+ files.map(async (file) => {
299
+ const fPath = path.join(dir, file.name);
300
+
301
+ if (file.isDirectory()) return await dirSize(fPath);
302
+
303
+ const stat = await fsp.stat(fPath);
304
+ return stat.size;
305
+ })
306
+ );
307
+
308
+ return sizes.reduce((sum, size) => sum + size, 0);
309
+ }
310
+
311
+ /** Do rm -rf on dir. */
312
+ async function rmRFDir(path: string) {
313
+ await fsp.rm(path, { recursive: true, force: true });
314
+ }
@@ -0,0 +1,79 @@
1
+ import { CachedFile, FilesCache } from './files_cache';
2
+ import { CallersCounter } from '@milaboratories/ts-helpers';
3
+
4
+ test('should delete blob3 when add 3 blobs, exceed a soft limit and nothing holds blob3', () => {
5
+ const cache = new FilesCache(20);
6
+ const callerId1 = 'callerId1';
7
+ const blob1: CachedFile = {
8
+ path: 'path1',
9
+ sizeBytes: 5,
10
+ counter: new CallersCounter()
11
+ };
12
+ const blob2: CachedFile = {
13
+ path: 'path2',
14
+ sizeBytes: 10,
15
+ counter: new CallersCounter()
16
+ };
17
+ const blob3: CachedFile = {
18
+ path: 'path3',
19
+ sizeBytes: 10,
20
+ counter: new CallersCounter()
21
+ };
22
+
23
+ // add blobs and check that we don't exceed the soft limit.
24
+ cache.addCache(blob1, callerId1);
25
+ cache.addCache(blob2, callerId1);
26
+ expect(cache.toDelete()).toHaveLength(0);
27
+
28
+ // add already existing blob and, again, check that we don't exceed the limit.
29
+ cache.addCache(blob2, callerId1);
30
+ expect(cache.toDelete()).toHaveLength(0);
31
+
32
+ // add the third blob. We exceeds a soft limit,
33
+ // but every blob has a positive counter,
34
+ // so we can't delete anything.
35
+ cache.addCache(blob3, callerId1);
36
+ expect(cache.toDelete()).toHaveLength(0);
37
+
38
+ // blob3 have a zero counter, we can delete it.
39
+ const toDelete = cache.removeFile(blob3.path, callerId1);
40
+ expect(toDelete).toStrictEqual([blob3]);
41
+
42
+ // removes blob3 from a cache, checks that others are still there.
43
+ cache.removeCache(blob3);
44
+ expect(cache.getFile(blob1.path, callerId1)).toBe(blob1);
45
+ expect(cache.getFile(blob2.path, callerId1)).toBe(blob2);
46
+ expect(cache.getFile(blob3.path, callerId1)).toBeUndefined();
47
+ });
48
+
49
+ test('regression should allow to add empty files', () => {
50
+ const cache = new FilesCache(1);
51
+ const callerId1 = 'callerId1';
52
+ const blob1: CachedFile = {
53
+ path: 'path1',
54
+ sizeBytes: 0,
55
+ counter: new CallersCounter()
56
+ };
57
+ const blob2: CachedFile = {
58
+ path: 'path2',
59
+ sizeBytes: 2,
60
+ counter: new CallersCounter()
61
+ };
62
+
63
+ // add a blob with 0 size.
64
+ cache.addCache(blob1, callerId1);
65
+ expect(cache.toDelete()).toHaveLength(0);
66
+
67
+ // add a blob that exceed a soft limit
68
+ cache.addCache(blob2, callerId1);
69
+ expect(cache.toDelete()).toHaveLength(0);
70
+
71
+ // this blob should be deleted
72
+ const toDelete = cache.removeFile(blob2.path, callerId1);
73
+ expect(toDelete).toStrictEqual([blob2]);
74
+
75
+ cache.removeCache(blob2);
76
+
77
+ // and the the blob with zero size remained
78
+ expect(cache.getFile(blob1.path, callerId1)).toBe(blob1);
79
+ });