velocious 1.0.643 → 1.0.644
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/build/src/sync/assets/cache.d.ts +288 -0
- package/build/src/sync/assets/cache.d.ts.map +1 -0
- package/build/src/sync/assets/cache.js +883 -0
- package/build/src/sync/assets/types.d.ts +199 -0
- package/build/src/sync/assets/types.d.ts.map +1 -0
- package/build/src/sync/assets/types.js +56 -0
- package/build/src/testing/test-runner.d.ts +8 -0
- package/build/src/testing/test-runner.d.ts.map +1 -1
- package/build/src/testing/test-runner.js +49 -548
- package/build/src/testing/velocious-attempt-executor.d.ts +69 -0
- package/build/src/testing/velocious-attempt-executor.d.ts.map +1 -0
- package/build/src/testing/velocious-attempt-executor.js +402 -0
- package/build/src/testing/velocious-runner-reporter.d.ts +72 -0
- package/build/src/testing/velocious-runner-reporter.d.ts.map +1 -0
- package/build/src/testing/velocious-runner-reporter.js +152 -0
- package/build/src/testing/velocious-suite-hook-executor.d.ts +37 -0
- package/build/src/testing/velocious-suite-hook-executor.d.ts.map +1 -0
- package/build/src/testing/velocious-suite-hook-executor.js +66 -0
- package/build/src/testing/velocious-test-arguments.d.ts +34 -0
- package/build/src/testing/velocious-test-arguments.d.ts.map +1 -0
- package/build/src/testing/velocious-test-arguments.js +47 -0
- package/build/src/utils/sha256-bytes-hex.d.ts +7 -0
- package/build/src/utils/sha256-bytes-hex.d.ts.map +1 -0
- package/build/src/utils/sha256-bytes-hex.js +117 -0
- package/build/src/utils/sha256-hex.d.ts +2 -4
- package/build/src/utils/sha256-hex.d.ts.map +1 -1
- package/build/src/utils/sha256-hex.js +6 -120
- package/build/sync/assets/cache.js +998 -0
- package/build/sync/assets/types.js +62 -0
- package/build/testing/test-runner.js +46 -584
- package/build/testing/velocious-attempt-executor.js +431 -0
- package/build/testing/velocious-runner-reporter.js +166 -0
- package/build/testing/velocious-suite-hook-executor.js +71 -0
- package/build/testing/velocious-test-arguments.js +55 -0
- package/build/tsconfig.tsbuildinfo +1 -1
- package/build/utils/sha256-bytes-hex.js +132 -0
- package/build/utils/sha256-hex.js +7 -135
- package/package.json +1 -1
- package/src/sync/assets/cache.js +998 -0
- package/src/sync/assets/types.js +62 -0
- package/src/testing/test-runner.js +46 -584
- package/src/testing/velocious-attempt-executor.js +431 -0
- package/src/testing/velocious-runner-reporter.js +166 -0
- package/src/testing/velocious-suite-hook-executor.js +71 -0
- package/src/testing/velocious-test-arguments.js +55 -0
- package/src/utils/sha256-bytes-hex.js +132 -0
- package/src/utils/sha256-hex.js +7 -135
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import sha256BytesHex from "../../utils/sha256-bytes-hex.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {{
|
|
7
|
+
* byteSize: number,
|
|
8
|
+
* contentType: string | null,
|
|
9
|
+
* promise: Promise<{error: Error, uri: null} | {error: null, uri: string}>
|
|
10
|
+
* }} SynchronizedAssetDownloadFlight */
|
|
11
|
+
|
|
12
|
+
const CACHE_STATE_VERSION = 1
|
|
13
|
+
const DEFAULT_RETRY_BASE_DELAY_MS = 1000
|
|
14
|
+
const DEFAULT_RETRY_MAX_DELAY_MS = 1000 * 60 * 5
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Core synchronized asset cache. Platform packages own byte and metadata
|
|
18
|
+
* persistence while this class owns policy, integrity, and lifecycle.
|
|
19
|
+
*/
|
|
20
|
+
export default class SynchronizedAssetCache {
|
|
21
|
+
/**
|
|
22
|
+
* Creates a synchronized asset cache.
|
|
23
|
+
* @param {object} args Options.
|
|
24
|
+
* @param {string} args.accountId Authenticated account namespace.
|
|
25
|
+
* @param {import("./types.js").SynchronizedAssetCacheAdapter} args.adapter Platform storage adapter.
|
|
26
|
+
* @param {(descriptor: import("./types.js").SynchronizedAssetCacheDescriptor) => Promise<Uint8Array>} args.download Authenticated byte downloader.
|
|
27
|
+
* @param {number} args.maxBytes Maximum evictable cache size.
|
|
28
|
+
* @param {() => Date} [args.now] Clock.
|
|
29
|
+
* @param {number} [args.retryBaseDelayMs] Initial retry delay.
|
|
30
|
+
* @param {number} [args.retryMaxDelayMs] Maximum retry delay.
|
|
31
|
+
*/
|
|
32
|
+
constructor({accountId, adapter, download, maxBytes, now = () => new Date(), retryBaseDelayMs = DEFAULT_RETRY_BASE_DELAY_MS, retryMaxDelayMs = DEFAULT_RETRY_MAX_DELAY_MS}) {
|
|
33
|
+
if (!accountId) throw new Error("Synchronized asset cache requires an account id")
|
|
34
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new Error("Synchronized asset cache maxBytes must be a non-negative safe integer")
|
|
35
|
+
if (!Number.isSafeInteger(retryBaseDelayMs) || retryBaseDelayMs < 1) throw new Error("Synchronized asset cache retryBaseDelayMs must be a positive safe integer")
|
|
36
|
+
if (!Number.isSafeInteger(retryMaxDelayMs) || retryMaxDelayMs < retryBaseDelayMs) throw new Error("Synchronized asset cache retryMaxDelayMs must be at least retryBaseDelayMs")
|
|
37
|
+
|
|
38
|
+
this.accountId = accountId
|
|
39
|
+
this.adapter = adapter
|
|
40
|
+
this.download = download
|
|
41
|
+
this.maxBytes = maxBytes
|
|
42
|
+
this.now = now
|
|
43
|
+
this.retryBaseDelayMs = retryBaseDelayMs
|
|
44
|
+
this.retryMaxDelayMs = retryMaxDelayMs
|
|
45
|
+
/** @type {Map<string, number>} */
|
|
46
|
+
this.activeDigestCounts = new Map()
|
|
47
|
+
/** @type {Map<string, Promise<void>>} */
|
|
48
|
+
this.deletionPromises = new Map()
|
|
49
|
+
/** @type {Set<string>} */
|
|
50
|
+
this.cleanupRequiredAfterReleaseDigests = new Set()
|
|
51
|
+
/** @type {Promise<number>} */
|
|
52
|
+
this.cleanupPromise = Promise.resolve(0)
|
|
53
|
+
/** @type {Map<string, SynchronizedAssetDownloadFlight>} */
|
|
54
|
+
this.downloadPromises = new Map()
|
|
55
|
+
/** @type {import("./types.js").SynchronizedAssetCacheState | null} */
|
|
56
|
+
this.state = null
|
|
57
|
+
/** @type {Promise<import("./types.js").SynchronizedAssetCacheState> | null} */
|
|
58
|
+
this.statePromise = null
|
|
59
|
+
/** @type {Promise<void>} */
|
|
60
|
+
this.saveStatePromise = Promise.resolve()
|
|
61
|
+
/** @type {Map<string, Promise<import("./types.js").SynchronizedAssetCacheSynchronizationResult>>} */
|
|
62
|
+
this.synchronizePromises = new Map()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reconciles the immutable descriptors for one synchronized scope and
|
|
67
|
+
* downloads eligible eager assets.
|
|
68
|
+
* @param {object} args Reconciliation inputs.
|
|
69
|
+
* @param {import("./types.js").SynchronizedAssetCacheDescriptor[]} args.descriptors Current descriptors in the scope.
|
|
70
|
+
* @param {boolean} args.online Whether authenticated downloads are available.
|
|
71
|
+
* @param {string} args.scopeKey Stable synchronized scope key.
|
|
72
|
+
* @returns {Promise<import("./types.js").SynchronizedAssetCacheSynchronizationResult>} Synchronization result.
|
|
73
|
+
*/
|
|
74
|
+
async synchronize({descriptors, online, scopeKey}) {
|
|
75
|
+
const synchronize = async () => await this.synchronizeScope({descriptors, online, scopeKey})
|
|
76
|
+
const previousSynchronizationPromise = this.synchronizePromises.get(scopeKey)
|
|
77
|
+
const synchronizationPromise = previousSynchronizationPromise
|
|
78
|
+
? previousSynchronizationPromise.then(synchronize, synchronize)
|
|
79
|
+
: synchronize()
|
|
80
|
+
|
|
81
|
+
this.synchronizePromises.set(scopeKey, synchronizationPromise)
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
return await synchronizationPromise
|
|
85
|
+
} finally {
|
|
86
|
+
if (this.synchronizePromises.get(scopeKey) === synchronizationPromise) {
|
|
87
|
+
this.synchronizePromises.delete(scopeKey)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Runs one scope synchronization after prior calls for that scope finish.
|
|
94
|
+
* @param {object} args Reconciliation inputs.
|
|
95
|
+
* @param {import("./types.js").SynchronizedAssetCacheDescriptor[]} args.descriptors Current descriptors in the scope.
|
|
96
|
+
* @param {boolean} args.online Whether authenticated downloads are available.
|
|
97
|
+
* @param {string} args.scopeKey Stable synchronized scope key.
|
|
98
|
+
* @returns {Promise<import("./types.js").SynchronizedAssetCacheSynchronizationResult>} Synchronization result.
|
|
99
|
+
*/
|
|
100
|
+
async synchronizeScope({descriptors, online, scopeKey}) {
|
|
101
|
+
await this.loadState()
|
|
102
|
+
/** @type {Map<string, import("./types.js").SynchronizedAssetCacheDescriptor[]>} */
|
|
103
|
+
const descriptorsByDigest = new Map()
|
|
104
|
+
/** @type {import("./types.js").SynchronizedAssetCacheFailure[]} */
|
|
105
|
+
const failures = []
|
|
106
|
+
/** @type {Set<string>} */
|
|
107
|
+
const activeDigests = new Set()
|
|
108
|
+
|
|
109
|
+
for (const descriptor of descriptors) {
|
|
110
|
+
const digestDescriptors = descriptorsByDigest.get(descriptor.digest) || []
|
|
111
|
+
|
|
112
|
+
digestDescriptors.push(descriptor)
|
|
113
|
+
descriptorsByDigest.set(descriptor.digest, digestDescriptors)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
for (const digest of descriptorsByDigest.keys()) {
|
|
118
|
+
await this.beginActiveDigest(digest)
|
|
119
|
+
activeDigests.add(digest)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const entriesById = await this.reconcileDescriptors({descriptors, scopeKey})
|
|
123
|
+
|
|
124
|
+
await this.deleteUnreferencedDigests()
|
|
125
|
+
|
|
126
|
+
for (const [digest, digestDescriptors] of descriptorsByDigest) {
|
|
127
|
+
const eagerDescriptors = online ? digestDescriptors.filter((descriptor) => descriptor.fetch === "eager") : []
|
|
128
|
+
|
|
129
|
+
if (eagerDescriptors.length === 0) {
|
|
130
|
+
activeDigests.delete(digest)
|
|
131
|
+
await this.finishActiveDigest(digest)
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @type {import("./types.js").SynchronizedAssetCacheEntry[]} */
|
|
136
|
+
const eagerEntries = []
|
|
137
|
+
|
|
138
|
+
for (const descriptor of eagerDescriptors) {
|
|
139
|
+
const entry = entriesById.get(descriptor.id)
|
|
140
|
+
|
|
141
|
+
if (!entry) throw new Error(`Missing reconciled synchronized asset descriptor ${descriptor.id}`)
|
|
142
|
+
|
|
143
|
+
eagerEntries.push(entry)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (eagerEntries.some((entry) => this.retryEligible(entry))) {
|
|
147
|
+
const cacheResult = await this.ensureCachedWhileActive(eagerEntries)
|
|
148
|
+
|
|
149
|
+
if (cacheResult.error) {
|
|
150
|
+
for (const entry of eagerEntries) {
|
|
151
|
+
failures.push({assetId: entry.descriptor.id, error: cacheResult.error})
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
activeDigests.delete(digest)
|
|
157
|
+
await this.finishActiveDigest(digest)
|
|
158
|
+
await this.cleanup()
|
|
159
|
+
}
|
|
160
|
+
} finally {
|
|
161
|
+
await this.finishActiveDigests([...activeDigests])
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await this.cleanup()
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
failures,
|
|
168
|
+
missingRequiredAssetIds: await this.missingRequiredAssetIds(scopeKey)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Resolves a cached asset URI, downloading it on demand when allowed.
|
|
174
|
+
* @param {object} args Resolution inputs.
|
|
175
|
+
* @param {string} args.assetId Attachment descriptor id.
|
|
176
|
+
* @param {boolean} args.online Whether authenticated downloads are available.
|
|
177
|
+
* @returns {Promise<string | null>} Cached asset URI.
|
|
178
|
+
*/
|
|
179
|
+
async resolve({assetId, online}) {
|
|
180
|
+
const state = await this.loadState()
|
|
181
|
+
const entry = state.assets.find((candidate) => candidate.descriptor.id === assetId)
|
|
182
|
+
|
|
183
|
+
if (!entry) return null
|
|
184
|
+
|
|
185
|
+
const digest = entry.descriptor.digest
|
|
186
|
+
let resolvedUri = null
|
|
187
|
+
let shouldCleanup = false
|
|
188
|
+
|
|
189
|
+
await this.beginActiveDigest(digest)
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const cachedUri = await this.cachedUriWhileActive(entry)
|
|
193
|
+
|
|
194
|
+
if (cachedUri) {
|
|
195
|
+
entry.lastAccessedAt = this.nowMilliseconds()
|
|
196
|
+
entry.status = "cached"
|
|
197
|
+
await this.saveState()
|
|
198
|
+
|
|
199
|
+
resolvedUri = cachedUri
|
|
200
|
+
} else if (online && this.retryEligible(entry)) {
|
|
201
|
+
const cacheResult = await this.ensureCachedWhileActive([entry])
|
|
202
|
+
|
|
203
|
+
if (cacheResult.error) throw cacheResult.error
|
|
204
|
+
|
|
205
|
+
if (cacheResult.uri) {
|
|
206
|
+
resolvedUri = cacheResult.uri
|
|
207
|
+
shouldCleanup = true
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} finally {
|
|
211
|
+
await this.finishActiveDigest(digest, shouldCleanup ? new Set([digest]) : new Set())
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (shouldCleanup) await this.cleanup(new Set([digest]))
|
|
215
|
+
const requiresUnprotectedCleanup = shouldCleanup || (entry.descriptor.byteSize > this.maxBytes && !state.assets.some((candidate) => {
|
|
216
|
+
return candidate.descriptor.digest === digest && candidate.descriptor.retention === "durable"
|
|
217
|
+
}))
|
|
218
|
+
|
|
219
|
+
if (requiresUnprotectedCleanup) await this.cleanup()
|
|
220
|
+
if (!resolvedUri) return null
|
|
221
|
+
const resolvedEntry = state.assets.find((candidate) => candidate.descriptor.id === assetId && candidate.descriptor.digest === digest)
|
|
222
|
+
|
|
223
|
+
if (!resolvedEntry) return null
|
|
224
|
+
|
|
225
|
+
return await this.cachedUri(resolvedEntry)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Evicts least-recently-used blobs until the unique cached byte total is
|
|
230
|
+
* within the configured budget. A blob stays durable when any live
|
|
231
|
+
* descriptor reference declares durable retention.
|
|
232
|
+
* @param {Set<string>} [protectedDigests] Digests needed by the active caller.
|
|
233
|
+
* @returns {Promise<number>} Bytes removed.
|
|
234
|
+
*/
|
|
235
|
+
async cleanup(protectedDigests = new Set()) {
|
|
236
|
+
const cleanup = async () => await this.performCleanup(protectedDigests)
|
|
237
|
+
const cleanupPromise = this.cleanupPromise.then(cleanup, cleanup)
|
|
238
|
+
|
|
239
|
+
this.cleanupPromise = cleanupPromise
|
|
240
|
+
|
|
241
|
+
return await cleanupPromise
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Performs one serialized eviction pass.
|
|
246
|
+
* @param {Set<string>} protectedDigests Digests needed by the active caller.
|
|
247
|
+
* @returns {Promise<number>} Bytes removed.
|
|
248
|
+
*/
|
|
249
|
+
async performCleanup(protectedDigests) {
|
|
250
|
+
const state = await this.loadState()
|
|
251
|
+
/** @type {Map<string, import("./types.js").SynchronizedAssetCacheEntry[]>} */
|
|
252
|
+
const entriesByDigest = new Map()
|
|
253
|
+
|
|
254
|
+
for (const entry of state.assets) {
|
|
255
|
+
const digestEntries = entriesByDigest.get(entry.descriptor.digest) || []
|
|
256
|
+
|
|
257
|
+
digestEntries.push(entry)
|
|
258
|
+
entriesByDigest.set(entry.descriptor.digest, digestEntries)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** @type {{byteSize: number, digest: string, lastAccessedAt: number}[]} */
|
|
262
|
+
const cachedBlobs = []
|
|
263
|
+
let cachedBytes = 0
|
|
264
|
+
|
|
265
|
+
for (const [digest, references] of entriesByDigest) {
|
|
266
|
+
const uri = await this.adapter.blobUri({accountId: this.accountId, digest})
|
|
267
|
+
|
|
268
|
+
if (!uri) {
|
|
269
|
+
for (const entry of references) {
|
|
270
|
+
if (entry.status === "cached") entry.status = "missing"
|
|
271
|
+
}
|
|
272
|
+
continue
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const byteSize = references[0].descriptor.byteSize
|
|
276
|
+
|
|
277
|
+
cachedBytes += byteSize
|
|
278
|
+
cachedBlobs.push({
|
|
279
|
+
byteSize,
|
|
280
|
+
digest,
|
|
281
|
+
lastAccessedAt: Math.max(...references.map((entry) => entry.lastAccessedAt))
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let removedBytes = 0
|
|
286
|
+
|
|
287
|
+
while (cachedBlobs.length > 0) {
|
|
288
|
+
if (cachedBytes <= this.maxBytes) break
|
|
289
|
+
|
|
290
|
+
for (const cachedBlob of cachedBlobs) {
|
|
291
|
+
const currentReferences = state.assets.filter((entry) => entry.descriptor.digest === cachedBlob.digest)
|
|
292
|
+
|
|
293
|
+
if (currentReferences.length > 0) {
|
|
294
|
+
cachedBlob.lastAccessedAt = Math.max(...currentReferences.map((entry) => entry.lastAccessedAt))
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
cachedBlobs.sort((left, right) => left.lastAccessedAt - right.lastAccessedAt || left.digest.localeCompare(right.digest))
|
|
299
|
+
|
|
300
|
+
const blob = cachedBlobs.shift()
|
|
301
|
+
|
|
302
|
+
if (!blob) throw new Error("Expected a synchronized asset cache eviction candidate")
|
|
303
|
+
if (protectedDigests.has(blob.digest)) continue
|
|
304
|
+
let blobWasAlreadyMissing = false
|
|
305
|
+
let deletionChecked = false
|
|
306
|
+
const deleted = await this.deleteDigestIfInactive(blob.digest, async () => {
|
|
307
|
+
deletionChecked = true
|
|
308
|
+
|
|
309
|
+
if (!this.state) throw new Error("Cannot clean synchronized asset blobs before loading state")
|
|
310
|
+
|
|
311
|
+
const currentUri = await this.adapter.blobUri({accountId: this.accountId, digest: blob.digest})
|
|
312
|
+
const currentReferences = this.state.assets.filter((entry) => entry.descriptor.digest === blob.digest)
|
|
313
|
+
|
|
314
|
+
if (!currentUri) {
|
|
315
|
+
blobWasAlreadyMissing = true
|
|
316
|
+
|
|
317
|
+
for (const entry of currentReferences) {
|
|
318
|
+
if (entry.status === "cached") entry.status = "missing"
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return false
|
|
322
|
+
}
|
|
323
|
+
if (currentReferences.some((entry) => entry.descriptor.retention === "durable")) return false
|
|
324
|
+
|
|
325
|
+
await this.adapter.deleteBlob({accountId: this.accountId, digest: blob.digest})
|
|
326
|
+
|
|
327
|
+
for (const entry of currentReferences) {
|
|
328
|
+
entry.attempts = 0
|
|
329
|
+
entry.nextRetryAt = null
|
|
330
|
+
entry.status = "missing"
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return true
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
if (!deletionChecked) this.cleanupRequiredAfterReleaseDigests.add(blob.digest)
|
|
337
|
+
if (blobWasAlreadyMissing) cachedBytes -= blob.byteSize
|
|
338
|
+
if (!deleted) continue
|
|
339
|
+
|
|
340
|
+
cachedBytes -= blob.byteSize
|
|
341
|
+
removedBytes += blob.byteSize
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
await this.saveState()
|
|
345
|
+
|
|
346
|
+
return removedBytes
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Loads cache state once for this cache instance.
|
|
351
|
+
* @returns {Promise<import("./types.js").SynchronizedAssetCacheState>} Loaded state.
|
|
352
|
+
*/
|
|
353
|
+
async loadState() {
|
|
354
|
+
if (this.state) return this.state
|
|
355
|
+
if (this.statePromise) return await this.statePromise
|
|
356
|
+
|
|
357
|
+
this.statePromise = this.loadStateFromAdapter()
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
this.state = await this.statePromise
|
|
361
|
+
|
|
362
|
+
return this.state
|
|
363
|
+
} finally {
|
|
364
|
+
this.statePromise = null
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Loads and recovers persisted cache state.
|
|
370
|
+
* @returns {Promise<import("./types.js").SynchronizedAssetCacheState>} Loaded state.
|
|
371
|
+
*/
|
|
372
|
+
async loadStateFromAdapter() {
|
|
373
|
+
const loadedState = await this.adapter.loadState({accountId: this.accountId})
|
|
374
|
+
|
|
375
|
+
if (!loadedState) return {assets: [], pendingDeletionDigests: [], version: CACHE_STATE_VERSION}
|
|
376
|
+
if (loadedState.version !== CACHE_STATE_VERSION) {
|
|
377
|
+
throw new Error(`Unsupported synchronized asset cache state version: ${loadedState.version}`)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
let recoveredInterruptedDownload = false
|
|
381
|
+
|
|
382
|
+
for (const entry of loadedState.assets) {
|
|
383
|
+
if (entry.status !== "downloading") continue
|
|
384
|
+
|
|
385
|
+
entry.attempts += 1
|
|
386
|
+
entry.nextRetryAt = this.nowMilliseconds()
|
|
387
|
+
entry.status = "failed"
|
|
388
|
+
recoveredInterruptedDownload = true
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (recoveredInterruptedDownload) {
|
|
392
|
+
await this.adapter.saveState({accountId: this.accountId, state: loadedState})
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return loadedState
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Persists the current cache state.
|
|
400
|
+
* @returns {Promise<void>} Resolves after state persistence.
|
|
401
|
+
*/
|
|
402
|
+
async saveState() {
|
|
403
|
+
if (!this.state) throw new Error("Cannot save synchronized asset cache before loading state")
|
|
404
|
+
const state = this.copyState(this.state)
|
|
405
|
+
|
|
406
|
+
const persist = async () => {
|
|
407
|
+
await this.adapter.saveState({accountId: this.accountId, state})
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
await this.serializeStatePersistence(persist)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Persists a detached reconciliation before exposing it through shared state.
|
|
415
|
+
* @param {object} args Reconciliation inputs.
|
|
416
|
+
* @param {import("./types.js").SynchronizedAssetCacheDescriptor[]} args.descriptors Current descriptors in the scope.
|
|
417
|
+
* @param {string} args.scopeKey Stable synchronized scope key.
|
|
418
|
+
* @returns {Promise<Map<string, import("./types.js").SynchronizedAssetCacheEntry>>} Reconciled live entries by id.
|
|
419
|
+
*/
|
|
420
|
+
async reconcileDescriptors({descriptors, scopeKey}) {
|
|
421
|
+
/** @type {Map<string, import("./types.js").SynchronizedAssetCacheEntry> | null} */
|
|
422
|
+
let entriesById = null
|
|
423
|
+
|
|
424
|
+
const persist = async () => {
|
|
425
|
+
if (!this.state) throw new Error("Cannot reconcile synchronized asset cache before loading state")
|
|
426
|
+
|
|
427
|
+
const candidateState = this.copyState(this.state)
|
|
428
|
+
const newEntryLastAccessedAt = this.nowMilliseconds()
|
|
429
|
+
|
|
430
|
+
this.applyDescriptorReconciliation({descriptors, newEntryLastAccessedAt, scopeKey, state: candidateState})
|
|
431
|
+
await this.adapter.saveState({accountId: this.accountId, state: candidateState})
|
|
432
|
+
entriesById = this.applyDescriptorReconciliation({descriptors, newEntryLastAccessedAt, scopeKey, state: this.state})
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
await this.serializeStatePersistence(persist)
|
|
436
|
+
|
|
437
|
+
if (!entriesById) throw new Error("Synchronized asset descriptor reconciliation completed without live entries")
|
|
438
|
+
|
|
439
|
+
return entriesById
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Applies one scope's descriptor set to cache state.
|
|
444
|
+
* @param {object} args Reconciliation inputs.
|
|
445
|
+
* @param {import("./types.js").SynchronizedAssetCacheDescriptor[]} args.descriptors Current descriptors in the scope.
|
|
446
|
+
* @param {number} args.newEntryLastAccessedAt Initial LRU timestamp for new entries.
|
|
447
|
+
* @param {string} args.scopeKey Stable synchronized scope key.
|
|
448
|
+
* @param {import("./types.js").SynchronizedAssetCacheState} args.state State to reconcile.
|
|
449
|
+
* @returns {Map<string, import("./types.js").SynchronizedAssetCacheEntry>} Live entries by id.
|
|
450
|
+
*/
|
|
451
|
+
applyDescriptorReconciliation({descriptors, newEntryLastAccessedAt, scopeKey, state}) {
|
|
452
|
+
const incomingIds = new Set(descriptors.map((asset) => asset.id))
|
|
453
|
+
const entriesById = new Map(state.assets.map((entry) => [entry.descriptor.id, entry]))
|
|
454
|
+
const descriptorsById = new Map(state.assets.map((entry) => [entry.descriptor.id, entry.descriptor]))
|
|
455
|
+
/** @type {Map<string, import("./types.js").SynchronizedAssetCacheDescriptor>} */
|
|
456
|
+
const removedDescriptorsByDigest = new Map()
|
|
457
|
+
|
|
458
|
+
for (const asset of descriptors) {
|
|
459
|
+
const knownDescriptor = descriptorsById.get(asset.id)
|
|
460
|
+
const downloadFlight = this.downloadPromises.get(asset.digest)
|
|
461
|
+
|
|
462
|
+
if (knownDescriptor && knownDescriptor.digest !== asset.digest) {
|
|
463
|
+
throw new Error(`Synchronized asset descriptor ${asset.id} changed its immutable digest`)
|
|
464
|
+
}
|
|
465
|
+
if (knownDescriptor && knownDescriptor.byteSize !== asset.byteSize) {
|
|
466
|
+
throw new Error(`Synchronized asset descriptor ${asset.id} changed its immutable byte size`)
|
|
467
|
+
}
|
|
468
|
+
if (knownDescriptor && knownDescriptor.contentType !== asset.contentType) {
|
|
469
|
+
throw new Error(`Synchronized asset descriptor ${asset.id} changed its immutable content type`)
|
|
470
|
+
}
|
|
471
|
+
if (downloadFlight && downloadFlight.byteSize !== asset.byteSize) {
|
|
472
|
+
throw new Error(`Synchronized asset digest ${asset.digest} has inconsistent byte sizes`)
|
|
473
|
+
}
|
|
474
|
+
if (downloadFlight && downloadFlight.contentType !== asset.contentType) {
|
|
475
|
+
throw new Error(`Synchronized asset digest ${asset.digest} has inconsistent content types`)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
descriptorsById.set(asset.id, asset)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
for (const entry of state.assets) {
|
|
482
|
+
if (!entry.scopeKeys.includes(scopeKey) || incomingIds.has(entry.descriptor.id)) continue
|
|
483
|
+
|
|
484
|
+
entry.scopeKeys = entry.scopeKeys.filter((candidate) => candidate !== scopeKey)
|
|
485
|
+
if (entry.scopeKeys.length === 0) removedDescriptorsByDigest.set(entry.descriptor.digest, entry.descriptor)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
state.assets = state.assets.filter((entry) => entry.scopeKeys.length > 0)
|
|
489
|
+
|
|
490
|
+
for (const asset of descriptors) {
|
|
491
|
+
const existing = entriesById.get(asset.id)
|
|
492
|
+
|
|
493
|
+
if (existing && state.assets.includes(existing)) {
|
|
494
|
+
existing.descriptor = asset
|
|
495
|
+
if (!existing.scopeKeys.includes(scopeKey)) existing.scopeKeys.push(scopeKey)
|
|
496
|
+
} else {
|
|
497
|
+
const newEntry = {
|
|
498
|
+
attempts: 0,
|
|
499
|
+
descriptor: asset,
|
|
500
|
+
lastAccessedAt: newEntryLastAccessedAt,
|
|
501
|
+
nextRetryAt: null,
|
|
502
|
+
scopeKeys: [scopeKey],
|
|
503
|
+
status: /** @type {const} */ ("missing")
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
state.assets.push(newEntry)
|
|
507
|
+
entriesById.set(asset.id, newEntry)
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** @type {Map<string, number>} */
|
|
512
|
+
const byteSizesByDigest = new Map()
|
|
513
|
+
/** @type {Map<string, string | null>} */
|
|
514
|
+
const contentTypesByDigest = new Map()
|
|
515
|
+
|
|
516
|
+
for (const entry of state.assets) {
|
|
517
|
+
const knownByteSize = byteSizesByDigest.get(entry.descriptor.digest)
|
|
518
|
+
const knownContentType = contentTypesByDigest.get(entry.descriptor.digest)
|
|
519
|
+
|
|
520
|
+
if (knownByteSize !== undefined && knownByteSize !== entry.descriptor.byteSize) {
|
|
521
|
+
throw new Error(`Synchronized asset digest ${entry.descriptor.digest} has inconsistent byte sizes`)
|
|
522
|
+
}
|
|
523
|
+
if (knownContentType !== undefined && knownContentType !== entry.descriptor.contentType) {
|
|
524
|
+
throw new Error(`Synchronized asset digest ${entry.descriptor.digest} has inconsistent content types`)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
byteSizesByDigest.set(entry.descriptor.digest, entry.descriptor.byteSize)
|
|
528
|
+
contentTypesByDigest.set(entry.descriptor.digest, entry.descriptor.contentType)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
for (const [digest, removedDescriptor] of removedDescriptorsByDigest) {
|
|
532
|
+
const retainedEntry = state.assets.find((entry) => entry.descriptor.digest === digest)
|
|
533
|
+
|
|
534
|
+
if (retainedEntry && retainedEntry.descriptor.byteSize === removedDescriptor.byteSize && retainedEntry.descriptor.contentType === removedDescriptor.contentType) continue
|
|
535
|
+
if (!state.pendingDeletionDigests.includes(digest)) state.pendingDeletionDigests.push(digest)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return entriesById
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Copies metadata into a detached persistence candidate.
|
|
543
|
+
* @param {import("./types.js").SynchronizedAssetCacheState} state State to copy.
|
|
544
|
+
* @returns {import("./types.js").SynchronizedAssetCacheState} Detached state.
|
|
545
|
+
*/
|
|
546
|
+
copyState(state) {
|
|
547
|
+
return {
|
|
548
|
+
assets: state.assets.map((entry) => ({
|
|
549
|
+
...entry,
|
|
550
|
+
descriptor: {...entry.descriptor},
|
|
551
|
+
scopeKeys: [...entry.scopeKeys]
|
|
552
|
+
})),
|
|
553
|
+
pendingDeletionDigests: [...state.pendingDeletionDigests],
|
|
554
|
+
version: state.version
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Serializes one metadata persistence operation after prior failures or successes.
|
|
560
|
+
* @param {() => Promise<void>} persist Persistence operation.
|
|
561
|
+
* @returns {Promise<void>} Resolves after persistence.
|
|
562
|
+
*/
|
|
563
|
+
async serializeStatePersistence(persist) {
|
|
564
|
+
this.saveStatePromise = this.saveStatePromise.then(persist, persist)
|
|
565
|
+
|
|
566
|
+
await this.saveStatePromise
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Ensures one descriptor has verified local bytes.
|
|
571
|
+
* @param {import("./types.js").SynchronizedAssetCacheEntry} entry Descriptor state.
|
|
572
|
+
* @returns {Promise<{error: Error | null, uri: string | null}>} Cache result.
|
|
573
|
+
*/
|
|
574
|
+
async ensureCached(entry) {
|
|
575
|
+
const digest = entry.descriptor.digest
|
|
576
|
+
|
|
577
|
+
await this.beginActiveDigest(digest)
|
|
578
|
+
|
|
579
|
+
try {
|
|
580
|
+
return await this.ensureCachedWhileActive([entry])
|
|
581
|
+
} finally {
|
|
582
|
+
await this.finishActiveDigest(digest)
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Resolves or downloads descriptors sharing one protected digest.
|
|
588
|
+
* @param {import("./types.js").SynchronizedAssetCacheEntry[]} entries Descriptor states.
|
|
589
|
+
* @returns {Promise<{error: Error | null, uri: string | null}>} Cache result.
|
|
590
|
+
*/
|
|
591
|
+
async ensureCachedWhileActive(entries) {
|
|
592
|
+
const entry = entries[0]
|
|
593
|
+
|
|
594
|
+
if (!entry) throw new Error("Cannot cache a synchronized asset digest without descriptor entries")
|
|
595
|
+
|
|
596
|
+
const existingUri = await this.cachedUriWhileActive(entry)
|
|
597
|
+
|
|
598
|
+
if (existingUri) {
|
|
599
|
+
await this.recordCachedEntries(entries)
|
|
600
|
+
|
|
601
|
+
return {error: null, uri: existingUri}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const digest = entry.descriptor.digest
|
|
605
|
+
let downloadFlight = this.downloadPromises.get(digest)
|
|
606
|
+
let ownsDownloadPromise = false
|
|
607
|
+
|
|
608
|
+
if (downloadFlight) {
|
|
609
|
+
for (const digestEntry of entries) {
|
|
610
|
+
if (downloadFlight.byteSize !== digestEntry.descriptor.byteSize) {
|
|
611
|
+
throw new Error(`Synchronized asset digest ${digest} has inconsistent byte sizes`)
|
|
612
|
+
}
|
|
613
|
+
if (downloadFlight.contentType !== digestEntry.descriptor.contentType) {
|
|
614
|
+
throw new Error(`Synchronized asset digest ${digest} has inconsistent content types`)
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
for (const digestEntry of entries) digestEntry.status = "downloading"
|
|
620
|
+
|
|
621
|
+
if (!downloadFlight) {
|
|
622
|
+
downloadFlight = {
|
|
623
|
+
byteSize: entry.descriptor.byteSize,
|
|
624
|
+
contentType: entry.descriptor.contentType,
|
|
625
|
+
promise: this.downloadAfterPersistingState(entry.descriptor)
|
|
626
|
+
}
|
|
627
|
+
this.downloadPromises.set(digest, downloadFlight)
|
|
628
|
+
ownsDownloadPromise = true
|
|
629
|
+
} else {
|
|
630
|
+
await this.saveState()
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
try {
|
|
634
|
+
const cacheResult = await downloadFlight.promise
|
|
635
|
+
|
|
636
|
+
if (cacheResult.error) {
|
|
637
|
+
if (entry.status === "downloading") await this.recordDownloadFailure(digest)
|
|
638
|
+
|
|
639
|
+
return cacheResult
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
await this.recordCachedEntries(entries)
|
|
643
|
+
|
|
644
|
+
return cacheResult
|
|
645
|
+
} finally {
|
|
646
|
+
if (ownsDownloadPromise && this.downloadPromises.get(digest) === downloadFlight) {
|
|
647
|
+
this.downloadPromises.delete(digest)
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Records one cached digest result for every participating descriptor.
|
|
654
|
+
* @param {import("./types.js").SynchronizedAssetCacheEntry[]} entries Descriptor states.
|
|
655
|
+
* @returns {Promise<void>} Resolves after persistence.
|
|
656
|
+
*/
|
|
657
|
+
async recordCachedEntries(entries) {
|
|
658
|
+
if (!this.state) throw new Error("Cannot record synchronized asset cache results before loading state")
|
|
659
|
+
const state = this.state
|
|
660
|
+
const lastAccessedAt = this.nowMilliseconds()
|
|
661
|
+
|
|
662
|
+
for (const entry of entries) {
|
|
663
|
+
entry.attempts = 0
|
|
664
|
+
entry.lastAccessedAt = lastAccessedAt
|
|
665
|
+
entry.nextRetryAt = null
|
|
666
|
+
entry.status = "cached"
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const verifiedDigests = new Set(entries.map((entry) => entry.descriptor.digest))
|
|
670
|
+
|
|
671
|
+
state.pendingDeletionDigests = state.pendingDeletionDigests.filter((digest) => {
|
|
672
|
+
return !verifiedDigests.has(digest) || !state.assets.some((entry) => entry.descriptor.digest === digest)
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
await this.saveState()
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Persists download intent, then downloads one digest and records a shared failure once.
|
|
680
|
+
* @param {import("./types.js").SynchronizedAssetCacheDescriptor} descriptor Asset descriptor.
|
|
681
|
+
* @returns {Promise<{error: Error, uri: null} | {error: null, uri: string}>} Shared cache result.
|
|
682
|
+
*/
|
|
683
|
+
async downloadAfterPersistingState(descriptor) {
|
|
684
|
+
await this.saveState()
|
|
685
|
+
|
|
686
|
+
try {
|
|
687
|
+
return {error: null, uri: await this.downloadVerified(descriptor)}
|
|
688
|
+
} catch (error) {
|
|
689
|
+
const failure = error instanceof Error ? error : new Error(String(error))
|
|
690
|
+
|
|
691
|
+
await this.recordDownloadFailure(descriptor.digest)
|
|
692
|
+
|
|
693
|
+
return {error: failure, uri: null}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Advances retry metadata for every live descriptor sharing one failed digest.
|
|
699
|
+
* @param {string} digest Content digest.
|
|
700
|
+
* @returns {Promise<void>} Resolves after persistence.
|
|
701
|
+
*/
|
|
702
|
+
async recordDownloadFailure(digest) {
|
|
703
|
+
if (!this.state) throw new Error("Cannot record synchronized asset download failure before loading state")
|
|
704
|
+
|
|
705
|
+
const failedAt = this.nowMilliseconds()
|
|
706
|
+
|
|
707
|
+
for (const entry of this.state.assets) {
|
|
708
|
+
if (entry.descriptor.digest !== digest) continue
|
|
709
|
+
if (entry.status !== "downloading") continue
|
|
710
|
+
|
|
711
|
+
entry.attempts += 1
|
|
712
|
+
entry.nextRetryAt = failedAt + this.retryDelay(entry.attempts)
|
|
713
|
+
entry.status = "failed"
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
await this.saveState()
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Downloads, verifies, and atomically persists one content digest.
|
|
721
|
+
* @param {import("./types.js").SynchronizedAssetCacheDescriptor} descriptor Asset descriptor.
|
|
722
|
+
* @returns {Promise<string>} Adapter URI.
|
|
723
|
+
*/
|
|
724
|
+
async downloadVerified(descriptor) {
|
|
725
|
+
const downloadedBytes = await this.download(descriptor)
|
|
726
|
+
|
|
727
|
+
if (!(downloadedBytes instanceof Uint8Array)) {
|
|
728
|
+
throw new Error(`Synchronized asset ${descriptor.id} download did not return Uint8Array bytes`)
|
|
729
|
+
}
|
|
730
|
+
if (downloadedBytes.byteLength !== descriptor.byteSize) {
|
|
731
|
+
throw new Error(`Synchronized asset ${descriptor.id} byte size did not match its descriptor`)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const digest = `sha256-${sha256BytesHex(downloadedBytes)}`
|
|
735
|
+
|
|
736
|
+
if (digest !== descriptor.digest) {
|
|
737
|
+
throw new Error(`Synchronized asset ${descriptor.id} digest did not match its descriptor`)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const uri = await this.adapter.writeBlob({
|
|
741
|
+
accountId: this.accountId,
|
|
742
|
+
bytes: downloadedBytes,
|
|
743
|
+
contentType: descriptor.contentType,
|
|
744
|
+
digest
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
if (!uri) throw new Error(`Synchronized asset adapter returned no URI for ${descriptor.id}`)
|
|
748
|
+
|
|
749
|
+
return uri
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Resolves an existing local URI after waiting for deletion work.
|
|
754
|
+
* @param {import("./types.js").SynchronizedAssetCacheEntry} entry Descriptor state.
|
|
755
|
+
* @returns {Promise<string | null>} Existing URI.
|
|
756
|
+
*/
|
|
757
|
+
async cachedUri(entry) {
|
|
758
|
+
const digest = entry.descriptor.digest
|
|
759
|
+
|
|
760
|
+
while (true) {
|
|
761
|
+
await this.beginActiveDigest(digest)
|
|
762
|
+
let revalidationRequired
|
|
763
|
+
let uri
|
|
764
|
+
|
|
765
|
+
try {
|
|
766
|
+
uri = await this.cachedUriWhileActive(entry)
|
|
767
|
+
} finally {
|
|
768
|
+
revalidationRequired = await this.finishActiveDigest(digest)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (!this.state) throw new Error("Cannot revalidate synchronized asset cache URI before loading state")
|
|
772
|
+
if (!this.state.assets.some((candidate) => {
|
|
773
|
+
return candidate.descriptor.id === entry.descriptor.id && candidate.descriptor.digest === digest
|
|
774
|
+
})) return null
|
|
775
|
+
if (!revalidationRequired) return uri
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Resolves an existing local URI while its digest is protected.
|
|
781
|
+
* @param {import("./types.js").SynchronizedAssetCacheEntry} entry Descriptor state.
|
|
782
|
+
* @returns {Promise<string | null>} Existing URI.
|
|
783
|
+
*/
|
|
784
|
+
async cachedUriWhileActive(entry) {
|
|
785
|
+
if (!this.state) throw new Error("Cannot resolve synchronized asset cache URI before loading state")
|
|
786
|
+
if (this.state.pendingDeletionDigests.includes(entry.descriptor.digest)) {
|
|
787
|
+
if (entry.status === "cached") entry.status = "missing"
|
|
788
|
+
|
|
789
|
+
return null
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const uri = await this.adapter.blobUri({
|
|
793
|
+
accountId: this.accountId,
|
|
794
|
+
digest: entry.descriptor.digest
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
if (!uri && entry.status === "cached") entry.status = "missing"
|
|
798
|
+
|
|
799
|
+
return uri
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Waits for deletion and protects a digest for one active cache operation.
|
|
804
|
+
* @param {string} digest Content digest.
|
|
805
|
+
* @returns {Promise<void>} Resolves after protection is registered.
|
|
806
|
+
*/
|
|
807
|
+
async beginActiveDigest(digest) {
|
|
808
|
+
let deletionPromise = this.deletionPromises.get(digest)
|
|
809
|
+
|
|
810
|
+
while (deletionPromise) {
|
|
811
|
+
await deletionPromise
|
|
812
|
+
deletionPromise = this.deletionPromises.get(digest)
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const activeCount = this.activeDigestCounts.get(digest) ?? 0
|
|
816
|
+
|
|
817
|
+
this.activeDigestCounts.set(digest, activeCount + 1)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Releases one cache operation and processes deferred deletion after the last.
|
|
822
|
+
* @param {string} digest Content digest.
|
|
823
|
+
* @param {Set<string>} [protectedCleanupDigests] Digests needed by the resolving caller.
|
|
824
|
+
* @returns {Promise<boolean>} Whether finalization requires URI revalidation.
|
|
825
|
+
*/
|
|
826
|
+
async finishActiveDigest(digest, protectedCleanupDigests = new Set()) {
|
|
827
|
+
const activeCount = this.activeDigestCounts.get(digest)
|
|
828
|
+
|
|
829
|
+
if (activeCount === undefined) {
|
|
830
|
+
throw new Error(`Missing active synchronized asset digest count for ${digest}`)
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (activeCount > 1) {
|
|
834
|
+
this.activeDigestCounts.set(digest, activeCount - 1)
|
|
835
|
+
return false
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
this.activeDigestCounts.delete(digest)
|
|
839
|
+
const pendingDigestDeleted = await this.deletePendingDigestIfUnreferenced(digest)
|
|
840
|
+
const deferredCleanupRequired = this.cleanupRequiredAfterReleaseDigests.delete(digest)
|
|
841
|
+
|
|
842
|
+
if (deferredCleanupRequired) await this.cleanup(protectedCleanupDigests)
|
|
843
|
+
|
|
844
|
+
return pendingDigestDeleted || deferredCleanupRequired
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Releases every acquired digest before propagating finalization failures.
|
|
849
|
+
* @param {string[]} digests Content digests.
|
|
850
|
+
* @returns {Promise<void>} Resolves after every digest is released.
|
|
851
|
+
*/
|
|
852
|
+
async finishActiveDigests(digests) {
|
|
853
|
+
/** @type {Error[]} */
|
|
854
|
+
const failures = []
|
|
855
|
+
|
|
856
|
+
for (const digest of digests) {
|
|
857
|
+
try {
|
|
858
|
+
await this.finishActiveDigest(digest)
|
|
859
|
+
} catch (error) {
|
|
860
|
+
failures.push(error instanceof Error ? error : new Error(String(error)))
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (failures.length === 1) throw failures[0]
|
|
865
|
+
if (failures.length > 1) {
|
|
866
|
+
throw new AggregateError(failures, "Multiple synchronized asset digest finalizers failed", {cause: failures[0]})
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Deletes blobs that lost their final descriptor reference.
|
|
872
|
+
* @returns {Promise<void>} Resolves after deletion.
|
|
873
|
+
*/
|
|
874
|
+
async deleteUnreferencedDigests() {
|
|
875
|
+
if (!this.state) throw new Error("Cannot delete synchronized asset blobs before loading state")
|
|
876
|
+
|
|
877
|
+
for (const digest of [...this.state.pendingDeletionDigests]) {
|
|
878
|
+
await this.deletePendingDigestIfUnreferenced(digest)
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Deletes one persisted pending digest when no descriptor or active operation owns it.
|
|
884
|
+
* @param {string} digest Content digest.
|
|
885
|
+
* @returns {Promise<boolean>} Whether the blob was deleted.
|
|
886
|
+
*/
|
|
887
|
+
async deletePendingDigestIfUnreferenced(digest) {
|
|
888
|
+
if (!this.state) throw new Error("Cannot delete synchronized asset blobs before loading state")
|
|
889
|
+
if (!this.state.pendingDeletionDigests.includes(digest)) return false
|
|
890
|
+
|
|
891
|
+
return await this.deleteDigestIfInactive(digest, async () => {
|
|
892
|
+
if (!this.state) throw new Error("Cannot delete synchronized asset blobs before loading state")
|
|
893
|
+
if (!this.state.pendingDeletionDigests.includes(digest)) return false
|
|
894
|
+
if (this.state.assets.some((entry) => entry.descriptor.digest === digest)) return false
|
|
895
|
+
|
|
896
|
+
await this.adapter.deleteBlob({accountId: this.accountId, digest})
|
|
897
|
+
|
|
898
|
+
const pendingDeletionDigests = this.state.pendingDeletionDigests
|
|
899
|
+
|
|
900
|
+
this.state.pendingDeletionDigests = pendingDeletionDigests.filter((candidate) => candidate !== digest)
|
|
901
|
+
|
|
902
|
+
try {
|
|
903
|
+
await this.saveState()
|
|
904
|
+
} catch (error) {
|
|
905
|
+
if (!this.state.pendingDeletionDigests.includes(digest)) this.state.pendingDeletionDigests.push(digest)
|
|
906
|
+
throw error
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
return true
|
|
910
|
+
})
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Runs one deletion only after earlier deletion work and when no cache operation owns the digest.
|
|
915
|
+
* @param {string} digest Content digest.
|
|
916
|
+
* @param {() => Promise<boolean>} callback Protected deletion callback.
|
|
917
|
+
* @returns {Promise<boolean>} Whether the callback deleted the blob.
|
|
918
|
+
*/
|
|
919
|
+
async deleteDigestIfInactive(digest, callback) {
|
|
920
|
+
let activeDeletionPromise = this.deletionPromises.get(digest)
|
|
921
|
+
|
|
922
|
+
while (activeDeletionPromise) {
|
|
923
|
+
await activeDeletionPromise
|
|
924
|
+
activeDeletionPromise = this.deletionPromises.get(digest)
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
if (this.activeDigestCounts.has(digest)) return false
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Releases callers waiting for deletion completion.
|
|
931
|
+
* @type {() => void}
|
|
932
|
+
*/
|
|
933
|
+
let releaseDeletion = () => {}
|
|
934
|
+
/**
|
|
935
|
+
* Blocks new digest activity until deletion completes.
|
|
936
|
+
* @type {Promise<void>}
|
|
937
|
+
*/
|
|
938
|
+
const deletionPromise = new Promise((resolve) => {
|
|
939
|
+
releaseDeletion = () => resolve(undefined)
|
|
940
|
+
})
|
|
941
|
+
|
|
942
|
+
this.deletionPromises.set(digest, deletionPromise)
|
|
943
|
+
|
|
944
|
+
try {
|
|
945
|
+
return await callback()
|
|
946
|
+
} finally {
|
|
947
|
+
if (this.deletionPromises.get(digest) === deletionPromise) this.deletionPromises.delete(digest)
|
|
948
|
+
releaseDeletion()
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Finds required assets without locally cached bytes.
|
|
954
|
+
* @param {string} scopeKey Synchronized scope to inspect.
|
|
955
|
+
* @returns {Promise<string[]>} Missing required descriptor ids.
|
|
956
|
+
*/
|
|
957
|
+
async missingRequiredAssetIds(scopeKey) {
|
|
958
|
+
const state = await this.loadState()
|
|
959
|
+
/** @type {string[]} */
|
|
960
|
+
const missingAssetIds = []
|
|
961
|
+
|
|
962
|
+
for (const entry of state.assets) {
|
|
963
|
+
if (!entry.scopeKeys.includes(scopeKey)) continue
|
|
964
|
+
if (entry.descriptor.offlineRequirement !== "required") continue
|
|
965
|
+
if (await this.cachedUri(entry)) continue
|
|
966
|
+
|
|
967
|
+
missingAssetIds.push(entry.descriptor.id)
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
return missingAssetIds
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Checks whether a failed or missing entry may be downloaded now.
|
|
975
|
+
* @param {import("./types.js").SynchronizedAssetCacheEntry} entry Descriptor state.
|
|
976
|
+
* @returns {boolean} Whether the retry deadline has passed.
|
|
977
|
+
*/
|
|
978
|
+
retryEligible(entry) {
|
|
979
|
+
return entry.status !== "failed" || entry.nextRetryAt === null || entry.nextRetryAt <= this.nowMilliseconds()
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Calculates bounded exponential retry delay.
|
|
984
|
+
* @param {number} attempts Consecutive failures.
|
|
985
|
+
* @returns {number} Retry delay.
|
|
986
|
+
*/
|
|
987
|
+
retryDelay(attempts) {
|
|
988
|
+
return Math.min(this.retryMaxDelayMs, this.retryBaseDelayMs * (2 ** Math.max(0, attempts - 1)))
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Reads the injectable wall clock.
|
|
993
|
+
* @returns {number} Current epoch milliseconds.
|
|
994
|
+
*/
|
|
995
|
+
nowMilliseconds() {
|
|
996
|
+
return this.now().getTime()
|
|
997
|
+
}
|
|
998
|
+
}
|