@takosjp/yurucommu-core 3.4.5 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/api/package.json +1 -1
- package/src/backend/lib/blocklist-purge.ts +6 -6
- package/src/backend/public.ts +9 -4
- package/src/backend/routes/account-teardown.ts +2 -2
- package/src/backend/routes/apps.ts +4 -5
- package/src/backend/routes/media.ts +4 -5
- package/src/backend/routes/posts/delete-cascade.ts +7 -7
- package/src/backend/routes/stories/query-helpers.ts +2 -2
- package/src/backend/routes/stories/routes.ts +2 -5
- package/src/backend/runtime/bun.ts +655 -320
- package/src/backend/runtime/cloudflare.ts +20 -58
- package/src/backend/runtime/managed-runtime.ts +117 -139
- package/src/backend/runtime/s3-fetch.ts +380 -0
- package/src/backend/runtime/types.ts +29 -61
- package/src/backend/types.ts +4 -4
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
import type {
|
|
9
9
|
FirstResult,
|
|
10
10
|
IDatabase,
|
|
11
|
-
|
|
11
|
+
ObjectStore,
|
|
12
|
+
ObjectStoreBody,
|
|
13
|
+
ObjectStoreObject,
|
|
14
|
+
ObjectStorePutOptions,
|
|
12
15
|
IStaticAssets,
|
|
13
|
-
ListObjectsResult,
|
|
14
|
-
ObjectMetadata,
|
|
15
16
|
PreparedStatement,
|
|
16
17
|
QueryResult,
|
|
17
18
|
RunResult,
|
|
18
|
-
StorageObject,
|
|
19
19
|
} from "./types.ts";
|
|
20
20
|
import { constants as fsConstants } from "node:fs";
|
|
21
21
|
import type { FileHandle } from "node:fs/promises";
|
|
@@ -37,8 +37,18 @@ declare const require: (specifier: string) => unknown;
|
|
|
37
37
|
// Re-export MemoryKV as it works in Bun too.
|
|
38
38
|
export { MemoryKV };
|
|
39
39
|
|
|
40
|
-
const {
|
|
41
|
-
|
|
40
|
+
const {
|
|
41
|
+
mkdir,
|
|
42
|
+
unlink,
|
|
43
|
+
readdir,
|
|
44
|
+
stat,
|
|
45
|
+
lstat,
|
|
46
|
+
realpath,
|
|
47
|
+
open,
|
|
48
|
+
rename,
|
|
49
|
+
readFile,
|
|
50
|
+
readlink,
|
|
51
|
+
} = await import("fs/promises");
|
|
42
52
|
|
|
43
53
|
// Generation state is deliberately kept outside the public object namespace.
|
|
44
54
|
// The sibling directory name is derived from the storage directory, so a
|
|
@@ -50,13 +60,16 @@ const GENERATION_MARKER = "generation-";
|
|
|
50
60
|
const TEMP_MARKER = "tmp-";
|
|
51
61
|
const LEASE_SUFFIX = "lease";
|
|
52
62
|
const READER_LEASE_MARKER = "reader-";
|
|
53
|
-
const TEMP_LEASE_TTL_MS = 30_000;
|
|
54
|
-
const TEMP_LEASE_HEARTBEAT_MS = 1_000;
|
|
55
63
|
const MARKER_READ_ATTEMPTS = 32;
|
|
56
64
|
const OBJECT_RESOLVE_ATTEMPTS = 64;
|
|
57
65
|
const OPEN_READ_FLAGS = fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW;
|
|
58
66
|
const GENERATION_ID_PATTERN = /^[0-9a-f-]{16,}$/u;
|
|
59
67
|
const DIGEST_PATTERN = /^[0-9a-f]{64}$/u;
|
|
68
|
+
const LEASE_BOOT_ID_PATTERN =
|
|
69
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
70
|
+
const LEASE_PID_NAMESPACE_ID_PATTERN = /^pid:\[[1-9][0-9]*\]$/u;
|
|
71
|
+
const LEASE_PROCESS_START_TOKEN_PATTERN = /^[0-9]{1,32}$/u;
|
|
72
|
+
const LEASE_TOKEN_MAX_LENGTH = 128;
|
|
60
73
|
|
|
61
74
|
type CommitState =
|
|
62
75
|
| { version: 1; state: "committed"; generation: string }
|
|
@@ -68,10 +81,46 @@ type ResolvedObject = {
|
|
|
68
81
|
filePath: string;
|
|
69
82
|
bodyHandle?: FileHandle;
|
|
70
83
|
metadata: {
|
|
71
|
-
|
|
72
|
-
customMetadata?: Record<string, string>;
|
|
84
|
+
contentType?: string;
|
|
73
85
|
};
|
|
74
|
-
|
|
86
|
+
releaseBody?: () => Promise<void>;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
type TempLease = (() => Promise<void>) & {
|
|
90
|
+
assertOwned(): Promise<void>;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
type LeaseProcessIdentity = {
|
|
94
|
+
bootId: string;
|
|
95
|
+
pidNamespaceId: string;
|
|
96
|
+
processStartToken: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
type LeaseOwner = {
|
|
100
|
+
version: 2;
|
|
101
|
+
pid: number;
|
|
102
|
+
token: string;
|
|
103
|
+
processIdentity: LeaseProcessIdentity;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
type LeaseOwnerState = "live" | "dead" | "unknown";
|
|
107
|
+
|
|
108
|
+
type LeaseProcessProbe = {
|
|
109
|
+
readPidNamespace(pid: number): Promise<string>;
|
|
110
|
+
readPidStat(pid: number): Promise<string>;
|
|
111
|
+
signal0(pid: number): void;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const defaultLeaseProcessProbe: LeaseProcessProbe = {
|
|
115
|
+
readPidNamespace(pid) {
|
|
116
|
+
return readlink(`/proc/${pid}/ns/pid`);
|
|
117
|
+
},
|
|
118
|
+
readPidStat(pid) {
|
|
119
|
+
return readFile(`/proc/${pid}/stat`, "utf8");
|
|
120
|
+
},
|
|
121
|
+
signal0(pid) {
|
|
122
|
+
process.kill(pid, 0);
|
|
123
|
+
},
|
|
75
124
|
};
|
|
76
125
|
|
|
77
126
|
type LiveLeaseState = {
|
|
@@ -86,6 +135,14 @@ type FileIdentity = {
|
|
|
86
135
|
mode: number;
|
|
87
136
|
};
|
|
88
137
|
|
|
138
|
+
type BunStorageOptions = {
|
|
139
|
+
syncDirectory?: typeof syncDirectory;
|
|
140
|
+
/** Deterministic test seam at the lease-assertion/publication boundary. */
|
|
141
|
+
beforeCommitRename?: (commitPath: string) => Promise<void>;
|
|
142
|
+
/** Deterministic test seam for Linux /proc visibility and PID incarnation. */
|
|
143
|
+
leaseProcessProbe?: LeaseProcessProbe;
|
|
144
|
+
};
|
|
145
|
+
|
|
89
146
|
function fileIdentity(stats: FileIdentity): FileIdentity {
|
|
90
147
|
return {
|
|
91
148
|
dev: stats.dev,
|
|
@@ -138,6 +195,190 @@ function isFileIdentityRace(error: unknown): boolean {
|
|
|
138
195
|
);
|
|
139
196
|
}
|
|
140
197
|
|
|
198
|
+
function hasExactObjectKeys(
|
|
199
|
+
value: Record<string, unknown>,
|
|
200
|
+
expected: readonly string[],
|
|
201
|
+
): boolean {
|
|
202
|
+
const keys = Object.keys(value).sort();
|
|
203
|
+
return (
|
|
204
|
+
keys.length === expected.length &&
|
|
205
|
+
expected.every((key, index) => keys[index] === key)
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseLeaseProcessIdentity(
|
|
210
|
+
value: unknown,
|
|
211
|
+
): LeaseProcessIdentity | undefined {
|
|
212
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
const record = value as Record<string, unknown>;
|
|
216
|
+
if (
|
|
217
|
+
!hasExactObjectKeys(record, [
|
|
218
|
+
"bootId",
|
|
219
|
+
"pidNamespaceId",
|
|
220
|
+
"processStartToken",
|
|
221
|
+
]) ||
|
|
222
|
+
typeof record.bootId !== "string" ||
|
|
223
|
+
!LEASE_BOOT_ID_PATTERN.test(record.bootId) ||
|
|
224
|
+
typeof record.pidNamespaceId !== "string" ||
|
|
225
|
+
!LEASE_PID_NAMESPACE_ID_PATTERN.test(record.pidNamespaceId) ||
|
|
226
|
+
typeof record.processStartToken !== "string" ||
|
|
227
|
+
!LEASE_PROCESS_START_TOKEN_PATTERN.test(record.processStartToken)
|
|
228
|
+
) {
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
bootId: record.bootId,
|
|
233
|
+
pidNamespaceId: record.pidNamespaceId,
|
|
234
|
+
processStartToken: record.processStartToken,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function parseLeaseOwner(ownerText: string): LeaseOwner | undefined {
|
|
239
|
+
try {
|
|
240
|
+
const value = JSON.parse(ownerText) as unknown;
|
|
241
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
const record = value as Record<string, unknown>;
|
|
245
|
+
const processIdentity = parseLeaseProcessIdentity(record.processIdentity);
|
|
246
|
+
if (
|
|
247
|
+
!hasExactObjectKeys(record, [
|
|
248
|
+
"pid",
|
|
249
|
+
"processIdentity",
|
|
250
|
+
"token",
|
|
251
|
+
"version",
|
|
252
|
+
]) ||
|
|
253
|
+
record.version !== 2 ||
|
|
254
|
+
typeof record.pid !== "number" ||
|
|
255
|
+
!Number.isSafeInteger(record.pid) ||
|
|
256
|
+
record.pid <= 0 ||
|
|
257
|
+
typeof record.token !== "string" ||
|
|
258
|
+
record.token.length === 0 ||
|
|
259
|
+
record.token.length > LEASE_TOKEN_MAX_LENGTH ||
|
|
260
|
+
!processIdentity
|
|
261
|
+
) {
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
version: 2,
|
|
266
|
+
pid: record.pid,
|
|
267
|
+
token: record.token,
|
|
268
|
+
processIdentity,
|
|
269
|
+
};
|
|
270
|
+
} catch {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function parseProcessStartToken(statText: string): string | undefined {
|
|
276
|
+
// /proc/<pid>/stat field 2 is parenthesized and may itself contain spaces or
|
|
277
|
+
// parentheses. The final ')' closes it; field 22 is then tail index 19.
|
|
278
|
+
const commandEnd = statText.lastIndexOf(")");
|
|
279
|
+
if (commandEnd < 0) return undefined;
|
|
280
|
+
const token = statText
|
|
281
|
+
.slice(commandEnd + 1)
|
|
282
|
+
.trim()
|
|
283
|
+
.split(/\s+/u)[19];
|
|
284
|
+
return token && LEASE_PROCESS_START_TOKEN_PATTERN.test(token)
|
|
285
|
+
? token
|
|
286
|
+
: undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function loadLocalLeaseProcessIdentity(): Promise<
|
|
290
|
+
LeaseProcessIdentity | undefined
|
|
291
|
+
> {
|
|
292
|
+
try {
|
|
293
|
+
const [bootId, pidNamespaceId, statText] = await Promise.all([
|
|
294
|
+
readFile("/proc/sys/kernel/random/boot_id", "utf8"),
|
|
295
|
+
readlink("/proc/self/ns/pid"),
|
|
296
|
+
readFile("/proc/self/stat", "utf8"),
|
|
297
|
+
]);
|
|
298
|
+
return parseLeaseProcessIdentity({
|
|
299
|
+
bootId: bootId.trim(),
|
|
300
|
+
pidNamespaceId,
|
|
301
|
+
processStartToken: parseProcessStartToken(statText),
|
|
302
|
+
});
|
|
303
|
+
} catch {
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
let localLeaseProcessIdentity:
|
|
309
|
+
Promise<LeaseProcessIdentity | undefined> | undefined;
|
|
310
|
+
|
|
311
|
+
function getLocalLeaseProcessIdentity(): Promise<
|
|
312
|
+
LeaseProcessIdentity | undefined
|
|
313
|
+
> {
|
|
314
|
+
localLeaseProcessIdentity ??= loadLocalLeaseProcessIdentity();
|
|
315
|
+
return localLeaseProcessIdentity;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function pidIsDefinitelyAbsent(pid: number, probe: LeaseProcessProbe): boolean {
|
|
319
|
+
try {
|
|
320
|
+
probe.signal0(pid);
|
|
321
|
+
return false;
|
|
322
|
+
} catch (error) {
|
|
323
|
+
return (
|
|
324
|
+
typeof error === "object" &&
|
|
325
|
+
error !== null &&
|
|
326
|
+
"code" in error &&
|
|
327
|
+
(error as { code?: unknown }).code === "ESRCH"
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function classifyLeaseOwner(
|
|
333
|
+
owner: LeaseOwner,
|
|
334
|
+
probe: LeaseProcessProbe,
|
|
335
|
+
): Promise<LeaseOwnerState> {
|
|
336
|
+
const localIdentity = await getLocalLeaseProcessIdentity();
|
|
337
|
+
if (
|
|
338
|
+
!localIdentity ||
|
|
339
|
+
owner.processIdentity.bootId !== localIdentity.bootId ||
|
|
340
|
+
owner.processIdentity.pidNamespaceId !== localIdentity.pidNamespaceId
|
|
341
|
+
) {
|
|
342
|
+
return "unknown";
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
let targetNamespaceId: string;
|
|
346
|
+
try {
|
|
347
|
+
targetNamespaceId = await probe.readPidNamespace(owner.pid);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
if (!isNotFoundError(error)) return "unknown";
|
|
350
|
+
// hidepid=2 deliberately presents a live different-UID process as ENOENT.
|
|
351
|
+
// Only ESRCH from signal 0 corroborates that this same-boot/same-namespace
|
|
352
|
+
// PID is absent; EPERM and every other result remain unknown/fail-safe.
|
|
353
|
+
return pidIsDefinitelyAbsent(owner.pid, probe) ? "dead" : "unknown";
|
|
354
|
+
}
|
|
355
|
+
if (targetNamespaceId !== localIdentity.pidNamespaceId) return "unknown";
|
|
356
|
+
|
|
357
|
+
let targetStat: string;
|
|
358
|
+
try {
|
|
359
|
+
targetStat = await probe.readPidStat(owner.pid);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
if (!isNotFoundError(error)) return "unknown";
|
|
362
|
+
return pidIsDefinitelyAbsent(owner.pid, probe) ? "dead" : "unknown";
|
|
363
|
+
}
|
|
364
|
+
const targetStartToken = parseProcessStartToken(targetStat);
|
|
365
|
+
if (!targetStartToken) return "unknown";
|
|
366
|
+
return targetStartToken === owner.processIdentity.processStartToken
|
|
367
|
+
? "live"
|
|
368
|
+
: "dead";
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const emittedLeaseDiagnostics = new Set<string>();
|
|
372
|
+
|
|
373
|
+
function reportUnknownLeaseRetention(): void {
|
|
374
|
+
const code = "lease-owner-unknown";
|
|
375
|
+
if (emittedLeaseDiagnostics.has(code)) return;
|
|
376
|
+
emittedLeaseDiagnostics.add(code);
|
|
377
|
+
console.warn(
|
|
378
|
+
"BunStorage retained an unverifiable lease; automatic reclaim is disabled. Confirm that no writer is active before operator cleanup (lease-owner-unknown).",
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
141
382
|
function yieldForFilesystem(): Promise<void> {
|
|
142
383
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
143
384
|
}
|
|
@@ -251,22 +492,114 @@ async function readFileHandleFully(handle: FileHandle): Promise<Uint8Array> {
|
|
|
251
492
|
return content;
|
|
252
493
|
}
|
|
253
494
|
|
|
495
|
+
/**
|
|
496
|
+
* Expose an already-open object file as a lazy body stream.
|
|
497
|
+
*
|
|
498
|
+
* Pathname leases are released before this stream is returned. A writer may
|
|
499
|
+
* publish a replacement or unlink the selected generation while a response is
|
|
500
|
+
* still being sent, but the open descriptor keeps those exact bytes stable on
|
|
501
|
+
* POSIX until the stream is consumed or cancelled.
|
|
502
|
+
*/
|
|
503
|
+
function createFileBodyStream(
|
|
504
|
+
handle: FileHandle,
|
|
505
|
+
byteLength: number,
|
|
506
|
+
release: () => Promise<void>,
|
|
507
|
+
): ReadableStream<Uint8Array> {
|
|
508
|
+
const chunkSize = 64 * 1024;
|
|
509
|
+
let offset = 0;
|
|
510
|
+
let released = false;
|
|
511
|
+
|
|
512
|
+
const finish = async (): Promise<void> => {
|
|
513
|
+
if (released) return;
|
|
514
|
+
released = true;
|
|
515
|
+
await release().catch(() => undefined);
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
return new ReadableStream<Uint8Array>(
|
|
519
|
+
{
|
|
520
|
+
async pull(controller) {
|
|
521
|
+
if (offset >= byteLength) {
|
|
522
|
+
controller.close();
|
|
523
|
+
await finish();
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const buffer = new Uint8Array(Math.min(chunkSize, byteLength - offset));
|
|
527
|
+
try {
|
|
528
|
+
const result = await handle.read(
|
|
529
|
+
buffer,
|
|
530
|
+
0,
|
|
531
|
+
buffer.byteLength,
|
|
532
|
+
offset,
|
|
533
|
+
);
|
|
534
|
+
if (
|
|
535
|
+
!Number.isInteger(result.bytesRead) ||
|
|
536
|
+
result.bytesRead <= 0 ||
|
|
537
|
+
result.bytesRead > buffer.byteLength
|
|
538
|
+
) {
|
|
539
|
+
throw new Error(
|
|
540
|
+
`filesystem short read: expected ${buffer.byteLength} bytes, received ${String(result.bytesRead)}`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
offset += result.bytesRead;
|
|
544
|
+
controller.enqueue(buffer.subarray(0, result.bytesRead));
|
|
545
|
+
if (offset >= byteLength) {
|
|
546
|
+
controller.close();
|
|
547
|
+
await finish();
|
|
548
|
+
}
|
|
549
|
+
} catch (error) {
|
|
550
|
+
controller.error(error);
|
|
551
|
+
await finish();
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
async cancel() {
|
|
555
|
+
await finish();
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
{ highWaterMark: 0 },
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
254
562
|
async function readFileHandleText(handle: FileHandle): Promise<string> {
|
|
255
563
|
return new TextDecoder().decode(await readFileHandleFully(handle));
|
|
256
564
|
}
|
|
257
565
|
|
|
566
|
+
function parseObjectMetadata(valueText: string): {
|
|
567
|
+
contentType?: string;
|
|
568
|
+
} {
|
|
569
|
+
const value = JSON.parse(valueText) as {
|
|
570
|
+
contentType?: unknown;
|
|
571
|
+
httpMetadata?: unknown;
|
|
572
|
+
};
|
|
573
|
+
if (value === null || typeof value !== "object") {
|
|
574
|
+
throw new Error("Invalid BunStorage generation metadata");
|
|
575
|
+
}
|
|
576
|
+
const legacyHttpMetadata = value.httpMetadata;
|
|
577
|
+
const legacyContentType =
|
|
578
|
+
legacyHttpMetadata !== null &&
|
|
579
|
+
typeof legacyHttpMetadata === "object" &&
|
|
580
|
+
"contentType" in legacyHttpMetadata
|
|
581
|
+
? (legacyHttpMetadata as { contentType?: unknown }).contentType
|
|
582
|
+
: undefined;
|
|
583
|
+
const contentType =
|
|
584
|
+
typeof value.contentType === "string"
|
|
585
|
+
? value.contentType
|
|
586
|
+
: typeof legacyContentType === "string"
|
|
587
|
+
? legacyContentType
|
|
588
|
+
: undefined;
|
|
589
|
+
return contentType === undefined ? {} : { contentType };
|
|
590
|
+
}
|
|
591
|
+
|
|
258
592
|
/**
|
|
259
593
|
* Read the JSON metadata sidecar for a storage key.
|
|
260
594
|
* Returns an empty object if the sidecar doesn't exist or can't be parsed.
|
|
261
595
|
*/
|
|
262
596
|
async function readMetadata(metaPath: string): Promise<{
|
|
263
|
-
|
|
264
|
-
customMetadata?: Record<string, string>;
|
|
597
|
+
contentType?: string;
|
|
265
598
|
}> {
|
|
266
599
|
try {
|
|
267
600
|
const metaFile = Bun.file(metaPath);
|
|
268
601
|
if (await metaFile.exists()) {
|
|
269
|
-
return
|
|
602
|
+
return parseObjectMetadata(await metaFile.text());
|
|
270
603
|
}
|
|
271
604
|
} catch {
|
|
272
605
|
// No metadata file or unreadable
|
|
@@ -391,15 +724,23 @@ class BunPreparedStatement implements PreparedStatement {
|
|
|
391
724
|
}
|
|
392
725
|
|
|
393
726
|
/**
|
|
394
|
-
* Bun Filesystem Storage Adapter
|
|
727
|
+
* Bun Filesystem Storage Adapter.
|
|
728
|
+
*
|
|
729
|
+
* The generation/lease protocol requires a local Linux POSIX filesystem with
|
|
730
|
+
* atomic same-directory rename and stable open-descriptor semantics. It does
|
|
731
|
+
* not claim multi-host fencing. A lease from another boot, PID namespace, or
|
|
732
|
+
* unreadable /proc owner is retained for explicit operator inspection rather
|
|
733
|
+
* than automatically reclaimed.
|
|
395
734
|
*/
|
|
396
|
-
export class BunStorage implements
|
|
735
|
+
export class BunStorage implements ObjectStore {
|
|
397
736
|
private basePath: string;
|
|
398
737
|
/**
|
|
399
738
|
* Keep the directory-sync dependency injectable for deterministic storage
|
|
400
739
|
* durability tests. Production callers use the real fsync implementation.
|
|
401
740
|
*/
|
|
402
741
|
private readonly syncDirectory: typeof syncDirectory;
|
|
742
|
+
private readonly beforeCommitRename?: (commitPath: string) => Promise<void>;
|
|
743
|
+
private readonly leaseProcessProbe: LeaseProcessProbe;
|
|
403
744
|
private realBasePath: string | null = null;
|
|
404
745
|
private realInternalStorePath: string | null = null;
|
|
405
746
|
/**
|
|
@@ -420,6 +761,16 @@ export class BunStorage implements IObjectStorage {
|
|
|
420
761
|
* acquiring the generation-specific lease.
|
|
421
762
|
*/
|
|
422
763
|
private readonly keyReadReservations = new Map<string, number>();
|
|
764
|
+
/**
|
|
765
|
+
* Serialize the conservative post-reader reclamation pass per key. A hot
|
|
766
|
+
* object can have many concurrent streaming readers; running a full
|
|
767
|
+
* directory scan for every EOF makes writers wait behind an unbounded queue
|
|
768
|
+
* of equivalent cleanup work.
|
|
769
|
+
*/
|
|
770
|
+
private readonly reclaimTasks = new Map<
|
|
771
|
+
string,
|
|
772
|
+
{ requested: boolean; promise: Promise<void> }
|
|
773
|
+
>();
|
|
423
774
|
/**
|
|
424
775
|
* Last marker successfully read by this adapter. Atomic marker replacement
|
|
425
776
|
* can make a path disappear or resolve to the next inode for a few syscalls;
|
|
@@ -428,17 +779,17 @@ export class BunStorage implements IObjectStorage {
|
|
|
428
779
|
*/
|
|
429
780
|
private readonly lastCommitRecords = new Map<string, CommitRecord>();
|
|
430
781
|
|
|
431
|
-
constructor(
|
|
432
|
-
basePath: string,
|
|
433
|
-
options: { syncDirectory?: typeof syncDirectory } = {},
|
|
434
|
-
) {
|
|
782
|
+
constructor(basePath: string, options: BunStorageOptions = {}) {
|
|
435
783
|
this.basePath = basePath;
|
|
436
784
|
this.syncDirectory = options.syncDirectory ?? syncDirectory;
|
|
785
|
+
this.beforeCommitRename = options.beforeCommitRename;
|
|
786
|
+
this.leaseProcessProbe =
|
|
787
|
+
options.leaseProcessProbe ?? defaultLeaseProcessProbe;
|
|
437
788
|
}
|
|
438
789
|
|
|
439
790
|
static async create(
|
|
440
791
|
basePath: string,
|
|
441
|
-
options:
|
|
792
|
+
options: BunStorageOptions = {},
|
|
442
793
|
): Promise<BunStorage> {
|
|
443
794
|
await mkdir(basePath, { recursive: true });
|
|
444
795
|
const storage = new BunStorage(basePath, options);
|
|
@@ -516,8 +867,9 @@ export class BunStorage implements IObjectStorage {
|
|
|
516
867
|
else this.generationReaders.set(leaseKey, count - 1);
|
|
517
868
|
// A writer may have deferred this generation while the reader held its
|
|
518
869
|
// lease. Re-run conservative GC after release; failure is non-fatal and
|
|
519
|
-
// startup recovery remains the final orphan cleanup authority.
|
|
520
|
-
|
|
870
|
+
// startup recovery remains the final orphan cleanup authority. Queue the
|
|
871
|
+
// pass so EOF never blocks behind another reader's identical scan.
|
|
872
|
+
this.scheduleReclaim(key);
|
|
521
873
|
};
|
|
522
874
|
}
|
|
523
875
|
|
|
@@ -547,49 +899,121 @@ export class BunStorage implements IObjectStorage {
|
|
|
547
899
|
return (this.keyReadReservations.get(key) ?? 0) > 0;
|
|
548
900
|
}
|
|
549
901
|
|
|
902
|
+
private scheduleReclaim(key: string): void {
|
|
903
|
+
const active = this.reclaimTasks.get(key);
|
|
904
|
+
if (active) {
|
|
905
|
+
// One trailing pass is enough to observe a lease released while the
|
|
906
|
+
// current scan was in flight; never build one promise per reader.
|
|
907
|
+
active.requested = true;
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const state: { requested: boolean; promise: Promise<void> } = {
|
|
911
|
+
requested: false,
|
|
912
|
+
promise: Promise.resolve(),
|
|
913
|
+
};
|
|
914
|
+
state.promise = (async () => {
|
|
915
|
+
state.requested = false;
|
|
916
|
+
await this.reclaimUnreferencedGenerations(key).catch(() => undefined);
|
|
917
|
+
})();
|
|
918
|
+
this.reclaimTasks.set(key, state);
|
|
919
|
+
void state.promise
|
|
920
|
+
.finally(() => {
|
|
921
|
+
if (this.reclaimTasks.get(key) === state) {
|
|
922
|
+
this.reclaimTasks.delete(key);
|
|
923
|
+
}
|
|
924
|
+
if (state.requested) this.scheduleReclaim(key);
|
|
925
|
+
})
|
|
926
|
+
.catch(() => undefined);
|
|
927
|
+
}
|
|
928
|
+
|
|
550
929
|
private async createTempLease(
|
|
551
930
|
objectPath: string,
|
|
552
931
|
leaseId: string,
|
|
553
932
|
kind: "generation" | "reader" = "generation",
|
|
554
|
-
): Promise<
|
|
933
|
+
): Promise<TempLease> {
|
|
555
934
|
const marker = kind === "reader" ? READER_LEASE_MARKER : "";
|
|
556
935
|
const leasePath = path.join(
|
|
557
936
|
objectPath,
|
|
558
937
|
`${TEMP_MARKER}${marker}${leaseId}.${LEASE_SUFFIX}`,
|
|
559
938
|
);
|
|
939
|
+
const leaseToken = crypto.randomUUID();
|
|
940
|
+
const processIdentity = await getLocalLeaseProcessIdentity();
|
|
560
941
|
const leaseOwner = JSON.stringify({
|
|
942
|
+
version: 2,
|
|
561
943
|
pid: process.pid,
|
|
562
|
-
token:
|
|
944
|
+
token: leaseToken,
|
|
945
|
+
// A missing /proc identity is deliberately persisted as unknown. This
|
|
946
|
+
// writer can still release its own inode/token, but a later process must
|
|
947
|
+
// retain it for operator inspection rather than guess that it is dead.
|
|
948
|
+
processIdentity: processIdentity ?? null,
|
|
563
949
|
});
|
|
564
950
|
let leaseHandle: FileHandle | undefined;
|
|
951
|
+
let leaseIdentity: FileIdentity | undefined;
|
|
565
952
|
try {
|
|
566
953
|
leaseHandle = await open(leasePath, "wx");
|
|
567
954
|
await writeBufferFully(leaseHandle, new TextEncoder().encode(leaseOwner));
|
|
568
|
-
await
|
|
569
|
-
|
|
955
|
+
await leaseHandle.sync();
|
|
956
|
+
leaseIdentity = fileIdentity(await leaseHandle.stat());
|
|
957
|
+
await this.assertOwnedInternalFile(leasePath);
|
|
570
958
|
} catch (error) {
|
|
571
959
|
if (leaseHandle) await leaseHandle.close().catch(() => undefined);
|
|
572
960
|
await this.unlinkOwnedInternalFile(leasePath);
|
|
573
961
|
throw error;
|
|
574
962
|
}
|
|
575
|
-
await this.assertOwnedInternalFile(leasePath);
|
|
576
963
|
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
964
|
+
const ownsLease = async (): Promise<boolean> => {
|
|
965
|
+
if (!leaseHandle || !leaseIdentity) return false;
|
|
966
|
+
let currentPathHandle: FileHandle | undefined;
|
|
967
|
+
try {
|
|
968
|
+
const heldIdentity = fileIdentity(await leaseHandle.stat());
|
|
969
|
+
if (
|
|
970
|
+
heldIdentity.nlink !== 1 ||
|
|
971
|
+
heldIdentity.dev !== leaseIdentity.dev ||
|
|
972
|
+
heldIdentity.ino !== leaseIdentity.ino ||
|
|
973
|
+
heldIdentity.mode !== leaseIdentity.mode
|
|
974
|
+
) {
|
|
975
|
+
return false;
|
|
976
|
+
}
|
|
977
|
+
const opened = await this.openOwnedInternalFile(leasePath);
|
|
978
|
+
currentPathHandle = opened.handle;
|
|
979
|
+
if (
|
|
980
|
+
opened.identity.dev !== leaseIdentity.dev ||
|
|
981
|
+
opened.identity.ino !== leaseIdentity.ino ||
|
|
982
|
+
opened.identity.mode !== leaseIdentity.mode
|
|
983
|
+
) {
|
|
984
|
+
return false;
|
|
985
|
+
}
|
|
986
|
+
return (await readFileHandleText(currentPathHandle)) === leaseOwner;
|
|
987
|
+
} catch {
|
|
988
|
+
return false;
|
|
989
|
+
} finally {
|
|
990
|
+
if (currentPathHandle) {
|
|
991
|
+
await currentPathHandle.close().catch(() => undefined);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
|
|
581
996
|
let released = false;
|
|
582
|
-
|
|
997
|
+
const release = async () => {
|
|
583
998
|
if (released) return;
|
|
584
999
|
released = true;
|
|
585
|
-
|
|
586
|
-
|
|
1000
|
+
try {
|
|
1001
|
+
if (await ownsLease()) await this.unlinkOwnedInternalFile(leasePath);
|
|
1002
|
+
} finally {
|
|
1003
|
+
await leaseHandle?.close().catch(() => undefined);
|
|
1004
|
+
leaseHandle = undefined;
|
|
1005
|
+
}
|
|
587
1006
|
};
|
|
1007
|
+
return Object.assign(release, {
|
|
1008
|
+
async assertOwned(): Promise<void> {
|
|
1009
|
+
if (released || !(await ownsLease())) {
|
|
1010
|
+
throw new Error("BunStorage writer lease ownership lost");
|
|
1011
|
+
}
|
|
1012
|
+
},
|
|
1013
|
+
});
|
|
588
1014
|
}
|
|
589
1015
|
|
|
590
|
-
private async createReaderLease(
|
|
591
|
-
key: string,
|
|
592
|
-
): Promise<(() => Promise<void>) | undefined> {
|
|
1016
|
+
private async createReaderLease(key: string): Promise<TempLease | undefined> {
|
|
593
1017
|
const objectPath = this.getInternalObjectPath(key);
|
|
594
1018
|
try {
|
|
595
1019
|
await assertPathChainWithinBasePath(
|
|
@@ -612,22 +1036,31 @@ export class BunStorage implements IObjectStorage {
|
|
|
612
1036
|
private async isLiveTempLease(leasePath: string): Promise<boolean> {
|
|
613
1037
|
let leaseHandle: FileHandle | undefined;
|
|
614
1038
|
try {
|
|
615
|
-
const opened = await this.openOwnedInternalFile(leasePath);
|
|
616
|
-
leaseHandle = opened.handle;
|
|
617
|
-
const leaseStats = await leaseHandle.stat();
|
|
618
|
-
if (Date.now() - leaseStats.mtimeMs > TEMP_LEASE_TTL_MS) return false;
|
|
619
|
-
const lease = JSON.parse(await readFileHandleText(leaseHandle)) as {
|
|
620
|
-
pid?: unknown;
|
|
621
|
-
};
|
|
622
|
-
if (!Number.isInteger(lease.pid) || Number(lease.pid) <= 0) return false;
|
|
623
1039
|
try {
|
|
624
|
-
|
|
1040
|
+
const opened = await this.openOwnedInternalFile(leasePath);
|
|
1041
|
+
leaseHandle = opened.handle;
|
|
1042
|
+
} catch (error) {
|
|
1043
|
+
if (isNotFoundError(error)) return false;
|
|
1044
|
+
reportUnknownLeaseRetention();
|
|
1045
|
+
return true;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const leaseOwner = parseLeaseOwner(await readFileHandleText(leaseHandle));
|
|
1049
|
+
if (!leaseOwner) {
|
|
1050
|
+
reportUnknownLeaseRetention();
|
|
625
1051
|
return true;
|
|
626
|
-
} catch {
|
|
627
|
-
return false;
|
|
628
1052
|
}
|
|
1053
|
+
const state = await classifyLeaseOwner(
|
|
1054
|
+
leaseOwner,
|
|
1055
|
+
this.leaseProcessProbe,
|
|
1056
|
+
);
|
|
1057
|
+
if (state === "unknown") reportUnknownLeaseRetention();
|
|
1058
|
+
// Absence or a different start token is proof that this exact local
|
|
1059
|
+
// process incarnation is dead. Every other state is fail-safe retained.
|
|
1060
|
+
return state !== "dead";
|
|
629
1061
|
} catch {
|
|
630
|
-
|
|
1062
|
+
reportUnknownLeaseRetention();
|
|
1063
|
+
return true;
|
|
631
1064
|
} finally {
|
|
632
1065
|
if (leaseHandle) await leaseHandle.close().catch(() => undefined);
|
|
633
1066
|
}
|
|
@@ -842,8 +1275,7 @@ export class BunStorage implements IObjectStorage {
|
|
|
842
1275
|
}
|
|
843
1276
|
|
|
844
1277
|
private async readOwnedInternalFileMetadata(filePath: string): Promise<{
|
|
845
|
-
|
|
846
|
-
customMetadata?: Record<string, string>;
|
|
1278
|
+
contentType?: string;
|
|
847
1279
|
}> {
|
|
848
1280
|
return this.parseGenerationMetadata(
|
|
849
1281
|
await this.readOwnedInternalFileText(filePath),
|
|
@@ -925,6 +1357,7 @@ export class BunStorage implements IObjectStorage {
|
|
|
925
1357
|
resolvedObjectPath,
|
|
926
1358
|
files,
|
|
927
1359
|
);
|
|
1360
|
+
let objectKey: string | undefined;
|
|
928
1361
|
let retainedGeneration: string | null = null;
|
|
929
1362
|
try {
|
|
930
1363
|
const markerPath = path.join(resolvedObjectPath, COMMIT_FILE);
|
|
@@ -937,7 +1370,12 @@ export class BunStorage implements IObjectStorage {
|
|
|
937
1370
|
record.version === 1 &&
|
|
938
1371
|
typeof record.key === "string" &&
|
|
939
1372
|
record.keyHash === objectEntry.name &&
|
|
940
|
-
keyHash(record.key) === objectEntry.name
|
|
1373
|
+
keyHash(record.key) === objectEntry.name
|
|
1374
|
+
) {
|
|
1375
|
+
objectKey = record.key;
|
|
1376
|
+
}
|
|
1377
|
+
if (
|
|
1378
|
+
objectKey !== undefined &&
|
|
941
1379
|
record.state === "committed" &&
|
|
942
1380
|
typeof record.generation === "string" &&
|
|
943
1381
|
GENERATION_ID_PATTERN.test(record.generation)
|
|
@@ -1009,6 +1447,16 @@ export class BunStorage implements IObjectStorage {
|
|
|
1009
1447
|
!liveLeaseState.generations.has(generationMatch[1]!) &&
|
|
1010
1448
|
!liveLeaseState.reader
|
|
1011
1449
|
) {
|
|
1450
|
+
// Recovery can overlap a rolling writer. The marker and lease
|
|
1451
|
+
// values above are only scan snapshots: if a valid marker gave us
|
|
1452
|
+
// the object key, re-establish the same fresh deletion authority
|
|
1453
|
+
// used by steady-state GC immediately before unlinking.
|
|
1454
|
+
if (
|
|
1455
|
+
objectKey !== undefined &&
|
|
1456
|
+
!(await this.canReclaimGeneration(objectKey, generationMatch[1]!))
|
|
1457
|
+
) {
|
|
1458
|
+
continue;
|
|
1459
|
+
}
|
|
1012
1460
|
await this.unlinkOwnedInternalFile(
|
|
1013
1461
|
path.join(resolvedObjectPath, entry.name),
|
|
1014
1462
|
);
|
|
@@ -1026,6 +1474,7 @@ export class BunStorage implements IObjectStorage {
|
|
|
1026
1474
|
|
|
1027
1475
|
private async readCommitRecord(
|
|
1028
1476
|
key: string,
|
|
1477
|
+
allowCachedFallback = true,
|
|
1029
1478
|
): Promise<CommitRecord | undefined> {
|
|
1030
1479
|
const markerPath = this.getCommitPath(key);
|
|
1031
1480
|
let markerObserved = false;
|
|
@@ -1076,7 +1525,11 @@ export class BunStorage implements IObjectStorage {
|
|
|
1076
1525
|
// legacy path after a rename race. The caller's resolve loop will reread
|
|
1077
1526
|
// the marker; a truly absent marker still enables legacy compatibility.
|
|
1078
1527
|
const cached = this.lastCommitRecords.get(key);
|
|
1079
|
-
if (cached && lastNotFound) return cached;
|
|
1528
|
+
if (allowCachedFallback && cached && lastNotFound) return cached;
|
|
1529
|
+
// Reclamation must never act on a cached marker. An absent/racing marker
|
|
1530
|
+
// is insufficient authority to delete a generation; a later pass or
|
|
1531
|
+
// startup recovery can retry after the marker is readable.
|
|
1532
|
+
if (!allowCachedFallback && lastNotFound) throw lastNotFound;
|
|
1080
1533
|
if (markerObserved && lastNotFound) throw lastNotFound;
|
|
1081
1534
|
return undefined;
|
|
1082
1535
|
}
|
|
@@ -1107,29 +1560,19 @@ export class BunStorage implements IObjectStorage {
|
|
|
1107
1560
|
}
|
|
1108
1561
|
|
|
1109
1562
|
private async readGenerationMetadata(metaPath: string): Promise<{
|
|
1110
|
-
|
|
1111
|
-
customMetadata?: Record<string, string>;
|
|
1563
|
+
contentType?: string;
|
|
1112
1564
|
}> {
|
|
1113
1565
|
return this.readOwnedInternalFileMetadata(metaPath);
|
|
1114
1566
|
}
|
|
1115
1567
|
|
|
1116
1568
|
private parseGenerationMetadata(valueText: string): {
|
|
1117
|
-
|
|
1118
|
-
customMetadata?: Record<string, string>;
|
|
1569
|
+
contentType?: string;
|
|
1119
1570
|
} {
|
|
1120
|
-
|
|
1121
|
-
httpMetadata?: ObjectMetadata["httpMetadata"];
|
|
1122
|
-
customMetadata?: Record<string, string>;
|
|
1123
|
-
};
|
|
1124
|
-
if (value === null || typeof value !== "object") {
|
|
1125
|
-
throw new Error("Invalid BunStorage generation metadata");
|
|
1126
|
-
}
|
|
1127
|
-
return value;
|
|
1571
|
+
return parseObjectMetadata(valueText);
|
|
1128
1572
|
}
|
|
1129
1573
|
|
|
1130
1574
|
private async readGenerationMetadataHandle(handle: FileHandle): Promise<{
|
|
1131
|
-
|
|
1132
|
-
customMetadata?: Record<string, string>;
|
|
1575
|
+
contentType?: string;
|
|
1133
1576
|
}> {
|
|
1134
1577
|
return this.parseGenerationMetadata(await readFileHandleText(handle));
|
|
1135
1578
|
}
|
|
@@ -1141,13 +1584,12 @@ export class BunStorage implements IObjectStorage {
|
|
|
1141
1584
|
const releaseKeyRead = withLease
|
|
1142
1585
|
? this.acquireKeyReadReservation(key)
|
|
1143
1586
|
: undefined;
|
|
1144
|
-
let readerLease:
|
|
1145
|
-
let readerLeaseOwned = true;
|
|
1587
|
+
let readerLease: TempLease | undefined;
|
|
1146
1588
|
const releaseReaderLease = async () => {
|
|
1147
1589
|
const release = readerLease;
|
|
1148
1590
|
readerLease = undefined;
|
|
1149
1591
|
await release?.();
|
|
1150
|
-
if (release)
|
|
1592
|
+
if (release) this.scheduleReclaim(key);
|
|
1151
1593
|
};
|
|
1152
1594
|
try {
|
|
1153
1595
|
if (withLease) readerLease = await this.createReaderLease(key);
|
|
@@ -1173,10 +1615,16 @@ export class BunStorage implements IObjectStorage {
|
|
|
1173
1615
|
: undefined;
|
|
1174
1616
|
let bodyHandle: FileHandle | undefined;
|
|
1175
1617
|
let metadataHandle: FileHandle | undefined;
|
|
1176
|
-
let
|
|
1177
|
-
const
|
|
1178
|
-
if (
|
|
1179
|
-
|
|
1618
|
+
let generationProtectionReleased = false;
|
|
1619
|
+
const releaseGenerationProtection = async () => {
|
|
1620
|
+
if (generationProtectionReleased) return;
|
|
1621
|
+
generationProtectionReleased = true;
|
|
1622
|
+
await generationLease?.();
|
|
1623
|
+
};
|
|
1624
|
+
let bodyReleased = false;
|
|
1625
|
+
const releaseBody = async () => {
|
|
1626
|
+
if (bodyReleased) return;
|
|
1627
|
+
bodyReleased = true;
|
|
1180
1628
|
if (metadataHandle) {
|
|
1181
1629
|
await metadataHandle.close().catch(() => undefined);
|
|
1182
1630
|
metadataHandle = undefined;
|
|
@@ -1185,11 +1633,10 @@ export class BunStorage implements IObjectStorage {
|
|
|
1185
1633
|
await bodyHandle.close().catch(() => undefined);
|
|
1186
1634
|
bodyHandle = undefined;
|
|
1187
1635
|
}
|
|
1188
|
-
await generationLease?.();
|
|
1189
1636
|
};
|
|
1190
|
-
const
|
|
1191
|
-
await
|
|
1192
|
-
await
|
|
1637
|
+
const releaseAttempt = async () => {
|
|
1638
|
+
await releaseBody();
|
|
1639
|
+
await releaseGenerationProtection();
|
|
1193
1640
|
};
|
|
1194
1641
|
try {
|
|
1195
1642
|
const filePath = this.getGenerationPath(key, generation, "body");
|
|
@@ -1203,7 +1650,7 @@ export class BunStorage implements IObjectStorage {
|
|
|
1203
1650
|
const resolvedMetaPath =
|
|
1204
1651
|
await this.resolveExistingInternalPath(metaPath);
|
|
1205
1652
|
if (!resolvedFilePath || !resolvedMetaPath) {
|
|
1206
|
-
await
|
|
1653
|
+
await releaseAttempt();
|
|
1207
1654
|
await yieldForFilesystem();
|
|
1208
1655
|
continue;
|
|
1209
1656
|
}
|
|
@@ -1220,12 +1667,20 @@ export class BunStorage implements IObjectStorage {
|
|
|
1220
1667
|
.handle;
|
|
1221
1668
|
const metadata =
|
|
1222
1669
|
await this.readGenerationMetadataHandle(metadataHandle);
|
|
1223
|
-
|
|
1670
|
+
await metadataHandle.close();
|
|
1671
|
+
metadataHandle = undefined;
|
|
1672
|
+
// Once the body FD is open and metadata is materialized, POSIX
|
|
1673
|
+
// unlink semantics keep those exact bytes readable without any
|
|
1674
|
+
// pathname lease. Release both local and cross-instance path
|
|
1675
|
+
// protection now so an undrained lazy stream cannot retain every
|
|
1676
|
+
// superseded generation or one lease FD per object lifetime.
|
|
1677
|
+
await releaseGenerationProtection();
|
|
1678
|
+
await releaseReaderLease();
|
|
1224
1679
|
return {
|
|
1225
1680
|
filePath: resolvedFilePath,
|
|
1226
1681
|
bodyHandle,
|
|
1227
1682
|
metadata,
|
|
1228
|
-
|
|
1683
|
+
releaseBody,
|
|
1229
1684
|
};
|
|
1230
1685
|
}
|
|
1231
1686
|
|
|
@@ -1235,10 +1690,10 @@ export class BunStorage implements IObjectStorage {
|
|
|
1235
1690
|
await openedMeta.handle.close();
|
|
1236
1691
|
const metadata =
|
|
1237
1692
|
await this.readGenerationMetadata(resolvedMetaPath);
|
|
1238
|
-
await
|
|
1693
|
+
await releaseGenerationProtection();
|
|
1239
1694
|
return { filePath: resolvedFilePath, metadata };
|
|
1240
1695
|
} catch (error) {
|
|
1241
|
-
await
|
|
1696
|
+
await releaseAttempt();
|
|
1242
1697
|
if (
|
|
1243
1698
|
isNotFoundError(error) &&
|
|
1244
1699
|
attempt + 1 < OBJECT_RESOLVE_ATTEMPTS
|
|
@@ -1296,27 +1751,24 @@ export class BunStorage implements IObjectStorage {
|
|
|
1296
1751
|
metadata = this.parseGenerationMetadata(
|
|
1297
1752
|
await readFileHandleText(metadataHandle),
|
|
1298
1753
|
);
|
|
1754
|
+
await metadataHandle.close();
|
|
1755
|
+
metadataHandle = undefined;
|
|
1299
1756
|
}
|
|
1300
1757
|
let released = false;
|
|
1301
|
-
const
|
|
1758
|
+
const releaseBody = async () => {
|
|
1302
1759
|
if (released) return;
|
|
1303
1760
|
released = true;
|
|
1304
|
-
if (metadataHandle) {
|
|
1305
|
-
await metadataHandle.close().catch(() => undefined);
|
|
1306
|
-
metadataHandle = undefined;
|
|
1307
|
-
}
|
|
1308
1761
|
if (bodyHandle) {
|
|
1309
1762
|
await bodyHandle.close().catch(() => undefined);
|
|
1310
1763
|
bodyHandle = undefined;
|
|
1311
1764
|
}
|
|
1312
|
-
await releaseReaderLease();
|
|
1313
1765
|
};
|
|
1314
|
-
|
|
1766
|
+
await releaseReaderLease();
|
|
1315
1767
|
return {
|
|
1316
1768
|
filePath: resolvedFilePath,
|
|
1317
1769
|
bodyHandle,
|
|
1318
1770
|
metadata,
|
|
1319
|
-
|
|
1771
|
+
releaseBody,
|
|
1320
1772
|
};
|
|
1321
1773
|
} catch (error) {
|
|
1322
1774
|
if (metadataHandle)
|
|
@@ -1337,7 +1789,7 @@ export class BunStorage implements IObjectStorage {
|
|
|
1337
1789
|
return null;
|
|
1338
1790
|
} finally {
|
|
1339
1791
|
releaseKeyRead?.();
|
|
1340
|
-
|
|
1792
|
+
await releaseReaderLease();
|
|
1341
1793
|
}
|
|
1342
1794
|
}
|
|
1343
1795
|
|
|
@@ -1380,6 +1832,8 @@ export class BunStorage implements IObjectStorage {
|
|
|
1380
1832
|
);
|
|
1381
1833
|
await syncAndClose(markerHandle);
|
|
1382
1834
|
markerHandle = undefined;
|
|
1835
|
+
await releaseLease.assertOwned();
|
|
1836
|
+
await this.beforeCommitRename?.(markerPath);
|
|
1383
1837
|
await rename(markerTempPath, markerPath);
|
|
1384
1838
|
// The marker rename is the publication point. Mark the temporary path
|
|
1385
1839
|
// gone immediately so a subsequent directory-fsync error cannot make
|
|
@@ -1397,44 +1851,72 @@ export class BunStorage implements IObjectStorage {
|
|
|
1397
1851
|
key: string,
|
|
1398
1852
|
generation: string | null | undefined,
|
|
1399
1853
|
): Promise<void> {
|
|
1400
|
-
if (
|
|
1401
|
-
|
|
1402
|
-
|
|
1854
|
+
if (generation && (await this.canReclaimGeneration(key, generation))) {
|
|
1855
|
+
for (const suffix of ["body", "meta.json"] as const) {
|
|
1856
|
+
// Re-establish authority for each unlink. A reader or delayed writer
|
|
1857
|
+
// can publish/acquire a lease after removal of the paired file starts;
|
|
1858
|
+
// leaving one orphan is safer than deleting a newly-current pair.
|
|
1859
|
+
if (!(await this.canReclaimGeneration(key, generation))) break;
|
|
1860
|
+
const filePath = this.getGenerationPath(key, generation, suffix);
|
|
1861
|
+
await this.unlinkOwnedInternalFile(filePath);
|
|
1862
|
+
}
|
|
1403
1863
|
}
|
|
1864
|
+
await this.reclaimUnreferencedGenerations(key);
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
/**
|
|
1868
|
+
* Establish deletion authority for one generation without trusting a stale
|
|
1869
|
+
* marker snapshot.
|
|
1870
|
+
*
|
|
1871
|
+
* Writer ordering is lease -> generation files -> commit marker -> lease
|
|
1872
|
+
* release. Reader ordering is reader lease -> marker/open body -> lease
|
|
1873
|
+
* release. Checking both leases around an uncached marker read therefore
|
|
1874
|
+
* prevents GC from unlinking either the currently published generation or a
|
|
1875
|
+
* generation that an already-started reader can still reference.
|
|
1876
|
+
*/
|
|
1877
|
+
private async canReclaimGeneration(
|
|
1878
|
+
key: string,
|
|
1879
|
+
generation: string,
|
|
1880
|
+
): Promise<boolean> {
|
|
1881
|
+
if (
|
|
1882
|
+
this.activeGenerations.has(`${keyHash(key)}:${generation}`) ||
|
|
1883
|
+
this.hasGenerationLease(key, generation) ||
|
|
1884
|
+
this.hasKeyReadReservation(key)
|
|
1885
|
+
) {
|
|
1886
|
+
return false;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
const objectPath = this.getInternalObjectPath(key);
|
|
1890
|
+
const generationLeasePath = path.join(
|
|
1891
|
+
objectPath,
|
|
1892
|
+
`${TEMP_MARKER}${generation}.${LEASE_SUFFIX}`,
|
|
1893
|
+
);
|
|
1894
|
+
if (
|
|
1895
|
+
(await this.isLiveTempLease(generationLeasePath)) ||
|
|
1896
|
+
(await this.hasLiveReaderLease(objectPath))
|
|
1897
|
+
) {
|
|
1898
|
+
return false;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1404
1901
|
let current: CommitRecord | undefined;
|
|
1405
1902
|
try {
|
|
1406
|
-
current = await this.readCommitRecord(key);
|
|
1903
|
+
current = await this.readCommitRecord(key, false);
|
|
1407
1904
|
} catch {
|
|
1408
|
-
|
|
1409
|
-
// rather than risking removal of a generation we cannot identify.
|
|
1410
|
-
return;
|
|
1905
|
+
return false;
|
|
1411
1906
|
}
|
|
1412
|
-
if (
|
|
1413
|
-
|
|
1414
|
-
)) {
|
|
1415
|
-
const activeKey = `${keyHash(key)}:${generation}`;
|
|
1416
|
-
const leasePath = path.join(
|
|
1417
|
-
this.getInternalObjectPath(key),
|
|
1418
|
-
`${TEMP_MARKER}${generation}.${LEASE_SUFFIX}`,
|
|
1419
|
-
);
|
|
1420
|
-
const liveLease = await this.isLiveTempLease(leasePath);
|
|
1421
|
-
const readerLease = await this.hasLiveReaderLease(
|
|
1422
|
-
this.getInternalObjectPath(key),
|
|
1423
|
-
);
|
|
1424
|
-
if (
|
|
1425
|
-
!this.activeGenerations.has(activeKey) &&
|
|
1426
|
-
!this.hasGenerationLease(key, generation) &&
|
|
1427
|
-
!this.hasKeyReadReservation(key) &&
|
|
1428
|
-
!liveLease &&
|
|
1429
|
-
!readerLease
|
|
1430
|
-
) {
|
|
1431
|
-
for (const suffix of ["body", "meta.json"] as const) {
|
|
1432
|
-
const filePath = this.getGenerationPath(key, generation, suffix);
|
|
1433
|
-
await this.unlinkOwnedInternalFile(filePath);
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1907
|
+
if (current?.state === "committed" && current.generation === generation) {
|
|
1908
|
+
return false;
|
|
1436
1909
|
}
|
|
1437
|
-
|
|
1910
|
+
|
|
1911
|
+
// A reader can acquire its cross-instance lease immediately before the
|
|
1912
|
+
// authoritative marker read and still hold the previous generation after
|
|
1913
|
+
// a concurrent publication. Recheck after the marker decision closes that
|
|
1914
|
+
// window. Rechecking the writer lease keeps deletion fail-closed if a
|
|
1915
|
+
// generation lease was transiently unreadable during the first probe.
|
|
1916
|
+
return !(
|
|
1917
|
+
(await this.hasLiveReaderLease(objectPath)) ||
|
|
1918
|
+
(await this.isLiveTempLease(generationLeasePath))
|
|
1919
|
+
);
|
|
1438
1920
|
}
|
|
1439
1921
|
|
|
1440
1922
|
private async reclaimUnreferencedGenerations(key: string): Promise<void> {
|
|
@@ -1482,14 +1964,19 @@ export class BunStorage implements IObjectStorage {
|
|
|
1482
1964
|
) {
|
|
1483
1965
|
continue;
|
|
1484
1966
|
}
|
|
1967
|
+
// `retainedGeneration` and `liveLeaseState` are scan snapshots. A writer
|
|
1968
|
+
// may publish and release its lease while this reclamation pass is in
|
|
1969
|
+
// flight, so re-establish current marker + lease authority immediately
|
|
1970
|
+
// before every destructive unlink.
|
|
1971
|
+
if (!(await this.canReclaimGeneration(key, generation))) continue;
|
|
1485
1972
|
await this.unlinkOwnedInternalFile(
|
|
1486
1973
|
path.join(resolvedObjectPath, entry.name),
|
|
1487
1974
|
);
|
|
1488
1975
|
}
|
|
1489
|
-
// A crashed writer can leave body/metadata/marker temp files after
|
|
1490
|
-
//
|
|
1491
|
-
//
|
|
1492
|
-
// startup recovery.
|
|
1976
|
+
// A crashed local writer can leave body/metadata/marker temp files after
|
|
1977
|
+
// its exact owner incarnation is proved dead. Keep only temps belonging to
|
|
1978
|
+
// an active/retained generation; proved-unowned artifacts are reclaimed
|
|
1979
|
+
// during steady-state GC as well as startup recovery.
|
|
1493
1980
|
for (const entry of entries) {
|
|
1494
1981
|
if (entry.isDirectory() || !entry.name.startsWith(TEMP_MARKER)) {
|
|
1495
1982
|
continue;
|
|
@@ -1513,17 +2000,17 @@ export class BunStorage implements IObjectStorage {
|
|
|
1513
2000
|
|
|
1514
2001
|
async put(
|
|
1515
2002
|
key: string,
|
|
1516
|
-
value:
|
|
1517
|
-
options?:
|
|
1518
|
-
httpMetadata?: ObjectMetadata["httpMetadata"];
|
|
1519
|
-
customMetadata?: Record<string, string>;
|
|
1520
|
-
},
|
|
2003
|
+
value: ObjectStoreBody,
|
|
2004
|
+
options?: ObjectStorePutOptions,
|
|
1521
2005
|
): Promise<void> {
|
|
1522
2006
|
// Serialize before touching the filesystem. A metadata-shape failure must
|
|
1523
2007
|
// leave the currently committed generation completely untouched.
|
|
1524
2008
|
const metadataPayload = JSON.stringify({
|
|
1525
|
-
|
|
1526
|
-
|
|
2009
|
+
contentType: options?.contentType,
|
|
2010
|
+
httpMetadata:
|
|
2011
|
+
options?.contentType === undefined
|
|
2012
|
+
? undefined
|
|
2013
|
+
: { contentType: options.contentType },
|
|
1527
2014
|
});
|
|
1528
2015
|
const filePath = this.getFilePath(key);
|
|
1529
2016
|
|
|
@@ -1575,7 +2062,7 @@ export class BunStorage implements IObjectStorage {
|
|
|
1575
2062
|
);
|
|
1576
2063
|
const activeGenerationKey = `${keyHash(key)}:${generation}`;
|
|
1577
2064
|
this.activeGenerations.add(activeGenerationKey);
|
|
1578
|
-
let releaseLease:
|
|
2065
|
+
let releaseLease: TempLease | undefined;
|
|
1579
2066
|
let bodyHandle: FileHandle | undefined;
|
|
1580
2067
|
let metadataHandle: FileHandle | undefined;
|
|
1581
2068
|
let commitHandle: FileHandle | undefined;
|
|
@@ -1625,6 +2112,12 @@ export class BunStorage implements IObjectStorage {
|
|
|
1625
2112
|
);
|
|
1626
2113
|
await syncAndClose(commitHandle);
|
|
1627
2114
|
commitHandle = undefined;
|
|
2115
|
+
// Publication is permitted only while this exact lease inode/token is
|
|
2116
|
+
// still owned. Recovery never expires a possibly-live owner solely by
|
|
2117
|
+
// age, so this assertion and the marker rename form the fencing edge:
|
|
2118
|
+
// a dead owner cannot resume, and a live owner cannot be reclaimed.
|
|
2119
|
+
await releaseLease.assertOwned();
|
|
2120
|
+
await this.beforeCommitRename?.(commitPath);
|
|
1628
2121
|
await rename(commitTempPath, commitPath);
|
|
1629
2122
|
// Rename makes the complete body+metadata generation authoritative.
|
|
1630
2123
|
// Set both flags before fsync: if syncing the containing directory
|
|
@@ -1665,52 +2158,46 @@ export class BunStorage implements IObjectStorage {
|
|
|
1665
2158
|
await this.removeGenerationIfUnreferenced(key, previousGeneration);
|
|
1666
2159
|
}
|
|
1667
2160
|
|
|
1668
|
-
async get(key: string): Promise<
|
|
1669
|
-
let
|
|
2161
|
+
async get(key: string): Promise<ObjectStoreObject | null> {
|
|
2162
|
+
let releaseBody: (() => Promise<void>) | undefined;
|
|
2163
|
+
let bodyHandedOff = false;
|
|
1670
2164
|
try {
|
|
1671
2165
|
const resolvedObject = await this.resolveObject(key, true);
|
|
1672
2166
|
if (!resolvedObject) return null;
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
const content = resolvedObject.bodyHandle
|
|
1676
|
-
? await readFileHandleFully(resolvedObject.bodyHandle)
|
|
1677
|
-
: new Uint8Array(await Bun.file(resolvedObject.filePath).arrayBuffer());
|
|
2167
|
+
releaseBody = resolvedObject.releaseBody;
|
|
1678
2168
|
const metadata = resolvedObject.metadata;
|
|
1679
2169
|
|
|
1680
|
-
|
|
2170
|
+
if (!resolvedObject.bodyHandle) {
|
|
2171
|
+
throw new Error("BunStorage object body is not open");
|
|
2172
|
+
}
|
|
2173
|
+
const byteLength = (await resolvedObject.bodyHandle.stat()).size;
|
|
2174
|
+
if (!Number.isSafeInteger(byteLength) || byteLength < 0) {
|
|
2175
|
+
throw new Error("invalid filesystem object size");
|
|
2176
|
+
}
|
|
2177
|
+
const body = createFileBodyStream(
|
|
2178
|
+
resolvedObject.bodyHandle,
|
|
2179
|
+
byteLength,
|
|
2180
|
+
async () => {
|
|
2181
|
+
await releaseBody?.();
|
|
2182
|
+
releaseBody = undefined;
|
|
2183
|
+
},
|
|
2184
|
+
);
|
|
2185
|
+
bodyHandedOff = true;
|
|
1681
2186
|
|
|
1682
2187
|
return {
|
|
1683
2188
|
key,
|
|
1684
|
-
body
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
controller.close();
|
|
1688
|
-
},
|
|
1689
|
-
}),
|
|
1690
|
-
bodyUsed,
|
|
1691
|
-
arrayBuffer: async () => {
|
|
1692
|
-
bodyUsed = true;
|
|
1693
|
-
return content.buffer as ArrayBuffer;
|
|
1694
|
-
},
|
|
1695
|
-
text: async () => {
|
|
1696
|
-
bodyUsed = true;
|
|
1697
|
-
return new TextDecoder().decode(content);
|
|
1698
|
-
},
|
|
1699
|
-
json: async <T>() => {
|
|
1700
|
-
bodyUsed = true;
|
|
1701
|
-
return JSON.parse(new TextDecoder().decode(content)) as T;
|
|
1702
|
-
},
|
|
1703
|
-
httpMetadata: metadata.httpMetadata,
|
|
1704
|
-
customMetadata: metadata.customMetadata,
|
|
2189
|
+
body,
|
|
2190
|
+
contentType: metadata.contentType,
|
|
2191
|
+
byteLength,
|
|
1705
2192
|
};
|
|
1706
2193
|
} catch {
|
|
1707
2194
|
return null;
|
|
1708
2195
|
} finally {
|
|
1709
|
-
await
|
|
2196
|
+
if (!bodyHandedOff) await releaseBody?.();
|
|
1710
2197
|
}
|
|
1711
2198
|
}
|
|
1712
2199
|
|
|
1713
|
-
async delete(key: string | string[]): Promise<void> {
|
|
2200
|
+
async delete(key: string | readonly string[]): Promise<void> {
|
|
1714
2201
|
const keys = Array.isArray(key) ? key : [key];
|
|
1715
2202
|
for (const k of keys) {
|
|
1716
2203
|
let markerPath: string;
|
|
@@ -1783,158 +2270,6 @@ export class BunStorage implements IObjectStorage {
|
|
|
1783
2270
|
await this.removeGenerationIfUnreferenced(k, previousGeneration);
|
|
1784
2271
|
}
|
|
1785
2272
|
}
|
|
1786
|
-
|
|
1787
|
-
async list(options?: {
|
|
1788
|
-
prefix?: string;
|
|
1789
|
-
limit?: number;
|
|
1790
|
-
cursor?: string;
|
|
1791
|
-
delimiter?: string;
|
|
1792
|
-
}): Promise<ListObjectsResult> {
|
|
1793
|
-
const objects: ListObjectsResult["objects"] = [];
|
|
1794
|
-
const realBasePath = await this.getRealBasePath();
|
|
1795
|
-
const committed = new Map<string, ResolvedObject>();
|
|
1796
|
-
const deleted = new Set<string>();
|
|
1797
|
-
const legacy: Array<{ key: string; filePath: string }> = [];
|
|
1798
|
-
|
|
1799
|
-
const readDirRecursive = async (dir: string, prefix: string = "") => {
|
|
1800
|
-
try {
|
|
1801
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
1802
|
-
for (const entry of entries) {
|
|
1803
|
-
const fullPath = `${dir}/${entry.name}`;
|
|
1804
|
-
const realFullPath = await realpath(fullPath);
|
|
1805
|
-
if (!isPathWithinBasePath(realBasePath, realFullPath)) continue;
|
|
1806
|
-
const key = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
1807
|
-
|
|
1808
|
-
if (entry.isDirectory()) {
|
|
1809
|
-
await readDirRecursive(fullPath, key);
|
|
1810
|
-
} else if (!isInternalStorageName(entry.name)) {
|
|
1811
|
-
legacy.push({ key, filePath: fullPath });
|
|
1812
|
-
}
|
|
1813
|
-
}
|
|
1814
|
-
} catch {
|
|
1815
|
-
// Directory doesn't exist
|
|
1816
|
-
}
|
|
1817
|
-
};
|
|
1818
|
-
|
|
1819
|
-
await readDirRecursive(realBasePath);
|
|
1820
|
-
|
|
1821
|
-
// Enumerate only validated commit records from the hidden generation
|
|
1822
|
-
// store. This is deliberately separate from the legacy body scan so no
|
|
1823
|
-
// user key can collide with an internal filename.
|
|
1824
|
-
const internalRoot = this.getInternalStorePath();
|
|
1825
|
-
try {
|
|
1826
|
-
const resolvedRoot = await this.resolveExistingInternalPath(internalRoot);
|
|
1827
|
-
if (resolvedRoot) {
|
|
1828
|
-
const objectEntries = await readdir(resolvedRoot, {
|
|
1829
|
-
withFileTypes: true,
|
|
1830
|
-
});
|
|
1831
|
-
for (const objectEntry of objectEntries) {
|
|
1832
|
-
if (
|
|
1833
|
-
!objectEntry.isDirectory() ||
|
|
1834
|
-
!DIGEST_PATTERN.test(objectEntry.name)
|
|
1835
|
-
) {
|
|
1836
|
-
continue;
|
|
1837
|
-
}
|
|
1838
|
-
const objectPath = path.join(resolvedRoot, objectEntry.name);
|
|
1839
|
-
const resolvedObjectPath =
|
|
1840
|
-
await this.resolveExistingInternalPath(objectPath);
|
|
1841
|
-
if (!resolvedObjectPath) continue;
|
|
1842
|
-
const markerPath = path.join(resolvedObjectPath, COMMIT_FILE);
|
|
1843
|
-
try {
|
|
1844
|
-
const record = JSON.parse(
|
|
1845
|
-
await this.readOwnedInternalFileText(markerPath),
|
|
1846
|
-
) as Partial<CommitRecord>;
|
|
1847
|
-
if (
|
|
1848
|
-
record.version !== 1 ||
|
|
1849
|
-
typeof record.key !== "string" ||
|
|
1850
|
-
record.keyHash !== objectEntry.name ||
|
|
1851
|
-
keyHash(record.key) !== objectEntry.name
|
|
1852
|
-
) {
|
|
1853
|
-
continue;
|
|
1854
|
-
}
|
|
1855
|
-
if (record.state === "deleted" && record.generation === null) {
|
|
1856
|
-
deleted.add(record.key);
|
|
1857
|
-
continue;
|
|
1858
|
-
}
|
|
1859
|
-
if (
|
|
1860
|
-
record.state !== "committed" ||
|
|
1861
|
-
typeof record.generation !== "string" ||
|
|
1862
|
-
!GENERATION_ID_PATTERN.test(record.generation)
|
|
1863
|
-
) {
|
|
1864
|
-
continue;
|
|
1865
|
-
}
|
|
1866
|
-
const resolved = await this.resolveObject(record.key, true);
|
|
1867
|
-
if (resolved) committed.set(record.key, resolved);
|
|
1868
|
-
} catch {
|
|
1869
|
-
// A malformed or incomplete marker is never exposed as an object.
|
|
1870
|
-
}
|
|
1871
|
-
}
|
|
1872
|
-
}
|
|
1873
|
-
} catch {
|
|
1874
|
-
// The hidden store may not exist yet.
|
|
1875
|
-
}
|
|
1876
|
-
|
|
1877
|
-
for (const [key, resolved] of committed) {
|
|
1878
|
-
try {
|
|
1879
|
-
if (deleted.has(key)) continue;
|
|
1880
|
-
if (options?.prefix && !key.startsWith(options.prefix)) continue;
|
|
1881
|
-
const stats = resolved.bodyHandle
|
|
1882
|
-
? await resolved.bodyHandle.stat()
|
|
1883
|
-
: await stat(resolved.filePath);
|
|
1884
|
-
objects.push({ key, size: stats.size, uploaded: stats.mtime });
|
|
1885
|
-
} catch {
|
|
1886
|
-
// The generation disappeared between the marker and this read.
|
|
1887
|
-
} finally {
|
|
1888
|
-
await resolved.releaseLease?.();
|
|
1889
|
-
}
|
|
1890
|
-
}
|
|
1891
|
-
for (const candidate of legacy) {
|
|
1892
|
-
if (committed.has(candidate.key) || deleted.has(candidate.key)) continue;
|
|
1893
|
-
if (options?.prefix && !candidate.key.startsWith(options.prefix)) {
|
|
1894
|
-
continue;
|
|
1895
|
-
}
|
|
1896
|
-
try {
|
|
1897
|
-
const stats = await stat(candidate.filePath);
|
|
1898
|
-
objects.push({
|
|
1899
|
-
key: candidate.key,
|
|
1900
|
-
size: stats.size,
|
|
1901
|
-
uploaded: stats.mtime,
|
|
1902
|
-
});
|
|
1903
|
-
} catch {
|
|
1904
|
-
// The legacy object disappeared between traversal and stat.
|
|
1905
|
-
}
|
|
1906
|
-
}
|
|
1907
|
-
|
|
1908
|
-
const limit = options?.limit ?? 1000;
|
|
1909
|
-
const truncated = objects.length > limit;
|
|
1910
|
-
|
|
1911
|
-
return {
|
|
1912
|
-
objects: objects.slice(0, limit),
|
|
1913
|
-
truncated,
|
|
1914
|
-
cursor: truncated ? String(limit) : undefined,
|
|
1915
|
-
};
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
async head(key: string): Promise<ObjectMetadata | null> {
|
|
1919
|
-
let releaseLease: (() => Promise<void>) | undefined;
|
|
1920
|
-
try {
|
|
1921
|
-
const resolvedObject = await this.resolveObject(key, true);
|
|
1922
|
-
if (!resolvedObject) return null;
|
|
1923
|
-
releaseLease = resolvedObject.releaseLease;
|
|
1924
|
-
const metadata = resolvedObject.metadata;
|
|
1925
|
-
return {
|
|
1926
|
-
contentLength: resolvedObject.bodyHandle
|
|
1927
|
-
? (await resolvedObject.bodyHandle.stat()).size
|
|
1928
|
-
: Bun.file(resolvedObject.filePath).size,
|
|
1929
|
-
httpMetadata: metadata.httpMetadata,
|
|
1930
|
-
customMetadata: metadata.customMetadata,
|
|
1931
|
-
};
|
|
1932
|
-
} catch {
|
|
1933
|
-
return null;
|
|
1934
|
-
} finally {
|
|
1935
|
-
await releaseLease?.();
|
|
1936
|
-
}
|
|
1937
|
-
}
|
|
1938
2273
|
}
|
|
1939
2274
|
|
|
1940
2275
|
/**
|