@milaboratories/pl-drivers 1.5.63 → 1.5.65
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/dist/drivers/download_blob/download_blob.d.ts +12 -3
- package/dist/drivers/download_blob/download_blob.d.ts.map +1 -1
- package/dist/drivers/download_blob/sparse_cache/cache.d.ts +79 -0
- package/dist/drivers/download_blob/sparse_cache/cache.d.ts.map +1 -0
- package/dist/drivers/download_blob/sparse_cache/file.d.ts +8 -0
- package/dist/drivers/download_blob/sparse_cache/file.d.ts.map +1 -0
- package/dist/drivers/download_blob/sparse_cache/ranges.d.ts +46 -0
- package/dist/drivers/download_blob/sparse_cache/ranges.d.ts.map +1 -0
- package/dist/drivers/helpers/download_remote_handle.d.ts +4 -1
- package/dist/drivers/helpers/download_remote_handle.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1584 -1391
- package/dist/index.mjs.map +1 -1
- package/package.json +10 -9
- package/src/drivers/download_blob/download_blob.test.ts +48 -41
- package/src/drivers/download_blob/download_blob.ts +38 -39
- package/src/drivers/download_blob/sparse_cache/cache.test.ts +371 -0
- package/src/drivers/download_blob/sparse_cache/cache.ts +240 -0
- package/src/drivers/download_blob/sparse_cache/create_sparse_file_script.js +123 -0
- package/src/drivers/download_blob/sparse_cache/file.ts +49 -0
- package/src/drivers/download_blob/sparse_cache/ranges.test.ts +115 -0
- package/src/drivers/download_blob/sparse_cache/ranges.ts +93 -0
- package/src/drivers/download_url.ts +1 -1
- package/src/drivers/helpers/download_remote_handle.ts +16 -9
- package/src/drivers/logs.test.ts +18 -6
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { SparseCache, type SparseFileStorage, type SparseCacheRanges } from './cache';
|
|
2
|
+
import { ConsoleLoggerAdapter } from '@milaboratories/ts-helpers';
|
|
3
|
+
import type { Ranges } from './ranges';
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
|
|
6
|
+
/** gen helpers */
|
|
7
|
+
|
|
8
|
+
/** Generates Uint8Array from string. */
|
|
9
|
+
function genData(string: string) {
|
|
10
|
+
const enc = new TextEncoder();
|
|
11
|
+
return enc.encode(string);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Converts uint 8 array to string. */
|
|
15
|
+
function toString(arr: Uint8Array) {
|
|
16
|
+
const dec = new TextDecoder();
|
|
17
|
+
return dec.decode(arr);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** In-memory implementation for cache ranges. */
|
|
21
|
+
class InMemoryRanges implements SparseCacheRanges {
|
|
22
|
+
constructor(
|
|
23
|
+
public readonly keyToRanges: Record<string, Ranges>,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
async get(key: string) {
|
|
27
|
+
const result = this.keyToRanges[key];
|
|
28
|
+
return result ?? { ranges: [] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async set(key: string, ranges: Ranges) {
|
|
32
|
+
this.keyToRanges[key] = ranges;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async delete(key: string) {
|
|
36
|
+
delete this.keyToRanges[key];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** In-memory implementation for a sparse file. */
|
|
41
|
+
class InMemoryFile implements SparseFileStorage {
|
|
42
|
+
constructor(
|
|
43
|
+
public readonly keyToFromToData: Record<string, Record<number, string>>,
|
|
44
|
+
) {}
|
|
45
|
+
|
|
46
|
+
async all() {
|
|
47
|
+
return Object.entries(this.keyToFromToData).map(([k, _]) => k);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async exists(key: string) {
|
|
51
|
+
return key in this.keyToFromToData;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
path(key: string) {
|
|
55
|
+
return key;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async write(key: string, data: Uint8Array, from: number) {
|
|
59
|
+
this.keyToFromToData[key] = this.keyToFromToData[key] ?? {};
|
|
60
|
+
this.keyToFromToData[key][from] = toString(data);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async delete(key: string) {
|
|
64
|
+
delete this.keyToFromToData[key];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function genCache(
|
|
69
|
+
keyToLastAccessTime: Record<string, Date>,
|
|
70
|
+
size: number,
|
|
71
|
+
maxSize: number,
|
|
72
|
+
ranges: Record<string, Ranges>,
|
|
73
|
+
storage: Record<string, Record<number, string>>,
|
|
74
|
+
): {
|
|
75
|
+
cache: SparseCache,
|
|
76
|
+
ranges: InMemoryRanges,
|
|
77
|
+
storage: InMemoryFile,
|
|
78
|
+
} {
|
|
79
|
+
const inMemRanges = new InMemoryRanges(ranges);
|
|
80
|
+
const inMemStorage = new InMemoryFile(storage);
|
|
81
|
+
|
|
82
|
+
const c = new SparseCache(
|
|
83
|
+
new ConsoleLoggerAdapter(),
|
|
84
|
+
maxSize,
|
|
85
|
+
inMemRanges,
|
|
86
|
+
inMemStorage,
|
|
87
|
+
);
|
|
88
|
+
c.keyToLastAccessTime = new Map(Object.entries(keyToLastAccessTime));
|
|
89
|
+
c.size = size;
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
cache: c,
|
|
93
|
+
ranges: inMemRanges,
|
|
94
|
+
storage: inMemStorage,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Tests */
|
|
99
|
+
|
|
100
|
+
describe('SparseCache.get', () => {
|
|
101
|
+
const cases: {
|
|
102
|
+
name: string;
|
|
103
|
+
initialCache: SparseCache;
|
|
104
|
+
key: string;
|
|
105
|
+
range: { from: number; to: number };
|
|
106
|
+
expectedPath?: string;
|
|
107
|
+
}[] = [
|
|
108
|
+
{
|
|
109
|
+
name: 'key does not exist in storage',
|
|
110
|
+
initialCache: genCache({}, 0, 100, {}, {}).cache,
|
|
111
|
+
key: 'key1',
|
|
112
|
+
range: { from: 0, to: 10 },
|
|
113
|
+
expectedPath: undefined,
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: 'key exists, but range does not exist in cache',
|
|
117
|
+
initialCache: genCache(
|
|
118
|
+
{ 'key1': new Date() },
|
|
119
|
+
10,
|
|
120
|
+
100,
|
|
121
|
+
{ 'key1': { ranges: [{ from: 0, to: 10 }] } },
|
|
122
|
+
{ 'key1': { 0: 'data' } },
|
|
123
|
+
).cache,
|
|
124
|
+
key: 'key1',
|
|
125
|
+
range: { from: 5, to: 15 },
|
|
126
|
+
expectedPath: undefined,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'key exists, and exact range exists in cache',
|
|
130
|
+
initialCache: genCache(
|
|
131
|
+
{ 'key1': new Date() },
|
|
132
|
+
10,
|
|
133
|
+
100,
|
|
134
|
+
{ 'key1': { ranges: [{ from: 0, to: 10 }] } },
|
|
135
|
+
{ 'key1': { 0: 'data' } },
|
|
136
|
+
).cache,
|
|
137
|
+
key: 'key1',
|
|
138
|
+
range: { from: 0, to: 10 },
|
|
139
|
+
expectedPath: 'key1',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: 'key exists, and sub-range exists in cache',
|
|
143
|
+
initialCache: genCache(
|
|
144
|
+
{ 'key1': new Date() },
|
|
145
|
+
20,
|
|
146
|
+
100,
|
|
147
|
+
{ 'key1': { ranges: [{ from: 0, to: 20 }] } },
|
|
148
|
+
{ 'key1': { 0: 'data' } },
|
|
149
|
+
).cache,
|
|
150
|
+
key: 'key1',
|
|
151
|
+
range: { from: 5, to: 15 },
|
|
152
|
+
expectedPath: 'key1',
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
for (const tc of cases) {
|
|
157
|
+
it(tc.name, async () => {
|
|
158
|
+
const result = await tc.initialCache.get(tc.key, tc.range);
|
|
159
|
+
|
|
160
|
+
expect(result).toEqual(tc.expectedPath);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('SparseCache.setWithoutEviction', () => {
|
|
166
|
+
it('should add a new key and range', async () => {
|
|
167
|
+
const c = genCache(
|
|
168
|
+
{},
|
|
169
|
+
0,
|
|
170
|
+
100,
|
|
171
|
+
{},
|
|
172
|
+
{},
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
await c.cache.setWithoutEviction('key1', { from: 0, to: 10 }, genData('abc123abc1'));
|
|
176
|
+
|
|
177
|
+
expect(c.ranges.keyToRanges).toMatchObject({
|
|
178
|
+
'key1': { ranges: [{ from: 0, to: 10 }] },
|
|
179
|
+
});
|
|
180
|
+
expect(c.storage.keyToFromToData).toMatchObject({
|
|
181
|
+
'key1': { 0: 'abc123abc1' },
|
|
182
|
+
});
|
|
183
|
+
expect(c.cache.size).toEqual(10);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should overwrite data when adding the same key and range', async () => {
|
|
187
|
+
const c = genCache(
|
|
188
|
+
{ 'key1': new Date(Date.now() - 1000) },
|
|
189
|
+
10,
|
|
190
|
+
100,
|
|
191
|
+
{ 'key1': { ranges: [{ from: 0, to: 10 }] } },
|
|
192
|
+
{ 'key1': { 0: 'data' } },
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
await c.cache.setWithoutEviction('key1', { from: 0, to: 10 }, genData('abc123abc1'));
|
|
196
|
+
|
|
197
|
+
expect(c.ranges.keyToRanges).toMatchObject({
|
|
198
|
+
'key1': { ranges: [{ from: 0, to: 10 }] },
|
|
199
|
+
});
|
|
200
|
+
expect(c.storage.keyToFromToData).toMatchObject({
|
|
201
|
+
'key1': { 0: 'abc123abc1' },
|
|
202
|
+
});
|
|
203
|
+
expect(c.cache.size).toEqual(10);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should add a new non-overlapping range to an existing key', async () => {
|
|
207
|
+
const c = genCache(
|
|
208
|
+
{ 'key1': new Date(Date.now() - 10000) },
|
|
209
|
+
10,
|
|
210
|
+
100,
|
|
211
|
+
{ 'key1': { ranges: [{ from: 0, to: 10 }] } },
|
|
212
|
+
{ 'key1': { 0: 'original ' } } // 10 chars
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
await c.cache.setWithoutEviction('key1', { from: 20, to: 30 }, genData('new ')); // 10 chars
|
|
216
|
+
|
|
217
|
+
expect(c.ranges.keyToRanges['key1']).toEqual({ ranges: [{ from: 0, to: 10 }, { from: 20, to: 30 }] });
|
|
218
|
+
expect(c.storage.keyToFromToData['key1']).toMatchObject({
|
|
219
|
+
0: 'original ',
|
|
220
|
+
20: 'new '
|
|
221
|
+
});
|
|
222
|
+
expect(c.cache.size).toEqual(20); // 10 for original + 10 for new
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should add an overlapping range and merge with existing range', async () => {
|
|
226
|
+
const c = genCache(
|
|
227
|
+
{ 'key1': new Date(Date.now() - 10000) },
|
|
228
|
+
10,
|
|
229
|
+
100,
|
|
230
|
+
{ 'key1': { ranges: [{ from: 0, to: 10 }] } }, // 0-10
|
|
231
|
+
{ 'key1': { 0: 'original ' } } // data for 0-10
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
// Add range 5-15, overlaps with 0-10
|
|
235
|
+
await c.cache.setWithoutEviction('key1', { from: 5, to: 15 }, genData('nal1231231')); // data for 5-15
|
|
236
|
+
|
|
237
|
+
expect(c.ranges.keyToRanges['key1']).toEqual({ ranges: [{ from: 0, to: 15 }] });
|
|
238
|
+
expect(c.storage.keyToFromToData['key1']).toMatchObject({
|
|
239
|
+
0: 'original ',
|
|
240
|
+
5: 'nal1231231'
|
|
241
|
+
});
|
|
242
|
+
expect(c.cache.size).toEqual(15); // Size of merged range 0-15
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should update size correctly when ranges are modified (subsumed range)', async () => {
|
|
246
|
+
const c = genCache(
|
|
247
|
+
{ 'key1': new Date() },
|
|
248
|
+
20,
|
|
249
|
+
100,
|
|
250
|
+
{ 'key1': { ranges: [{ from: 0, to: 20 }] } },
|
|
251
|
+
{ 'key1': { 0: 'longoriginaldata ' } }
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
// Add range 5-15, which is a sub-range of 0-20. The total range 0-20 should remain.
|
|
255
|
+
// The size should remain 20 as normalizeRanges will keep the outer [0, 20] range.
|
|
256
|
+
await c.cache.setWithoutEviction('key1', { from: 5, to: 15 }, genData('subrange '));
|
|
257
|
+
|
|
258
|
+
expect(c.cache.size).toEqual(20);
|
|
259
|
+
expect(c.ranges.keyToRanges['key1']).toEqual({ ranges: [{ from: 0, to: 20 }] });
|
|
260
|
+
expect(c.storage.keyToFromToData['key1']).toMatchObject({
|
|
261
|
+
0: 'longoriginaldata ',
|
|
262
|
+
5: 'subrange '
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe('SparseCache.ensureEvicted', () => {
|
|
268
|
+
it('should do nothing if cache size is below or equal to maxSize', async () => {
|
|
269
|
+
const c = genCache(
|
|
270
|
+
{
|
|
271
|
+
'key1': new Date(2023, 0, 1, 10, 0, 0), // oldest
|
|
272
|
+
'key2': new Date(2023, 0, 1, 11, 0, 0), // newest
|
|
273
|
+
},
|
|
274
|
+
20,
|
|
275
|
+
30,
|
|
276
|
+
{
|
|
277
|
+
'key1': { ranges: [{ from: 0, to: 10 }] },
|
|
278
|
+
'key2': { ranges: [{ from: 0, to: 10 }] },
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
'key1': { 0: 'data1' },
|
|
282
|
+
'key2': { 0: 'data2' },
|
|
283
|
+
}
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
await c.cache.ensureEvicted();
|
|
287
|
+
|
|
288
|
+
expect(c.cache.size).toEqual(20);
|
|
289
|
+
expect(c.ranges.keyToRanges).toMatchObject({
|
|
290
|
+
'key1': { ranges: [{ from: 0, to: 10 }] },
|
|
291
|
+
'key2': { ranges: [{ from: 0, to: 10 }] },
|
|
292
|
+
});
|
|
293
|
+
expect(c.storage.keyToFromToData).toMatchObject({
|
|
294
|
+
'key1': { 0: 'data1' },
|
|
295
|
+
'key2': { 0: 'data2' },
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should evict the oldest item(s) if cache size is above maxSize', async () => {
|
|
300
|
+
const c = genCache(
|
|
301
|
+
{
|
|
302
|
+
'key1': new Date(2023, 0, 1, 10, 0, 0),
|
|
303
|
+
'key2': new Date(2023, 0, 1, 11, 0, 0),
|
|
304
|
+
'key3': new Date(2023, 0, 1, 12, 0, 0),
|
|
305
|
+
},
|
|
306
|
+
41,
|
|
307
|
+
25,
|
|
308
|
+
{
|
|
309
|
+
'key1': { ranges: [{ from: 0, to: 11 }] },
|
|
310
|
+
'key2': { ranges: [{ from: 0, to: 20 }] },
|
|
311
|
+
'key3': { ranges: [{ from: 0, to: 10 }] },
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
'key1': { 0: 'data1' },
|
|
315
|
+
'key2': { 0: 'data2' },
|
|
316
|
+
'key3': { 0: 'data3' },
|
|
317
|
+
}
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
await c.cache.ensureEvicted();
|
|
321
|
+
|
|
322
|
+
expect(c.cache.size).toEqual(10);
|
|
323
|
+
expect(c.ranges.keyToRanges).toMatchObject({
|
|
324
|
+
'key3': { ranges: [{ from: 0, to: 10 }] },
|
|
325
|
+
});
|
|
326
|
+
expect(c.storage.keyToFromToData).toMatchObject({
|
|
327
|
+
'key3': { 0: 'data3' },
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should evict all items if necessary to meet maxSize (e.g., maxSize is 0)', async () => {
|
|
332
|
+
const c = genCache(
|
|
333
|
+
{
|
|
334
|
+
'key1': new Date(2023, 0, 1, 10, 0, 0),
|
|
335
|
+
'key2': new Date(2023, 0, 1, 11, 0, 0),
|
|
336
|
+
},
|
|
337
|
+
30, // size = 10 (key1) + 20 (key2)
|
|
338
|
+
0, // maxSize
|
|
339
|
+
{
|
|
340
|
+
'key1': { ranges: [{ from: 0, to: 10 }] },
|
|
341
|
+
'key2': { ranges: [{ from: 0, to: 20 }] },
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
'key1': { 0: 'data1' },
|
|
345
|
+
'key2': { 0: 'data2' },
|
|
346
|
+
}
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
await c.cache.ensureEvicted();
|
|
350
|
+
|
|
351
|
+
expect(c.cache.size).toEqual(0);
|
|
352
|
+
expect(Object.keys(c.ranges.keyToRanges).length).toEqual(0);
|
|
353
|
+
expect(Object.keys(c.storage.keyToFromToData).length).toEqual(0);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('should handle an empty cache initially', async () => {
|
|
357
|
+
const c = genCache(
|
|
358
|
+
{},
|
|
359
|
+
0,
|
|
360
|
+
10,
|
|
361
|
+
{},
|
|
362
|
+
{}
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
await c.cache.ensureEvicted();
|
|
366
|
+
|
|
367
|
+
expect(c.cache.size).toEqual(0);
|
|
368
|
+
expect(Object.keys(c.ranges.keyToRanges).length).toEqual(0);
|
|
369
|
+
expect(Object.keys(c.storage.keyToFromToData).length).toEqual(0);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { RangeBytes } from '@milaboratories/pl-model-common';
|
|
2
|
+
import { ensureDirExists, fileExists, mapEntries, MiLogger } from '@milaboratories/ts-helpers';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { addRange, doesRangeExist, Ranges, rangesFileName, rangesFilePostfix, rangesSize, readRangesFile, writeRangesFile } from './ranges';
|
|
6
|
+
import { writeToSparseFile } from './file';
|
|
7
|
+
import { functions } from '@milaboratories/helpers';
|
|
8
|
+
|
|
9
|
+
/** The implementer of SparseCacheRanges could throw it if ranges were corrupted. */
|
|
10
|
+
export class CorruptedRangesError extends Error {}
|
|
11
|
+
|
|
12
|
+
/** Extracted ranges methods to be able to store ranges somewhere else (e.g. in memory for tests). */
|
|
13
|
+
export interface SparseCacheRanges {
|
|
14
|
+
get(key: string): Promise<Ranges>;
|
|
15
|
+
set(key: string, ranges: Ranges): Promise<void>;
|
|
16
|
+
delete(key: string): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Stores ranges in a directory as JSON files (the default implementation). */
|
|
20
|
+
export class SparseCacheFsRanges implements SparseCacheRanges {
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly logger: MiLogger,
|
|
23
|
+
private readonly cacheDir: string,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
private fPath(key: string): string {
|
|
27
|
+
return path.join(this.cacheDir, rangesFileName(key));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async get(key: string): Promise<Ranges> {
|
|
31
|
+
return await readRangesFile(this.logger, this.fPath(key));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async set(key: string, ranges: Ranges) {
|
|
35
|
+
return await writeRangesFile(this.logger, this.fPath(key), ranges);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async delete(key: string) {
|
|
39
|
+
await fs.rm(this.fPath(key));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Extracted interface for storing sparse files. */
|
|
44
|
+
export interface SparseFileStorage {
|
|
45
|
+
all(): Promise<string[]>;
|
|
46
|
+
exists(key: string): Promise<boolean>;
|
|
47
|
+
path(key: string): string;
|
|
48
|
+
write(key: string, data: Uint8Array, from: number): Promise<void>;
|
|
49
|
+
delete(key: string): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Stores sparse files in a directory (the default implementation). */
|
|
53
|
+
export class SparseCacheFsFile implements SparseFileStorage {
|
|
54
|
+
private readonly suffix = '.sparse.bin';
|
|
55
|
+
|
|
56
|
+
constructor(
|
|
57
|
+
private readonly logger: MiLogger,
|
|
58
|
+
private readonly cacheDir: string,
|
|
59
|
+
) {}
|
|
60
|
+
|
|
61
|
+
async all(): Promise<string[]> {
|
|
62
|
+
await ensureDirExists(this.cacheDir);
|
|
63
|
+
const files = await fs.readdir(this.cacheDir);
|
|
64
|
+
return files.filter((f) => f.endsWith(this.suffix));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async exists(key: string): Promise<boolean> {
|
|
68
|
+
return await fileExists(this.path(key));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
path(key: string): string {
|
|
72
|
+
return path.join(this.cacheDir, key + this.suffix);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async write(key: string, data: Uint8Array, from: number): Promise<void> {
|
|
76
|
+
await ensureDirExists(this.cacheDir);
|
|
77
|
+
await writeToSparseFile(this.logger, process.platform, this.path(key), data, from);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async delete(key: string): Promise<void> {
|
|
81
|
+
await fs.rm(this.path(key));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** LRU cache for ranges of sparse files. */
|
|
86
|
+
export class SparseCache {
|
|
87
|
+
/** Fields are public for tests. */
|
|
88
|
+
|
|
89
|
+
/** The lock to make sure cache requests are done one by one. */
|
|
90
|
+
private lock = new functions.AwaitLock()
|
|
91
|
+
|
|
92
|
+
public keyToLastAccessTime = new Map<string, Date>();
|
|
93
|
+
public size = 0;
|
|
94
|
+
|
|
95
|
+
constructor(
|
|
96
|
+
public readonly logger: MiLogger,
|
|
97
|
+
/** The hard limit in bytes. */
|
|
98
|
+
public readonly maxSize: number,
|
|
99
|
+
public readonly ranges: SparseCacheRanges,
|
|
100
|
+
public readonly storage: SparseFileStorage,
|
|
101
|
+
) {}
|
|
102
|
+
|
|
103
|
+
/** Resets a cache's size by rereading everything we already store.
|
|
104
|
+
* Safe for concurrent use. */
|
|
105
|
+
async reset() {
|
|
106
|
+
await withLock(this.lock, async () => {
|
|
107
|
+
await this.resetUnsafe();
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Returns a path to the key if the range exists in a cache, otherwise returns undefined.
|
|
112
|
+
* Safe for concurrent use. */
|
|
113
|
+
async get(key: string, range: RangeBytes): Promise<string | undefined> {
|
|
114
|
+
return await withLock(this.lock, async () => {
|
|
115
|
+
return await this.getUnsafe(key, range);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Sets data to the cache's file and clear the cache if it's needed.
|
|
120
|
+
* Safe for concurrent use. */
|
|
121
|
+
async set(key: string, range: RangeBytes, data: Uint8Array): Promise<void> {
|
|
122
|
+
await withLock(this.lock, async () => {
|
|
123
|
+
await this.setUnsafe(key, range, data);
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private async resetUnsafe() {
|
|
128
|
+
this.size = 0;
|
|
129
|
+
this.keyToLastAccessTime = new Map<string, Date>();
|
|
130
|
+
|
|
131
|
+
const now = new Date();
|
|
132
|
+
// In rmKey method we first deletes the key from a storage and only then from ranges,
|
|
133
|
+
// so if something goes wrong between 2 operations,
|
|
134
|
+
// on reset the logic will be correct.
|
|
135
|
+
for (const key of await this.storage.all()) {
|
|
136
|
+
const ranges = await this.ranges.get(key);
|
|
137
|
+
this.size += rangesSize(ranges);
|
|
138
|
+
this.keyToLastAccessTime.set(key, now);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private async getUnsafe(key: string, range: RangeBytes): Promise<string | undefined> {
|
|
143
|
+
// It first checks the storage, and then the ranges.
|
|
144
|
+
// In another method, when we remove a key, it first deletes a key from a storage and then from ranges,
|
|
145
|
+
// so if we don't have a key in storage but have it in ranges, the logic here is correct.
|
|
146
|
+
// We probably could reverse the operations here and there, and everywhere we work with both storage and ranges.
|
|
147
|
+
if (await this.storage.exists(key)) {
|
|
148
|
+
this.keyToLastAccessTime.set(key, new Date());
|
|
149
|
+
|
|
150
|
+
const ranges = await this.getRanges(key);
|
|
151
|
+
if (doesRangeExist(ranges, range)) {
|
|
152
|
+
return this.storage.path(key);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private async setUnsafe(key: string, range: { from: number; to: number; }, data: Uint8Array) {
|
|
162
|
+
await this.setWithoutEviction(key, range, data);
|
|
163
|
+
await this.ensureEvicted();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Sets a key and recalculates a size, but doesn't ensures that the size is less than the hard limit. */
|
|
167
|
+
async setWithoutEviction(key: string, range: RangeBytes, data: Uint8Array): Promise<void> {
|
|
168
|
+
if (range.to - range.from !== data.length) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`SparseCache.set: trying to set ${key} with wrong range length: `
|
|
171
|
+
+ `range: ${JSON.stringify(range)}, data: ${data.length}`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
this.keyToLastAccessTime.set(key, new Date());
|
|
176
|
+
|
|
177
|
+
const ranges = await this.getRanges(key);
|
|
178
|
+
this.size -= rangesSize(ranges);
|
|
179
|
+
|
|
180
|
+
await this.storage.write(key, data, range.from);
|
|
181
|
+
|
|
182
|
+
const newRanges = addRange(ranges, range);
|
|
183
|
+
this.size += rangesSize(newRanges);
|
|
184
|
+
|
|
185
|
+
await this.ranges.set(key, newRanges);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Ensures the size is less than hard limit by deleting the oldest keys. */
|
|
189
|
+
async ensureEvicted(): Promise<void> {
|
|
190
|
+
const byTime = mapEntries(this.keyToLastAccessTime);
|
|
191
|
+
byTime.sort(([_, aDate], [__, bDate]) => bDate.getTime() - aDate.getTime());
|
|
192
|
+
|
|
193
|
+
while (this.size > this.maxSize) {
|
|
194
|
+
const keyAndDate = byTime.pop(); // removes the oldest
|
|
195
|
+
if (!keyAndDate) {
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
const [key, _] = keyAndDate;
|
|
199
|
+
|
|
200
|
+
const ranges = await this.getRanges(key);
|
|
201
|
+
this.size -= rangesSize(ranges);
|
|
202
|
+
this.rmKey(key);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Gets ranges and if they were corrupted, then remove the file from the cache and reset the cache's size. */
|
|
207
|
+
private async getRanges(key: string) {
|
|
208
|
+
try {
|
|
209
|
+
return await this.ranges.get(key);
|
|
210
|
+
} catch (e: unknown) {
|
|
211
|
+
if (e instanceof CorruptedRangesError) {
|
|
212
|
+
// We need to reset a state of the cache and update current size,
|
|
213
|
+
// it's the only way to calculate the real size when one of the ranges were corrupted.
|
|
214
|
+
await this.rmKey(key);
|
|
215
|
+
await this.resetUnsafe();
|
|
216
|
+
|
|
217
|
+
return await this.ranges.get(key);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
throw e;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Removes a key the state of the cache. The size should be updated. */
|
|
225
|
+
private async rmKey(key: string) {
|
|
226
|
+
await this.storage.delete(key);
|
|
227
|
+
await this.ranges.delete(key);
|
|
228
|
+
this.keyToLastAccessTime.delete(key);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Acquires the lock and executes a callback. */
|
|
233
|
+
async function withLock<T>(lock: functions.AwaitLock, cb: () => Promise<T>): Promise<T> {
|
|
234
|
+
try {
|
|
235
|
+
await lock.acquireAsync();
|
|
236
|
+
return await cb();
|
|
237
|
+
} finally {
|
|
238
|
+
lock.release();
|
|
239
|
+
}
|
|
240
|
+
}
|