diff --git a/fission/src/mirabuf/MirabufLoader.ts b/fission/src/mirabuf/MirabufLoader.ts index 42daf2301a..0939768fca 100644 --- a/fission/src/mirabuf/MirabufLoader.ts +++ b/fission/src/mirabuf/MirabufLoader.ts @@ -83,6 +83,13 @@ export function unzipMira(buff: Uint8Array): Uint8Array { } class MirabufCachingService { + // Request deduplication: track ongoing fetch requests to prevent race conditions + private static _ongoingRequests = new Map>() + + // Storage operation locks to prevent concurrent storage race conditions + private static _storageOperations = new Map>() + private static _idCounter = 0 + /** * Get the map of mirabuf keys and paired MirabufCacheInfo from local storage * @@ -124,6 +131,27 @@ class MirabufCachingService { const target = map[fetchLocation] if (target) return target } + + // Check if there's already an ongoing request for this URL + const requestKey = `${fetchLocation}:${miraType ?? "unknown"}` + const ongoingRequest = this._ongoingRequests.get(requestKey) + if (ongoingRequest) { + return ongoingRequest + } + + const requestPromise = this._fetchAndCache(fetchLocation, miraType) + this._ongoingRequests.set(requestKey, requestPromise) + requestPromise.finally(() => { + this._ongoingRequests.delete(requestKey) + }) + + return requestPromise + } + + private static async _fetchAndCache( + fetchLocation: string, + miraType?: MiraType + ): Promise { try { // grab file remote const resp = await fetch(encodeURI(fetchLocation), import.meta.env.DEV ? { cache: "no-store" } : undefined) @@ -138,17 +166,55 @@ class MirabufCachingService { const cached = await MirabufCachingService.storeInCache(fetchLocation, miraBuff, miraType) - if (cached) return cached + if (cached) { + // Verify that the cached data is actually retrievable + const verification = await MirabufCachingService.get(cached.id, cached.miraType) + if (verification) { + return cached + } else { + console.warn( + `Storage verification failed for "${fetchLocation}" - data not retrievable despite successful cache operation` + ) + } + } + + console.warn(`Primary caching failed for "${fetchLocation}", creating emergency fallback`) + globalAddToast("error", "Cache Fallback", `Unable to cache "${fetchLocation}". Using raw buffer instead.`) - globalAddToast("error", "Cache Fallback", `Unable to cache “${fetchLocation}”. Using raw buffer instead.`) + // Emergency fallback: return raw buffer wrapped in MirabufCacheInfo + const fallbackId = `${Date.now()}_${++this._idCounter}_fallback` + const resolvedMiraType = + miraType ?? (this.assemblyFromBuffer(miraBuff).dynamic ? MiraType.ROBOT : MiraType.FIELD) - // fallback: return raw buffer wrapped in MirabufCacheInfo - return { - id: Date.now().toString(), - miraType: miraType ?? (this.assemblyFromBuffer(miraBuff).dynamic ? MiraType.ROBOT : MiraType.FIELD), + const fallbackInfo: MirabufCacheInfo = { + id: fallbackId, + miraType: resolvedMiraType, cacheKey: fetchLocation, buffer: miraBuff, } + + // Store fallback in memory cache so get() can find it later + const cache = resolvedMiraType == MiraType.ROBOT ? backUpRobots : backUpFields + cache[fallbackId] = fallbackInfo + + // Also try to update localStorage for future reference + try { + const map: MapCache = this.getCacheMap(resolvedMiraType) + map[fetchLocation] = { + id: fallbackId, + miraType: resolvedMiraType, + cacheKey: fetchLocation, + name: `Fallback: ${fetchLocation}`, + } + window.localStorage.setItem( + resolvedMiraType == MiraType.ROBOT ? robotsDirName : fieldsDirName, + JSON.stringify(map) + ) + } catch (lsError) { + console.warn(`Fallback localStorage update failed:`, lsError) + } + + return fallbackInfo } catch (e) { console.warn("Caching failed", e) return undefined @@ -326,37 +392,84 @@ class MirabufCachingService { * @returns {Promise} Promise with the result of the promise. Assembly of the mirabuf file if successful, undefined if not. */ public static async get(id: MirabufCacheID, miraType: MiraType): Promise { - try { - // Get buffer from hashMap. If not in hashMap, check OPFS. Otherwise, buff is undefined - const cache = miraType == MiraType.ROBOT ? backUpRobots : backUpFields - const buff = - cache[id]?.buffer ?? - (await (async () => { - const fileHandle = canOPFS - ? await (miraType == MiraType.ROBOT ? robotFolderHandle : fieldFolderHandle).getFileHandle(id, { - create: false, - }) - : undefined - return fileHandle ? await fileHandle.getFile().then(x => x.arrayBuffer()) : undefined - })()) - - // If we have buffer, get assembly - if (buff) { - const assembly = this.assemblyFromBuffer(buff) - World.analyticsSystem?.event("Cache Get", { - key: id, - type: miraType == MiraType.ROBOT ? "robot" : "field", - assemblyName: assembly.info!.name!, - fileSize: buff.byteLength, - }) - return assembly - } else { - console.error(`Failed to find arrayBuffer for id: ${id}`) + // Retry logic to handle race conditions where storage might still be in progress + const maxRetries = 3 + const retryDelay = 50 // ms + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const cache = miraType == MiraType.ROBOT ? backUpRobots : backUpFields + let buff: ArrayBuffer | undefined + + // Try memory cache first (most reliable and fastest) + if (cache[id]?.buffer) { + buff = cache[id].buffer + } else if (canOPFS) { + // Try OPFS as fallback + try { + const fileHandle = await ( + miraType == MiraType.ROBOT ? robotFolderHandle : fieldFolderHandle + ).getFileHandle(id, { create: false }) + const file = await fileHandle.getFile() + buff = await file.arrayBuffer() + + // If we found it in OPFS but not in memory, update memory cache for next time + if (buff && !cache[id]?.buffer) { + if (cache[id]) { + cache[id].buffer = buff + } else { + // Create a minimal cache entry if it doesn't exist + cache[id] = { + id: id, + miraType: miraType, + cacheKey: `opfs_recovered_${id}`, + buffer: buff, + } + } + } + } catch (opfsError) { + console.debug(`OPFS retrieval failed for ${id}:`, opfsError) + } + } + + // If we have buffer, return assembly + if (buff) { + const assembly = this.assemblyFromBuffer(buff) + World.analyticsSystem?.event("Cache Get", { + key: id, + type: miraType == MiraType.ROBOT ? "robot" : "field", + assemblyName: assembly.info!.name!, + fileSize: buff.byteLength, + }) + return assembly + } + + // If we didn't find the buffer and this isn't the last attempt, wait and retry + if (attempt < maxRetries - 1) { + console.debug( + `Cache miss for ${id}, retrying in ${retryDelay}ms (attempt ${attempt + 1}/${maxRetries})` + ) + await new Promise(resolve => setTimeout(resolve, retryDelay)) + continue + } + + // Last attempt failed + console.error(`Failed to find arrayBuffer for id: ${id} after ${maxRetries} attempts`) + return undefined + } catch (e) { + console.error(`Failed to find file for id ${id} (attempt ${attempt + 1}):`, e) + + // If this is the last attempt, give up + if (attempt === maxRetries - 1) { + return undefined + } + + // Wait before retrying + await new Promise(resolve => setTimeout(resolve, retryDelay)) } - } catch (e) { - console.error(`Failed to find file\n${e}`) - return undefined } + + return undefined } /** @@ -476,24 +589,58 @@ class MirabufCachingService { miraBuff: ArrayBuffer, miraType?: MiraType, name?: string + ): Promise { + // Check if there's already an ongoing storage operation for this key + const storageKey = `${key}:${miraType ?? "unknown"}` + const ongoingStorage = this._storageOperations.get(storageKey) + if (ongoingStorage) { + return ongoingStorage + } + + const storagePromise = this._performStoreInCache(key, miraBuff, miraType, name) + this._storageOperations.set(storageKey, storagePromise) + + storagePromise.finally(() => { + this._storageOperations.delete(storageKey) + }) + + return storagePromise + } + + private static async _performStoreInCache( + key: string, + miraBuff: ArrayBuffer, + miraType?: MiraType, + name?: string ): Promise { try { - const backupID = Date.now().toString() + // Generate unique ID using timestamp + counter to avoid collisions + const backupID = `${Date.now()}_${++this._idCounter}` + if (!miraType) { console.debug("Double loading") miraType = this.assemblyFromBuffer(miraBuff).dynamic ? MiraType.ROBOT : MiraType.FIELD } - // Local cache map - const map: MapCache = this.getCacheMap(miraType) - const info: MirabufCacheInfo = { + // Create the cache info that will be used consistently across all storage locations + const cacheInfo: MirabufCacheInfo = { id: backupID, miraType: miraType, cacheKey: key, name: name, } - map[key] = info - window.localStorage.setItem(miraType == MiraType.ROBOT ? robotsDirName : fieldsDirName, JSON.stringify(map)) + + const memoryInfo: MirabufCacheInfo = { + id: backupID, + miraType: miraType, + cacheKey: key, + buffer: miraBuff, + name: name, + } + + // Store in memory cache FIRST (most reliable) + const cache = miraType == MiraType.ROBOT ? backUpRobots : backUpFields + cache[backupID] = memoryInfo World.analyticsSystem?.event("Cache Store", { name: name ?? "-", @@ -502,29 +649,33 @@ class MirabufCachingService { fileSize: miraBuff.byteLength, }) - // Store buffer + // Store in OPFS (can fail silently) if (canOPFS) { - // Store in OPFS - const fileHandle = await ( - miraType == MiraType.ROBOT ? robotFolderHandle : fieldFolderHandle - ).getFileHandle(backupID, { create: true }) - const writable = await fileHandle.createWritable() - await writable.write(miraBuff) - await writable.close() + try { + const fileHandle = await ( + miraType == MiraType.ROBOT ? robotFolderHandle : fieldFolderHandle + ).getFileHandle(backupID, { create: true }) + const writable = await fileHandle.createWritable() + await writable.write(miraBuff) + await writable.close() + } catch (opfsError) { + console.warn(`OPFS storage failed for ${key}, using fallback:`, opfsError) + } } - // Store in hash - const cache = miraType == MiraType.ROBOT ? backUpRobots : backUpFields - const mapInfo: MirabufCacheInfo = { - id: backupID, - miraType: miraType, - cacheKey: key, - buffer: miraBuff, - name: name, + // Update localStorage cache map LAST (after memory cache is secured) + try { + const map: MapCache = this.getCacheMap(miraType) + map[key] = cacheInfo + window.localStorage.setItem( + miraType == MiraType.ROBOT ? robotsDirName : fieldsDirName, + JSON.stringify(map) + ) + } catch (lsError) { + console.warn(`localStorage update failed for ${key}:`, lsError) } - cache[backupID] = mapInfo - return info + return cacheInfo } catch (e) { console.error("Failed to cache mira " + e) World.analyticsSystem?.exception("Failed to store in cache") diff --git a/fission/src/test/physics/PhysicsSystemRobotSpawning.test.ts b/fission/src/test/physics/PhysicsSystemRobotSpawning.test.ts index d88466f073..51dc3d2436 100644 --- a/fission/src/test/physics/PhysicsSystemRobotSpawning.test.ts +++ b/fission/src/test/physics/PhysicsSystemRobotSpawning.test.ts @@ -5,10 +5,23 @@ import PhysicsSystem, { LayerReserve } from "@/systems/physics/PhysicsSystem" describe("Mirabuf Physics Loading", () => { test("Body Loading (Dozer)", async () => { - const assembly = await MirabufCachingService.cacheRemote("/api/mira/robots/Dozer_v9.mira", MiraType.ROBOT).then( - x => MirabufCachingService.get(x!.id, MiraType.ROBOT) - ) - const parser = new MirabufParser(assembly!) + const cacheInfo = await MirabufCachingService.cacheRemote("/api/mira/robots/Dozer_v9.mira", MiraType.ROBOT) + + if (!cacheInfo) { + console.warn("Warning: Remote mirabuf file not accessible - cacheRemote returned undefined") + console.warn("This may indicate network issues or API unavailability in the test environment") + throw new Error("Failed to cache remote mirabuf file") + } + + const assembly = await MirabufCachingService.get(cacheInfo.id, MiraType.ROBOT) + + if (!assembly) { + console.warn(`Warning: Failed to load mirabuf assembly from cache with ID: ${cacheInfo.id}`) + console.warn("This may indicate storage issues or fallback mechanism problems") + throw new Error("Failed to load mirabuf assembly from cache") + } + + const parser = new MirabufParser(assembly) const physSystem = new PhysicsSystem() const mapping = physSystem.createBodiesFromParser(parser, new LayerReserve()) @@ -23,11 +36,26 @@ describe("Mirabuf Physics Loading", () => { * Mira File: https://synthesis.autodesk.com/api/mira/private/Multi-Joint_Wheels_v0.mira */ test("Body Loading (Multi-Joint Wheels)", async () => { - const assembly = await MirabufCachingService.cacheRemote( + const cacheInfo = await MirabufCachingService.cacheRemote( "/api/mira/private/Multi-Joint_Wheels_v0.mira", MiraType.ROBOT - ).then(x => MirabufCachingService.get(x!.id, MiraType.ROBOT)) - const parser = new MirabufParser(assembly!) + ) + + if (!cacheInfo) { + console.warn("Warning: Remote mirabuf file not accessible - cacheRemote returned undefined") + console.warn("This may indicate network issues or API unavailability in the test environment") + throw new Error("Failed to cache remote mirabuf file") + } + + const assembly = await MirabufCachingService.get(cacheInfo.id, MiraType.ROBOT) + + if (!assembly) { + console.warn(`Warning: Failed to load mirabuf assembly from cache with ID: ${cacheInfo.id}`) + console.warn("This may indicate storage issues or fallback mechanism problems") + throw new Error("Failed to load mirabuf assembly from cache") + } + + const parser = new MirabufParser(assembly) const physSystem = new PhysicsSystem() const mapping = physSystem.createBodiesFromParser(parser, new LayerReserve())