Skip to content

Commit b66d29c

Browse files
committed
fix: make the asset-bundle cache safe across processes
The content-key lock was per-process: a second sdk-commands start on the same project — another terminal, a stale process, CI running preview twice — still converted concurrently and interleaved bundle files into one cache directory, which readCache would then serve as a mix. Conversions now stage into a private directory and publish with a single rename. A reader sees the entry whole or not at all, and two previews racing the same content produce identical bytes, so the loser discards its own rather than merging into a directory it does not own. An advisory lock keeps the second preview off work already running. Every failure mode in it — stale lock, timeout, unwritable directory — degrades to converting twice, which the atomic publish already makes safe, so correctness never rests on the lock and it can stay approximate. A killed conversion's staging is swept on the next start, age-gated so a live conversion's directory is never taken out from under it. Each mechanism is mutation-checked: publishing without staging fails the two publish tests, never inspecting a held lock's age fails the wait test, and sweeping without the age gate fails the sweep test.
1 parent 79a708a commit b66d29c

2 files changed

Lines changed: 242 additions & 8 deletions

File tree

packages/@dcl/sdk-commands/src/commands/start/asset-bundles.ts

Lines changed: 141 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ export type AssetBundles = {
3030
invalidate(): void
3131
}
3232

33+
/** Past this, a lock's owner is presumed dead and its staging is litter. */
34+
const STALE_LOCK_MS = 10 * 60_000
35+
/** Past this, converting twice beats waiting any longer on another preview. */
36+
const WAIT_FOR_LOCK_MS = 2 * 60_000
37+
const POLL_LOCK_MS = 250
38+
3339
/** "windows" | "mac" | "linux" — the platforms abgen's export lane accepts. */
3440
export function hostPlatform(): string {
3541
return process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'mac' : 'linux'
@@ -63,6 +69,8 @@ export async function setupAssetBundles(
6369
const converted = new Map<string, ConvertedScene>()
6470
const platform = hostPlatform()
6571

72+
await sweepStaging(components, cacheRoot)
73+
6674
// One conversion per distinct content, and only the newest publishes.
6775
//
6876
// Two separate faults lived here. A second invalidate() while a conversion
@@ -197,12 +205,98 @@ async function convertScene(
197205
input: { files: SceneFile[]; contentKey: string }
198206
): Promise<ConvertedScene | undefined> {
199207
const { files, contentKey } = input
200-
const cached = await readCache(components, { ...job, contentKey })
208+
const full = { ...job, contentKey }
209+
const cached = await readCache(components, full)
201210
if (cached) {
202211
components.logger.log(`asset-bundles: scene already converted (${cached.bundles.size} bundles, cached)`)
203212
return cached
204213
}
205214

215+
const lock = await acquireLock(components, full)
216+
try {
217+
// The lock may have been held by another preview that has since finished,
218+
// so the miss above can be out of date by the time we get here.
219+
const justFinished = await readCache(components, full)
220+
if (justFinished) {
221+
components.logger.log(
222+
`asset-bundles: scene converted by another preview (${justFinished.bundles.size} bundles, cached)`
223+
)
224+
return justFinished
225+
}
226+
return await runConversion(components, abgen, full, files)
227+
} finally {
228+
await lock.release()
229+
}
230+
}
231+
232+
/**
233+
* Keeps a second preview of the same project off work already in progress.
234+
*
235+
* Advisory only, and deliberately so: every failure mode here — a stale lock, a
236+
* timeout, an unwritable directory — degrades to converting twice, which
237+
* writeCache's atomic publish already makes safe. Correctness never rests on
238+
* the lock, so it can be as approximate as it likes.
239+
*/
240+
async function acquireLock(
241+
components: Pick<CliComponents, 'fs' | 'logger'>,
242+
job: { cacheRoot: string; platform: string; contentKey: string }
243+
): Promise<{ release(): Promise<void> }> {
244+
const lockPath = `${cacheDir(job)}.lock`
245+
const noop = { release: async () => undefined }
246+
const drop = {
247+
release: async () => {
248+
try {
249+
await components.fs.unlink(lockPath)
250+
} catch {
251+
// Someone judged it stale and took it. Theirs to remove now.
252+
}
253+
}
254+
}
255+
256+
const deadline = Date.now() + WAIT_FOR_LOCK_MS
257+
for (;;) {
258+
try {
259+
await components.fs.mkdir(job.cacheRoot, { recursive: true })
260+
await components.fs.writeFile(lockPath, `${process.pid}\n`, { flag: 'wx' })
261+
return drop
262+
} catch (error: any) {
263+
if (error?.code !== 'EEXIST') return noop
264+
}
265+
266+
const age = await lockAge(components, lockPath)
267+
if (age === undefined) continue // released while we looked; try to take it
268+
if (age > STALE_LOCK_MS) {
269+
// Its owner died mid-conversion. Nothing here is transactional enough to
270+
// be worth a handshake — the cost of being wrong is a second conversion.
271+
try {
272+
await components.fs.unlink(lockPath)
273+
} catch {
274+
// Lost the race to another waiter; it holds the lock now.
275+
}
276+
continue
277+
}
278+
if (Date.now() > deadline) {
279+
components.logger.warn('asset-bundles: another preview is still converting this scene; converting anyway')
280+
return noop
281+
}
282+
await new Promise((resolve) => setTimeout(resolve, POLL_LOCK_MS))
283+
}
284+
}
285+
286+
async function lockAge(components: Pick<CliComponents, 'fs'>, lockPath: string): Promise<number | undefined> {
287+
try {
288+
return Date.now() - (await components.fs.stat(lockPath)).mtimeMs
289+
} catch {
290+
return undefined
291+
}
292+
}
293+
294+
async function runConversion(
295+
components: Pick<CliComponents, 'fs' | 'logger'>,
296+
abgen: AbgenModule,
297+
job: { projectRoot: string; entityId: string; platform: string; cacheRoot: string; contentKey: string },
298+
files: SceneFile[]
299+
): Promise<ConvertedScene | undefined> {
206300
const started = Date.now()
207301
printProgressInfo(components.logger, 'asset-bundles: converting the scene (cached after the first run)...')
208302

@@ -233,7 +327,7 @@ async function convertScene(
233327
manifest,
234328
bundles: new Map(result.bundles.map((b) => [b.name, b.data]))
235329
}
236-
await writeCache(components, { ...job, contentKey }, scene)
330+
await writeCache(components, job, scene)
237331
components.logger.log(`asset-bundles: scene converted (${scene.bundles.size} bundles, ${elapsed}s)`)
238332
return scene
239333
}
@@ -291,18 +385,59 @@ async function readCache(
291385
}
292386
}
293387

388+
/**
389+
* Publishes the conversion as one indivisible step.
390+
*
391+
* Written into a private staging directory and moved into place with a single
392+
* rename, which is atomic on every filesystem this runs on. A reader therefore
393+
* sees the entry complete or not at all, and two previews converting the same
394+
* content race harmlessly: the loser's bytes are identical to the winner's, so
395+
* it discards them rather than merging into a directory someone else owns.
396+
*/
294397
async function writeCache(
295398
components: Pick<CliComponents, 'fs'>,
296399
job: { cacheRoot: string; entityId: string; platform: string; contentKey: string },
297400
scene: ConvertedScene
298401
): Promise<void> {
299402
const dir = cacheDir(job)
300-
await components.fs.mkdir(path.join(dir, 'bundles'), { recursive: true })
403+
const staging = `${dir}.tmp-${crypto.randomBytes(6).toString('hex')}`
404+
await components.fs.mkdir(path.join(staging, 'bundles'), { recursive: true })
301405
for (const [name, data] of scene.bundles) {
302-
await components.fs.writeFile(path.join(dir, 'bundles', name), data)
406+
await components.fs.writeFile(path.join(staging, 'bundles', name), data)
407+
}
408+
await components.fs.writeFile(path.join(staging, 'manifest.json'), scene.manifest)
409+
410+
try {
411+
await components.fs.rename(staging, dir)
412+
} catch {
413+
await components.fs.rm(staging, { recursive: true, force: true })
414+
}
415+
}
416+
417+
/**
418+
* Clears staging directories a killed conversion left behind.
419+
*
420+
* Age-gated because a young one is not litter — it belongs to a preview that is
421+
* converting right now, and removing it would delete that conversion's output
422+
* from under it.
423+
*/
424+
async function sweepStaging(components: Pick<CliComponents, 'fs'>, cacheRoot: string): Promise<void> {
425+
let entries: string[]
426+
try {
427+
entries = await components.fs.readdir(cacheRoot)
428+
} catch {
429+
return // no cache yet, nothing to sweep
430+
}
431+
for (const entry of entries) {
432+
if (!/\.tmp-[0-9a-f]+$/.test(entry)) continue
433+
const full = path.join(cacheRoot, entry)
434+
try {
435+
if (Date.now() - (await components.fs.stat(full)).mtimeMs < STALE_LOCK_MS) continue
436+
await components.fs.rm(full, { recursive: true, force: true })
437+
} catch {
438+
// Being removed by whoever owns it, or not ours to remove.
439+
}
303440
}
304-
// Written last: its presence is what marks the cache complete.
305-
await components.fs.writeFile(path.join(dir, 'manifest.json'), scene.manifest)
306441
}
307442

308443
/** The slice of @dcl/abgen-node this command uses. */

test/sdk-commands/commands/start/asset-bundles.spec.ts

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,12 @@ function makeComponents({ cached = false }: { cached?: boolean } = {}) {
2020
fileExists: jest.fn(async () => cached),
2121
readFile: jest.fn(async (p: string) => (p.endsWith('manifest.json') ? '{"exitCode":0}' : Buffer.from('bytes'))),
2222
readdir: jest.fn(async () => ['a_mac']),
23-
mkdir: jest.fn(async () => undefined),
24-
writeFile: jest.fn(async () => undefined)
23+
mkdir: jest.fn(async (_p: string, _opts?: any) => undefined),
24+
writeFile: jest.fn(async (_p: string, _data?: any, _opts?: any) => undefined),
25+
rename: jest.fn(async (_from: string, _to: string) => undefined),
26+
rm: jest.fn(async (_p: string, _opts?: any) => undefined),
27+
unlink: jest.fn(async (_p: string) => undefined),
28+
stat: jest.fn(async (_p: string) => ({ mtimeMs: Date.now() }))
2529
}
2630
const config = { getString: jest.fn(async () => undefined), requireString: jest.fn(), getNumber: jest.fn() }
2731
const fetch = { fetch: jest.fn() }
@@ -239,3 +243,98 @@ describe('start/asset-bundles reconversion', () => {
239243
expect(assetBundles!.get(hostPlatform())?.bundles.has('old_mac')).toBeFalsy()
240244
})
241245
})
246+
247+
describe('start/asset-bundles cache publishing', () => {
248+
const files = require('../../../../packages/@dcl/sdk-commands/src/logic/project-files')
249+
afterEach(() => {
250+
jest.clearAllMocks()
251+
files.getPublishableFiles.mockResolvedValue(['scene.json', 'models/a.glb'])
252+
})
253+
254+
const ok = {
255+
code: 0,
256+
bundles: [{ name: 'a_mac', data: Buffer.from('B') }],
257+
events: [],
258+
errors: [],
259+
manifest: '{"exitCode":0}'
260+
}
261+
262+
it('stages the conversion and publishes it with a single rename', async () => {
263+
const { components, fs } = makeComponents()
264+
convert.mockResolvedValue(ok)
265+
266+
const assetBundles = await setupAssetBundles(components, PROJECT)
267+
await assetBundles!.ready
268+
269+
// Nothing may be written straight into the directory a reader looks at.
270+
const written = fs.writeFile.mock.calls.map((c: any) => c[0])
271+
const bundles = written.filter((p: string) => p.includes('bundles'))
272+
expect(bundles.length).toBeGreaterThan(0)
273+
for (const p of written) {
274+
if (p.endsWith('.lock')) continue
275+
expect(p).toContain('.tmp-')
276+
}
277+
278+
const [from, to] = fs.rename.mock.calls[0]
279+
expect(from).toContain('.tmp-')
280+
expect(to).not.toContain('.tmp-')
281+
expect(to.endsWith(`_${hostPlatform()}`)).toBe(true)
282+
})
283+
284+
it('discards its own output when another preview published first', async () => {
285+
const { components, fs } = makeComponents()
286+
convert.mockResolvedValue(ok)
287+
fs.rename.mockRejectedValue(Object.assign(new Error('not empty'), { code: 'ENOTEMPTY' }))
288+
289+
const assetBundles = await setupAssetBundles(components, PROJECT)
290+
291+
// The scene still resolves: the winner's bytes are ours byte for byte.
292+
await expect(assetBundles!.ready).resolves.toBeDefined()
293+
expect(fs.rm).toHaveBeenCalledWith(expect.stringContaining('.tmp-'), { recursive: true, force: true })
294+
})
295+
296+
it('takes over a lock whose owner died', async () => {
297+
const { components, fs } = makeComponents()
298+
convert.mockResolvedValue(ok)
299+
fs.writeFile.mockRejectedValueOnce(Object.assign(new Error('exists'), { code: 'EEXIST' }))
300+
fs.stat.mockResolvedValueOnce({ mtimeMs: Date.now() - 60 * 60_000 })
301+
302+
const assetBundles = await setupAssetBundles(components, PROJECT)
303+
await assetBundles!.ready
304+
305+
expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining('.lock'))
306+
expect(convert).toHaveBeenCalledTimes(1)
307+
})
308+
309+
it('uses what a live lock holder produced rather than converting again', async () => {
310+
const { components, fs } = makeComponents()
311+
convert.mockResolvedValue(ok)
312+
fs.writeFile.mockRejectedValueOnce(Object.assign(new Error('exists'), { code: 'EEXIST' }))
313+
// Absent for the pre-lock miss, present once the holder has published.
314+
fs.fileExists.mockResolvedValueOnce(false).mockResolvedValue(true)
315+
316+
const assetBundles = await setupAssetBundles(components, PROJECT)
317+
318+
await expect(assetBundles!.ready).resolves.toBeDefined()
319+
expect(convert).not.toHaveBeenCalled()
320+
// Held and fresh, so it was waited on. Without this the test passes even
321+
// if the lock is ignored outright, since the re-read alone finds the entry.
322+
expect(fs.stat).toHaveBeenCalledWith(expect.stringContaining('.lock'))
323+
})
324+
325+
it('sweeps abandoned staging directories but leaves live ones alone', async () => {
326+
const { components, fs } = makeComponents()
327+
convert.mockResolvedValue(ok)
328+
fs.readdir.mockResolvedValue(['abc_mac', 'abc_mac.tmp-0011aabb', 'abc_mac.tmp-ffee2233'])
329+
fs.stat.mockImplementation(async (p: string) => ({
330+
mtimeMs: p.endsWith('0011aabb') ? Date.now() - 60 * 60_000 : Date.now()
331+
}))
332+
333+
await setupAssetBundles(components, PROJECT)
334+
335+
const swept = fs.rm.mock.calls.map((c: any) => c[0])
336+
expect(swept).toContain(`${PROJECT}/.dcl-optimized-assets/abc_mac.tmp-0011aabb`)
337+
expect(swept).not.toContain(`${PROJECT}/.dcl-optimized-assets/abc_mac.tmp-ffee2233`)
338+
expect(swept).not.toContain(`${PROJECT}/.dcl-optimized-assets/abc_mac`)
339+
})
340+
})

0 commit comments

Comments
 (0)