@takosjp/yurucommu-core 3.4.4 → 3.4.5
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.
|
@@ -17,6 +17,9 @@ import type {
|
|
|
17
17
|
RunResult,
|
|
18
18
|
StorageObject,
|
|
19
19
|
} from "./types.ts";
|
|
20
|
+
import { constants as fsConstants } from "node:fs";
|
|
21
|
+
import type { FileHandle } from "node:fs/promises";
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
20
23
|
import {
|
|
21
24
|
assertPathChainWithinBasePath,
|
|
22
25
|
isPathWithinBasePath,
|
|
@@ -34,40 +37,222 @@ declare const require: (specifier: string) => unknown;
|
|
|
34
37
|
// Re-export MemoryKV as it works in Bun too.
|
|
35
38
|
export { MemoryKV };
|
|
36
39
|
|
|
37
|
-
const { mkdir, unlink, readdir, stat, realpath } =
|
|
40
|
+
const { mkdir, unlink, readdir, stat, lstat, realpath, open, rename, utimes } =
|
|
41
|
+
await import("fs/promises");
|
|
42
|
+
|
|
43
|
+
// Generation state is deliberately kept outside the public object namespace.
|
|
44
|
+
// The sibling directory name is derived from the storage directory, so a
|
|
45
|
+
// legacy object literally named `.yurucommu-objects` (or any descendant) is
|
|
46
|
+
// still a valid user key under the public root.
|
|
47
|
+
const INTERNAL_STORE_ROOT = ".yurucommu-objects";
|
|
48
|
+
const COMMIT_FILE = "commit.json";
|
|
49
|
+
const GENERATION_MARKER = "generation-";
|
|
50
|
+
const TEMP_MARKER = "tmp-";
|
|
51
|
+
const LEASE_SUFFIX = "lease";
|
|
52
|
+
const READER_LEASE_MARKER = "reader-";
|
|
53
|
+
const TEMP_LEASE_TTL_MS = 30_000;
|
|
54
|
+
const TEMP_LEASE_HEARTBEAT_MS = 1_000;
|
|
55
|
+
const MARKER_READ_ATTEMPTS = 32;
|
|
56
|
+
const OBJECT_RESOLVE_ATTEMPTS = 64;
|
|
57
|
+
const OPEN_READ_FLAGS = fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW;
|
|
58
|
+
const GENERATION_ID_PATTERN = /^[0-9a-f-]{16,}$/u;
|
|
59
|
+
const DIGEST_PATTERN = /^[0-9a-f]{64}$/u;
|
|
60
|
+
|
|
61
|
+
type CommitState =
|
|
62
|
+
| { version: 1; state: "committed"; generation: string }
|
|
63
|
+
| { version: 1; state: "deleted"; generation: null };
|
|
64
|
+
|
|
65
|
+
type CommitRecord = CommitState & { key: string; keyHash: string };
|
|
66
|
+
|
|
67
|
+
type ResolvedObject = {
|
|
68
|
+
filePath: string;
|
|
69
|
+
bodyHandle?: FileHandle;
|
|
70
|
+
metadata: {
|
|
71
|
+
httpMetadata?: ObjectMetadata["httpMetadata"];
|
|
72
|
+
customMetadata?: Record<string, string>;
|
|
73
|
+
};
|
|
74
|
+
releaseLease?: () => Promise<void>;
|
|
75
|
+
};
|
|
38
76
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
77
|
+
type LiveLeaseState = {
|
|
78
|
+
generations: Set<string>;
|
|
79
|
+
reader: boolean;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
type FileIdentity = {
|
|
83
|
+
dev: number;
|
|
84
|
+
ino: number;
|
|
85
|
+
nlink: number;
|
|
86
|
+
mode: number;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function fileIdentity(stats: FileIdentity): FileIdentity {
|
|
90
|
+
return {
|
|
91
|
+
dev: stats.dev,
|
|
92
|
+
ino: stats.ino,
|
|
93
|
+
nlink: stats.nlink,
|
|
94
|
+
mode: stats.mode,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isSameFileIdentity(
|
|
99
|
+
expected: FileIdentity,
|
|
100
|
+
actual: FileIdentity,
|
|
101
|
+
): boolean {
|
|
102
|
+
return (
|
|
103
|
+
expected.dev === actual.dev &&
|
|
104
|
+
expected.ino === actual.ino &&
|
|
105
|
+
// A concurrent atomic replacement may unlink the just-opened inode
|
|
106
|
+
// between lstat() and fstat(); POSIX then reports nlink=0 on the still
|
|
107
|
+
// valid descriptor. It is safe to accept that transition because the
|
|
108
|
+
// pre-open lstat required nlink===1 and dev/ino/type still match. Any
|
|
109
|
+
// other link count indicates a hardlink or inode substitution.
|
|
110
|
+
(expected.nlink === actual.nlink ||
|
|
111
|
+
actual.nlink === 0 ||
|
|
112
|
+
(expected.nlink === 0 && actual.nlink === 1)) &&
|
|
113
|
+
expected.mode === actual.mode
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isInternalStorageName(name: string): boolean {
|
|
118
|
+
return name.endsWith(".meta.json");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function keyHash(key: string): string {
|
|
122
|
+
return createHash("sha256").update(key, "utf8").digest("hex");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isNotFoundError(error: unknown): boolean {
|
|
126
|
+
return (
|
|
127
|
+
typeof error === "object" &&
|
|
128
|
+
error !== null &&
|
|
129
|
+
"code" in error &&
|
|
130
|
+
(error as { code?: unknown }).code === "ENOENT"
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isFileIdentityRace(error: unknown): boolean {
|
|
135
|
+
return (
|
|
136
|
+
error instanceof Error &&
|
|
137
|
+
error.message === "BunStorage file changed while opening"
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function yieldForFilesystem(): Promise<void> {
|
|
142
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function writeBufferFully(
|
|
146
|
+
handle: FileHandle,
|
|
147
|
+
buffer: Uint8Array,
|
|
148
|
+
): Promise<void> {
|
|
149
|
+
let offset = 0;
|
|
150
|
+
while (offset < buffer.byteLength) {
|
|
151
|
+
const result = await handle.write(buffer.subarray(offset));
|
|
152
|
+
const bytesWritten =
|
|
153
|
+
typeof result === "number" ? result : result?.bytesWritten;
|
|
154
|
+
if (
|
|
155
|
+
!Number.isInteger(bytesWritten) ||
|
|
156
|
+
bytesWritten <= 0 ||
|
|
157
|
+
bytesWritten > buffer.byteLength - offset
|
|
158
|
+
) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`filesystem short write: expected ${buffer.byteLength - offset} bytes, received ${String(bytesWritten)}`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
offset += bytesWritten;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function writeStreamToFile(
|
|
168
|
+
handle: FileHandle,
|
|
43
169
|
stream: ReadableStream<Uint8Array>,
|
|
44
|
-
): Promise<
|
|
45
|
-
const chunks: Uint8Array[] = [];
|
|
170
|
+
): Promise<void> {
|
|
46
171
|
const reader = stream.getReader();
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
172
|
+
try {
|
|
173
|
+
while (true) {
|
|
174
|
+
const { done, value } = await reader.read();
|
|
175
|
+
if (done) return;
|
|
176
|
+
if (!(value instanceof Uint8Array)) {
|
|
177
|
+
throw new Error("filesystem stream yielded a non-byte chunk");
|
|
178
|
+
}
|
|
179
|
+
await writeBufferFully(handle, value);
|
|
180
|
+
}
|
|
181
|
+
} catch (error) {
|
|
182
|
+
await reader.cancel(error).catch(() => undefined);
|
|
183
|
+
throw error;
|
|
184
|
+
} finally {
|
|
185
|
+
reader.releaseLock();
|
|
51
186
|
}
|
|
52
|
-
|
|
53
|
-
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function writeValueToFile(
|
|
190
|
+
handle: FileHandle,
|
|
191
|
+
value: Blob | ReadableStream | ArrayBuffer | string,
|
|
192
|
+
): Promise<void> {
|
|
193
|
+
if (typeof value === "string") {
|
|
194
|
+
await writeBufferFully(handle, new TextEncoder().encode(value));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (value instanceof ArrayBuffer) {
|
|
198
|
+
await writeBufferFully(handle, new Uint8Array(value));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const stream = value instanceof Blob ? value.stream() : value;
|
|
202
|
+
await writeStreamToFile(handle, stream as ReadableStream<Uint8Array>);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function syncAndClose(handle: FileHandle): Promise<void> {
|
|
206
|
+
try {
|
|
207
|
+
await handle.sync();
|
|
208
|
+
} finally {
|
|
209
|
+
await handle.close();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function syncDirectory(directoryPath: string): Promise<void> {
|
|
214
|
+
let handle: FileHandle | undefined;
|
|
215
|
+
try {
|
|
216
|
+
// On Bun's POSIX filesystem, syncing the containing directory makes the
|
|
217
|
+
// preceding atomic rename durable across power loss, not just the file
|
|
218
|
+
// contents themselves.
|
|
219
|
+
handle = await open(directoryPath, "r");
|
|
220
|
+
await handle.sync();
|
|
221
|
+
} finally {
|
|
222
|
+
if (handle) await handle.close().catch(() => undefined);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function readFileHandleFully(handle: FileHandle): Promise<Uint8Array> {
|
|
227
|
+
const size = (await handle.stat()).size;
|
|
228
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
229
|
+
throw new Error("invalid filesystem object size");
|
|
230
|
+
}
|
|
231
|
+
const content = new Uint8Array(size);
|
|
54
232
|
let offset = 0;
|
|
55
|
-
|
|
56
|
-
result.
|
|
57
|
-
|
|
233
|
+
while (offset < content.byteLength) {
|
|
234
|
+
const result = await handle.read(
|
|
235
|
+
content,
|
|
236
|
+
offset,
|
|
237
|
+
content.byteLength - offset,
|
|
238
|
+
null,
|
|
239
|
+
);
|
|
240
|
+
if (
|
|
241
|
+
!Number.isInteger(result.bytesRead) ||
|
|
242
|
+
result.bytesRead <= 0 ||
|
|
243
|
+
result.bytesRead > content.byteLength - offset
|
|
244
|
+
) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`filesystem short read: expected ${content.byteLength - offset} bytes, received ${String(result.bytesRead)}`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
offset += result.bytesRead;
|
|
58
250
|
}
|
|
59
|
-
return
|
|
251
|
+
return content;
|
|
60
252
|
}
|
|
61
253
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
*/
|
|
65
|
-
async function toUint8Array(
|
|
66
|
-
value: ReadableStream | ArrayBuffer | string,
|
|
67
|
-
): Promise<Uint8Array> {
|
|
68
|
-
if (typeof value === "string") return new TextEncoder().encode(value);
|
|
69
|
-
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
70
|
-
return drainStream(value);
|
|
254
|
+
async function readFileHandleText(handle: FileHandle): Promise<string> {
|
|
255
|
+
return new TextDecoder().decode(await readFileHandleFully(handle));
|
|
71
256
|
}
|
|
72
257
|
|
|
73
258
|
/**
|
|
@@ -210,15 +395,55 @@ class BunPreparedStatement implements PreparedStatement {
|
|
|
210
395
|
*/
|
|
211
396
|
export class BunStorage implements IObjectStorage {
|
|
212
397
|
private basePath: string;
|
|
398
|
+
/**
|
|
399
|
+
* Keep the directory-sync dependency injectable for deterministic storage
|
|
400
|
+
* durability tests. Production callers use the real fsync implementation.
|
|
401
|
+
*/
|
|
402
|
+
private readonly syncDirectory: typeof syncDirectory;
|
|
213
403
|
private realBasePath: string | null = null;
|
|
404
|
+
private realInternalStorePath: string | null = null;
|
|
405
|
+
/**
|
|
406
|
+
* Generations currently being assembled by this adapter instance. A
|
|
407
|
+
* concurrent writer can have renamed its body before publishing the commit
|
|
408
|
+
* marker; keep that generation out of eager GC until its marker is durable.
|
|
409
|
+
*/
|
|
410
|
+
private readonly activeGenerations = new Set<string>();
|
|
411
|
+
/**
|
|
412
|
+
* In-process read leases keep a resolved generation alive until its bytes
|
|
413
|
+
* have been opened/read. GC may run concurrently with a reader, so path
|
|
414
|
+
* resolution alone is not a sufficient lifetime guarantee.
|
|
415
|
+
*/
|
|
416
|
+
private readonly generationReaders = new Map<string, number>();
|
|
417
|
+
/**
|
|
418
|
+
* A short reservation held while a reader resolves the current marker and
|
|
419
|
+
* opens its generation. It closes the race between reading the marker and
|
|
420
|
+
* acquiring the generation-specific lease.
|
|
421
|
+
*/
|
|
422
|
+
private readonly keyReadReservations = new Map<string, number>();
|
|
423
|
+
/**
|
|
424
|
+
* Last marker successfully read by this adapter. Atomic marker replacement
|
|
425
|
+
* can make a path disappear or resolve to the next inode for a few syscalls;
|
|
426
|
+
* retaining the last validated record lets readers use the old generation
|
|
427
|
+
* while the marker is in flight instead of reporting a false null.
|
|
428
|
+
*/
|
|
429
|
+
private readonly lastCommitRecords = new Map<string, CommitRecord>();
|
|
214
430
|
|
|
215
|
-
constructor(
|
|
431
|
+
constructor(
|
|
432
|
+
basePath: string,
|
|
433
|
+
options: { syncDirectory?: typeof syncDirectory } = {},
|
|
434
|
+
) {
|
|
216
435
|
this.basePath = basePath;
|
|
436
|
+
this.syncDirectory = options.syncDirectory ?? syncDirectory;
|
|
217
437
|
}
|
|
218
438
|
|
|
219
|
-
static async create(
|
|
439
|
+
static async create(
|
|
440
|
+
basePath: string,
|
|
441
|
+
options: { syncDirectory?: typeof syncDirectory } = {},
|
|
442
|
+
): Promise<BunStorage> {
|
|
220
443
|
await mkdir(basePath, { recursive: true });
|
|
221
|
-
|
|
444
|
+
const storage = new BunStorage(basePath, options);
|
|
445
|
+
await storage.recoverInternalStore();
|
|
446
|
+
return storage;
|
|
222
447
|
}
|
|
223
448
|
|
|
224
449
|
private getFilePath(key: string): string {
|
|
@@ -232,6 +457,217 @@ export class BunStorage implements IObjectStorage {
|
|
|
232
457
|
);
|
|
233
458
|
}
|
|
234
459
|
|
|
460
|
+
private getInternalStorePath(): string {
|
|
461
|
+
const publicRoot = this.getResolvedBasePath();
|
|
462
|
+
return path.join(
|
|
463
|
+
path.dirname(publicRoot),
|
|
464
|
+
`.${path.basename(publicRoot) || "root"}${INTERNAL_STORE_ROOT}`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private getInternalObjectPath(key: string): string {
|
|
469
|
+
return path.join(this.getInternalStorePath(), keyHash(key));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private isInternalStorePath(filePath: string): boolean {
|
|
473
|
+
return isPathWithinBasePath(
|
|
474
|
+
this.getInternalStorePath(),
|
|
475
|
+
path.resolve(filePath),
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private getCommitPath(key: string): string {
|
|
480
|
+
return path.join(this.getInternalObjectPath(key), COMMIT_FILE);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private getGenerationPath(
|
|
484
|
+
key: string,
|
|
485
|
+
generation: string,
|
|
486
|
+
suffix: "body" | "meta.json",
|
|
487
|
+
): string {
|
|
488
|
+
if (!GENERATION_ID_PATTERN.test(generation)) {
|
|
489
|
+
throw new Error("Invalid BunStorage generation identifier");
|
|
490
|
+
}
|
|
491
|
+
return path.join(
|
|
492
|
+
this.getInternalObjectPath(key),
|
|
493
|
+
`${GENERATION_MARKER}${generation}.${suffix}`,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private generationLeaseKey(key: string, generation: string): string {
|
|
498
|
+
return `${keyHash(key)}:${generation}`;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private acquireGenerationLease(
|
|
502
|
+
key: string,
|
|
503
|
+
generation: string,
|
|
504
|
+
): () => Promise<void> {
|
|
505
|
+
const leaseKey = this.generationLeaseKey(key, generation);
|
|
506
|
+
this.generationReaders.set(
|
|
507
|
+
leaseKey,
|
|
508
|
+
(this.generationReaders.get(leaseKey) ?? 0) + 1,
|
|
509
|
+
);
|
|
510
|
+
let released = false;
|
|
511
|
+
return async () => {
|
|
512
|
+
if (released) return;
|
|
513
|
+
released = true;
|
|
514
|
+
const count = this.generationReaders.get(leaseKey) ?? 0;
|
|
515
|
+
if (count <= 1) this.generationReaders.delete(leaseKey);
|
|
516
|
+
else this.generationReaders.set(leaseKey, count - 1);
|
|
517
|
+
// A writer may have deferred this generation while the reader held its
|
|
518
|
+
// lease. Re-run conservative GC after release; failure is non-fatal and
|
|
519
|
+
// startup recovery remains the final orphan cleanup authority.
|
|
520
|
+
await this.reclaimUnreferencedGenerations(key);
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private hasGenerationLease(key: string, generation: string): boolean {
|
|
525
|
+
return (
|
|
526
|
+
(this.generationReaders.get(this.generationLeaseKey(key, generation)) ??
|
|
527
|
+
0) > 0
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private acquireKeyReadReservation(key: string): () => void {
|
|
532
|
+
this.keyReadReservations.set(
|
|
533
|
+
key,
|
|
534
|
+
(this.keyReadReservations.get(key) ?? 0) + 1,
|
|
535
|
+
);
|
|
536
|
+
let released = false;
|
|
537
|
+
return () => {
|
|
538
|
+
if (released) return;
|
|
539
|
+
released = true;
|
|
540
|
+
const count = this.keyReadReservations.get(key) ?? 0;
|
|
541
|
+
if (count <= 1) this.keyReadReservations.delete(key);
|
|
542
|
+
else this.keyReadReservations.set(key, count - 1);
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private hasKeyReadReservation(key: string): boolean {
|
|
547
|
+
return (this.keyReadReservations.get(key) ?? 0) > 0;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private async createTempLease(
|
|
551
|
+
objectPath: string,
|
|
552
|
+
leaseId: string,
|
|
553
|
+
kind: "generation" | "reader" = "generation",
|
|
554
|
+
): Promise<() => Promise<void>> {
|
|
555
|
+
const marker = kind === "reader" ? READER_LEASE_MARKER : "";
|
|
556
|
+
const leasePath = path.join(
|
|
557
|
+
objectPath,
|
|
558
|
+
`${TEMP_MARKER}${marker}${leaseId}.${LEASE_SUFFIX}`,
|
|
559
|
+
);
|
|
560
|
+
const leaseOwner = JSON.stringify({
|
|
561
|
+
pid: process.pid,
|
|
562
|
+
token: crypto.randomUUID(),
|
|
563
|
+
});
|
|
564
|
+
let leaseHandle: FileHandle | undefined;
|
|
565
|
+
try {
|
|
566
|
+
leaseHandle = await open(leasePath, "wx");
|
|
567
|
+
await writeBufferFully(leaseHandle, new TextEncoder().encode(leaseOwner));
|
|
568
|
+
await syncAndClose(leaseHandle);
|
|
569
|
+
leaseHandle = undefined;
|
|
570
|
+
} catch (error) {
|
|
571
|
+
if (leaseHandle) await leaseHandle.close().catch(() => undefined);
|
|
572
|
+
await this.unlinkOwnedInternalFile(leasePath);
|
|
573
|
+
throw error;
|
|
574
|
+
}
|
|
575
|
+
await this.assertOwnedInternalFile(leasePath);
|
|
576
|
+
|
|
577
|
+
const heartbeat = setInterval(() => {
|
|
578
|
+
void utimes(leasePath, new Date(), new Date()).catch(() => undefined);
|
|
579
|
+
}, TEMP_LEASE_HEARTBEAT_MS);
|
|
580
|
+
(heartbeat as unknown as { unref?: () => void }).unref?.();
|
|
581
|
+
let released = false;
|
|
582
|
+
return async () => {
|
|
583
|
+
if (released) return;
|
|
584
|
+
released = true;
|
|
585
|
+
clearInterval(heartbeat);
|
|
586
|
+
await this.unlinkOwnedInternalFile(leasePath);
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private async createReaderLease(
|
|
591
|
+
key: string,
|
|
592
|
+
): Promise<(() => Promise<void>) | undefined> {
|
|
593
|
+
const objectPath = this.getInternalObjectPath(key);
|
|
594
|
+
try {
|
|
595
|
+
await assertPathChainWithinBasePath(
|
|
596
|
+
await this.getRealInternalStorePath(),
|
|
597
|
+
objectPath,
|
|
598
|
+
realpath,
|
|
599
|
+
);
|
|
600
|
+
await this.assertRealInternalDirectory(objectPath);
|
|
601
|
+
return await this.createTempLease(
|
|
602
|
+
objectPath,
|
|
603
|
+
crypto.randomUUID(),
|
|
604
|
+
"reader",
|
|
605
|
+
);
|
|
606
|
+
} catch (error) {
|
|
607
|
+
if (isNotFoundError(error)) return undefined;
|
|
608
|
+
throw error;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private async isLiveTempLease(leasePath: string): Promise<boolean> {
|
|
613
|
+
let leaseHandle: FileHandle | undefined;
|
|
614
|
+
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
|
+
try {
|
|
624
|
+
process.kill(Number(lease.pid), 0);
|
|
625
|
+
return true;
|
|
626
|
+
} catch {
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
} catch {
|
|
630
|
+
return false;
|
|
631
|
+
} finally {
|
|
632
|
+
if (leaseHandle) await leaseHandle.close().catch(() => undefined);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
private async liveTempLeases(
|
|
637
|
+
objectPath: string,
|
|
638
|
+
entries: Array<{ name: string; isDirectory(): boolean }>,
|
|
639
|
+
): Promise<LiveLeaseState> {
|
|
640
|
+
const generations = new Set<string>();
|
|
641
|
+
let reader = false;
|
|
642
|
+
for (const entry of entries) {
|
|
643
|
+
if (entry.isDirectory()) continue;
|
|
644
|
+
const generationMatch = entry.name.match(
|
|
645
|
+
/^tmp-([0-9a-f-]{16,})\.lease$/u,
|
|
646
|
+
);
|
|
647
|
+
const readerMatch = entry.name.match(
|
|
648
|
+
/^tmp-reader-([0-9a-f-]{16,})\.lease$/u,
|
|
649
|
+
);
|
|
650
|
+
if (!generationMatch && !readerMatch) continue;
|
|
651
|
+
const leasePath = path.join(objectPath, entry.name);
|
|
652
|
+
if (await this.isLiveTempLease(leasePath)) {
|
|
653
|
+
if (readerMatch) reader = true;
|
|
654
|
+
else generations.add(generationMatch![1]!);
|
|
655
|
+
} else {
|
|
656
|
+
await this.unlinkOwnedInternalFile(leasePath);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return { generations, reader };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
private async hasLiveReaderLease(objectPath: string): Promise<boolean> {
|
|
663
|
+
try {
|
|
664
|
+
const entries = await readdir(objectPath, { withFileTypes: true });
|
|
665
|
+
return (await this.liveTempLeases(objectPath, entries)).reader;
|
|
666
|
+
} catch {
|
|
667
|
+
return false;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
235
671
|
private getResolvedBasePath(): string {
|
|
236
672
|
return path.resolve(this.basePath);
|
|
237
673
|
}
|
|
@@ -247,6 +683,47 @@ export class BunStorage implements IObjectStorage {
|
|
|
247
683
|
return this.realBasePath;
|
|
248
684
|
}
|
|
249
685
|
|
|
686
|
+
private async getRealInternalStorePath(): Promise<string> {
|
|
687
|
+
if (this.realInternalStorePath) {
|
|
688
|
+
// Re-check the lexical root on every access. A rolling process or an
|
|
689
|
+
// operator-side repair can replace the directory with a symlink after
|
|
690
|
+
// startup; the cached realpath must not make that substitution trusted.
|
|
691
|
+
await this.assertRealInternalDirectory(this.getInternalStorePath());
|
|
692
|
+
return this.realInternalStorePath;
|
|
693
|
+
}
|
|
694
|
+
const internalRoot = this.getInternalStorePath();
|
|
695
|
+
const internalParent = path.dirname(internalRoot);
|
|
696
|
+
const realInternalParent = await realpath(internalParent);
|
|
697
|
+
// The metadata namespace is allowed outside the public root, but it must
|
|
698
|
+
// remain within the same trusted parent and may not be redirected through
|
|
699
|
+
// a symlink to an unrelated filesystem location.
|
|
700
|
+
await assertPathChainWithinBasePath(
|
|
701
|
+
realInternalParent,
|
|
702
|
+
internalRoot,
|
|
703
|
+
realpath,
|
|
704
|
+
);
|
|
705
|
+
try {
|
|
706
|
+
const rootStats = await lstat(internalRoot);
|
|
707
|
+
if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) {
|
|
708
|
+
throw new Error("BunStorage metadata root must be a real directory");
|
|
709
|
+
}
|
|
710
|
+
} catch (error) {
|
|
711
|
+
if (!isNotFoundError(error)) throw error;
|
|
712
|
+
}
|
|
713
|
+
await mkdir(internalRoot, { recursive: true });
|
|
714
|
+
const createdRootStats = await lstat(internalRoot);
|
|
715
|
+
if (createdRootStats.isSymbolicLink() || !createdRootStats.isDirectory()) {
|
|
716
|
+
throw new Error("BunStorage metadata root must be a real directory");
|
|
717
|
+
}
|
|
718
|
+
await this.syncDirectory(internalParent);
|
|
719
|
+
const resolvedRoot = await realpath(internalRoot);
|
|
720
|
+
if (!isPathWithinBasePath(realInternalParent, resolvedRoot)) {
|
|
721
|
+
throw new Error("BunStorage metadata path escapes its parent");
|
|
722
|
+
}
|
|
723
|
+
this.realInternalStorePath = resolvedRoot;
|
|
724
|
+
return resolvedRoot;
|
|
725
|
+
}
|
|
726
|
+
|
|
250
727
|
private async resolveExistingPath(filePath: string): Promise<string | null> {
|
|
251
728
|
try {
|
|
252
729
|
const realPath = await realpath(filePath);
|
|
@@ -260,16 +737,795 @@ export class BunStorage implements IObjectStorage {
|
|
|
260
737
|
}
|
|
261
738
|
}
|
|
262
739
|
|
|
740
|
+
private async resolveExistingInternalPath(
|
|
741
|
+
filePath: string,
|
|
742
|
+
): Promise<string | null> {
|
|
743
|
+
try {
|
|
744
|
+
const realPath = await realpath(filePath);
|
|
745
|
+
const realInternalRoot = await this.getRealInternalStorePath();
|
|
746
|
+
if (!isPathWithinBasePath(realInternalRoot, realPath)) {
|
|
747
|
+
throw new Error("BunStorage metadata path escapes its root");
|
|
748
|
+
}
|
|
749
|
+
return realPath;
|
|
750
|
+
} catch {
|
|
751
|
+
return null;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
private async assertRealInternalDirectory(
|
|
756
|
+
directoryPath: string,
|
|
757
|
+
): Promise<void> {
|
|
758
|
+
const directoryStats = await lstat(directoryPath);
|
|
759
|
+
if (directoryStats.isSymbolicLink() || !directoryStats.isDirectory()) {
|
|
760
|
+
throw new Error("BunStorage internal path must be a real directory");
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Open an adapter-owned regular file without following a final symlink or
|
|
766
|
+
* accepting a hardlink to an external inode. The lstat/fstat identity check
|
|
767
|
+
* closes the realpath-to-open replacement window; callers read through the
|
|
768
|
+
* returned descriptor rather than reopening the path.
|
|
769
|
+
*/
|
|
770
|
+
private async openOwnedRegularFile(
|
|
771
|
+
filePath: string,
|
|
772
|
+
rootPath: string,
|
|
773
|
+
): Promise<{ handle: FileHandle; identity: FileIdentity }> {
|
|
774
|
+
await this.assertOwnedPathChain(rootPath, filePath);
|
|
775
|
+
const before = await lstat(filePath);
|
|
776
|
+
if (
|
|
777
|
+
before.isSymbolicLink() ||
|
|
778
|
+
!before.isFile() ||
|
|
779
|
+
// During an atomic rename/unlink, Bun can expose the just-unlinked
|
|
780
|
+
// inode as nlink=0 for one stat result. It is still safe to open only
|
|
781
|
+
// when no additional link (nlink>1) is present.
|
|
782
|
+
before.nlink < 0 ||
|
|
783
|
+
before.nlink > 1
|
|
784
|
+
) {
|
|
785
|
+
throw new Error("BunStorage file is not an owned regular file");
|
|
786
|
+
}
|
|
787
|
+
const expected = fileIdentity(before);
|
|
788
|
+
let handle: FileHandle | undefined;
|
|
789
|
+
try {
|
|
790
|
+
handle = await open(filePath, OPEN_READ_FLAGS);
|
|
791
|
+
const after = fileIdentity(await handle.stat());
|
|
792
|
+
if (!isSameFileIdentity(expected, after)) {
|
|
793
|
+
throw new Error("BunStorage file changed while opening");
|
|
794
|
+
}
|
|
795
|
+
// Re-check the path after opening as well. O_NOFOLLOW protects the
|
|
796
|
+
// final component, while this catches a parent-directory replacement
|
|
797
|
+
// that happened between the initial containment check and open().
|
|
798
|
+
await this.assertOwnedPathChain(rootPath, filePath, false);
|
|
799
|
+
return { handle, identity: after };
|
|
800
|
+
} catch (error) {
|
|
801
|
+
if (handle) await handle.close().catch(() => undefined);
|
|
802
|
+
throw error;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Internal files are adapter-owned, so every path component must be a real
|
|
808
|
+
* directory/file rather than a symlink. realpath containment alone is not
|
|
809
|
+
* sufficient: a symlink can point to another in-root inode and still win a
|
|
810
|
+
* later path lookup, and the final lstat/open pair has a TOCTOU window.
|
|
811
|
+
*/
|
|
812
|
+
private async assertOwnedPathChain(
|
|
813
|
+
rootPath: string,
|
|
814
|
+
targetPath: string,
|
|
815
|
+
includeTarget = true,
|
|
816
|
+
): Promise<void> {
|
|
817
|
+
const root = path.resolve(rootPath);
|
|
818
|
+
const target = path.resolve(targetPath);
|
|
819
|
+
if (!isPathWithinBasePath(root, target)) {
|
|
820
|
+
throw new Error("BunStorage path escapes its owned root");
|
|
821
|
+
}
|
|
822
|
+
const relativePath = path.relative(root, target);
|
|
823
|
+
let current = root;
|
|
824
|
+
const components = relativePath ? relativePath.split(path.sep) : [];
|
|
825
|
+
if (!includeTarget) components.pop();
|
|
826
|
+
for (const component of components) {
|
|
827
|
+
current = path.join(current, component);
|
|
828
|
+
const stats = await lstat(current);
|
|
829
|
+
if (stats.isSymbolicLink()) {
|
|
830
|
+
throw new Error("BunStorage internal path contains a symlink");
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
private async readOwnedInternalFileText(filePath: string): Promise<string> {
|
|
836
|
+
const opened = await this.openOwnedInternalFile(filePath);
|
|
837
|
+
try {
|
|
838
|
+
return await readFileHandleText(opened.handle);
|
|
839
|
+
} finally {
|
|
840
|
+
await opened.handle.close().catch(() => undefined);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
private async readOwnedInternalFileMetadata(filePath: string): Promise<{
|
|
845
|
+
httpMetadata?: ObjectMetadata["httpMetadata"];
|
|
846
|
+
customMetadata?: Record<string, string>;
|
|
847
|
+
}> {
|
|
848
|
+
return this.parseGenerationMetadata(
|
|
849
|
+
await this.readOwnedInternalFileText(filePath),
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
private async openOwnedInternalFile(
|
|
854
|
+
filePath: string,
|
|
855
|
+
): Promise<{ handle: FileHandle; identity: FileIdentity }> {
|
|
856
|
+
return this.openOwnedRegularFile(
|
|
857
|
+
filePath,
|
|
858
|
+
await this.getRealInternalStorePath(),
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
private async assertOwnedRegularFile(
|
|
863
|
+
filePath: string,
|
|
864
|
+
rootPath: string,
|
|
865
|
+
): Promise<FileIdentity> {
|
|
866
|
+
const opened = await this.openOwnedRegularFile(filePath, rootPath);
|
|
867
|
+
await opened.handle.close();
|
|
868
|
+
return opened.identity;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
private async assertOwnedInternalFile(
|
|
872
|
+
filePath: string,
|
|
873
|
+
): Promise<FileIdentity> {
|
|
874
|
+
return this.assertOwnedRegularFile(
|
|
875
|
+
filePath,
|
|
876
|
+
await this.getRealInternalStorePath(),
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Remove only an owned regular file. A symlink or hardlink is left in place
|
|
882
|
+
* for recovery/security inspection rather than unlinking an unowned inode.
|
|
883
|
+
*/
|
|
884
|
+
private async unlinkOwnedRegularFile(
|
|
885
|
+
filePath: string,
|
|
886
|
+
rootPath: string,
|
|
887
|
+
): Promise<void> {
|
|
888
|
+
try {
|
|
889
|
+
await this.assertOwnedRegularFile(filePath, rootPath);
|
|
890
|
+
} catch (error) {
|
|
891
|
+
if (isNotFoundError(error)) return;
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
await unlink(filePath).catch(() => undefined);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
private async unlinkOwnedInternalFile(filePath: string): Promise<void> {
|
|
898
|
+
await this.unlinkOwnedRegularFile(
|
|
899
|
+
filePath,
|
|
900
|
+
await this.getRealInternalStorePath(),
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
private async recoverInternalStore(): Promise<void> {
|
|
905
|
+
try {
|
|
906
|
+
// Validate the sibling namespace before creating or traversing it. A
|
|
907
|
+
// symlink at this exact path must never redirect recovery elsewhere.
|
|
908
|
+
const resolvedRoot = await this.getRealInternalStorePath();
|
|
909
|
+
const objectEntries = await readdir(resolvedRoot, {
|
|
910
|
+
withFileTypes: true,
|
|
911
|
+
});
|
|
912
|
+
for (const objectEntry of objectEntries) {
|
|
913
|
+
if (!DIGEST_PATTERN.test(objectEntry.name)) {
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
const objectPath = path.join(resolvedRoot, objectEntry.name);
|
|
917
|
+
await this.assertRealInternalDirectory(objectPath);
|
|
918
|
+
const resolvedObjectPath =
|
|
919
|
+
await this.resolveExistingInternalPath(objectPath);
|
|
920
|
+
if (!resolvedObjectPath) continue;
|
|
921
|
+
const files = await readdir(resolvedObjectPath, {
|
|
922
|
+
withFileTypes: true,
|
|
923
|
+
});
|
|
924
|
+
const liveLeaseState = await this.liveTempLeases(
|
|
925
|
+
resolvedObjectPath,
|
|
926
|
+
files,
|
|
927
|
+
);
|
|
928
|
+
let retainedGeneration: string | null = null;
|
|
929
|
+
try {
|
|
930
|
+
const markerPath = path.join(resolvedObjectPath, COMMIT_FILE);
|
|
931
|
+
const markerStats = await lstat(markerPath).catch(() => null);
|
|
932
|
+
if (markerStats && markerStats.isFile() && markerStats.nlink === 1) {
|
|
933
|
+
const record = JSON.parse(
|
|
934
|
+
await this.readOwnedInternalFileText(markerPath),
|
|
935
|
+
) as Partial<CommitRecord>;
|
|
936
|
+
if (
|
|
937
|
+
record.version === 1 &&
|
|
938
|
+
typeof record.key === "string" &&
|
|
939
|
+
record.keyHash === objectEntry.name &&
|
|
940
|
+
keyHash(record.key) === objectEntry.name &&
|
|
941
|
+
record.state === "committed" &&
|
|
942
|
+
typeof record.generation === "string" &&
|
|
943
|
+
GENERATION_ID_PATTERN.test(record.generation)
|
|
944
|
+
) {
|
|
945
|
+
const bodyPath = path.join(
|
|
946
|
+
resolvedObjectPath,
|
|
947
|
+
`${GENERATION_MARKER}${record.generation}.body`,
|
|
948
|
+
);
|
|
949
|
+
const metaPath = path.join(
|
|
950
|
+
resolvedObjectPath,
|
|
951
|
+
`${GENERATION_MARKER}${record.generation}.meta.json`,
|
|
952
|
+
);
|
|
953
|
+
try {
|
|
954
|
+
const body = await this.openOwnedInternalFile(bodyPath);
|
|
955
|
+
await body.handle.close();
|
|
956
|
+
// Parse metadata during recovery so a partially-written or
|
|
957
|
+
// corrupt sidecar cannot be retained as a live generation.
|
|
958
|
+
await this.readGenerationMetadata(metaPath);
|
|
959
|
+
retainedGeneration = record.generation;
|
|
960
|
+
} catch {
|
|
961
|
+
// Missing, symlinked, hardlinked, or corrupt generations are
|
|
962
|
+
// not eligible to remain referenced by the marker.
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
} catch {
|
|
967
|
+
retainedGeneration = null;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
for (const entry of files) {
|
|
971
|
+
if (entry.isDirectory()) continue;
|
|
972
|
+
const leaseMatch = entry.name.match(/^tmp-([0-9a-f-]{16,})\.lease$/u);
|
|
973
|
+
const readerLeaseMatch = entry.name.match(
|
|
974
|
+
/^tmp-reader-([0-9a-f-]{16,})\.lease$/u,
|
|
975
|
+
);
|
|
976
|
+
if (leaseMatch || readerLeaseMatch) {
|
|
977
|
+
if (
|
|
978
|
+
(leaseMatch && liveLeaseState.generations.has(leaseMatch[1]!)) ||
|
|
979
|
+
(readerLeaseMatch && liveLeaseState.reader)
|
|
980
|
+
) {
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
await this.unlinkOwnedInternalFile(
|
|
984
|
+
path.join(resolvedObjectPath, entry.name),
|
|
985
|
+
);
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
if (entry.name.startsWith(TEMP_MARKER)) {
|
|
989
|
+
const tempGeneration = entry.name.match(
|
|
990
|
+
/^tmp-([0-9a-f-]{16,})\./u,
|
|
991
|
+
)?.[1];
|
|
992
|
+
if (
|
|
993
|
+
tempGeneration &&
|
|
994
|
+
liveLeaseState.generations.has(tempGeneration)
|
|
995
|
+
) {
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
await this.unlinkOwnedInternalFile(
|
|
999
|
+
path.join(resolvedObjectPath, entry.name),
|
|
1000
|
+
);
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
1003
|
+
const generationMatch = entry.name.match(
|
|
1004
|
+
/^generation-([0-9a-f-]{16,})\.(?:body|meta\.json)$/u,
|
|
1005
|
+
);
|
|
1006
|
+
if (
|
|
1007
|
+
generationMatch &&
|
|
1008
|
+
generationMatch[1] !== retainedGeneration &&
|
|
1009
|
+
!liveLeaseState.generations.has(generationMatch[1]!) &&
|
|
1010
|
+
!liveLeaseState.reader
|
|
1011
|
+
) {
|
|
1012
|
+
await this.unlinkOwnedInternalFile(
|
|
1013
|
+
path.join(resolvedObjectPath, entry.name),
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
// A storage directory can disappear between mkdir and recovery; the
|
|
1020
|
+
// first normal put will recreate its digest directory. Security and
|
|
1021
|
+
// namespace validation failures must remain observable instead of being
|
|
1022
|
+
// mistaken for an empty store.
|
|
1023
|
+
if (!isNotFoundError(error)) throw error;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
private async readCommitRecord(
|
|
1028
|
+
key: string,
|
|
1029
|
+
): Promise<CommitRecord | undefined> {
|
|
1030
|
+
const markerPath = this.getCommitPath(key);
|
|
1031
|
+
let markerObserved = false;
|
|
1032
|
+
let markerText: string | undefined;
|
|
1033
|
+
let lastNotFound: unknown;
|
|
1034
|
+
for (let attempt = 0; attempt < MARKER_READ_ATTEMPTS; attempt += 1) {
|
|
1035
|
+
let markerHandle: FileHandle | undefined;
|
|
1036
|
+
try {
|
|
1037
|
+
// Validate every existing path component before opening the marker.
|
|
1038
|
+
// This distinguishes a missing marker (normal legacy fallback) from a
|
|
1039
|
+
// symlinked internal namespace that escapes its sibling root.
|
|
1040
|
+
const opened = await this.openOwnedInternalFile(markerPath);
|
|
1041
|
+
markerHandle = opened.handle;
|
|
1042
|
+
markerObserved = true;
|
|
1043
|
+
// Read through an open descriptor. Resolving the path and then asking
|
|
1044
|
+
// Bun.file() to open it leaves a rename/unlink window where Bun can
|
|
1045
|
+
// retain a stale `(... deleted)` path; an FD is either the old
|
|
1046
|
+
// complete marker or the new complete marker after atomic rename.
|
|
1047
|
+
markerText = await readFileHandleText(markerHandle);
|
|
1048
|
+
break;
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
if (!isNotFoundError(error) && !isFileIdentityRace(error)) throw error;
|
|
1051
|
+
lastNotFound = error;
|
|
1052
|
+
try {
|
|
1053
|
+
const markerStats = await lstat(markerPath);
|
|
1054
|
+
if (
|
|
1055
|
+
markerStats.isSymbolicLink() ||
|
|
1056
|
+
!markerStats.isFile() ||
|
|
1057
|
+
markerStats.nlink < 0 ||
|
|
1058
|
+
markerStats.nlink > 1
|
|
1059
|
+
) {
|
|
1060
|
+
throw new Error("BunStorage commit marker is not owned");
|
|
1061
|
+
}
|
|
1062
|
+
markerObserved = true;
|
|
1063
|
+
} catch (probeError) {
|
|
1064
|
+
if (!isNotFoundError(probeError)) throw probeError;
|
|
1065
|
+
}
|
|
1066
|
+
if (attempt + 1 < MARKER_READ_ATTEMPTS) {
|
|
1067
|
+
await yieldForFilesystem();
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
} finally {
|
|
1071
|
+
if (markerHandle) await markerHandle.close().catch(() => undefined);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
if (markerText === undefined) {
|
|
1075
|
+
// A marker that was ever visible must not silently fall through to the
|
|
1076
|
+
// legacy path after a rename race. The caller's resolve loop will reread
|
|
1077
|
+
// the marker; a truly absent marker still enables legacy compatibility.
|
|
1078
|
+
const cached = this.lastCommitRecords.get(key);
|
|
1079
|
+
if (cached && lastNotFound) return cached;
|
|
1080
|
+
if (markerObserved && lastNotFound) throw lastNotFound;
|
|
1081
|
+
return undefined;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const record = JSON.parse(markerText) as Partial<CommitRecord>;
|
|
1085
|
+
if (
|
|
1086
|
+
record.version !== 1 ||
|
|
1087
|
+
typeof record.key !== "string" ||
|
|
1088
|
+
typeof record.keyHash !== "string" ||
|
|
1089
|
+
record.key !== key ||
|
|
1090
|
+
record.keyHash !== keyHash(key)
|
|
1091
|
+
) {
|
|
1092
|
+
throw new Error("Invalid BunStorage commit marker identity");
|
|
1093
|
+
}
|
|
1094
|
+
if (record.state === "deleted" && record.generation === null) {
|
|
1095
|
+
this.lastCommitRecords.set(key, record as CommitRecord);
|
|
1096
|
+
return record as CommitRecord;
|
|
1097
|
+
}
|
|
1098
|
+
if (
|
|
1099
|
+
record.state !== "committed" ||
|
|
1100
|
+
typeof record.generation !== "string" ||
|
|
1101
|
+
!GENERATION_ID_PATTERN.test(record.generation)
|
|
1102
|
+
) {
|
|
1103
|
+
throw new Error("Invalid BunStorage commit marker");
|
|
1104
|
+
}
|
|
1105
|
+
this.lastCommitRecords.set(key, record as CommitRecord);
|
|
1106
|
+
return record as CommitRecord;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
private async readGenerationMetadata(metaPath: string): Promise<{
|
|
1110
|
+
httpMetadata?: ObjectMetadata["httpMetadata"];
|
|
1111
|
+
customMetadata?: Record<string, string>;
|
|
1112
|
+
}> {
|
|
1113
|
+
return this.readOwnedInternalFileMetadata(metaPath);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
private parseGenerationMetadata(valueText: string): {
|
|
1117
|
+
httpMetadata?: ObjectMetadata["httpMetadata"];
|
|
1118
|
+
customMetadata?: Record<string, string>;
|
|
1119
|
+
} {
|
|
1120
|
+
const value = JSON.parse(valueText) as {
|
|
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;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
private async readGenerationMetadataHandle(handle: FileHandle): Promise<{
|
|
1131
|
+
httpMetadata?: ObjectMetadata["httpMetadata"];
|
|
1132
|
+
customMetadata?: Record<string, string>;
|
|
1133
|
+
}> {
|
|
1134
|
+
return this.parseGenerationMetadata(await readFileHandleText(handle));
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
private async resolveObject(
|
|
1138
|
+
key: string,
|
|
1139
|
+
withLease = false,
|
|
1140
|
+
): Promise<ResolvedObject | null> {
|
|
1141
|
+
const releaseKeyRead = withLease
|
|
1142
|
+
? this.acquireKeyReadReservation(key)
|
|
1143
|
+
: undefined;
|
|
1144
|
+
let readerLease: (() => Promise<void>) | undefined;
|
|
1145
|
+
let readerLeaseOwned = true;
|
|
1146
|
+
const releaseReaderLease = async () => {
|
|
1147
|
+
const release = readerLease;
|
|
1148
|
+
readerLease = undefined;
|
|
1149
|
+
await release?.();
|
|
1150
|
+
if (release) await this.reclaimUnreferencedGenerations(key);
|
|
1151
|
+
};
|
|
1152
|
+
try {
|
|
1153
|
+
if (withLease) readerLease = await this.createReaderLease(key);
|
|
1154
|
+
for (let attempt = 0; attempt < OBJECT_RESOLVE_ATTEMPTS; attempt += 1) {
|
|
1155
|
+
let commit: CommitRecord | undefined;
|
|
1156
|
+
try {
|
|
1157
|
+
commit = await this.readCommitRecord(key);
|
|
1158
|
+
} catch (error) {
|
|
1159
|
+
if (
|
|
1160
|
+
(isNotFoundError(error) || isFileIdentityRace(error)) &&
|
|
1161
|
+
attempt + 1 < OBJECT_RESOLVE_ATTEMPTS
|
|
1162
|
+
) {
|
|
1163
|
+
await yieldForFilesystem();
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
throw error;
|
|
1167
|
+
}
|
|
1168
|
+
if (commit) {
|
|
1169
|
+
if (commit.state === "deleted") return null;
|
|
1170
|
+
const generation = commit.generation;
|
|
1171
|
+
const generationLease = withLease
|
|
1172
|
+
? this.acquireGenerationLease(key, generation)
|
|
1173
|
+
: undefined;
|
|
1174
|
+
let bodyHandle: FileHandle | undefined;
|
|
1175
|
+
let metadataHandle: FileHandle | undefined;
|
|
1176
|
+
let released = false;
|
|
1177
|
+
const releaseGenerationLease = async () => {
|
|
1178
|
+
if (released) return;
|
|
1179
|
+
released = true;
|
|
1180
|
+
if (metadataHandle) {
|
|
1181
|
+
await metadataHandle.close().catch(() => undefined);
|
|
1182
|
+
metadataHandle = undefined;
|
|
1183
|
+
}
|
|
1184
|
+
if (bodyHandle) {
|
|
1185
|
+
await bodyHandle.close().catch(() => undefined);
|
|
1186
|
+
bodyHandle = undefined;
|
|
1187
|
+
}
|
|
1188
|
+
await generationLease?.();
|
|
1189
|
+
};
|
|
1190
|
+
const releaseLease = async () => {
|
|
1191
|
+
await releaseGenerationLease();
|
|
1192
|
+
await releaseReaderLease();
|
|
1193
|
+
};
|
|
1194
|
+
try {
|
|
1195
|
+
const filePath = this.getGenerationPath(key, generation, "body");
|
|
1196
|
+
const metaPath = this.getGenerationPath(
|
|
1197
|
+
key,
|
|
1198
|
+
generation,
|
|
1199
|
+
"meta.json",
|
|
1200
|
+
);
|
|
1201
|
+
const resolvedFilePath =
|
|
1202
|
+
await this.resolveExistingInternalPath(filePath);
|
|
1203
|
+
const resolvedMetaPath =
|
|
1204
|
+
await this.resolveExistingInternalPath(metaPath);
|
|
1205
|
+
if (!resolvedFilePath || !resolvedMetaPath) {
|
|
1206
|
+
await releaseGenerationLease();
|
|
1207
|
+
await yieldForFilesystem();
|
|
1208
|
+
continue;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
if (withLease) {
|
|
1212
|
+
// Open both files before releasing the marker read. Once the body
|
|
1213
|
+
// descriptor is open, a concurrent unlink cannot invalidate this
|
|
1214
|
+
// read on POSIX; a missing path simply causes a bounded marker
|
|
1215
|
+
// reread/retry against the winning generation. The owned-open
|
|
1216
|
+
// helper rejects hardlinks/symlinks and reads through the FD,
|
|
1217
|
+
// closing both the identity and path-swap windows.
|
|
1218
|
+
bodyHandle = (await this.openOwnedInternalFile(filePath)).handle;
|
|
1219
|
+
metadataHandle = (await this.openOwnedInternalFile(metaPath))
|
|
1220
|
+
.handle;
|
|
1221
|
+
const metadata =
|
|
1222
|
+
await this.readGenerationMetadataHandle(metadataHandle);
|
|
1223
|
+
readerLeaseOwned = false;
|
|
1224
|
+
return {
|
|
1225
|
+
filePath: resolvedFilePath,
|
|
1226
|
+
bodyHandle,
|
|
1227
|
+
metadata,
|
|
1228
|
+
releaseLease,
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
const openedBody = await this.openOwnedInternalFile(filePath);
|
|
1233
|
+
const openedMeta = await this.openOwnedInternalFile(metaPath);
|
|
1234
|
+
await openedBody.handle.close();
|
|
1235
|
+
await openedMeta.handle.close();
|
|
1236
|
+
const metadata =
|
|
1237
|
+
await this.readGenerationMetadata(resolvedMetaPath);
|
|
1238
|
+
await releaseGenerationLease();
|
|
1239
|
+
return { filePath: resolvedFilePath, metadata };
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
await releaseGenerationLease();
|
|
1242
|
+
if (
|
|
1243
|
+
isNotFoundError(error) &&
|
|
1244
|
+
attempt + 1 < OBJECT_RESOLVE_ATTEMPTS
|
|
1245
|
+
) {
|
|
1246
|
+
await yieldForFilesystem();
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
throw error;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// If the generation namespace exists but its marker was observed as
|
|
1254
|
+
// missing, an atomic rename may be between unlink and replacement.
|
|
1255
|
+
// Do not fall through to the legacy path (and return null) during that
|
|
1256
|
+
// churn; retry the marker read while the internal object directory is
|
|
1257
|
+
// present. A genuinely marker-less legacy object still falls through
|
|
1258
|
+
// after the bounded resolve attempts.
|
|
1259
|
+
if (withLease) {
|
|
1260
|
+
const internalObjectPath = this.getInternalObjectPath(key);
|
|
1261
|
+
if (
|
|
1262
|
+
(await this.resolveExistingInternalPath(internalObjectPath)) &&
|
|
1263
|
+
attempt + 1 < OBJECT_RESOLVE_ATTEMPTS
|
|
1264
|
+
) {
|
|
1265
|
+
await yieldForFilesystem();
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Objects written by older BunStorage versions use the body + metadata
|
|
1271
|
+
// sidecar layout. Keep those reads working until the object is rewritten.
|
|
1272
|
+
const filePath = this.getFilePath(key);
|
|
1273
|
+
const resolvedFilePath = await this.resolveExistingPath(filePath);
|
|
1274
|
+
if (!resolvedFilePath) return null;
|
|
1275
|
+
const file = Bun.file(resolvedFilePath);
|
|
1276
|
+
if (!(await file.exists())) return null;
|
|
1277
|
+
if (withLease) {
|
|
1278
|
+
let bodyHandle: FileHandle | undefined;
|
|
1279
|
+
let metadataHandle: FileHandle | undefined;
|
|
1280
|
+
try {
|
|
1281
|
+
const openedBody = await this.openOwnedRegularFile(
|
|
1282
|
+
resolvedFilePath,
|
|
1283
|
+
await this.getRealBasePath(),
|
|
1284
|
+
);
|
|
1285
|
+
bodyHandle = openedBody.handle;
|
|
1286
|
+
const resolvedMetaPath = await this.resolveExistingPath(
|
|
1287
|
+
this.getMetaPath(key),
|
|
1288
|
+
);
|
|
1289
|
+
let metadata: ResolvedObject["metadata"] = {};
|
|
1290
|
+
if (resolvedMetaPath) {
|
|
1291
|
+
const openedMeta = await this.openOwnedRegularFile(
|
|
1292
|
+
resolvedMetaPath,
|
|
1293
|
+
await this.getRealBasePath(),
|
|
1294
|
+
);
|
|
1295
|
+
metadataHandle = openedMeta.handle;
|
|
1296
|
+
metadata = this.parseGenerationMetadata(
|
|
1297
|
+
await readFileHandleText(metadataHandle),
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
let released = false;
|
|
1301
|
+
const releaseLease = async () => {
|
|
1302
|
+
if (released) return;
|
|
1303
|
+
released = true;
|
|
1304
|
+
if (metadataHandle) {
|
|
1305
|
+
await metadataHandle.close().catch(() => undefined);
|
|
1306
|
+
metadataHandle = undefined;
|
|
1307
|
+
}
|
|
1308
|
+
if (bodyHandle) {
|
|
1309
|
+
await bodyHandle.close().catch(() => undefined);
|
|
1310
|
+
bodyHandle = undefined;
|
|
1311
|
+
}
|
|
1312
|
+
await releaseReaderLease();
|
|
1313
|
+
};
|
|
1314
|
+
readerLeaseOwned = false;
|
|
1315
|
+
return {
|
|
1316
|
+
filePath: resolvedFilePath,
|
|
1317
|
+
bodyHandle,
|
|
1318
|
+
metadata,
|
|
1319
|
+
releaseLease,
|
|
1320
|
+
};
|
|
1321
|
+
} catch (error) {
|
|
1322
|
+
if (metadataHandle)
|
|
1323
|
+
await metadataHandle.close().catch(() => undefined);
|
|
1324
|
+
if (bodyHandle) await bodyHandle.close().catch(() => undefined);
|
|
1325
|
+
if (isNotFoundError(error) && attempt < 2) continue;
|
|
1326
|
+
throw error;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
const resolvedMetaPath = await this.resolveExistingPath(
|
|
1330
|
+
this.getMetaPath(key),
|
|
1331
|
+
);
|
|
1332
|
+
const metadata = resolvedMetaPath
|
|
1333
|
+
? await readMetadata(resolvedMetaPath)
|
|
1334
|
+
: {};
|
|
1335
|
+
return { filePath: resolvedFilePath, metadata };
|
|
1336
|
+
}
|
|
1337
|
+
return null;
|
|
1338
|
+
} finally {
|
|
1339
|
+
releaseKeyRead?.();
|
|
1340
|
+
if (readerLeaseOwned) await releaseReaderLease();
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
private async writeCommitRecord(
|
|
1345
|
+
key: string,
|
|
1346
|
+
record: CommitState,
|
|
1347
|
+
): Promise<void> {
|
|
1348
|
+
const objectPath = this.getInternalObjectPath(key);
|
|
1349
|
+
await assertPathChainWithinBasePath(
|
|
1350
|
+
await this.getRealInternalStorePath(),
|
|
1351
|
+
objectPath,
|
|
1352
|
+
realpath,
|
|
1353
|
+
);
|
|
1354
|
+
await mkdir(objectPath, { recursive: true });
|
|
1355
|
+
await this.assertRealInternalDirectory(objectPath);
|
|
1356
|
+
await this.syncDirectory(path.dirname(objectPath));
|
|
1357
|
+
const markerPath = this.getCommitPath(key);
|
|
1358
|
+
const markerGeneration = crypto.randomUUID();
|
|
1359
|
+
const markerTempPath = path.join(
|
|
1360
|
+
objectPath,
|
|
1361
|
+
`${TEMP_MARKER}${markerGeneration}.commit.json`,
|
|
1362
|
+
);
|
|
1363
|
+
const releaseLease = await this.createTempLease(
|
|
1364
|
+
objectPath,
|
|
1365
|
+
markerGeneration,
|
|
1366
|
+
);
|
|
1367
|
+
const commitPayload: CommitRecord = {
|
|
1368
|
+
...record,
|
|
1369
|
+
key,
|
|
1370
|
+
keyHash: keyHash(key),
|
|
1371
|
+
};
|
|
1372
|
+
let markerHandle: FileHandle | undefined;
|
|
1373
|
+
let markerTempCreated = false;
|
|
1374
|
+
try {
|
|
1375
|
+
markerHandle = await open(markerTempPath, "wx");
|
|
1376
|
+
markerTempCreated = true;
|
|
1377
|
+
await writeBufferFully(
|
|
1378
|
+
markerHandle,
|
|
1379
|
+
new TextEncoder().encode(JSON.stringify(commitPayload)),
|
|
1380
|
+
);
|
|
1381
|
+
await syncAndClose(markerHandle);
|
|
1382
|
+
markerHandle = undefined;
|
|
1383
|
+
await rename(markerTempPath, markerPath);
|
|
1384
|
+
// The marker rename is the publication point. Mark the temporary path
|
|
1385
|
+
// gone immediately so a subsequent directory-fsync error cannot make
|
|
1386
|
+
// finally misclassify the committed marker as unpublished.
|
|
1387
|
+
markerTempCreated = false;
|
|
1388
|
+
await this.syncDirectory(objectPath);
|
|
1389
|
+
} finally {
|
|
1390
|
+
if (markerHandle) await markerHandle.close().catch(() => undefined);
|
|
1391
|
+
if (markerTempCreated) await this.unlinkOwnedInternalFile(markerTempPath);
|
|
1392
|
+
await releaseLease();
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
private async removeGenerationIfUnreferenced(
|
|
1397
|
+
key: string,
|
|
1398
|
+
generation: string | null | undefined,
|
|
1399
|
+
): Promise<void> {
|
|
1400
|
+
if (!generation) {
|
|
1401
|
+
await this.reclaimUnreferencedGenerations(key);
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
let current: CommitRecord | undefined;
|
|
1405
|
+
try {
|
|
1406
|
+
current = await this.readCommitRecord(key);
|
|
1407
|
+
} catch {
|
|
1408
|
+
// A malformed marker is fail-closed; leave bytes for startup recovery
|
|
1409
|
+
// rather than risking removal of a generation we cannot identify.
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
if (!(
|
|
1413
|
+
current?.state === "committed" && current.generation === generation
|
|
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
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
await this.reclaimUnreferencedGenerations(key);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
private async reclaimUnreferencedGenerations(key: string): Promise<void> {
|
|
1441
|
+
let current: CommitRecord | undefined;
|
|
1442
|
+
try {
|
|
1443
|
+
current = await this.readCommitRecord(key);
|
|
1444
|
+
} catch {
|
|
1445
|
+
// A malformed marker is fail-closed; startup recovery will handle it.
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
const retainedGeneration =
|
|
1449
|
+
current?.state === "committed" ? current.generation : null;
|
|
1450
|
+
const objectPath = this.getInternalObjectPath(key);
|
|
1451
|
+
const resolvedObjectPath =
|
|
1452
|
+
await this.resolveExistingInternalPath(objectPath);
|
|
1453
|
+
if (!resolvedObjectPath) return;
|
|
1454
|
+
let entries;
|
|
1455
|
+
try {
|
|
1456
|
+
entries = await readdir(resolvedObjectPath, { withFileTypes: true });
|
|
1457
|
+
} catch {
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
const liveLeaseState = await this.liveTempLeases(
|
|
1461
|
+
resolvedObjectPath,
|
|
1462
|
+
entries,
|
|
1463
|
+
);
|
|
1464
|
+
if (liveLeaseState.reader) return;
|
|
1465
|
+
for (const entry of entries) {
|
|
1466
|
+
if (entry.isDirectory()) continue;
|
|
1467
|
+
const generationMatch = entry.name.match(
|
|
1468
|
+
/^generation-([0-9a-f-]{16,})\.(?:body|meta\.json)$/u,
|
|
1469
|
+
);
|
|
1470
|
+
if (!generationMatch) continue;
|
|
1471
|
+
const generation = generationMatch[1]!;
|
|
1472
|
+
if (generation === retainedGeneration) continue;
|
|
1473
|
+
const activeKey = `${keyHash(key)}:${generation}`;
|
|
1474
|
+
// A writer may have renamed one or both generation files while its
|
|
1475
|
+
// commit marker is still pending. Preserve it until that writer has
|
|
1476
|
+
// either committed or cleaned up its temporary files.
|
|
1477
|
+
if (
|
|
1478
|
+
this.activeGenerations.has(activeKey) ||
|
|
1479
|
+
this.hasGenerationLease(key, generation) ||
|
|
1480
|
+
this.hasKeyReadReservation(key) ||
|
|
1481
|
+
liveLeaseState.generations.has(generation)
|
|
1482
|
+
) {
|
|
1483
|
+
continue;
|
|
1484
|
+
}
|
|
1485
|
+
await this.unlinkOwnedInternalFile(
|
|
1486
|
+
path.join(resolvedObjectPath, entry.name),
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
// A crashed writer can leave body/metadata/marker temp files after its
|
|
1490
|
+
// lease expires. Keep only temps belonging to an active/live generation;
|
|
1491
|
+
// stale artifacts must be reclaimed during steady-state GC as well as
|
|
1492
|
+
// startup recovery.
|
|
1493
|
+
for (const entry of entries) {
|
|
1494
|
+
if (entry.isDirectory() || !entry.name.startsWith(TEMP_MARKER)) {
|
|
1495
|
+
continue;
|
|
1496
|
+
}
|
|
1497
|
+
if (entry.name.endsWith(`.${LEASE_SUFFIX}`)) continue;
|
|
1498
|
+
const tempGeneration = entry.name.match(/^tmp-([0-9a-f-]{16,})\./u)?.[1];
|
|
1499
|
+
if (
|
|
1500
|
+
tempGeneration &&
|
|
1501
|
+
(this.activeGenerations.has(`${keyHash(key)}:${tempGeneration}`) ||
|
|
1502
|
+
this.hasGenerationLease(key, tempGeneration) ||
|
|
1503
|
+
this.hasKeyReadReservation(key) ||
|
|
1504
|
+
liveLeaseState.generations.has(tempGeneration))
|
|
1505
|
+
) {
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
await this.unlinkOwnedInternalFile(
|
|
1509
|
+
path.join(resolvedObjectPath, entry.name),
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
263
1514
|
async put(
|
|
264
1515
|
key: string,
|
|
265
|
-
value: ReadableStream | ArrayBuffer | string,
|
|
1516
|
+
value: Blob | ReadableStream | ArrayBuffer | string,
|
|
266
1517
|
options?: {
|
|
267
1518
|
httpMetadata?: ObjectMetadata["httpMetadata"];
|
|
268
1519
|
customMetadata?: Record<string, string>;
|
|
269
1520
|
},
|
|
270
1521
|
): Promise<void> {
|
|
1522
|
+
// Serialize before touching the filesystem. A metadata-shape failure must
|
|
1523
|
+
// leave the currently committed generation completely untouched.
|
|
1524
|
+
const metadataPayload = JSON.stringify({
|
|
1525
|
+
httpMetadata: options?.httpMetadata,
|
|
1526
|
+
customMetadata: options?.customMetadata,
|
|
1527
|
+
});
|
|
271
1528
|
const filePath = this.getFilePath(key);
|
|
272
|
-
const dir = path.dirname(filePath);
|
|
273
1529
|
|
|
274
1530
|
await assertPathChainWithinBasePath(
|
|
275
1531
|
await this.getRealBasePath(),
|
|
@@ -277,56 +1533,149 @@ export class BunStorage implements IObjectStorage {
|
|
|
277
1533
|
realpath,
|
|
278
1534
|
);
|
|
279
1535
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
1536
|
+
const internalObjectPath = this.getInternalObjectPath(key);
|
|
1537
|
+
await assertPathChainWithinBasePath(
|
|
1538
|
+
await this.getRealInternalStorePath(),
|
|
1539
|
+
internalObjectPath,
|
|
1540
|
+
realpath,
|
|
1541
|
+
);
|
|
1542
|
+
await mkdir(internalObjectPath, { recursive: true });
|
|
1543
|
+
await this.assertRealInternalDirectory(internalObjectPath);
|
|
1544
|
+
await this.syncDirectory(path.dirname(internalObjectPath));
|
|
1545
|
+
|
|
1546
|
+
const previousCommit = await this.readCommitRecord(key);
|
|
1547
|
+
const previousGeneration =
|
|
1548
|
+
previousCommit?.state === "committed"
|
|
1549
|
+
? previousCommit.generation
|
|
1550
|
+
: undefined;
|
|
1551
|
+
|
|
1552
|
+
// Body and metadata are prepared as one generation. Readers only follow
|
|
1553
|
+
// the commit marker, which is atomically replaced after both files are
|
|
1554
|
+
// complete and synced. This prevents bytes from one writer pairing with
|
|
1555
|
+
// metadata from another writer.
|
|
1556
|
+
const generation = crypto.randomUUID();
|
|
1557
|
+
const generationPath = this.getGenerationPath(key, generation, "body");
|
|
1558
|
+
const generationMetaPath = this.getGenerationPath(
|
|
1559
|
+
key,
|
|
1560
|
+
generation,
|
|
1561
|
+
"meta.json",
|
|
1562
|
+
);
|
|
1563
|
+
const commitPath = this.getCommitPath(key);
|
|
1564
|
+
const bodyTempPath = path.join(
|
|
1565
|
+
internalObjectPath,
|
|
1566
|
+
`${TEMP_MARKER}${generation}.body`,
|
|
1567
|
+
);
|
|
1568
|
+
const metadataTempPath = path.join(
|
|
1569
|
+
internalObjectPath,
|
|
1570
|
+
`${TEMP_MARKER}${generation}.meta.json`,
|
|
1571
|
+
);
|
|
1572
|
+
const commitTempPath = path.join(
|
|
1573
|
+
internalObjectPath,
|
|
1574
|
+
`${TEMP_MARKER}${generation}.commit.json`,
|
|
1575
|
+
);
|
|
1576
|
+
const activeGenerationKey = `${keyHash(key)}:${generation}`;
|
|
1577
|
+
this.activeGenerations.add(activeGenerationKey);
|
|
1578
|
+
let releaseLease: (() => Promise<void>) | undefined;
|
|
1579
|
+
let bodyHandle: FileHandle | undefined;
|
|
1580
|
+
let metadataHandle: FileHandle | undefined;
|
|
1581
|
+
let commitHandle: FileHandle | undefined;
|
|
1582
|
+
let bodyTempCreated = false;
|
|
1583
|
+
let metadataTempCreated = false;
|
|
1584
|
+
let commitTempCreated = false;
|
|
1585
|
+
let bodyGenerationCreated = false;
|
|
1586
|
+
let metadataGenerationCreated = false;
|
|
1587
|
+
let commitPublished = false;
|
|
284
1588
|
try {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
1589
|
+
releaseLease = await this.createTempLease(internalObjectPath, generation);
|
|
1590
|
+
bodyHandle = await open(bodyTempPath, "wx");
|
|
1591
|
+
bodyTempCreated = true;
|
|
1592
|
+
await writeValueToFile(bodyHandle, value);
|
|
1593
|
+
await syncAndClose(bodyHandle);
|
|
1594
|
+
bodyHandle = undefined;
|
|
1595
|
+
await rename(bodyTempPath, generationPath);
|
|
1596
|
+
await this.syncDirectory(internalObjectPath);
|
|
1597
|
+
bodyTempCreated = false;
|
|
1598
|
+
bodyGenerationCreated = true;
|
|
1599
|
+
|
|
1600
|
+
metadataHandle = await open(metadataTempPath, "wx");
|
|
1601
|
+
metadataTempCreated = true;
|
|
1602
|
+
await writeBufferFully(
|
|
1603
|
+
metadataHandle,
|
|
1604
|
+
new TextEncoder().encode(metadataPayload),
|
|
1605
|
+
);
|
|
1606
|
+
await syncAndClose(metadataHandle);
|
|
1607
|
+
metadataHandle = undefined;
|
|
1608
|
+
await rename(metadataTempPath, generationMetaPath);
|
|
1609
|
+
await this.syncDirectory(internalObjectPath);
|
|
1610
|
+
metadataTempCreated = false;
|
|
1611
|
+
metadataGenerationCreated = true;
|
|
1612
|
+
|
|
1613
|
+
const commitPayload: CommitRecord = {
|
|
1614
|
+
version: 1,
|
|
1615
|
+
key,
|
|
1616
|
+
keyHash: keyHash(key),
|
|
1617
|
+
state: "committed",
|
|
1618
|
+
generation,
|
|
1619
|
+
};
|
|
1620
|
+
commitHandle = await open(commitTempPath, "wx");
|
|
1621
|
+
commitTempCreated = true;
|
|
1622
|
+
await writeBufferFully(
|
|
1623
|
+
commitHandle,
|
|
1624
|
+
new TextEncoder().encode(JSON.stringify(commitPayload)),
|
|
1625
|
+
);
|
|
1626
|
+
await syncAndClose(commitHandle);
|
|
1627
|
+
commitHandle = undefined;
|
|
1628
|
+
await rename(commitTempPath, commitPath);
|
|
1629
|
+
// Rename makes the complete body+metadata generation authoritative.
|
|
1630
|
+
// Set both flags before fsync: if syncing the containing directory
|
|
1631
|
+
// fails, the marker still names a valid committed generation and must
|
|
1632
|
+
// never be deleted by the cleanup path below.
|
|
1633
|
+
commitTempCreated = false;
|
|
1634
|
+
commitPublished = true;
|
|
1635
|
+
await this.syncDirectory(internalObjectPath);
|
|
1636
|
+
} finally {
|
|
1637
|
+
if (bodyHandle) await bodyHandle.close().catch(() => undefined);
|
|
1638
|
+
if (metadataHandle) await metadataHandle.close().catch(() => undefined);
|
|
1639
|
+
if (commitHandle) await commitHandle.close().catch(() => undefined);
|
|
1640
|
+
if (!commitPublished) {
|
|
1641
|
+
if (bodyTempCreated) await this.unlinkOwnedInternalFile(bodyTempPath);
|
|
1642
|
+
if (metadataTempCreated)
|
|
1643
|
+
await this.unlinkOwnedInternalFile(metadataTempPath);
|
|
1644
|
+
if (commitTempCreated)
|
|
1645
|
+
await this.unlinkOwnedInternalFile(commitTempPath);
|
|
1646
|
+
if (bodyGenerationCreated)
|
|
1647
|
+
await this.unlinkOwnedInternalFile(generationPath);
|
|
1648
|
+
if (metadataGenerationCreated)
|
|
1649
|
+
await this.unlinkOwnedInternalFile(generationMetaPath);
|
|
297
1650
|
}
|
|
1651
|
+
this.activeGenerations.delete(activeGenerationKey);
|
|
1652
|
+
await releaseLease?.();
|
|
298
1653
|
}
|
|
299
1654
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if (
|
|
304
|
-
await
|
|
305
|
-
this.getMetaPath(key),
|
|
306
|
-
JSON.stringify({
|
|
307
|
-
httpMetadata: options.httpMetadata,
|
|
308
|
-
customMetadata: options.customMetadata,
|
|
309
|
-
}),
|
|
310
|
-
);
|
|
1655
|
+
// Legacy body/sidecar files are no longer needed once the generation is
|
|
1656
|
+
// committed. Removing them is best-effort; the marker remains the source
|
|
1657
|
+
// of truth even if an operator-owned filesystem refuses cleanup.
|
|
1658
|
+
if (!this.isInternalStorePath(filePath)) {
|
|
1659
|
+
await unlink(filePath).catch(() => undefined);
|
|
311
1660
|
}
|
|
1661
|
+
const legacyMetaPath = this.getMetaPath(key);
|
|
1662
|
+
if (!this.isInternalStorePath(legacyMetaPath)) {
|
|
1663
|
+
await unlink(legacyMetaPath).catch(() => undefined);
|
|
1664
|
+
}
|
|
1665
|
+
await this.removeGenerationIfUnreferenced(key, previousGeneration);
|
|
312
1666
|
}
|
|
313
1667
|
|
|
314
1668
|
async get(key: string): Promise<StorageObject | null> {
|
|
315
|
-
|
|
316
|
-
|
|
1669
|
+
let releaseLease: (() => Promise<void>) | undefined;
|
|
317
1670
|
try {
|
|
318
|
-
const
|
|
319
|
-
if (!
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
);
|
|
327
|
-
const metadata = resolvedMetaPath
|
|
328
|
-
? await readMetadata(resolvedMetaPath)
|
|
329
|
-
: {};
|
|
1671
|
+
const resolvedObject = await this.resolveObject(key, true);
|
|
1672
|
+
if (!resolvedObject) return null;
|
|
1673
|
+
releaseLease = resolvedObject.releaseLease;
|
|
1674
|
+
|
|
1675
|
+
const content = resolvedObject.bodyHandle
|
|
1676
|
+
? await readFileHandleFully(resolvedObject.bodyHandle)
|
|
1677
|
+
: new Uint8Array(await Bun.file(resolvedObject.filePath).arrayBuffer());
|
|
1678
|
+
const metadata = resolvedObject.metadata;
|
|
330
1679
|
|
|
331
1680
|
let bodyUsed = false;
|
|
332
1681
|
|
|
@@ -356,24 +1705,82 @@ export class BunStorage implements IObjectStorage {
|
|
|
356
1705
|
};
|
|
357
1706
|
} catch {
|
|
358
1707
|
return null;
|
|
1708
|
+
} finally {
|
|
1709
|
+
await releaseLease?.();
|
|
359
1710
|
}
|
|
360
1711
|
}
|
|
361
1712
|
|
|
362
1713
|
async delete(key: string | string[]): Promise<void> {
|
|
363
1714
|
const keys = Array.isArray(key) ? key : [key];
|
|
364
1715
|
for (const k of keys) {
|
|
1716
|
+
let markerPath: string;
|
|
1717
|
+
let legacyPath: string;
|
|
1718
|
+
let legacyMetaPath: string;
|
|
1719
|
+
try {
|
|
1720
|
+
markerPath = this.getCommitPath(k);
|
|
1721
|
+
legacyPath = this.getFilePath(k);
|
|
1722
|
+
legacyMetaPath = this.getMetaPath(k);
|
|
1723
|
+
} catch {
|
|
1724
|
+
// Preserve the historical delete contract: an invalid/traversal key
|
|
1725
|
+
// is ignored rather than becoming an observable filesystem error.
|
|
1726
|
+
continue;
|
|
1727
|
+
}
|
|
1728
|
+
try {
|
|
1729
|
+
await assertPathChainWithinBasePath(
|
|
1730
|
+
await this.getRealInternalStorePath(),
|
|
1731
|
+
markerPath,
|
|
1732
|
+
realpath,
|
|
1733
|
+
);
|
|
1734
|
+
} catch {
|
|
1735
|
+
// Do not create a tombstone through a symlinked path outside storage.
|
|
1736
|
+
continue;
|
|
1737
|
+
}
|
|
1738
|
+
const markerExists = await Bun.file(markerPath).exists();
|
|
1739
|
+
const legacyExists =
|
|
1740
|
+
!this.isInternalStorePath(legacyPath) &&
|
|
1741
|
+
(await Bun.file(legacyPath).exists());
|
|
1742
|
+
const legacyMetaExists =
|
|
1743
|
+
!this.isInternalStorePath(legacyMetaPath) &&
|
|
1744
|
+
(await Bun.file(legacyMetaPath).exists());
|
|
1745
|
+
if (!markerExists && !legacyExists && !legacyMetaExists) continue;
|
|
1746
|
+
|
|
1747
|
+
let previousGeneration: string | undefined;
|
|
365
1748
|
try {
|
|
366
|
-
const
|
|
367
|
-
|
|
1749
|
+
const previousCommit = await this.readCommitRecord(k);
|
|
1750
|
+
previousGeneration =
|
|
1751
|
+
previousCommit?.state === "committed"
|
|
1752
|
+
? previousCommit.generation
|
|
1753
|
+
: undefined;
|
|
1754
|
+
} catch {
|
|
1755
|
+
// A malformed marker is still replaced with a tombstone below; no
|
|
1756
|
+
// generation is trusted for eager cleanup in that case.
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// Publish a tombstone before removing legacy files. If cleanup is
|
|
1760
|
+
// interrupted, the tombstone still prevents a stale legacy body from
|
|
1761
|
+
// resurfacing through the compatibility read path.
|
|
1762
|
+
await this.writeCommitRecord(k, {
|
|
1763
|
+
version: 1,
|
|
1764
|
+
state: "deleted",
|
|
1765
|
+
generation: null,
|
|
1766
|
+
});
|
|
1767
|
+
try {
|
|
1768
|
+
if (!this.isInternalStorePath(legacyPath)) {
|
|
1769
|
+
const filePath = await this.resolveExistingPath(legacyPath);
|
|
1770
|
+
if (filePath) await unlink(filePath);
|
|
1771
|
+
}
|
|
368
1772
|
} catch {
|
|
369
1773
|
/* ignore */
|
|
370
1774
|
}
|
|
371
1775
|
try {
|
|
372
|
-
|
|
373
|
-
|
|
1776
|
+
if (!this.isInternalStorePath(legacyMetaPath)) {
|
|
1777
|
+
const metaPath = await this.resolveExistingPath(legacyMetaPath);
|
|
1778
|
+
if (metaPath) await unlink(metaPath);
|
|
1779
|
+
}
|
|
374
1780
|
} catch {
|
|
375
1781
|
/* ignore */
|
|
376
1782
|
}
|
|
1783
|
+
await this.removeGenerationIfUnreferenced(k, previousGeneration);
|
|
377
1784
|
}
|
|
378
1785
|
}
|
|
379
1786
|
|
|
@@ -385,6 +1792,9 @@ export class BunStorage implements IObjectStorage {
|
|
|
385
1792
|
}): Promise<ListObjectsResult> {
|
|
386
1793
|
const objects: ListObjectsResult["objects"] = [];
|
|
387
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 }> = [];
|
|
388
1798
|
|
|
389
1799
|
const readDirRecursive = async (dir: string, prefix: string = "") => {
|
|
390
1800
|
try {
|
|
@@ -397,15 +1807,8 @@ export class BunStorage implements IObjectStorage {
|
|
|
397
1807
|
|
|
398
1808
|
if (entry.isDirectory()) {
|
|
399
1809
|
await readDirRecursive(fullPath, key);
|
|
400
|
-
} else if (!entry.name
|
|
401
|
-
|
|
402
|
-
const stats = await stat(fullPath);
|
|
403
|
-
objects.push({
|
|
404
|
-
key,
|
|
405
|
-
size: stats.size,
|
|
406
|
-
uploaded: stats.mtime,
|
|
407
|
-
});
|
|
408
|
-
}
|
|
1810
|
+
} else if (!isInternalStorageName(entry.name)) {
|
|
1811
|
+
legacy.push({ key, filePath: fullPath });
|
|
409
1812
|
}
|
|
410
1813
|
}
|
|
411
1814
|
} catch {
|
|
@@ -415,6 +1818,93 @@ export class BunStorage implements IObjectStorage {
|
|
|
415
1818
|
|
|
416
1819
|
await readDirRecursive(realBasePath);
|
|
417
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
|
+
|
|
418
1908
|
const limit = options?.limit ?? 1000;
|
|
419
1909
|
const truncated = objects.length > limit;
|
|
420
1910
|
|
|
@@ -426,27 +1916,23 @@ export class BunStorage implements IObjectStorage {
|
|
|
426
1916
|
}
|
|
427
1917
|
|
|
428
1918
|
async head(key: string): Promise<ObjectMetadata | null> {
|
|
429
|
-
|
|
430
|
-
|
|
1919
|
+
let releaseLease: (() => Promise<void>) | undefined;
|
|
431
1920
|
try {
|
|
432
|
-
const
|
|
433
|
-
if (!
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
const resolvedMetaPath = await this.resolveExistingPath(
|
|
438
|
-
this.getMetaPath(key),
|
|
439
|
-
);
|
|
440
|
-
const metadata = resolvedMetaPath
|
|
441
|
-
? await readMetadata(resolvedMetaPath)
|
|
442
|
-
: {};
|
|
1921
|
+
const resolvedObject = await this.resolveObject(key, true);
|
|
1922
|
+
if (!resolvedObject) return null;
|
|
1923
|
+
releaseLease = resolvedObject.releaseLease;
|
|
1924
|
+
const metadata = resolvedObject.metadata;
|
|
443
1925
|
return {
|
|
444
|
-
contentLength:
|
|
1926
|
+
contentLength: resolvedObject.bodyHandle
|
|
1927
|
+
? (await resolvedObject.bodyHandle.stat()).size
|
|
1928
|
+
: Bun.file(resolvedObject.filePath).size,
|
|
445
1929
|
httpMetadata: metadata.httpMetadata,
|
|
446
1930
|
customMetadata: metadata.customMetadata,
|
|
447
1931
|
};
|
|
448
1932
|
} catch {
|
|
449
1933
|
return null;
|
|
1934
|
+
} finally {
|
|
1935
|
+
await releaseLease?.();
|
|
450
1936
|
}
|
|
451
1937
|
}
|
|
452
1938
|
}
|