@milaboratories/pl-drivers 1.15.6 → 1.16.1
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/clients/download.cjs +73 -7
- package/dist/clients/download.cjs.map +1 -1
- package/dist/clients/download.d.ts +15 -1
- package/dist/clients/download.d.ts.map +1 -1
- package/dist/clients/download.js +73 -7
- package/dist/clients/download.js.map +1 -1
- package/dist/clients/download_url_cache.cjs +102 -0
- package/dist/clients/download_url_cache.cjs.map +1 -0
- package/dist/clients/download_url_cache.js +101 -0
- package/dist/clients/download_url_cache.js.map +1 -0
- package/dist/drivers/download_blob/download_blob.cjs +40 -2
- package/dist/drivers/download_blob/download_blob.cjs.map +1 -1
- package/dist/drivers/download_blob/download_blob.d.ts +17 -1
- package/dist/drivers/download_blob/download_blob.d.ts.map +1 -1
- package/dist/drivers/download_blob/download_blob.js +40 -2
- package/dist/drivers/download_blob/download_blob.js.map +1 -1
- package/package.json +9 -8
- package/src/clients/download.ts +114 -19
- package/src/clients/download_url_cache.test.ts +68 -0
- package/src/clients/download_url_cache.ts +118 -0
- package/src/drivers/download_blob/download_blob.test.ts +44 -0
- package/src/drivers/download_blob/download_blob.ts +65 -2
package/src/clients/download.ts
CHANGED
|
@@ -20,11 +20,16 @@ import { Readable } from "node:stream";
|
|
|
20
20
|
import type { Dispatcher } from "undici";
|
|
21
21
|
import type { LocalStorageProjection } from "../drivers/types";
|
|
22
22
|
import { type ContentHandler, RemoteFileDownloader } from "../helpers/download";
|
|
23
|
+
import { isDownloadNetworkError400 } from "../helpers/download_errors";
|
|
23
24
|
import { validateAbsolute } from "../helpers/validate";
|
|
24
25
|
import type { DownloadAPI_GetDownloadURL_Response } from "../proto-grpc/github.com/milaboratory/pl/controllers/shared/grpc/downloadapi/protocol";
|
|
25
26
|
import { DownloadClient } from "../proto-grpc/github.com/milaboratory/pl/controllers/shared/grpc/downloadapi/protocol.client";
|
|
26
27
|
import type { DownloadApiPaths, DownloadRestClientType } from "../proto-rest";
|
|
27
|
-
import { type GetContentOptions } from "@milaboratories/pl-model-common";
|
|
28
|
+
import { type GetContentOptions, type BlobDriverMetrics } from "@milaboratories/pl-model-common";
|
|
29
|
+
import { DownloadUrlCache } from "./download_url_cache";
|
|
30
|
+
|
|
31
|
+
/** Subset of {@link BlobDriverMetrics} owned by the download client (presigned cache + in-flight downloads). */
|
|
32
|
+
type ClientDownloadMetrics = Omit<BlobDriverMetrics, "uncachedRequests" | "uncachedRequestBytes">;
|
|
28
33
|
|
|
29
34
|
/** Gets URLs for downloading from pl-core, parses them and reads or downloads
|
|
30
35
|
* files locally and from the web. */
|
|
@@ -38,6 +43,16 @@ export class ClientDownload {
|
|
|
38
43
|
/** Concurrency limiter for local file reads - limit to 32 parallel reads */
|
|
39
44
|
private readonly localFileReadLimiter = new ConcurrencyLimitingExecutor(32);
|
|
40
45
|
|
|
46
|
+
/** Caches presigned download URLs by resource id until they (almost) expire. */
|
|
47
|
+
private readonly urlCache: DownloadUrlCache;
|
|
48
|
+
|
|
49
|
+
/** Active remote downloads; in-flight gauges are summed over this set on read. */
|
|
50
|
+
private readonly inFlight = new Set<{ size: number; received: number }>();
|
|
51
|
+
private presignedUrlCacheHits = 0;
|
|
52
|
+
private presignedUrlCacheMisses = 0;
|
|
53
|
+
private presignedUrlStaleHits = 0;
|
|
54
|
+
private presignedUrlRequestSumLatencyMs = 0;
|
|
55
|
+
|
|
41
56
|
constructor(
|
|
42
57
|
wireClientProviderFactory: WireClientProviderFactory,
|
|
43
58
|
public readonly httpClient: Dispatcher,
|
|
@@ -59,6 +74,7 @@ export class ClientDownload {
|
|
|
59
74
|
});
|
|
60
75
|
this.remoteFileDownloader = new RemoteFileDownloader(httpClient);
|
|
61
76
|
this.localStorageIdsToRoot = newLocalStorageIdsToRoot(localProjections);
|
|
77
|
+
this.urlCache = new DownloadUrlCache(logger);
|
|
62
78
|
}
|
|
63
79
|
|
|
64
80
|
close() {}
|
|
@@ -75,24 +91,103 @@ export class ClientDownload {
|
|
|
75
91
|
ops: GetContentOptions,
|
|
76
92
|
handler: ContentHandler<T>,
|
|
77
93
|
): Promise<T> {
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
`
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
const attempt = async ({
|
|
95
|
+
downloadUrl,
|
|
96
|
+
headers,
|
|
97
|
+
}: DownloadAPI_GetDownloadURL_Response): Promise<T> => {
|
|
98
|
+
const remoteHeaders = Object.fromEntries(headers.map(({ name, value }) => [name, value]));
|
|
99
|
+
this.logger.info(
|
|
100
|
+
`blob ${stringifyWithResourceId(info)} download started, ` +
|
|
101
|
+
`url: ${downloadUrl}, ` +
|
|
102
|
+
`range: ${JSON.stringify(ops.range ?? null)}`,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const timer = PerfTimer.start();
|
|
106
|
+
const result = isLocal(downloadUrl)
|
|
107
|
+
? await this.withLocalFileContent(downloadUrl, ops, handler)
|
|
108
|
+
: await this.withTrackedRemoteContent(downloadUrl, remoteHeaders, ops, handler);
|
|
109
|
+
|
|
110
|
+
this.logger.info(
|
|
111
|
+
`blob ${stringifyWithResourceId(info)} download finished, ` + `took: ${timer.elapsed()}`,
|
|
112
|
+
);
|
|
113
|
+
return result;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const cached = this.urlCache.get(info.id);
|
|
117
|
+
if (cached !== undefined) {
|
|
118
|
+
try {
|
|
119
|
+
const result = await attempt(cached);
|
|
120
|
+
this.presignedUrlCacheHits++;
|
|
121
|
+
return result;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (!isDownloadNetworkError400(error)) throw error;
|
|
124
|
+
this.urlCache.delete(info.id);
|
|
125
|
+
this.presignedUrlStaleHits++;
|
|
126
|
+
this.logger.info(
|
|
127
|
+
`cached download URL for blob ${stringifyWithResourceId(info)} rejected ` +
|
|
128
|
+
`(status ${error.statusCode}), re-fetching`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
this.presignedUrlCacheMisses++;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const urlFetchStartMs = performance.now();
|
|
136
|
+
const fresh = await this.grpcGetDownloadUrl(info, options, ops.signal);
|
|
137
|
+
this.presignedUrlRequestSumLatencyMs += performance.now() - urlFetchStartMs;
|
|
138
|
+
this.urlCache.set(info.id, fresh);
|
|
139
|
+
return await attempt(fresh);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Download-client-owned slice of {@link BlobDriverMetrics}: presigned-URL cache + in-flight downloads. */
|
|
143
|
+
metrics(): ClientDownloadMetrics {
|
|
144
|
+
let inFlightBytesTotal = 0;
|
|
145
|
+
let inFlightBytesReceived = 0;
|
|
146
|
+
for (const rec of this.inFlight) {
|
|
147
|
+
inFlightBytesTotal += rec.size;
|
|
148
|
+
inFlightBytesReceived += rec.received;
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
downloadsInFlight: this.inFlight.size,
|
|
152
|
+
inFlightBytesTotal,
|
|
153
|
+
inFlightBytesReceived,
|
|
154
|
+
presignedUrlCacheHits: this.presignedUrlCacheHits,
|
|
155
|
+
presignedUrlCacheMisses: this.presignedUrlCacheMisses,
|
|
156
|
+
presignedUrlStaleHits: this.presignedUrlStaleHits,
|
|
157
|
+
presignedUrlRequestSumLatencyMs: this.presignedUrlRequestSumLatencyMs,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Wraps a remote download so it appears in the in-flight gauges and its received bytes are counted live. */
|
|
162
|
+
private async withTrackedRemoteContent<T>(
|
|
163
|
+
url: string,
|
|
164
|
+
headers: Record<string, string>,
|
|
165
|
+
ops: GetContentOptions,
|
|
166
|
+
handler: ContentHandler<T>,
|
|
167
|
+
): Promise<T> {
|
|
168
|
+
const rec = { size: 0, received: 0 };
|
|
169
|
+
this.inFlight.add(rec);
|
|
170
|
+
try {
|
|
171
|
+
return await this.remoteFileDownloader.withContent(
|
|
172
|
+
url,
|
|
173
|
+
headers,
|
|
174
|
+
ops,
|
|
175
|
+
async (content, size) => {
|
|
176
|
+
rec.size = size;
|
|
177
|
+
const counted = content.pipeThrough(
|
|
178
|
+
new TransformStream<Uint8Array, Uint8Array>({
|
|
179
|
+
transform: (chunk, controller) => {
|
|
180
|
+
rec.received += chunk.byteLength;
|
|
181
|
+
controller.enqueue(chunk);
|
|
182
|
+
},
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
return await handler(counted, size);
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
} finally {
|
|
189
|
+
this.inFlight.delete(rec);
|
|
190
|
+
}
|
|
96
191
|
}
|
|
97
192
|
|
|
98
193
|
async withLocalFileContent<T>(
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { test, expect, describe, vi, afterEach } from "vitest";
|
|
2
|
+
import { downloadUrlCacheTtlMs, extractUrlExpiryMs } from "./download_url_cache";
|
|
3
|
+
|
|
4
|
+
// 2026-06-16T12:00:00Z, expressed timezone-independently.
|
|
5
|
+
const SIGNED_AT = Date.UTC(2026, 5, 16, 12, 0, 0);
|
|
6
|
+
|
|
7
|
+
describe("extractUrlExpiryMs", () => {
|
|
8
|
+
test("AWS SigV4 URL (S3 / FS remote driver): X-Amz-Date + X-Amz-Expires", () => {
|
|
9
|
+
const url =
|
|
10
|
+
"https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=AWS4-HMAC-SHA256" +
|
|
11
|
+
"&X-Amz-Date=20260616T120000Z&X-Amz-Expires=3600&X-Amz-Signature=deadbeef";
|
|
12
|
+
expect(extractUrlExpiryMs(url)).toBe(SIGNED_AT + 3600_000);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("GCS V4 signed URL: X-Goog-Date + X-Goog-Expires", () => {
|
|
16
|
+
const url =
|
|
17
|
+
"https://storage.googleapis.com/bucket/key?X-Goog-Algorithm=GOOG4-RSA-SHA256" +
|
|
18
|
+
"&X-Goog-Date=20260616T120000Z&X-Goog-Expires=600&X-Goog-Signature=deadbeef";
|
|
19
|
+
expect(extractUrlExpiryMs(url)).toBe(SIGNED_AT + 600_000);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("the encoded timestamp is UTC, independent of host timezone", () => {
|
|
23
|
+
// The compact form ends with 'Z' (Zulu/UTC); the result must equal the
|
|
24
|
+
// UTC epoch and never shift by the runner's local offset.
|
|
25
|
+
const url = "https://x/key?X-Amz-Date=20260101T000000Z&X-Amz-Expires=1&X-Amz-Signature=x";
|
|
26
|
+
expect(extractUrlExpiryMs(url)).toBe(Date.UTC(2026, 0, 1, 0, 0, 0) + 1000);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("local storage:// URL has no encoded expiry", () => {
|
|
30
|
+
expect(extractUrlExpiryMs("storage://main/some/relative/path.json")).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("plain URL without presign params has no expiry", () => {
|
|
34
|
+
expect(extractUrlExpiryMs("https://example.com/file?foo=bar")).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("malformed presign params yield null (present but invalid - do not cache)", () => {
|
|
38
|
+
const url = "https://x/key?X-Amz-Date=not-a-date&X-Amz-Expires=3600&X-Amz-Signature=x";
|
|
39
|
+
expect(extractUrlExpiryMs(url)).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("non-URL input yields no expiry", () => {
|
|
43
|
+
expect(extractUrlExpiryMs("not a url")).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("downloadUrlCacheTtlMs", () => {
|
|
48
|
+
// downloadUrlCacheTtlMs reads Date.now(); pin it so the TTL math is deterministic.
|
|
49
|
+
afterEach(() => vi.useRealTimers());
|
|
50
|
+
|
|
51
|
+
test("subtracts the 30s safety margin from the encoded expiry", () => {
|
|
52
|
+
vi.useFakeTimers();
|
|
53
|
+
vi.setSystemTime(SIGNED_AT); // now = signing time -> raw remaining 3600s, minus 30s margin.
|
|
54
|
+
const url = "https://x/key?X-Amz-Date=20260616T120000Z&X-Amz-Expires=3600&X-Amz-Signature=x";
|
|
55
|
+
expect(downloadUrlCacheTtlMs(url)).toBe(3600_000 - 30_000);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("returns non-positive when within the safety margin of expiry", () => {
|
|
59
|
+
vi.useFakeTimers();
|
|
60
|
+
vi.setSystemTime(SIGNED_AT + 50_000); // 10s of life left -> after a 30s margin, not worth caching.
|
|
61
|
+
const url = "https://x/key?X-Amz-Date=20260616T120000Z&X-Amz-Expires=60&X-Amz-Signature=x";
|
|
62
|
+
expect(downloadUrlCacheTtlMs(url)).toBeLessThanOrEqual(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("falls back to a positive default TTL for URLs without encoded expiry", () => {
|
|
66
|
+
expect(downloadUrlCacheTtlMs("storage://main/path")).toBeGreaterThan(0);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { LRUCache } from "lru-cache";
|
|
2
|
+
import type { SignedResourceId } from "@milaboratories/pl-client";
|
|
3
|
+
import type { MiLogger } from "@milaboratories/ts-helpers";
|
|
4
|
+
import type { DownloadAPI_GetDownloadURL_Response } from "../proto-grpc/github.com/milaboratory/pl/controllers/shared/grpc/downloadapi/protocol";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Safety margin subtracted from the encoded URL expiry. Covers clock skew
|
|
8
|
+
* between this host and pl-core (the timestamp is the server's signing clock,
|
|
9
|
+
* not ours) plus in-flight time between signing and use.
|
|
10
|
+
*/
|
|
11
|
+
const SAFETY_MARGIN_MS = 30_000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* TTL applied to download URLs that carry no expiry in their query string -
|
|
15
|
+
* notably local `storage://` URLs, which are deterministic for a given resource
|
|
16
|
+
* and effectively never expire. Bounded so a changed storage projection is
|
|
17
|
+
* eventually re-resolved.
|
|
18
|
+
*/
|
|
19
|
+
const NO_EXPIRY_DEFAULT_TTL_MS = 60 * 60 * 1000; // 1h
|
|
20
|
+
|
|
21
|
+
/** Max number of cached {url, headers} entries. Each entry is tiny. */
|
|
22
|
+
const DEFAULT_MAX_ENTRIES = 4096;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extracts the absolute expiry time (epoch ms) encoded in a presigned download
|
|
26
|
+
* URL, or `undefined` if the URL carries no expiry.
|
|
27
|
+
*
|
|
28
|
+
* Supports AWS SigV4 (`X-Amz-Date` + `X-Amz-Expires`, used by pl's S3 and FS
|
|
29
|
+
* remote drivers) and GCS V4 (`X-Goog-Date` + `X-Goog-Expires`). Both encode
|
|
30
|
+
* the date as a compact ISO-8601 UTC timestamp `YYYYMMDDTHHMMSSZ` (trailing `Z`
|
|
31
|
+
* = Zulu/UTC; verified against pl `util/storage/v4sign/presigner.go`) and the
|
|
32
|
+
* lifetime as integer seconds.
|
|
33
|
+
*/
|
|
34
|
+
export function extractUrlExpiryMs(url: string): number | null | undefined {
|
|
35
|
+
let query: URLSearchParams;
|
|
36
|
+
try {
|
|
37
|
+
query = new URL(url).searchParams;
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const prefix of ["X-Amz", "X-Goog"]) {
|
|
43
|
+
const date = query.get(`${prefix}-Date`);
|
|
44
|
+
const expires = query.get(`${prefix}-Expires`);
|
|
45
|
+
if (date === null || expires === null) continue;
|
|
46
|
+
|
|
47
|
+
const signedAtMs = parseCompactIso8601Utc(date);
|
|
48
|
+
const expiresSec = Number(expires);
|
|
49
|
+
if (signedAtMs === undefined || !Number.isFinite(expiresSec) || expiresSec <= 0) return null;
|
|
50
|
+
|
|
51
|
+
return signedAtMs + expiresSec * 1000;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parses a compact ISO-8601 UTC timestamp `YYYYMMDDTHHMMSSZ` into epoch ms.
|
|
59
|
+
* `new Date()` cannot parse the compact form, so we expand it to the extended
|
|
60
|
+
* form `YYYY-MM-DDTHH:MM:SSZ`; the trailing `Z` makes it UTC regardless of the
|
|
61
|
+
* host's local timezone.
|
|
62
|
+
*/
|
|
63
|
+
function parseCompactIso8601Utc(value: string): number | undefined {
|
|
64
|
+
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(value);
|
|
65
|
+
if (m === null) return undefined;
|
|
66
|
+
const [, y, mo, d, h, mi, s] = m;
|
|
67
|
+
const ms = Date.parse(`${y}-${mo}-${d}T${h}:${mi}:${s}Z`);
|
|
68
|
+
return Number.isNaN(ms) ? undefined : ms;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Computes the cache TTL (ms from now) for a download URL, with the safety
|
|
73
|
+
* margin already subtracted. Returns a non-positive number when the URL is
|
|
74
|
+
* already within the margin of expiring - the caller should then skip caching.
|
|
75
|
+
*/
|
|
76
|
+
export function downloadUrlCacheTtlMs(url: string): number {
|
|
77
|
+
const expiry = extractUrlExpiryMs(url);
|
|
78
|
+
if (expiry === null) return 0;
|
|
79
|
+
if (expiry === undefined) return NO_EXPIRY_DEFAULT_TTL_MS;
|
|
80
|
+
return expiry - Date.now() - SAFETY_MARGIN_MS;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* LRU cache of `GetDownloadURL` responses keyed by `SignedResourceId`. Each
|
|
85
|
+
* entry's TTL is derived from the expiry encoded in the presigned URL (minus a
|
|
86
|
+
* safety margin), so an entry never outlives the URL it holds.
|
|
87
|
+
*
|
|
88
|
+
* Note: the key intentionally omits `isInternalUse` because the only caller
|
|
89
|
+
* always requests `isInternalUse: false`. Revisit if that ever varies.
|
|
90
|
+
*/
|
|
91
|
+
export class DownloadUrlCache {
|
|
92
|
+
private readonly cache: LRUCache<SignedResourceId, DownloadAPI_GetDownloadURL_Response>;
|
|
93
|
+
|
|
94
|
+
constructor(
|
|
95
|
+
private readonly logger: MiLogger,
|
|
96
|
+
maxEntries: number = DEFAULT_MAX_ENTRIES,
|
|
97
|
+
) {
|
|
98
|
+
this.cache = new LRUCache<SignedResourceId, DownloadAPI_GetDownloadURL_Response>({
|
|
99
|
+
max: maxEntries,
|
|
100
|
+
// URL expiry is absolute, not sliding - do not extend TTL on access.
|
|
101
|
+
updateAgeOnGet: false,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get(key: SignedResourceId): DownloadAPI_GetDownloadURL_Response | undefined {
|
|
106
|
+
return this.cache.get(key);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
set(key: SignedResourceId, value: DownloadAPI_GetDownloadURL_Response): void {
|
|
110
|
+
const ttl = downloadUrlCacheTtlMs(value.downloadUrl);
|
|
111
|
+
if (ttl <= 0) return; // Cache miss.
|
|
112
|
+
this.cache.set(key, value, { ttl });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
delete(key: SignedResourceId): void {
|
|
116
|
+
this.cache.delete(key);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -227,10 +227,54 @@ test("should get undefined when releasing a blob from a small cache and the blob
|
|
|
227
227
|
});
|
|
228
228
|
});
|
|
229
229
|
|
|
230
|
+
test("getContentDirect returns the same content but bypasses the ranges cache", async () => {
|
|
231
|
+
await TestHelpers.withTempRoot(async (client) => {
|
|
232
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "test-download-direct-"));
|
|
233
|
+
const rangesCacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "test-download-ranges-"));
|
|
234
|
+
|
|
235
|
+
const driver = await genDriver(client, dir, rangesCacheDir, genSigner());
|
|
236
|
+
const downloadable = await makeDownloadableBlobFromAssets(client, fileName);
|
|
237
|
+
|
|
238
|
+
// On-demand (remote) handle - this is the path that uses the ranges cache.
|
|
239
|
+
const c = driver.getOnDemandBlob(downloadable);
|
|
240
|
+
const blob = await c.getValue();
|
|
241
|
+
expect(blob).toBeDefined();
|
|
242
|
+
expect(blob.size).toEqual(3);
|
|
243
|
+
|
|
244
|
+
const baseline = await dirSizeBytes(rangesCacheDir);
|
|
245
|
+
|
|
246
|
+
// Direct reads return the correct bytes (full + range) ...
|
|
247
|
+
expect((await driver.getContentDirect(blob.handle))?.toString()).toBe("42\n");
|
|
248
|
+
expect(
|
|
249
|
+
(await driver.getContentDirect(blob.handle, { range: { from: 0, to: 2 } }))?.toString(),
|
|
250
|
+
).toBe("42");
|
|
251
|
+
|
|
252
|
+
// ... and leave the ranges cache untouched (cache writes are fire-and-forget, so settle first).
|
|
253
|
+
await scheduler.wait(100);
|
|
254
|
+
expect(await dirSizeBytes(rangesCacheDir)).toBe(baseline);
|
|
255
|
+
|
|
256
|
+
// A normal cached read of the same handle DOES populate the ranges cache.
|
|
257
|
+
expect((await driver.getContent(blob.handle))?.toString()).toBe("42\n");
|
|
258
|
+
await scheduler.wait(100);
|
|
259
|
+
expect(await dirSizeBytes(rangesCacheDir)).toBeGreaterThan(baseline);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
230
263
|
function genSigner() {
|
|
231
264
|
return new HmacSha256Signer(HmacSha256Signer.generateSecret());
|
|
232
265
|
}
|
|
233
266
|
|
|
267
|
+
/** Total bytes of all files under `dir` (recursive), for asserting cache population. */
|
|
268
|
+
async function dirSizeBytes(dir: string): Promise<number> {
|
|
269
|
+
let total = 0;
|
|
270
|
+
for (const entry of await fsp.readdir(dir, { withFileTypes: true })) {
|
|
271
|
+
const full = path.join(dir, entry.name);
|
|
272
|
+
if (entry.isDirectory()) total += await dirSizeBytes(full);
|
|
273
|
+
else if (entry.isFile()) total += (await fsp.stat(full)).size;
|
|
274
|
+
}
|
|
275
|
+
return total;
|
|
276
|
+
}
|
|
277
|
+
|
|
234
278
|
async function genDriver(
|
|
235
279
|
client: PlClient,
|
|
236
280
|
dir: string,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import type {
|
|
10
10
|
AnyLogHandle,
|
|
11
11
|
BlobDriver,
|
|
12
|
+
BlobDriverMetrics,
|
|
12
13
|
ContentHandler,
|
|
13
14
|
GetContentOptions,
|
|
14
15
|
LocalBlobHandle,
|
|
@@ -101,6 +102,10 @@ export class DownloadDriver implements BlobDriver, AsyncDisposable {
|
|
|
101
102
|
|
|
102
103
|
private readonly saveDir: string;
|
|
103
104
|
|
|
105
|
+
/** Downloads that bypassed the ranges cache; counted when issued. */
|
|
106
|
+
private uncachedRequests = 0;
|
|
107
|
+
private uncachedRequestBytes = 0;
|
|
108
|
+
|
|
104
109
|
constructor(
|
|
105
110
|
private readonly logger: MiLogger,
|
|
106
111
|
private readonly clientDownload: ClientDownload,
|
|
@@ -322,9 +327,50 @@ export class DownloadDriver implements BlobDriver, AsyncDisposable {
|
|
|
322
327
|
}
|
|
323
328
|
}
|
|
324
329
|
|
|
330
|
+
return await this.getContentImpl({ handle, options });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Same as {@link getContent}, but bypasses the ranges cache entirely (no read, no write).
|
|
335
|
+
* For local handles this is identical to {@link getContent}, since local content never
|
|
336
|
+
* uses the ranges cache.
|
|
337
|
+
*/
|
|
338
|
+
public async getContentDirect(
|
|
339
|
+
handle: LocalBlobHandle | RemoteBlobHandle,
|
|
340
|
+
options?: GetContentOptions,
|
|
341
|
+
): Promise<Uint8Array> {
|
|
342
|
+
return await this.getContentImpl({
|
|
343
|
+
handle,
|
|
344
|
+
options: options ?? {},
|
|
345
|
+
bypassRangesCache: true,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Operational metrics for a monitoring panel. Serv cache metrics are reported separately
|
|
351
|
+
* (different owner) — the panel composes both.
|
|
352
|
+
*/
|
|
353
|
+
public getMetrics(): BlobDriverMetrics {
|
|
354
|
+
return {
|
|
355
|
+
uncachedRequests: this.uncachedRequests,
|
|
356
|
+
uncachedRequestBytes: this.uncachedRequestBytes,
|
|
357
|
+
...this.clientDownload.metrics(),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private async getContentImpl({
|
|
362
|
+
handle,
|
|
363
|
+
options,
|
|
364
|
+
bypassRangesCache = false,
|
|
365
|
+
}: {
|
|
366
|
+
handle: LocalBlobHandle | RemoteBlobHandle;
|
|
367
|
+
options: GetContentOptions;
|
|
368
|
+
bypassRangesCache?: boolean;
|
|
369
|
+
}): Promise<Uint8Array> {
|
|
325
370
|
const request = () =>
|
|
326
371
|
this.withContent(handle, {
|
|
327
372
|
...options,
|
|
373
|
+
bypassRangesCache,
|
|
328
374
|
handler: async (content) => {
|
|
329
375
|
const chunks: Uint8Array[] = [];
|
|
330
376
|
for await (const chunk of content) {
|
|
@@ -350,9 +396,10 @@ export class DownloadDriver implements BlobDriver, AsyncDisposable {
|
|
|
350
396
|
handle: LocalBlobHandle | RemoteBlobHandle,
|
|
351
397
|
options: GetContentOptions & {
|
|
352
398
|
handler: ContentHandler<T>;
|
|
399
|
+
bypassRangesCache?: boolean;
|
|
353
400
|
},
|
|
354
401
|
): Promise<T> {
|
|
355
|
-
const { range, signal, handler } = options;
|
|
402
|
+
const { range, signal, handler, bypassRangesCache } = options;
|
|
356
403
|
|
|
357
404
|
if (isLocalBlobHandle(handle)) {
|
|
358
405
|
return await withFileContent({ path: this.getLocalPath(handle), range, signal, handler });
|
|
@@ -362,16 +409,32 @@ export class DownloadDriver implements BlobDriver, AsyncDisposable {
|
|
|
362
409
|
const result = parseRemoteHandle(handle, this.signer);
|
|
363
410
|
|
|
364
411
|
const key = blobKey(result.info.id);
|
|
365
|
-
const filePath =
|
|
412
|
+
const filePath = bypassRangesCache
|
|
413
|
+
? undefined
|
|
414
|
+
: await this.rangesCache.get(key, range ?? { from: 0, to: result.size });
|
|
366
415
|
signal?.throwIfAborted();
|
|
367
416
|
|
|
368
417
|
if (filePath) return await withFileContent({ path: filePath, range, signal, handler });
|
|
369
418
|
|
|
419
|
+
if (bypassRangesCache) this.uncachedRequests++;
|
|
420
|
+
|
|
370
421
|
return await this.clientDownload.withBlobContent(
|
|
371
422
|
result.info,
|
|
372
423
|
{ signal },
|
|
373
424
|
options,
|
|
374
425
|
async (content, size) => {
|
|
426
|
+
if (bypassRangesCache) {
|
|
427
|
+
const counted = content.pipeThrough(
|
|
428
|
+
new TransformStream<Uint8Array, Uint8Array>({
|
|
429
|
+
transform: (chunk, controller) => {
|
|
430
|
+
this.uncachedRequestBytes += chunk.byteLength;
|
|
431
|
+
controller.enqueue(chunk);
|
|
432
|
+
},
|
|
433
|
+
}),
|
|
434
|
+
);
|
|
435
|
+
return await handler(counted, size);
|
|
436
|
+
}
|
|
437
|
+
|
|
375
438
|
const [handlerStream, cacheStream] = content.tee();
|
|
376
439
|
|
|
377
440
|
const handlerPromise = handler(handlerStream, size);
|