@augmentd-labs/canvas-edge 0.6.3 → 0.7.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/node_modules/canvas-stored/README.md +189 -9
- package/node_modules/canvas-stored/package.json +2 -2
- package/node_modules/canvas-stored/src/backends/BackendManager.js +11 -1
- package/node_modules/canvas-stored/src/backends/StorageBackend.js +24 -12
- package/node_modules/canvas-stored/src/backends/cacache/index.js +49 -6
- package/node_modules/canvas-stored/src/backends/canvas/index.js +41 -4
- package/node_modules/canvas-stored/src/backends/file/index.js +181 -39
- package/node_modules/canvas-stored/src/backends/gdrive/index.js +57 -10
- package/node_modules/canvas-stored/src/backends/http/index.js +3 -1
- package/node_modules/canvas-stored/src/backends/s3/index.js +244 -15
- package/node_modules/canvas-stored/src/cache/collect-garbage.js +47 -0
- package/node_modules/canvas-stored/src/cache/compact-index.js +13 -0
- package/node_modules/canvas-stored/src/cache/index.js +39 -5
- package/node_modules/canvas-stored/src/cache/ingest-stream.js +65 -0
- package/node_modules/canvas-stored/src/index/index.js +5 -3
- package/node_modules/canvas-stored/src/index.js +733 -132
- package/node_modules/canvas-stored/src/runtime/MutationQueue.js +60 -0
- package/node_modules/canvas-stored/src/storage/lmdb.js +62 -0
- package/node_modules/canvas-stored/src/storage/recover-index.js +33 -0
- package/node_modules/canvas-stored/src/storage/verify-worker.js +23 -0
- package/node_modules/canvas-stored/src/sync/Ledger.js +21 -3
- package/node_modules/canvas-stored/src/sync/Mirror.js +334 -25
- package/node_modules/canvas-stored/src/sync/REPLICAS.md +64 -0
- package/node_modules/canvas-stored/src/sync/ReplicaService.js +623 -0
- package/node_modules/canvas-stored/src/sync/SyncQueue.js +23 -103
- package/node_modules/canvas-stored/src/trash.js +219 -0
- package/node_modules/canvas-stored/src/utils/common.js +7 -0
- package/package.json +5 -4
- package/src/binding.js +33 -0
- package/src/control.js +13 -0
- package/src/daemon.js +14 -1
- package/src/mirror-runtime.js +14 -3
|
@@ -1,25 +1,254 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createReadStream, promises as fs } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { S3Client, ListObjectsV2Command, HeadObjectCommand, GetObjectCommand, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
|
2
4
|
import StorageBackend from '../StorageBackend.js';
|
|
5
|
+
import { checksumBuffer, checksumFile, checksumStream } from '../../utils/checksum.js';
|
|
6
|
+
import { detectMimeFromHead } from '../../utils/mime.js';
|
|
3
7
|
|
|
4
|
-
const
|
|
8
|
+
const MAX_PUT = 5 * 1024 ** 3;
|
|
9
|
+
const PROBES = '.canvas-probe/';
|
|
10
|
+
const fault = (code, message, retryable = false) => Object.assign(new Error(message), { code, retryable });
|
|
11
|
+
const status = error => error?.$metadata?.httpStatusCode;
|
|
12
|
+
const missing = error => status(error) === 404 && !['NoSuchBucket', 'NotFoundBucket'].includes(error.name);
|
|
13
|
+
const bodyBuffer = async body => {
|
|
14
|
+
if (Buffer.isBuffer(body)) return body;
|
|
15
|
+
const chunks = [];
|
|
16
|
+
for await (const chunk of body) chunks.push(Buffer.from(chunk));
|
|
17
|
+
return Buffer.concat(chunks);
|
|
18
|
+
};
|
|
5
19
|
|
|
6
|
-
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Registered so `stored://s3:<account>/<key>` URLs parse and dispatch, but the
|
|
10
|
-
* CRUD methods are not yet implemented (they inherit `Not implemented` throws
|
|
11
|
-
* from StorageBackend). Wire up an S3 client (`@aws-sdk/client-s3`) in a
|
|
12
|
-
* follow-up: `config` carries { bucket, region, account, prefix?, credentials? }.
|
|
13
|
-
*/
|
|
20
|
+
/** One existing bucket/prefix. Creates are conditional; destructive policy
|
|
21
|
+
* capabilities stay disabled. ETags/version IDs are observation tokens, never
|
|
22
|
+
* content checksums or stable inodes. No implicit host IAM credentials. */
|
|
14
23
|
export default class S3Backend extends StorageBackend {
|
|
24
|
+
#client; #bucket; #prefix; #timer; #polling; #rows = new Map(); #hashes = new Map(); #writeCheck;
|
|
15
25
|
constructor(name, config = {}) {
|
|
16
26
|
super(name, config);
|
|
17
27
|
this.type = 'remote';
|
|
18
|
-
|
|
28
|
+
if (!config.bucket || /[\s/\\]/.test(config.bucket)) throw fault('INVALID_CONFIG', 'S3 bucket is required and cannot contain slashes or whitespace');
|
|
29
|
+
this.#bucket = String(config.bucket);
|
|
30
|
+
this.#prefix = String(config.prefix || '').replace(/^\/+|\/+$/g, '');
|
|
31
|
+
if (this.#prefix && !this.#safe(this.#prefix)) throw fault('INVALID_CONFIG', 'Invalid S3 prefix');
|
|
32
|
+
if (this.#prefix) this.#prefix += '/';
|
|
33
|
+
let endpoint;
|
|
34
|
+
if (config.endpoint) {
|
|
35
|
+
endpoint = new URL(config.endpoint);
|
|
36
|
+
if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) throw fault('INVALID_CONFIG', 'S3 endpoint must be an HTTP(S) URL without credentials, query or fragment');
|
|
37
|
+
}
|
|
38
|
+
const credentials = config.credentials || (config.accessKeyId && config.secretAccessKey ? {
|
|
39
|
+
accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey,
|
|
40
|
+
...(config.sessionToken ? { sessionToken: config.sessionToken } : {}),
|
|
41
|
+
} : null);
|
|
42
|
+
if (!credentials && !config.client) throw fault('INVALID_CONFIG', 'S3 accessKeyId and secretAccessKey are required');
|
|
43
|
+
this.#client = config.client || new S3Client({
|
|
44
|
+
region: config.region || 'us-east-1', endpoint: endpoint?.href,
|
|
45
|
+
forcePathStyle: config.forcePathStyle ?? Boolean(endpoint), credentials,
|
|
46
|
+
requestChecksumCalculation: 'WHEN_REQUIRED', responseChecksumValidation: 'WHEN_REQUIRED',
|
|
47
|
+
requestHandler: { connectionTimeout: 10_000, requestTimeout: 120_000 }, maxAttempts: 3,
|
|
48
|
+
});
|
|
19
49
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
50
|
+
static getCapabilities(config = {}) {
|
|
51
|
+
return { ...super.getCapabilities(config), delete: config.readOnly !== true && config.permanentDelete === true, canEnumerate: true, remote: true, paths: true, upload: config.readOnly !== true };
|
|
52
|
+
}
|
|
53
|
+
get transport() { return 's3'; }
|
|
54
|
+
get watching() { return Boolean(this.#timer); }
|
|
55
|
+
#safe(key) { return ![...key].some(c => c < ' ' || c === '\\') && !key.split('/').some(s => !s || s === '.' || s === '..'); }
|
|
56
|
+
#key(key) {
|
|
57
|
+
if (typeof key !== 'string' || !this.#safe(key) || key.startsWith(PROBES)) throw fault('INVALID_KEY', 'Invalid or reserved S3 object key');
|
|
58
|
+
return this.#prefix + key;
|
|
59
|
+
}
|
|
60
|
+
nativeUrl(key) { return `s3://${this.#bucket}/${this.#key(key).split('/').map(encodeURIComponent).join('/')}`; }
|
|
61
|
+
async #send(Command, input) {
|
|
62
|
+
try { return await this.#client.send(new Command({ Bucket: this.#bucket, ...input })); }
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (status(error) === 412) throw fault('EEXIST', 'S3 precondition failed: destination exists or source changed');
|
|
65
|
+
if (status(error) === 409 || status(error) >= 500 || ['SlowDown', 'RequestTimeout', 'TimeoutError'].includes(error.name)) error.retryable = true;
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async verifyRoot() {
|
|
70
|
+
try {
|
|
71
|
+
// Prefix-scoped permissions need not permit HeadBucket.
|
|
72
|
+
await this.#send(ListObjectsV2Command, { Prefix: this.#prefix, MaxKeys: 1 });
|
|
73
|
+
return { ok: true, fsid: null };
|
|
74
|
+
} catch (error) { return { ok: false, reason: error.name || error.code || 'unreachable', error: error.message, fsid: null }; }
|
|
75
|
+
}
|
|
76
|
+
/** Some endpoints ignore conditional headers. Probe a unique private key;
|
|
77
|
+
* never fall back to a racy HEAD followed by an unconditional user write. */
|
|
78
|
+
async verifyWrites() {
|
|
79
|
+
if (!this.canWrite) throw fault('READ_ONLY', 'S3 backend is read-only');
|
|
80
|
+
if (!this.#writeCheck) this.#writeCheck = this.#probeWrites().catch(error => { this.#writeCheck = null; throw error; });
|
|
81
|
+
return this.#writeCheck;
|
|
82
|
+
}
|
|
83
|
+
async #probeWrites() {
|
|
84
|
+
const Key = this.#prefix + PROBES + randomUUID();
|
|
85
|
+
const versions = new Set();
|
|
86
|
+
try {
|
|
87
|
+
const first = await this.#send(PutObjectCommand, { Key, Body: Buffer.from('canvas-probe'), IfNoneMatch: '*' });
|
|
88
|
+
versions.add(first.VersionId);
|
|
89
|
+
let rejected = false;
|
|
90
|
+
try {
|
|
91
|
+
const second = await this.#send(PutObjectCommand, { Key, Body: Buffer.from('must-not-replace'), IfNoneMatch: '*' });
|
|
92
|
+
versions.add(second.VersionId);
|
|
93
|
+
} catch (error) { if (error.code === 'EEXIST') rejected = true; else throw error; }
|
|
94
|
+
const found = await this.#send(GetObjectCommand, { Key });
|
|
95
|
+
const bytes = await bodyBuffer(found.Body);
|
|
96
|
+
if (!rejected || bytes.toString() !== 'canvas-probe') throw fault('CONDITIONAL_WRITES_UNSUPPORTED', 'S3 endpoint does not enforce If-None-Match; user-key writes are disabled');
|
|
97
|
+
return { ok: true, conditionalCreate: true };
|
|
98
|
+
} finally {
|
|
99
|
+
// Exact random keys only, including any versions created by a
|
|
100
|
+
// nonconforming endpoint; never sweep a user prefix.
|
|
101
|
+
if (!versions.size) versions.add(undefined);
|
|
102
|
+
for (const VersionId of versions) await this.#send(DeleteObjectCommand, { Key, ...(VersionId ? { VersionId } : {}) });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
#row(key, result) {
|
|
106
|
+
return { backend: this.name, key, size: Number(result.ContentLength ?? result.Size ?? 0),
|
|
107
|
+
modified: result.LastModified?.getTime() ?? null, mimeType: result.ContentType || 'application/octet-stream',
|
|
108
|
+
id: JSON.stringify([result.ETag || null, result.VersionId || null]), etag: result.ETag, versionId: result.VersionId };
|
|
109
|
+
}
|
|
110
|
+
async stat(key) {
|
|
111
|
+
try { return this.#row(key, await this.#send(HeadObjectCommand, { Key: this.#key(key) })); }
|
|
112
|
+
catch (error) { if (missing(error)) return null; throw error; }
|
|
113
|
+
}
|
|
114
|
+
async get(key, { stream = false, ifMatch, versionId } = {}) {
|
|
115
|
+
try {
|
|
116
|
+
const result = await this.#send(GetObjectCommand, { Key: this.#key(key), ...(ifMatch ? { IfMatch: ifMatch } : {}), ...(versionId ? { VersionId: versionId } : {}) });
|
|
117
|
+
return stream ? result.Body : bodyBuffer(result.Body);
|
|
118
|
+
} catch (error) { if (missing(error)) return null; throw error; }
|
|
119
|
+
}
|
|
120
|
+
async getRange(key, { start = 0, end } = {}) {
|
|
121
|
+
if (!Number.isSafeInteger(start) || start < 0 || (end != null && (!Number.isSafeInteger(end) || end < start))) throw fault('INVALID_RANGE', 'Invalid byte range');
|
|
122
|
+
try { return (await this.#send(GetObjectCommand, { Key: this.#key(key), Range: `bytes=${start}-${end ?? ''}` })).Body; }
|
|
123
|
+
catch (error) { if (missing(error)) return null; throw error; }
|
|
124
|
+
}
|
|
125
|
+
async put(key, data) {
|
|
126
|
+
if (!Buffer.isBuffer(data) && typeof data !== 'string') throw fault('SIZE_REQUIRED', 'Use createFrom for streamed S3 uploads with a known size');
|
|
127
|
+
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
128
|
+
return this.#create(key, buffer, buffer.length, checksumBuffer(buffer, ['md5']), await detectMimeFromHead(buffer.subarray(0, 4096), key));
|
|
129
|
+
}
|
|
130
|
+
async commit(key, file) { return this.createFrom(key, file); }
|
|
131
|
+
async createFrom(key, file) {
|
|
132
|
+
const { size } = await fs.stat(file);
|
|
133
|
+
if (size > MAX_PUT) throw fault('OBJECT_TOO_LARGE', 'S3 uploads currently support objects up to 5 GiB; multipart upload is not enabled');
|
|
134
|
+
const handle = await fs.open(file, 'r');
|
|
135
|
+
let head;
|
|
136
|
+
try {
|
|
137
|
+
const buffer = Buffer.alloc(4096);
|
|
138
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
139
|
+
head = buffer.subarray(0, bytesRead);
|
|
140
|
+
} finally { await handle.close(); }
|
|
141
|
+
const sums = await checksumFile(file, ['md5']);
|
|
142
|
+
const input = createReadStream(file);
|
|
143
|
+
try { return await this.#create(key, input, size, sums, await detectMimeFromHead(head, key)); }
|
|
144
|
+
finally { input.destroy(); }
|
|
145
|
+
}
|
|
146
|
+
async #create(key, Body, ContentLength, checksums, ContentType) {
|
|
147
|
+
const Key = this.#key(key);
|
|
148
|
+
if (ContentLength > MAX_PUT) throw fault('OBJECT_TOO_LARGE', 'S3 uploads currently support objects up to 5 GiB');
|
|
149
|
+
await this.verifyWrites();
|
|
150
|
+
await this.#send(PutObjectCommand, { Key, Body, ContentLength, ContentType, ContentMD5: Buffer.from(checksums.md5, 'hex').toString('base64'), IfNoneMatch: '*' });
|
|
151
|
+
this.#hashes.delete(key);
|
|
152
|
+
return this.stat(key);
|
|
153
|
+
}
|
|
154
|
+
async delete(key) {
|
|
155
|
+
if (!this.canDelete) throw fault('RECOVERY_UNAVAILABLE', 'S3 removal requires explicit permanentDelete; recoverable mirror removal is not implemented');
|
|
156
|
+
await this.#send(DeleteObjectCommand, { Key: this.#key(key) });
|
|
157
|
+
this.#hashes.delete(key);
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
async *list({ prefix = '' } = {}) {
|
|
161
|
+
if (prefix && !this.#safe(prefix.replace(/\/$/, ''))) throw fault('INVALID_KEY', 'Invalid list prefix');
|
|
162
|
+
let token;
|
|
163
|
+
const seen = new Set();
|
|
164
|
+
do {
|
|
165
|
+
const result = await this.#send(ListObjectsV2Command, { Prefix: this.#prefix + prefix, ContinuationToken: token, EncodingType: 'url' });
|
|
166
|
+
for (const item of result.Contents || []) {
|
|
167
|
+
const absolute = result.EncodingType === 'url' ? decodeURIComponent(item.Key) : item.Key;
|
|
168
|
+
if (typeof absolute !== 'string' || !absolute.startsWith(this.#prefix)) throw fault('INVALID_LIST', 'S3 listing escaped the configured prefix');
|
|
169
|
+
const key = absolute.slice(this.#prefix.length);
|
|
170
|
+
if (!key || key.endsWith('/') || key.startsWith(PROBES)) continue;
|
|
171
|
+
if (!this.#safe(key)) throw fault('UNSUPPORTED_KEY', `S3 key cannot be represented safely: ${key}`);
|
|
172
|
+
yield this.#row(key, item);
|
|
173
|
+
}
|
|
174
|
+
if (!result.IsTruncated) break;
|
|
175
|
+
token = result.NextContinuationToken;
|
|
176
|
+
if (!token || seen.has(token)) throw fault('INVALID_PAGINATION', 'S3 returned an incomplete listing without a new continuation token');
|
|
177
|
+
seen.add(token);
|
|
178
|
+
} while (true);
|
|
179
|
+
}
|
|
180
|
+
async shape() {
|
|
181
|
+
const dirs = new Set(); let files = 0;
|
|
182
|
+
for await (const row of this.list()) {
|
|
183
|
+
files++;
|
|
184
|
+
const parts = row.key.split('/'); parts.pop();
|
|
185
|
+
while (parts.length) { dirs.add(parts.join('/')); parts.pop(); }
|
|
186
|
+
}
|
|
187
|
+
return { dirs: [...dirs], files };
|
|
188
|
+
}
|
|
189
|
+
async scan({ algorithms = ['sha256'], onFile } = {}) {
|
|
190
|
+
const files = [], errors = { root: null, dirs: [], files: [] };
|
|
191
|
+
this.emit('scan:start', { backend: this.name });
|
|
192
|
+
try {
|
|
193
|
+
for await (const listed of this.list()) {
|
|
194
|
+
let row = { ...listed, checksums: null };
|
|
195
|
+
try {
|
|
196
|
+
const before = await this.stat(listed.key);
|
|
197
|
+
if (!before) throw fault('SOURCE_CHANGED', 'S3 object disappeared during scan');
|
|
198
|
+
const cached = this.#hashes.get(before.key);
|
|
199
|
+
let checksums = cached?.id === before.id && cached.size === before.size && cached.modified === before.modified && algorithms.every(a => cached.checksums[a]) ? cached.checksums : null;
|
|
200
|
+
let mimeType = checksums ? cached.mimeType : before.mimeType;
|
|
201
|
+
if (!checksums) {
|
|
202
|
+
const stream = await this.get(before.key, { stream: true, ifMatch: before.etag, versionId: before.versionId });
|
|
203
|
+
if (!stream) throw fault('SOURCE_CHANGED', 'S3 object disappeared during scan');
|
|
204
|
+
const heads = []; let remaining = 4096;
|
|
205
|
+
checksums = await checksumStream((async function* () {
|
|
206
|
+
for await (const chunk of stream) {
|
|
207
|
+
if (remaining) { const head = Buffer.from(chunk).subarray(0, remaining); heads.push(head); remaining -= head.length; }
|
|
208
|
+
yield chunk;
|
|
209
|
+
}
|
|
210
|
+
})(), algorithms);
|
|
211
|
+
if (!mimeType || mimeType === 'application/octet-stream') mimeType = await detectMimeFromHead(Buffer.concat(heads), before.key);
|
|
212
|
+
const after = await this.stat(before.key);
|
|
213
|
+
if (!after || after.id !== before.id || after.size !== before.size || after.modified !== before.modified) throw fault('SOURCE_CHANGED', 'S3 object changed during scan');
|
|
214
|
+
}
|
|
215
|
+
row = { ...before, checksums, mimeType };
|
|
216
|
+
this.#hashes.set(row.key, row);
|
|
217
|
+
} catch (error) { errors.files.push({ key: listed.key, code: error.code || error.name }); }
|
|
218
|
+
files.push(row);
|
|
219
|
+
if (onFile) await onFile(row);
|
|
220
|
+
}
|
|
221
|
+
} catch (error) { errors.root = error.code || error.name || 'unreachable'; }
|
|
222
|
+
const complete = !errors.root && !errors.files.length;
|
|
223
|
+
if (complete) {
|
|
224
|
+
const present = new Set(files.map(row => row.key));
|
|
225
|
+
for (const key of this.#hashes.keys()) if (!present.has(key)) this.#hashes.delete(key);
|
|
226
|
+
}
|
|
227
|
+
this.emit('scan:complete', { backend: this.name, count: files.length, complete, errors });
|
|
228
|
+
return { files, complete, errors, fsid: null };
|
|
229
|
+
}
|
|
230
|
+
async poll() {
|
|
231
|
+
if (this.#polling) return this.#polling;
|
|
232
|
+
this.#polling = (async () => {
|
|
233
|
+
const snapshot = await this.scan();
|
|
234
|
+
if (!snapshot.complete) return false;
|
|
235
|
+
const next = new Map(snapshot.files.map(row => [row.key, row]));
|
|
236
|
+
for (const [key, row] of next) {
|
|
237
|
+
const previous = this.#rows.get(key);
|
|
238
|
+
if (!previous || previous.id !== row.id || previous.modified !== row.modified) this.emit(previous ? 'file:change' : 'file:add', { ...row, stats: { size: row.size, mtime: row.modified } });
|
|
239
|
+
}
|
|
240
|
+
for (const key of this.#rows.keys()) if (!next.has(key)) this.emit('file:unlink', { backend: this.name, key });
|
|
241
|
+
this.#rows = next;
|
|
242
|
+
return true;
|
|
243
|
+
})();
|
|
244
|
+
try { return await this.#polling; } finally { this.#polling = null; }
|
|
245
|
+
}
|
|
246
|
+
async watch() {
|
|
247
|
+
if (this.#timer) return true;
|
|
248
|
+
this.#timer = setInterval(() => { this.poll().catch(error => this.emit('watch:error', error)); }, Math.max(5000, Number(this.config.pollInterval) || 60000));
|
|
249
|
+
this.#timer.unref?.();
|
|
250
|
+
await this.poll();
|
|
251
|
+
return true;
|
|
24
252
|
}
|
|
253
|
+
async stop() { clearInterval(this.#timer); this.#timer = null; await this.#polling; this.#client.destroy?.(); }
|
|
25
254
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { opendir, stat, unlink } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import cacache from 'cacache';
|
|
4
|
+
import contentPath from 'cacache/lib/content/path.js';
|
|
5
|
+
|
|
6
|
+
// Explicit maintenance only. The caller must exclude writes for this root.
|
|
7
|
+
// Unlike cacache.verify(), this never reads/hashes payloads, rebuilds the key
|
|
8
|
+
// index, or removes tmp files. Integrity scrubbing is a separate operation.
|
|
9
|
+
// This still walks the index and holds the live digest paths in memory; do not
|
|
10
|
+
// put it on a timer or a shutdown path for a large store.
|
|
11
|
+
export default async function collectGarbage(root) {
|
|
12
|
+
const live = new Set();
|
|
13
|
+
for await (const entry of cacache.ls.stream(root)) {
|
|
14
|
+
// Buffer writes may publish more than one hash algorithm. Preserve
|
|
15
|
+
// every digest named by the entry, not just the strongest one.
|
|
16
|
+
for (const integrity of String(entry.integrity).trim().split(/\s+/)) {
|
|
17
|
+
live.add(contentPath(root, integrity));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const result = { reclaimedCount: 0, reclaimedSize: 0 };
|
|
21
|
+
for await (const file of files(contentPath.contentDir(root))) {
|
|
22
|
+
if (live.has(file)) continue;
|
|
23
|
+
try {
|
|
24
|
+
const info = await stat(file);
|
|
25
|
+
await unlink(file);
|
|
26
|
+
result.reclaimedCount++;
|
|
27
|
+
result.reclaimedSize += info.size;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error.code !== 'ENOENT') throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function* files(directory) {
|
|
36
|
+
let entries;
|
|
37
|
+
try { entries = await opendir(directory); }
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error.code === 'ENOENT') return;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
for await (const entry of entries) {
|
|
43
|
+
const file = path.join(directory, entry.name);
|
|
44
|
+
if (entry.isDirectory()) yield* files(file);
|
|
45
|
+
else if (entry.isFile()) yield file;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import cacache from 'cacache';
|
|
2
|
+
|
|
3
|
+
// Explicit maintenance under the cache root's mutation lock. Retain the newest
|
|
4
|
+
// record for each live key, including its metadata. Cacache's default handling
|
|
5
|
+
// of deletion markers prevents resurrecting older entries.
|
|
6
|
+
export default async function compactIndex(root) {
|
|
7
|
+
let compactedCount = 0;
|
|
8
|
+
for await (const entry of cacache.ls.stream(root)) {
|
|
9
|
+
await cacache.index.compact(root, entry.key, (a, b) => a.key === b.key);
|
|
10
|
+
compactedCount++;
|
|
11
|
+
}
|
|
12
|
+
return { compactedCount };
|
|
13
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import cacache from 'cacache';
|
|
2
2
|
import Debug from 'debug';
|
|
3
|
+
import { Writable } from 'node:stream';
|
|
4
|
+
import collectGarbage from './collect-garbage.js';
|
|
5
|
+
import ingestStream from './ingest-stream.js';
|
|
6
|
+
import compactIndex from './compact-index.js';
|
|
3
7
|
|
|
4
8
|
const debug = Debug('stored:cache');
|
|
5
9
|
|
|
@@ -11,6 +15,7 @@ const CACHE_OPTS = { memoize: false };
|
|
|
11
15
|
export default class Cache {
|
|
12
16
|
#root;
|
|
13
17
|
#algorithms;
|
|
18
|
+
#mutations = Promise.resolve();
|
|
14
19
|
|
|
15
20
|
constructor(config) {
|
|
16
21
|
if (!config?.path) throw new Error('Cache path required');
|
|
@@ -21,6 +26,10 @@ export default class Cache {
|
|
|
21
26
|
|
|
22
27
|
get root() { return this.#root; }
|
|
23
28
|
|
|
29
|
+
ingestStream(source, resolveEntry) {
|
|
30
|
+
return this.#serialize(() => ingestStream(this.#root, source, resolveEntry, this.#algorithms));
|
|
31
|
+
}
|
|
32
|
+
|
|
24
33
|
// Materializes the entire index into one object — fine for small stores, but
|
|
25
34
|
// prefer listStream() at scale (1M+ entries) to avoid the in-RAM map.
|
|
26
35
|
list() { return cacache.ls(this.#root); }
|
|
@@ -32,11 +41,30 @@ export default class Cache {
|
|
|
32
41
|
has(key) { return cacache.get.info(this.#root, key); }
|
|
33
42
|
|
|
34
43
|
put(key, data, metadata = {}) {
|
|
35
|
-
return cacache.put(this.#root, key, data, { algorithms: this.#algorithms, metadata, ...CACHE_OPTS });
|
|
44
|
+
return this.#serialize(() => cacache.put(this.#root, key, data, { algorithms: this.#algorithms, metadata, ...CACHE_OPTS }));
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
putStream(key, metadata = {}) {
|
|
39
|
-
|
|
48
|
+
let sink, release;
|
|
49
|
+
const self = this;
|
|
50
|
+
return new Writable({
|
|
51
|
+
construct(done) {
|
|
52
|
+
const output = this;
|
|
53
|
+
self.#serialize(() => new Promise(resolve => {
|
|
54
|
+
release = resolve;
|
|
55
|
+
sink = cacache.put.stream(self.#root, key, { algorithms: self.#algorithms, metadata, ...CACHE_OPTS });
|
|
56
|
+
sink.on('error', error => output.destroy(error));
|
|
57
|
+
sink.on('integrity', value => output.emit('integrity', value));
|
|
58
|
+
// cacache initializes content on the first write. An empty
|
|
59
|
+
// HTTP/file stream can reach EOF without emitting any chunk.
|
|
60
|
+
sink.write(Buffer.alloc(0));
|
|
61
|
+
done();
|
|
62
|
+
})).catch(error => output.destroy(error));
|
|
63
|
+
},
|
|
64
|
+
write(chunk, encoding, done) { sink.write(chunk, encoding, done); },
|
|
65
|
+
final(done) { sink.once('finish', () => { release(); done(); }); sink.end(); },
|
|
66
|
+
destroy(error, done) { sink?.destroy(); release?.(); done(error); },
|
|
67
|
+
});
|
|
40
68
|
}
|
|
41
69
|
|
|
42
70
|
get(key) { return cacache.get(this.#root, key, CACHE_OPTS); }
|
|
@@ -45,11 +73,17 @@ export default class Cache {
|
|
|
45
73
|
|
|
46
74
|
getInfo(key) { return cacache.get.info(this.#root, key); }
|
|
47
75
|
|
|
48
|
-
delete(key) { return cacache.rm.entry(this.#root, key, { removeFully: true }); }
|
|
76
|
+
delete(key) { return this.#serialize(() => cacache.rm.entry(this.#root, key, { removeFully: true })); }
|
|
49
77
|
|
|
50
|
-
clear() { return cacache.rm.all(this.#root); }
|
|
78
|
+
clear() { return this.#serialize(() => cacache.rm.all(this.#root)); }
|
|
51
79
|
|
|
52
|
-
|
|
80
|
+
#serialize(action) { const next = this.#mutations.catch(() => {}).then(action); this.#mutations = next; return next; }
|
|
81
|
+
// Explicit maintenance only. verify() reads all bytes and can discard
|
|
82
|
+
// corrupt content; collection only reclaims blobs without a key reference.
|
|
83
|
+
verify() { return this.#serialize(() => cacache.verify(this.#root)); }
|
|
84
|
+
collectGarbage() { return this.#serialize(() => collectGarbage(this.#root)); }
|
|
85
|
+
compact() { return this.#serialize(() => compactIndex(this.#root)); }
|
|
86
|
+
async stop() { await this.#mutations; }
|
|
53
87
|
|
|
54
88
|
// Stream the index so a 1M+ entry store is tallied without ever holding the
|
|
55
89
|
// full key set in memory.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { open, mkdir, rename, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
5
|
+
import cacache from 'cacache';
|
|
6
|
+
import contentPath from 'cacache/lib/content/path.js';
|
|
7
|
+
import writeContent from 'cacache/lib/content/write.js';
|
|
8
|
+
|
|
9
|
+
// The caller holds the root's mutation lock until key publication completes.
|
|
10
|
+
// This uses the same content writer as cacache.put.stream(), but defers the
|
|
11
|
+
// key until EOF so checksum-derived keys do not require a second payload copy.
|
|
12
|
+
export default async function ingestStream(root, source, resolveEntry, algorithms) {
|
|
13
|
+
const output = writeContent.stream(root, { algorithms, memoize: false });
|
|
14
|
+
let integrity, size;
|
|
15
|
+
output.on('integrity', value => { integrity = value; });
|
|
16
|
+
output.on('size', value => { size = value; });
|
|
17
|
+
output.write(Buffer.alloc(0));
|
|
18
|
+
await pipeline(source, output);
|
|
19
|
+
const file = contentPath(root, integrity);
|
|
20
|
+
const handle = await open(file, 'r');
|
|
21
|
+
try { await handle.sync(); } finally { await handle.close(); }
|
|
22
|
+
const { key, metadata } = await resolveEntry({ file, size, integrity });
|
|
23
|
+
if (typeof key !== 'string' || !key) throw new Error('Blob key required');
|
|
24
|
+
const entry = await cacache.index.insert(root, key, integrity, { size, metadata });
|
|
25
|
+
if (!entry) throw new Error('Blob index disappeared during publication');
|
|
26
|
+
return { key, size, file };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Publish a finished file the caller hands over (a resumable upload's part)
|
|
31
|
+
* by moving it into the content store: no second copy. `sha256` (hex) is the
|
|
32
|
+
* file's digest, which the caller vouches for (it hashed the bytes as they
|
|
33
|
+
* arrived). Content already present is deduplicated and the file dropped.
|
|
34
|
+
* Another filesystem, or a root keyed by other algorithms, streams it in.
|
|
35
|
+
*/
|
|
36
|
+
export async function adoptFile(root, file, { sha256, size }, resolveEntry, algorithms) {
|
|
37
|
+
if (algorithms.length !== 1 || algorithms[0] !== 'sha256') {
|
|
38
|
+
const result = await ingestStream(root, createReadStream(file), resolveEntry, algorithms);
|
|
39
|
+
await rm(file, { force: true });
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
const integrity = `sha256-${Buffer.from(sha256, 'hex').toString('base64')}`;
|
|
43
|
+
const target = contentPath(root, integrity);
|
|
44
|
+
const present = await stat(target).then(() => true, () => false);
|
|
45
|
+
if (present) {
|
|
46
|
+
await rm(file, { force: true });
|
|
47
|
+
} else {
|
|
48
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
49
|
+
try {
|
|
50
|
+
const handle = await open(file, 'r');
|
|
51
|
+
try { await handle.sync(); } finally { await handle.close(); }
|
|
52
|
+
await rename(file, target);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error.code !== 'EXDEV') throw error;
|
|
55
|
+
const result = await ingestStream(root, createReadStream(file), resolveEntry, algorithms);
|
|
56
|
+
await rm(file, { force: true });
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const { key, metadata } = await resolveEntry({ file: target, size, integrity });
|
|
61
|
+
if (typeof key !== 'string' || !key) throw new Error('Blob key required');
|
|
62
|
+
const entry = await cacache.index.insert(root, key, integrity, { size, metadata });
|
|
63
|
+
if (!entry) throw new Error('Blob index disappeared during publication');
|
|
64
|
+
return { key, size, file: target };
|
|
65
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { openKeyValueStore } from '../storage/lmdb.js';
|
|
2
2
|
import Debug from 'debug';
|
|
3
3
|
|
|
4
4
|
const debug = Debug('stored:index');
|
|
@@ -38,7 +38,9 @@ export default class Index {
|
|
|
38
38
|
#appendsSinceTrim = 0;
|
|
39
39
|
|
|
40
40
|
constructor(path = './.stored/index', options = {}) {
|
|
41
|
-
|
|
41
|
+
// Object indexes, edge replicas, operation receipts, ingress and Trash
|
|
42
|
+
// share this environment. LMDB's default named-DB limit is too small.
|
|
43
|
+
this.#db = openKeyValueStore({ path, name: 'metadata', compression: true, maxDbs: 64 });
|
|
42
44
|
this.#pathDb = this.#db.openDB('paths');
|
|
43
45
|
this.#changesDb = this.#db.openDB('changes');
|
|
44
46
|
this.#metaDb = this.#db.openDB('index-meta');
|
|
@@ -355,6 +357,6 @@ export default class Index {
|
|
|
355
357
|
}
|
|
356
358
|
|
|
357
359
|
close() {
|
|
358
|
-
this.#db.close();
|
|
360
|
+
return this.#db.close();
|
|
359
361
|
}
|
|
360
362
|
}
|