@cloudbitmaps/gcs 0.11.1 → 0.12.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/README.md +47 -4
- package/dist/backend.d.ts +32 -3
- package/dist/download-retry.d.ts +8 -0
- package/dist/gcs-errors.d.ts +16 -1
- package/dist/index.js +498 -74
- package/dist/index.js.map +4 -4
- package/dist/read-once.d.ts +52 -0
- package/dist/read-timeout.d.ts +28 -0
- package/dist/registry.d.ts +50 -9
- package/dist/storage.d.ts +50 -2
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// src/backend.ts
|
|
2
|
-
import { ValidationError as
|
|
2
|
+
import { ValidationError as ValidationError5, brandAsBackend } from "@cloudbitmaps/core/driver-kit";
|
|
3
3
|
import { Storage as GcsClient } from "@google-cloud/storage";
|
|
4
4
|
|
|
5
5
|
// src/storage.ts
|
|
6
6
|
import {
|
|
7
7
|
NotFoundError,
|
|
8
8
|
TransientError,
|
|
9
|
-
ValidationError as
|
|
9
|
+
ValidationError as ValidationError3,
|
|
10
10
|
WriteConflictError,
|
|
11
11
|
isNotFoundError,
|
|
12
12
|
isValidationError,
|
|
@@ -72,20 +72,145 @@ function networkCode(err) {
|
|
|
72
72
|
function isPreconditionFailed(err) {
|
|
73
73
|
return httpStatus(err) === 412;
|
|
74
74
|
}
|
|
75
|
+
function isThrottle(err) {
|
|
76
|
+
const status = httpStatus(err);
|
|
77
|
+
return status === 429 || status === 503;
|
|
78
|
+
}
|
|
75
79
|
function isNotFound(err) {
|
|
76
80
|
return httpStatus(err) === 404;
|
|
77
81
|
}
|
|
78
82
|
function isInvalidRange(err) {
|
|
79
83
|
return httpStatus(err) === 416;
|
|
80
84
|
}
|
|
85
|
+
var SDK_RETRIED_STATUSES = [408, 429, 500, 502, 503, 504];
|
|
86
|
+
function isSdkConnectionProblem(reason) {
|
|
87
|
+
return reason.includes("eai_again") || reason === "econnreset" || reason === "unexpected connection closure" || reason === "epipe" || reason === "socket connection timeout";
|
|
88
|
+
}
|
|
89
|
+
var TRANSPORT_CODES = /* @__PURE__ */ new Set([
|
|
90
|
+
"ECONNRESET",
|
|
91
|
+
"ECONNREFUSED",
|
|
92
|
+
"ECONNABORTED",
|
|
93
|
+
"ETIMEDOUT",
|
|
94
|
+
"ENOTFOUND",
|
|
95
|
+
"EAI_AGAIN",
|
|
96
|
+
"EHOSTUNREACH",
|
|
97
|
+
"ENETUNREACH",
|
|
98
|
+
"EPIPE",
|
|
99
|
+
"ERR_STREAM_PREMATURE_CLOSE"
|
|
100
|
+
]);
|
|
101
|
+
function isTransportFault(err) {
|
|
102
|
+
if (httpStatus(err) !== void 0) return false;
|
|
103
|
+
const code = networkCode(err);
|
|
104
|
+
return code !== void 0 && TRANSPORT_CODES.has(code);
|
|
105
|
+
}
|
|
106
|
+
function isDownloadRetryable(err) {
|
|
107
|
+
if (err === null || typeof err !== "object") return false;
|
|
108
|
+
if (isTransportFault(err)) return true;
|
|
109
|
+
const e = err;
|
|
110
|
+
if (typeof e.code === "number" && SDK_RETRIED_STATUSES.includes(e.code)) return true;
|
|
111
|
+
if (typeof e.code === "string") {
|
|
112
|
+
if (SDK_RETRIED_STATUSES.map(String).includes(e.code)) return true;
|
|
113
|
+
if (isSdkConnectionProblem(e.code.toLowerCase())) return true;
|
|
114
|
+
}
|
|
115
|
+
if (Array.isArray(e.errors)) {
|
|
116
|
+
for (const inner of e.errors) {
|
|
117
|
+
const reason = inner?.reason?.toString().toLowerCase();
|
|
118
|
+
if (reason !== void 0 && isSdkConnectionProblem(reason)) return true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
81
123
|
function isTransient(err) {
|
|
82
124
|
if (isPreconditionFailed(err) || isNotFound(err) || isInvalidRange(err)) return false;
|
|
83
125
|
const status = httpStatus(err);
|
|
84
|
-
if (status === 429 || status !== void 0 && status >= 500 && status < 600)
|
|
126
|
+
if (status === 408 || status === 429 || status !== void 0 && status >= 500 && status < 600)
|
|
127
|
+
return true;
|
|
85
128
|
const net = networkCode(err);
|
|
86
129
|
return net === "ECONNRESET" || net === "ETIMEDOUT" || net === "ECONNREFUSED" || net === "EPIPE" || net === "EAI_AGAIN" || net === "ENOTFOUND";
|
|
87
130
|
}
|
|
88
131
|
|
|
132
|
+
// src/read-timeout.ts
|
|
133
|
+
import { ValidationError as ValidationError2 } from "@cloudbitmaps/core/driver-kit";
|
|
134
|
+
var DEFAULT_READ_TIMEOUT_MS = 0;
|
|
135
|
+
var MAX_TIMER_MS = 2147483647;
|
|
136
|
+
function resolveReadTimeoutMs(value) {
|
|
137
|
+
if (value === void 0) return DEFAULT_READ_TIMEOUT_MS;
|
|
138
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > MAX_TIMER_MS) {
|
|
139
|
+
throw new ValidationError2(
|
|
140
|
+
`readTimeoutMs must be a non-negative safe integer no larger than ${MAX_TIMER_MS}; got ${describe(value)}`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
function describe(value) {
|
|
146
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
147
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
|
148
|
+
return String(value);
|
|
149
|
+
}
|
|
150
|
+
return value === null ? "null" : typeof value;
|
|
151
|
+
}
|
|
152
|
+
var ReadTimedOut = class extends Error {
|
|
153
|
+
code = "ETIMEDOUT";
|
|
154
|
+
constructor(read, ms, cause) {
|
|
155
|
+
super(`GCS ${read} timed out after ${ms} ms`, cause === void 0 ? void 0 : { cause });
|
|
156
|
+
this.name = "ReadTimedOut";
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
var Deadline = class {
|
|
160
|
+
constructor(ms, read) {
|
|
161
|
+
this.ms = ms;
|
|
162
|
+
this.read = read;
|
|
163
|
+
this.endsAt = performance.now() + ms;
|
|
164
|
+
}
|
|
165
|
+
ms;
|
|
166
|
+
read;
|
|
167
|
+
endsAt;
|
|
168
|
+
/** What is left of it, in milliseconds: 0 once it has passed. */
|
|
169
|
+
remaining() {
|
|
170
|
+
return Math.max(0, this.endsAt - performance.now());
|
|
171
|
+
}
|
|
172
|
+
/** The error a read fails with once this has passed; `cause` is the last attempt's own error, if one failed. */
|
|
173
|
+
expired(cause) {
|
|
174
|
+
return new ReadTimedOut(this.read, this.ms, cause);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
function startDeadline(ms, read) {
|
|
178
|
+
return ms === 0 ? void 0 : new Deadline(ms, read);
|
|
179
|
+
}
|
|
180
|
+
function withDeadline(send, deadline) {
|
|
181
|
+
if (deadline === void 0) return send();
|
|
182
|
+
if (deadline.remaining() === 0) return Promise.reject(deadline.expired());
|
|
183
|
+
const request = send();
|
|
184
|
+
let timer;
|
|
185
|
+
const timedOut = new Promise((_, reject) => {
|
|
186
|
+
timer = setTimeout(() => reject(deadline.expired()), deadline.remaining());
|
|
187
|
+
});
|
|
188
|
+
return Promise.race([request, timedOut]).finally(() => clearTimeout(timer));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/download-retry.ts
|
|
192
|
+
var RETRIES = 3;
|
|
193
|
+
var BASE_DELAY_MS = 100;
|
|
194
|
+
var MAX_DELAY_MS = 1e3;
|
|
195
|
+
var MIN_RETRY_MS = 10;
|
|
196
|
+
async function retryDownload(download, deadline) {
|
|
197
|
+
for (let attempt = 0; ; attempt++) {
|
|
198
|
+
try {
|
|
199
|
+
return await download();
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (err instanceof ReadTimedOut) throw err;
|
|
202
|
+
if (attempt >= RETRIES || !isDownloadRetryable(err)) throw err;
|
|
203
|
+
const left = deadline?.remaining() ?? Infinity;
|
|
204
|
+
if (deadline !== void 0 && left < MIN_RETRY_MS) throw deadline.expired(err);
|
|
205
|
+
const ceiling = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
|
|
206
|
+
const delay = Math.random() * ceiling;
|
|
207
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, left)));
|
|
208
|
+
if (deadline !== void 0 && deadline.remaining() < MIN_RETRY_MS)
|
|
209
|
+
throw deadline.expired(err);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
89
214
|
// src/send-once.ts
|
|
90
215
|
import { once } from "node:events";
|
|
91
216
|
async function saveOnce(file, body, options) {
|
|
@@ -95,18 +220,96 @@ async function saveOnce(file, body, options) {
|
|
|
95
220
|
await stored;
|
|
96
221
|
}
|
|
97
222
|
|
|
223
|
+
// src/read-once.ts
|
|
224
|
+
var OFF_THE_SHARED_AGENT = {
|
|
225
|
+
request: (reqOpts) => ({ ...reqOpts, forever: false })
|
|
226
|
+
};
|
|
227
|
+
function downloadFile(storage, bucket, name) {
|
|
228
|
+
const file = storage.bucket(bucket).file(name);
|
|
229
|
+
file.interceptors.push(OFF_THE_SHARED_AGENT);
|
|
230
|
+
return file;
|
|
231
|
+
}
|
|
232
|
+
function singleHeader(headers, name) {
|
|
233
|
+
const value = headers[name];
|
|
234
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
235
|
+
}
|
|
236
|
+
function readOnce(file, options, maxBytes, oversize, deadline) {
|
|
237
|
+
if (deadline !== void 0 && deadline.remaining() === 0)
|
|
238
|
+
return Promise.reject(deadline.expired());
|
|
239
|
+
return new Promise((resolve, reject) => {
|
|
240
|
+
const stream = file.createReadStream(options);
|
|
241
|
+
const chunks = [];
|
|
242
|
+
let total = 0;
|
|
243
|
+
let status = 0;
|
|
244
|
+
let headers = {};
|
|
245
|
+
let responded = false;
|
|
246
|
+
let settled = false;
|
|
247
|
+
const timer = deadline === void 0 ? void 0 : setTimeout(() => fail(deadline.expired()), deadline.remaining());
|
|
248
|
+
const release = () => void setImmediate(() => stream.destroy());
|
|
249
|
+
const fail = (err) => {
|
|
250
|
+
if (settled) return;
|
|
251
|
+
settled = true;
|
|
252
|
+
clearTimeout(timer);
|
|
253
|
+
if (responded) release();
|
|
254
|
+
reject(err);
|
|
255
|
+
};
|
|
256
|
+
stream.on("response", (res) => {
|
|
257
|
+
responded = true;
|
|
258
|
+
if (settled) return release();
|
|
259
|
+
status = typeof res.statusCode === "number" ? res.statusCode : 0;
|
|
260
|
+
headers = res.headers ?? {};
|
|
261
|
+
if (status < 200 || status > 299) return;
|
|
262
|
+
const advertised = singleHeader(headers, "content-length");
|
|
263
|
+
if (advertised !== void 0 && /^\d+$/.test(advertised) && Number(advertised) > maxBytes) {
|
|
264
|
+
fail(oversize(Number(advertised)));
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
stream.on("data", (chunk) => {
|
|
268
|
+
if (settled) return;
|
|
269
|
+
total += chunk.length;
|
|
270
|
+
if (total > maxBytes) return fail(oversize(void 0));
|
|
271
|
+
chunks.push(chunk);
|
|
272
|
+
});
|
|
273
|
+
stream.on("error", fail);
|
|
274
|
+
stream.on("end", () => {
|
|
275
|
+
if (settled) return;
|
|
276
|
+
settled = true;
|
|
277
|
+
clearTimeout(timer);
|
|
278
|
+
resolve({ status, headers, bytes: new Uint8Array(Buffer.concat(chunks, total)) });
|
|
279
|
+
});
|
|
280
|
+
stream.on(
|
|
281
|
+
"close",
|
|
282
|
+
() => fail(
|
|
283
|
+
Object.assign(new Error("GCS read ended before the response completed"), {
|
|
284
|
+
code: "ECONNRESET"
|
|
285
|
+
})
|
|
286
|
+
)
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
98
291
|
// src/storage.ts
|
|
99
292
|
var DEFAULT_MAX_OBJECT_BYTES = 5 * 1024 * 1024 * 1024 * 1024;
|
|
100
293
|
var WRITE_ID_KEY = "cbwid";
|
|
294
|
+
var THROTTLE_RESENDS = 3;
|
|
295
|
+
var THROTTLE_BASE_DELAY_MS = 500;
|
|
296
|
+
var REAL_TIME = {
|
|
297
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
298
|
+
};
|
|
101
299
|
var DEFAULT_UPLOAD_THRESHOLD_BYTES = 8 * 1024 * 1024;
|
|
102
300
|
var GcsStorageDriver = class {
|
|
103
301
|
storage;
|
|
302
|
+
readStorage;
|
|
104
303
|
bucket;
|
|
105
304
|
prefix;
|
|
106
305
|
maxObjectBytes;
|
|
107
306
|
threshold;
|
|
307
|
+
readTimeoutMs;
|
|
308
|
+
clock;
|
|
108
309
|
constructor(options) {
|
|
109
310
|
this.storage = options.storage;
|
|
311
|
+
this.clock = options.clock ?? REAL_TIME;
|
|
312
|
+
this.readStorage = options.readStorage ?? options.storage;
|
|
110
313
|
this.bucket = options.bucket;
|
|
111
314
|
this.prefix = normalizeGcsPrefix(options.prefix);
|
|
112
315
|
for (const [name, value] of [
|
|
@@ -114,11 +317,12 @@ var GcsStorageDriver = class {
|
|
|
114
317
|
["simpleUploadThresholdBytes", options.simpleUploadThresholdBytes]
|
|
115
318
|
]) {
|
|
116
319
|
if (value !== void 0 && (!Number.isSafeInteger(value) || value < 1)) {
|
|
117
|
-
throw new
|
|
320
|
+
throw new ValidationError3(`${name} must be a positive safe integer; got ${value}`);
|
|
118
321
|
}
|
|
119
322
|
}
|
|
120
323
|
this.maxObjectBytes = options.maxObjectBytes ?? DEFAULT_MAX_OBJECT_BYTES;
|
|
121
324
|
this.threshold = options.simpleUploadThresholdBytes ?? DEFAULT_UPLOAD_THRESHOLD_BYTES;
|
|
325
|
+
this.readTimeoutMs = resolveReadTimeoutMs(options.readTimeoutMs);
|
|
122
326
|
}
|
|
123
327
|
capabilities() {
|
|
124
328
|
return { rangeRead: true, maxObjectBytes: this.maxObjectBytes, conditionalPut: true };
|
|
@@ -126,9 +330,22 @@ var GcsStorageDriver = class {
|
|
|
126
330
|
file(name) {
|
|
127
331
|
return this.storage.bucket(this.bucket).file(name);
|
|
128
332
|
}
|
|
333
|
+
/** The same object on the download client, off the SDK's shared agent ({@link downloadFile}). */
|
|
334
|
+
downloadable(name) {
|
|
335
|
+
return downloadFile(this.readStorage, this.bucket, name);
|
|
336
|
+
}
|
|
337
|
+
/** The deadline a read of `key` made now runs under, every attempt included, named for its error; none when off. */
|
|
338
|
+
deadline(read, key) {
|
|
339
|
+
return startDeadline(this.readTimeoutMs, `${read} of ${key.segment}.${key.generation}`);
|
|
340
|
+
}
|
|
129
341
|
async putImmutable(key, write) {
|
|
130
342
|
const objectName = storageObjectName(this.prefix, key);
|
|
131
|
-
const sink = new GcsUploadSink(
|
|
343
|
+
const sink = new GcsUploadSink(
|
|
344
|
+
this.file(objectName),
|
|
345
|
+
this.maxObjectBytes,
|
|
346
|
+
this.threshold,
|
|
347
|
+
this.clock
|
|
348
|
+
);
|
|
132
349
|
try {
|
|
133
350
|
await write(sink);
|
|
134
351
|
return await sink.finish();
|
|
@@ -145,41 +362,150 @@ var GcsStorageDriver = class {
|
|
|
145
362
|
}
|
|
146
363
|
async getRange(key, offset, length) {
|
|
147
364
|
if (!Number.isInteger(offset) || !Number.isInteger(length) || offset < 0 || length < 0) {
|
|
148
|
-
throw new
|
|
365
|
+
throw new ValidationError3(`invalid range offset=${offset} length=${length}`);
|
|
149
366
|
}
|
|
150
367
|
if (length === 0) return new Uint8Array(0);
|
|
151
368
|
const objectName = storageObjectName(this.prefix, key);
|
|
152
369
|
try {
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
370
|
+
const deadline = this.deadline("range read", key);
|
|
371
|
+
const res = await retryDownload(
|
|
372
|
+
() => readOnce(
|
|
373
|
+
this.downloadable(objectName),
|
|
374
|
+
{ start: offset, end: offset + length - 1, decompress: false },
|
|
375
|
+
length,
|
|
376
|
+
() => this.badRange(key, `the response is longer than the ${length}B requested`),
|
|
377
|
+
deadline
|
|
378
|
+
),
|
|
379
|
+
deadline
|
|
380
|
+
);
|
|
381
|
+
this.checkRange(res, key, offset, length);
|
|
382
|
+
return res.bytes;
|
|
163
383
|
} catch (err) {
|
|
164
384
|
throw this.mapReadError(err, key);
|
|
165
385
|
}
|
|
166
386
|
}
|
|
387
|
+
/**
|
|
388
|
+
* Refuse a range response unless it is exactly the bytes asked for. A 206 must name them in `Content-Range: bytes
|
|
389
|
+
* a-b/total`, starting at `offset`, and hold that many; a 200 (a server that ignored the range) is the whole object,
|
|
390
|
+
* which is the range only when the range starts at 0. A short read means the range ran past the end of the object:
|
|
391
|
+
* out of bounds, never a partial result.
|
|
392
|
+
*/
|
|
393
|
+
checkRange(res, key, offset, length) {
|
|
394
|
+
const received = res.bytes.length;
|
|
395
|
+
if (res.status === 200) {
|
|
396
|
+
if (offset !== 0)
|
|
397
|
+
throw this.badRange(key, `whole object returned for a range from ${offset}`);
|
|
398
|
+
} else if (res.status === 206) {
|
|
399
|
+
const header = singleHeader(res.headers, "content-range");
|
|
400
|
+
const m = header === void 0 ? null : /^bytes (\d+)-(\d+)\/(\d+)$/.exec(header);
|
|
401
|
+
if (m === null) throw this.badRange(key, "Content-Range is missing or malformed");
|
|
402
|
+
const [first, last, total] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
403
|
+
if (![first, last, total].every(Number.isSafeInteger)) {
|
|
404
|
+
throw this.badRange(key, "Content-Range holds a number that is not a safe integer");
|
|
405
|
+
}
|
|
406
|
+
if (first !== offset || last - first + 1 !== received || last >= total) {
|
|
407
|
+
throw this.badRange(
|
|
408
|
+
key,
|
|
409
|
+
`Content-Range ${first}-${last}/${total} disagrees with ${received}B received from ${offset}`
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
} else {
|
|
413
|
+
throw this.badRange(key, `unexpected HTTP ${res.status}`);
|
|
414
|
+
}
|
|
415
|
+
if (received !== length) {
|
|
416
|
+
throw new ValidationError3(
|
|
417
|
+
`range [${offset}, ${offset + length}) out of bounds (got ${received}B)`
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
badRange(key, why) {
|
|
422
|
+
return new ValidationError3(
|
|
423
|
+
`GCS range read of ${key.segment}.${key.generation} refused: ${why}`
|
|
424
|
+
);
|
|
425
|
+
}
|
|
167
426
|
async getTail(key, maxBytes) {
|
|
168
427
|
const objectName = storageObjectName(this.prefix, key);
|
|
428
|
+
const deadline = this.deadline("tail read", key);
|
|
169
429
|
try {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (!Number.isSafeInteger(
|
|
173
|
-
throw new
|
|
430
|
+
if (maxBytes <= 0)
|
|
431
|
+
return { bytes: new Uint8Array(0), size: await this.sizeOf(objectName, deadline) };
|
|
432
|
+
if (!Number.isSafeInteger(maxBytes)) {
|
|
433
|
+
throw new ValidationError3(`invalid tail length ${maxBytes}`);
|
|
434
|
+
}
|
|
435
|
+
let res;
|
|
436
|
+
try {
|
|
437
|
+
res = await retryDownload(
|
|
438
|
+
() => readOnce(
|
|
439
|
+
this.downloadable(objectName),
|
|
440
|
+
{ end: -maxBytes, decompress: false },
|
|
441
|
+
maxBytes,
|
|
442
|
+
() => this.badTail(key, `the response is longer than the ${maxBytes}B requested`),
|
|
443
|
+
deadline
|
|
444
|
+
),
|
|
445
|
+
deadline
|
|
446
|
+
);
|
|
447
|
+
} catch (err) {
|
|
448
|
+
if (!isInvalidRange(err)) throw err;
|
|
449
|
+
const size = await this.sizeOf(objectName, deadline);
|
|
450
|
+
if (size !== 0) throw err;
|
|
451
|
+
return { bytes: new Uint8Array(0), size };
|
|
452
|
+
}
|
|
453
|
+
if (res.bytes.length === 0) {
|
|
454
|
+
const size = await this.sizeOf(objectName, deadline);
|
|
455
|
+
if (size !== 0) throw this.badTail(key, `no bytes returned for an object of ${size}B`);
|
|
456
|
+
return { bytes: res.bytes, size };
|
|
174
457
|
}
|
|
175
|
-
|
|
176
|
-
const start = Math.max(0, size - maxBytes);
|
|
177
|
-
const [buf] = await this.file(objectName).download({ start, end: size - 1 });
|
|
178
|
-
return { bytes: new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength), size };
|
|
458
|
+
return { bytes: res.bytes, size: this.tailSize(res, maxBytes, key) };
|
|
179
459
|
} catch (err) {
|
|
180
460
|
throw this.mapReadError(err, key);
|
|
181
461
|
}
|
|
182
462
|
}
|
|
463
|
+
/**
|
|
464
|
+
* The object's size from its metadata, validated. Read on the download client and retried as a download is, within
|
|
465
|
+
* the calling read's `deadline` ({@link withDeadline}), so nothing but the one request in flight runs past it.
|
|
466
|
+
*/
|
|
467
|
+
async sizeOf(objectName, deadline) {
|
|
468
|
+
const [meta] = await retryDownload(
|
|
469
|
+
() => withDeadline(() => this.downloadable(objectName).getMetadata(), deadline),
|
|
470
|
+
deadline
|
|
471
|
+
);
|
|
472
|
+
const size = Number(meta.size ?? 0);
|
|
473
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
474
|
+
throw new ValidationError3(`GCS returned an invalid object size: ${String(meta.size)}`);
|
|
475
|
+
}
|
|
476
|
+
return size;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* The object's total size from a tail response, refused unless every header agrees with the bytes received. A 206
|
|
480
|
+
* must carry `Content-Range: bytes a-b/total` that names exactly the suffix asked for and exactly the bytes held;
|
|
481
|
+
* a 200 (a server that ignored the range) is the whole object, and is accepted only when it fits in the request.
|
|
482
|
+
*/
|
|
483
|
+
tailSize(res, maxBytes, key) {
|
|
484
|
+
const received = res.bytes.length;
|
|
485
|
+
if (res.status === 200) {
|
|
486
|
+
if (received > maxBytes)
|
|
487
|
+
throw this.badTail(key, "whole object returned past the requested length");
|
|
488
|
+
return received;
|
|
489
|
+
}
|
|
490
|
+
if (res.status !== 206) throw this.badTail(key, `unexpected HTTP ${res.status}`);
|
|
491
|
+
const header = singleHeader(res.headers, "content-range");
|
|
492
|
+
const m = header === void 0 ? null : /^bytes (\d+)-(\d+)\/(\d+)$/.exec(header);
|
|
493
|
+
if (m === null) throw this.badTail(key, `Content-Range is missing or malformed`);
|
|
494
|
+
const [first, last, total] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
495
|
+
if (![first, last, total].every(Number.isSafeInteger)) {
|
|
496
|
+
throw this.badTail(key, "Content-Range holds a number that is not a safe integer");
|
|
497
|
+
}
|
|
498
|
+
if (last !== total - 1 || first !== Math.max(0, total - maxBytes) || last - first + 1 !== received) {
|
|
499
|
+
throw this.badTail(
|
|
500
|
+
key,
|
|
501
|
+
`Content-Range ${first}-${last}/${total} disagrees with ${received}B received for a ${maxBytes}B tail`
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
return total;
|
|
505
|
+
}
|
|
506
|
+
badTail(key, why) {
|
|
507
|
+
return new ValidationError3(`GCS tail read of ${key.segment}.${key.generation} refused: ${why}`);
|
|
508
|
+
}
|
|
183
509
|
async delete(key) {
|
|
184
510
|
try {
|
|
185
511
|
await this.file(storageObjectName(this.prefix, key)).delete({ ignoreNotFound: true });
|
|
@@ -208,15 +534,24 @@ var GcsStorageDriver = class {
|
|
|
208
534
|
if (isNotFound(err))
|
|
209
535
|
return new NotFoundError(`no such generation: ${key.segment}.${key.generation}`);
|
|
210
536
|
if (isInvalidRange(err)) {
|
|
211
|
-
return new
|
|
537
|
+
return new ValidationError3(`range out of bounds for ${key.segment}.${key.generation}`);
|
|
538
|
+
}
|
|
539
|
+
if (err instanceof ReadTimedOut) return new TransientError(err.message, { cause: err });
|
|
540
|
+
if (isTransportFault(err)) {
|
|
541
|
+
return new TransientError(
|
|
542
|
+
`transient GCS fault: ${String(err.code)}`,
|
|
543
|
+
{
|
|
544
|
+
cause: err
|
|
545
|
+
}
|
|
546
|
+
);
|
|
212
547
|
}
|
|
213
548
|
return this.mapError(err);
|
|
214
549
|
}
|
|
215
550
|
/**
|
|
216
551
|
* Reclassify a transient GCS fault (throttle/5xx/dropped connection) as a retryable {@link TransientError},
|
|
217
552
|
* so the store's read retry can ride it out and a write's caller can tell it from a deterministic failure;
|
|
218
|
-
* everything else propagates unchanged. The final fallback at every client-call site
|
|
219
|
-
*
|
|
553
|
+
* everything else propagates unchanged. The final fallback at every client-call site: an HTTP status and a fault
|
|
554
|
+
* in transit arrive typed, and anything else (a checksum mismatch, a credentials failure) as the SDK raised it.
|
|
220
555
|
*/
|
|
221
556
|
mapError(err) {
|
|
222
557
|
if (isTransient(err)) {
|
|
@@ -238,14 +573,16 @@ function concatBytes(parts, total) {
|
|
|
238
573
|
return out;
|
|
239
574
|
}
|
|
240
575
|
var GcsUploadSink = class _GcsUploadSink {
|
|
241
|
-
constructor(file, maxObjectBytes, threshold) {
|
|
576
|
+
constructor(file, maxObjectBytes, threshold, clock) {
|
|
242
577
|
this.file = file;
|
|
243
578
|
this.maxObjectBytes = maxObjectBytes;
|
|
244
579
|
this.threshold = threshold;
|
|
580
|
+
this.clock = clock;
|
|
245
581
|
}
|
|
246
582
|
file;
|
|
247
583
|
maxObjectBytes;
|
|
248
584
|
threshold;
|
|
585
|
+
clock;
|
|
249
586
|
hash = createHash("sha256");
|
|
250
587
|
total = 0;
|
|
251
588
|
buffered = [];
|
|
@@ -260,16 +597,26 @@ var GcsUploadSink = class _GcsUploadSink {
|
|
|
260
597
|
metadata: { contentType: "application/octet-stream" }
|
|
261
598
|
};
|
|
262
599
|
/**
|
|
263
|
-
* This write's own id,
|
|
264
|
-
*
|
|
600
|
+
* This write's own id, stored in the object's custom metadata (outside the `.crbm` bytes) so a conflict can be told
|
|
601
|
+
* from a replay of this same write: the resumable session's, or the simple upload's re-send after a throttle.
|
|
265
602
|
*/
|
|
266
603
|
writeId = randomBytes(16).toString("hex");
|
|
604
|
+
/** The upload options with this write's id in the object's custom metadata. */
|
|
605
|
+
tagged() {
|
|
606
|
+
return {
|
|
607
|
+
..._GcsUploadSink.WRITE_OPTS,
|
|
608
|
+
metadata: {
|
|
609
|
+
..._GcsUploadSink.WRITE_OPTS.metadata,
|
|
610
|
+
metadata: { [WRITE_ID_KEY]: this.writeId }
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
}
|
|
267
614
|
async write(bytes) {
|
|
268
615
|
if (this.failure !== void 0) throw this.failure;
|
|
269
616
|
if (bytes.length === 0) return;
|
|
270
617
|
this.total += bytes.length;
|
|
271
618
|
if (this.total > this.maxObjectBytes) {
|
|
272
|
-
throw new
|
|
619
|
+
throw new ValidationError3(`object exceeds maxObjectBytes ${this.maxObjectBytes}`);
|
|
273
620
|
}
|
|
274
621
|
this.hash.update(bytes);
|
|
275
622
|
if (this.stream !== void 0) {
|
|
@@ -282,14 +629,7 @@ var GcsUploadSink = class _GcsUploadSink {
|
|
|
282
629
|
}
|
|
283
630
|
/** Cross into resumable streaming: open the stream, flush the buffered bytes, keep only ~one threshold resident. */
|
|
284
631
|
startResumable() {
|
|
285
|
-
const stream = this.file.createWriteStream({
|
|
286
|
-
resumable: true,
|
|
287
|
-
..._GcsUploadSink.WRITE_OPTS,
|
|
288
|
-
metadata: {
|
|
289
|
-
..._GcsUploadSink.WRITE_OPTS.metadata,
|
|
290
|
-
metadata: { [WRITE_ID_KEY]: this.writeId }
|
|
291
|
-
}
|
|
292
|
-
});
|
|
632
|
+
const stream = this.file.createWriteStream({ resumable: true, ...this.tagged() });
|
|
293
633
|
this.stream = stream;
|
|
294
634
|
this.done = once2(stream, "finish");
|
|
295
635
|
stream.on("error", (e) => {
|
|
@@ -304,11 +644,7 @@ var GcsUploadSink = class _GcsUploadSink {
|
|
|
304
644
|
async finish() {
|
|
305
645
|
const sha256 = this.hash.digest("hex");
|
|
306
646
|
if (this.stream === void 0) {
|
|
307
|
-
await
|
|
308
|
-
this.file,
|
|
309
|
-
concatBytes(this.buffered, this.bufferedLen),
|
|
310
|
-
_GcsUploadSink.WRITE_OPTS
|
|
311
|
-
);
|
|
647
|
+
await this.sendSimple(concatBytes(this.buffered, this.bufferedLen));
|
|
312
648
|
return { size: this.total, sha256 };
|
|
313
649
|
}
|
|
314
650
|
try {
|
|
@@ -320,6 +656,34 @@ var GcsUploadSink = class _GcsUploadSink {
|
|
|
320
656
|
}
|
|
321
657
|
return { size: this.total, sha256 };
|
|
322
658
|
}
|
|
659
|
+
/**
|
|
660
|
+
* Send the simple upload once, and again after a `429` or `503`, up to {@link THROTTLE_RESENDS} times, waiting a
|
|
661
|
+
* full-jitter backoff before each. Any other failure is thrown at once. Once it has been sent again, a `412` can be
|
|
662
|
+
* an earlier send that landed after all, so the object's write id decides: this write's own is a success, and any
|
|
663
|
+
* other object, or one with no id, is the conflict. A failed read-back throws, and is neither. Exhausted, it throws
|
|
664
|
+
* {@link TransientError}; nothing is deleted.
|
|
665
|
+
*/
|
|
666
|
+
async sendSimple(body) {
|
|
667
|
+
for (let resent = 0; ; resent++) {
|
|
668
|
+
try {
|
|
669
|
+
await saveOnce(this.file, body, this.tagged());
|
|
670
|
+
return;
|
|
671
|
+
} catch (err) {
|
|
672
|
+
if (resent > 0 && isPreconditionFailed(err)) {
|
|
673
|
+
if (await this.storedWriteId() === this.writeId) return;
|
|
674
|
+
throw err;
|
|
675
|
+
}
|
|
676
|
+
if (!isThrottle(err)) throw err;
|
|
677
|
+
if (resent >= THROTTLE_RESENDS) {
|
|
678
|
+
throw new TransientError(
|
|
679
|
+
`GCS upload was throttled on each of its ${resent + 1} sends; the object may or may not exist`,
|
|
680
|
+
{ cause: err }
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
await this.clock.sleep(Math.floor(Math.random() * THROTTLE_BASE_DELAY_MS * 2 ** resent));
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
323
687
|
/** The id the stored object carries in its custom metadata, or `undefined` when it carries none or is gone. */
|
|
324
688
|
async storedWriteId() {
|
|
325
689
|
try {
|
|
@@ -340,44 +704,61 @@ import {
|
|
|
340
704
|
IntegrityError,
|
|
341
705
|
MAX_ROW_BYTES,
|
|
342
706
|
ObjectStoreRegistry,
|
|
343
|
-
ObjectVersionRaced,
|
|
344
707
|
TransientError as TransientError2,
|
|
708
|
+
ValidationError as ValidationError4,
|
|
345
709
|
WriteConflictError as WriteConflictError2,
|
|
346
710
|
normalizeObjectPrefix
|
|
347
711
|
} from "@cloudbitmaps/core/driver-kit";
|
|
348
|
-
var
|
|
349
|
-
constructor(storage, bucket) {
|
|
712
|
+
var GcsRegistryStore = class {
|
|
713
|
+
constructor(storage, readStorage, bucket, readTimeoutMs, conditionalDelete) {
|
|
350
714
|
this.storage = storage;
|
|
715
|
+
this.readStorage = readStorage;
|
|
351
716
|
this.bucket = bucket;
|
|
717
|
+
this.readTimeoutMs = readTimeoutMs;
|
|
718
|
+
this.conditionalDelete = conditionalDelete;
|
|
352
719
|
}
|
|
353
720
|
storage;
|
|
721
|
+
readStorage;
|
|
354
722
|
bucket;
|
|
723
|
+
readTimeoutMs;
|
|
724
|
+
conditionalDelete;
|
|
355
725
|
label = "GCS";
|
|
356
|
-
/** A
|
|
357
|
-
file(name
|
|
358
|
-
return
|
|
726
|
+
/** A handle on one registry object. */
|
|
727
|
+
file(name) {
|
|
728
|
+
return this.storage.bucket(this.bucket).file(name);
|
|
729
|
+
}
|
|
730
|
+
/** The same file handle on the download client, off the SDK's shared agent ({@link downloadFile}). */
|
|
731
|
+
downloadable(name) {
|
|
732
|
+
return downloadFile(this.readStorage, this.bucket, name);
|
|
359
733
|
}
|
|
360
734
|
async read(key) {
|
|
361
|
-
let
|
|
362
|
-
let size;
|
|
735
|
+
let res;
|
|
363
736
|
try {
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
737
|
+
const deadline = startDeadline(this.readTimeoutMs, `registry read of ${key}`);
|
|
738
|
+
res = await retryDownload(
|
|
739
|
+
() => readOnce(
|
|
740
|
+
this.downloadable(key),
|
|
741
|
+
{},
|
|
742
|
+
MAX_ROW_BYTES,
|
|
743
|
+
(size) => new IntegrityError(
|
|
744
|
+
size === void 0 ? `registry object exceeds cap ${MAX_ROW_BYTES}B` : `registry object ${size}B exceeds cap ${MAX_ROW_BYTES}B`
|
|
745
|
+
),
|
|
746
|
+
deadline
|
|
747
|
+
),
|
|
748
|
+
deadline
|
|
749
|
+
);
|
|
367
750
|
} catch (err) {
|
|
368
751
|
if (isNotFound(err)) return null;
|
|
369
|
-
throw
|
|
752
|
+
throw mapReadError(err);
|
|
370
753
|
}
|
|
371
|
-
if (
|
|
372
|
-
throw new IntegrityError(`registry
|
|
754
|
+
if (res.status !== 200) {
|
|
755
|
+
throw new IntegrityError(`registry read answered HTTP ${res.status}, not 200: ${key}`);
|
|
373
756
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
} catch (err) {
|
|
378
|
-
if (isNotFound(err)) throw new ObjectVersionRaced(key);
|
|
379
|
-
throw mapError(err);
|
|
757
|
+
const generation = singleHeader(res.headers, "x-goog-generation");
|
|
758
|
+
if (generation === void 0 || !/^[1-9]\d*$/.test(generation) || !Number.isSafeInteger(Number(generation))) {
|
|
759
|
+
throw new IntegrityError(`registry read carries no usable x-goog-generation: ${key}`);
|
|
380
760
|
}
|
|
761
|
+
return { bytes: res.bytes, version: generation };
|
|
381
762
|
}
|
|
382
763
|
async write(key, body, expect) {
|
|
383
764
|
try {
|
|
@@ -394,6 +775,16 @@ var GcsStore = class {
|
|
|
394
775
|
throw mapError(err);
|
|
395
776
|
}
|
|
396
777
|
}
|
|
778
|
+
async delete(key, expect) {
|
|
779
|
+
try {
|
|
780
|
+
await this.file(key).delete({ ifGenerationMatch: generationFence(expect.version, key) });
|
|
781
|
+
} catch (err) {
|
|
782
|
+
if (isPreconditionFailed(err) || isNotFound(err)) {
|
|
783
|
+
throw new WriteConflictError2(`registry OCC conflict deleting ${key}`);
|
|
784
|
+
}
|
|
785
|
+
throw mapError(err);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
397
788
|
async *listKeys(prefix) {
|
|
398
789
|
let pageToken;
|
|
399
790
|
do {
|
|
@@ -416,19 +807,43 @@ function generationFence(version, key) {
|
|
|
416
807
|
}
|
|
417
808
|
return generation;
|
|
418
809
|
}
|
|
810
|
+
function mapReadError(err) {
|
|
811
|
+
if (err instanceof ReadTimedOut) return new TransientError2(err.message, { cause: err });
|
|
812
|
+
if (isTransportFault(err)) {
|
|
813
|
+
return new TransientError2(`transient GCS fault: ${String(err.code)}`, {
|
|
814
|
+
cause: err
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
return mapError(err);
|
|
818
|
+
}
|
|
419
819
|
function mapError(err) {
|
|
420
820
|
if (isTransient(err)) {
|
|
421
821
|
return new TransientError2(
|
|
422
|
-
`transient GCS fault: ${err?.name ?? "unknown"}`,
|
|
822
|
+
`transient GCS fault: ${err?.code ?? err?.name ?? "unknown"}`,
|
|
423
823
|
{ cause: err }
|
|
424
824
|
);
|
|
425
825
|
}
|
|
426
826
|
return err;
|
|
427
827
|
}
|
|
828
|
+
function resolveConditionalDelete(value) {
|
|
829
|
+
if (value === void 0) {
|
|
830
|
+
return false;
|
|
831
|
+
}
|
|
832
|
+
if (typeof value !== "boolean") {
|
|
833
|
+
throw new ValidationError4(`conditionalDelete must be a boolean; got ${String(value)}`);
|
|
834
|
+
}
|
|
835
|
+
return value;
|
|
836
|
+
}
|
|
428
837
|
var GcsRegistryDriver = class extends ObjectStoreRegistry {
|
|
429
838
|
constructor(options) {
|
|
430
839
|
super(
|
|
431
|
-
new
|
|
840
|
+
new GcsRegistryStore(
|
|
841
|
+
options.storage,
|
|
842
|
+
options.readStorage ?? options.storage,
|
|
843
|
+
options.bucket,
|
|
844
|
+
resolveReadTimeoutMs(options.readTimeoutMs),
|
|
845
|
+
resolveConditionalDelete(options.conditionalDelete)
|
|
846
|
+
),
|
|
432
847
|
normalizeObjectPrefix(options.prefix),
|
|
433
848
|
options.now ?? (() => Date.now())
|
|
434
849
|
);
|
|
@@ -444,19 +859,21 @@ var GCS_STORAGE_OPTION_KEYS = [
|
|
|
444
859
|
"apiEndpoint",
|
|
445
860
|
"maxObjectBytes",
|
|
446
861
|
"simpleUploadThresholdBytes",
|
|
447
|
-
"now"
|
|
862
|
+
"now",
|
|
863
|
+
"readTimeoutMs",
|
|
864
|
+
"conditionalDelete"
|
|
448
865
|
];
|
|
449
866
|
var CLIENT_SETTINGS = ["projectId", "apiEndpoint"];
|
|
450
867
|
function refuseUnknown(name, options, keys, hint) {
|
|
451
868
|
if (options === null || typeof options !== "object") {
|
|
452
|
-
throw new
|
|
869
|
+
throw new ValidationError5(
|
|
453
870
|
`${name} needs an options object \u2014 got ${options === null ? "null" : typeof options}`
|
|
454
871
|
);
|
|
455
872
|
}
|
|
456
873
|
const unknown = Object.keys(options).filter((k) => !keys.includes(k));
|
|
457
874
|
if (unknown.length > 0) {
|
|
458
875
|
const list = (ks) => ks.map((k) => `\`${k}\``).join(", ");
|
|
459
|
-
throw new
|
|
876
|
+
throw new ValidationError5(
|
|
460
877
|
`${name} does not take ${list(unknown)}. It takes ${list(keys)}; ${hint}.`
|
|
461
878
|
);
|
|
462
879
|
}
|
|
@@ -467,6 +884,7 @@ var GcsStorage = class {
|
|
|
467
884
|
/** The client both halves share — built here unless one was supplied. */
|
|
468
885
|
client;
|
|
469
886
|
constructor(options) {
|
|
887
|
+
let readClient;
|
|
470
888
|
refuseUnknown(
|
|
471
889
|
"GcsStorage",
|
|
472
890
|
options,
|
|
@@ -476,21 +894,26 @@ var GcsStorage = class {
|
|
|
476
894
|
if (options.client !== void 0 && options.client !== null) {
|
|
477
895
|
const ignored = CLIENT_SETTINGS.filter((k) => options[k] !== void 0);
|
|
478
896
|
if (ignored.length > 0) {
|
|
479
|
-
throw new
|
|
897
|
+
throw new ValidationError5(
|
|
480
898
|
`GcsStorage takes \`client\` OR ${CLIENT_SETTINGS.map((k) => `\`${k}\``).join(" / ")}, not both \u2014 got \`client\` with ${ignored.map((k) => `\`${k}\``).join(", ")}; the \`client\` already carries them, so configure them on the client, or drop \`client\``
|
|
481
899
|
);
|
|
482
900
|
}
|
|
483
901
|
this.client = options.client;
|
|
902
|
+
readClient = options.client;
|
|
484
903
|
} else {
|
|
485
|
-
|
|
904
|
+
const settings = {
|
|
486
905
|
...options.projectId === void 0 ? {} : { projectId: options.projectId },
|
|
487
906
|
...options.apiEndpoint === void 0 ? {} : { apiEndpoint: options.apiEndpoint }
|
|
488
|
-
}
|
|
907
|
+
};
|
|
908
|
+
this.client = new GcsClient(settings);
|
|
909
|
+
readClient = new GcsClient({ ...settings, retryOptions: { autoRetry: false } });
|
|
489
910
|
}
|
|
490
911
|
const shared = {
|
|
491
912
|
storage: this.client,
|
|
913
|
+
readStorage: readClient,
|
|
492
914
|
bucket: options.bucket,
|
|
493
|
-
...options.prefix === void 0 ? {} : { prefix: options.prefix }
|
|
915
|
+
...options.prefix === void 0 ? {} : { prefix: options.prefix },
|
|
916
|
+
...options.readTimeoutMs === void 0 ? {} : { readTimeoutMs: options.readTimeoutMs }
|
|
494
917
|
};
|
|
495
918
|
this.storage = new GcsStorageDriver({
|
|
496
919
|
...shared,
|
|
@@ -499,7 +922,8 @@ var GcsStorage = class {
|
|
|
499
922
|
});
|
|
500
923
|
this.registry = new GcsRegistryDriver({
|
|
501
924
|
...shared,
|
|
502
|
-
...options.now === void 0 ? {} : { now: options.now }
|
|
925
|
+
...options.now === void 0 ? {} : { now: options.now },
|
|
926
|
+
...options.conditionalDelete === void 0 ? {} : { conditionalDelete: options.conditionalDelete }
|
|
503
927
|
});
|
|
504
928
|
brandAsBackend(this);
|
|
505
929
|
}
|