Skip to content

Commit 1833359

Browse files
fix: Recover obsolete fallback (#108)
* fix: Atomic persisting and rotating * fix: Recover obsolete fallback * fix: Tests * fix: Linting * fix: Linting
1 parent e00fa84 commit 1833359

5 files changed

Lines changed: 309 additions & 47 deletions

File tree

src/adapters/db.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,11 +263,11 @@ export function createDbAdapter({ pg }: Pick<AppComponents, 'pg'>): IDbComponent
263263
const lowerCasePointers = registry.pointers.map((p) => p.toLowerCase())
264264

265265
const query: SQLStatement = SQL`
266-
SELECT
266+
SELECT
267267
id, pointers, timestamp, status, bundles, versions
268-
FROM
268+
FROM
269269
registries
270-
WHERE
270+
WHERE
271271
pointers && ${lowerCasePointers}::varchar(255)[]
272272
AND LOWER(id) != ${registry.id.toLocaleLowerCase()}
273273
AND status != ${Registry.Status.OBSOLETE}

src/logic/registry/component.ts

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,7 @@ export function createRegistryComponent({
105105
registry.bundles.assets.windows === Registry.SimplifiedStatus.COMPLETE
106106

107107
if (areAssetsComplete) {
108-
const hasNewerPending = splitRelatedEntities.newerEntities.some(
109-
(entity) => entity.status === Registry.Status.PENDING
110-
)
111-
return hasNewerPending ? Registry.Status.FALLBACK : Registry.Status.COMPLETE
108+
return Registry.Status.COMPLETE
112109
}
113110

114111
return Registry.Status.PENDING
@@ -132,16 +129,6 @@ export function createRegistryComponent({
132129
status: registryStatus
133130
})
134131

135-
if (splitRelatedEntities.olderEntities.length) {
136-
const olderEntitiesIds = splitRelatedEntities.olderEntities.map((entity: Registry.PartialDbEntity) => entity.id)
137-
logger.debug('Marking older entities as outdated', {
138-
newEntityId: registry.id,
139-
olderEntitiesIds: olderEntitiesIds.join(', ')
140-
})
141-
142-
await db.updateRegistriesStatus(olderEntitiesIds, Registry.Status.OBSOLETE)
143-
}
144-
145132
if (splitRelatedEntities.fallback) {
146133
logger.debug('Marking entity as fallback', {
147134
entityId: registry.id,
@@ -233,16 +220,17 @@ export function createRegistryComponent({
233220
fallback: splitRelatedEntities.fallback?.id || ''
234221
})
235222

236-
const olderEntityIds = splitRelatedEntities.olderEntities.map((e) => e.id)
223+
// Only rotate older entities and fallback when the current entity completes or is obsolete.
224+
// When FAILED or PENDING, leave other entities untouched so fallbacks are preserved.
225+
const shouldRotate = status === Registry.Status.COMPLETE || status === Registry.Status.OBSOLETE
226+
227+
const olderEntityIds = shouldRotate ? splitRelatedEntities.olderEntities.map((e) => e.id) : []
237228

238229
let fallbackUpdate: { id: string; status: Registry.Status } | null = null
239-
if (splitRelatedEntities.fallback) {
230+
if (splitRelatedEntities.fallback && shouldRotate) {
240231
fallbackUpdate = {
241232
id: splitRelatedEntities.fallback.id,
242-
status:
243-
status === Registry.Status.OBSOLETE || status === Registry.Status.COMPLETE
244-
? Registry.Status.OBSOLETE
245-
: Registry.Status.FALLBACK
233+
status: Registry.Status.OBSOLETE
246234
}
247235
}
248236

test/integration/deployment.spec.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,11 +123,11 @@ test('deployment message handling', async ({ components, spyComponents }) => {
123123
await components.messageProcessor.process(createDeploymentMessage(newerEntityId))
124124
})
125125

126-
it('should mark the older registry as obsolete', async () => {
126+
it('should keep the older registry as pending since it has not completed conversion yet', async () => {
127127
const olderRegistry = await components.db.getRegistryById(olderEntityId)
128128

129129
expect(olderRegistry).not.toBeNull()
130-
expect(olderRegistry!.status).toBe(Registry.Status.OBSOLETE)
130+
expect(olderRegistry!.status).toBe(Registry.Status.PENDING)
131131
})
132132

133133
it('should persist the newer registry with pending status', async () => {
@@ -278,11 +278,11 @@ test('deployment message handling', async ({ components, spyComponents }) => {
278278
await components.messageProcessor.process(createDeploymentMessage(newerEntityId, [worldContentServerUrl]))
279279
})
280280

281-
it('should mark the older registry as obsolete', async () => {
281+
it('should keep the older registry as pending since it has not completed conversion yet', async () => {
282282
const olderRegistry = await components.db.getRegistryById(olderEntityId)
283283

284284
expect(olderRegistry).not.toBeNull()
285-
expect(olderRegistry!.status).toBe(Registry.Status.OBSOLETE)
285+
expect(olderRegistry!.status).toBe(Registry.Status.PENDING)
286286
})
287287

288288
it('should persist the newer registry with pending status', async () => {
@@ -487,11 +487,11 @@ test('deployment message handling', async ({ components, spyComponents }) => {
487487
await components.messageProcessor.process(createDeploymentMessage(newerEntityId, [worldContentServerUrl]))
488488
})
489489

490-
it('should mark the older registry as obsolete', async () => {
490+
it('should keep the older registry as pending since it has not completed conversion yet', async () => {
491491
const olderRegistry = await components.db.getRegistryById(olderEntityId)
492492

493493
expect(olderRegistry).not.toBeNull()
494-
expect(olderRegistry!.status).toBe(Registry.Status.OBSOLETE)
494+
expect(olderRegistry!.status).toBe(Registry.Status.PENDING)
495495
})
496496

497497
it('should persist the newer registry with pending status', async () => {

test/integration/texture-conversion.spec.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,4 +279,197 @@ test('texture conversion handling', async ({ components, spyComponents }) => {
279279
expect(olderRegistry!.status).toBe(Registry.Status.OBSOLETE)
280280
})
281281
})
282+
283+
describe('when a redeployment arrives before the first scene finishes conversion and the second one fails', () => {
284+
let olderEntityId: string
285+
let newerEntityId: string
286+
287+
beforeEach(async () => {
288+
olderEntityId = `texture-redeploy-older-${Date.now()}`
289+
newerEntityId = `texture-redeploy-newer-${Date.now()}`
290+
291+
// Deploy the older scene (textures have NOT completed yet)
292+
const olderEntity = createEntity({ id: olderEntityId, pointers: ['905,905'], timestamp: 1000 })
293+
mockGenesisCityDeployment(olderEntity)
294+
registriesToCleanUp.push(olderEntityId)
295+
await components.messageProcessor.process(createDeploymentMessage(olderEntityId))
296+
297+
// Deploy the newer scene at the same pointers BEFORE older scene's textures complete
298+
const newerEntity = createEntity({ id: newerEntityId, pointers: ['905,905'], timestamp: 2000 })
299+
mockGenesisCityDeployment(newerEntity)
300+
registriesToCleanUp.push(newerEntityId)
301+
await components.messageProcessor.process(createDeploymentMessage(newerEntityId))
302+
303+
// The older scene's textures complete — it was left as PENDING (not marked OBSOLETE)
304+
await components.messageProcessor.process(createTextureEvent(olderEntityId, 'windows'))
305+
await components.messageProcessor.process(createTextureEvent(olderEntityId, 'mac'))
306+
307+
// The newer scene's textures fail
308+
await components.messageProcessor.process(createTextureEvent(newerEntityId, 'windows'))
309+
await components.messageProcessor.process(
310+
createTextureEvent(newerEntityId, 'mac', {
311+
metadata: {
312+
entityId: newerEntityId,
313+
platform: 'mac',
314+
statusCode: ManifestStatusCode.ASSET_BUNDLE_BUILD_FAIL,
315+
isLods: false,
316+
isWorld: false,
317+
version: 'v1'
318+
}
319+
})
320+
)
321+
})
322+
323+
it('should keep the older registry as complete since it was never marked obsolete', async () => {
324+
const olderRegistry = await components.db.getRegistryById(olderEntityId)
325+
326+
expect(olderRegistry).not.toBeNull()
327+
expect(olderRegistry!.status).toBe(Registry.Status.COMPLETE)
328+
})
329+
330+
it('should have the older registry bundles as complete', async () => {
331+
const olderRegistry = await components.db.getRegistryById(olderEntityId)
332+
333+
expect(olderRegistry!.bundles.assets.windows).toBe(Registry.SimplifiedStatus.COMPLETE)
334+
expect(olderRegistry!.bundles.assets.mac).toBe(Registry.SimplifiedStatus.COMPLETE)
335+
})
336+
337+
it('should mark the newer registry as failed', async () => {
338+
const newerRegistry = await components.db.getRegistryById(newerEntityId)
339+
340+
expect(newerRegistry).not.toBeNull()
341+
expect(newerRegistry!.status).toBe(Registry.Status.FAILED)
342+
})
343+
344+
it('should still have an active registry at the pointers', async () => {
345+
const olderRegistry = await components.db.getRegistryById(olderEntityId)
346+
347+
// The older registry was never marked OBSOLETE — it completed and is served
348+
expect(olderRegistry!.status).toBe(Registry.Status.COMPLETE)
349+
})
350+
})
351+
352+
describe('when the newer scene completes before the older one (out-of-order)', () => {
353+
let olderEntityId: string
354+
let newerEntityId: string
355+
356+
beforeEach(async () => {
357+
olderEntityId = `texture-ooo-older-${Date.now()}`
358+
newerEntityId = `texture-ooo-newer-${Date.now()}`
359+
360+
// Deploy both scenes before any textures arrive
361+
const olderEntity = createEntity({ id: olderEntityId, pointers: ['906,906'], timestamp: 1000 })
362+
mockGenesisCityDeployment(olderEntity)
363+
registriesToCleanUp.push(olderEntityId)
364+
await components.messageProcessor.process(createDeploymentMessage(olderEntityId))
365+
366+
const newerEntity = createEntity({ id: newerEntityId, pointers: ['906,906'], timestamp: 2000 })
367+
mockGenesisCityDeployment(newerEntity)
368+
registriesToCleanUp.push(newerEntityId)
369+
await components.messageProcessor.process(createDeploymentMessage(newerEntityId))
370+
371+
// Newer scene completes FIRST
372+
await components.messageProcessor.process(createTextureEvent(newerEntityId, 'windows'))
373+
await components.messageProcessor.process(createTextureEvent(newerEntityId, 'mac'))
374+
375+
// Older scene completes AFTER
376+
await components.messageProcessor.process(createTextureEvent(olderEntityId, 'windows'))
377+
await components.messageProcessor.process(createTextureEvent(olderEntityId, 'mac'))
378+
})
379+
380+
it('should mark the older registry as obsolete since a newer one is already complete', async () => {
381+
const olderRegistry = await components.db.getRegistryById(olderEntityId)
382+
383+
expect(olderRegistry).not.toBeNull()
384+
expect(olderRegistry!.status).toBe(Registry.Status.OBSOLETE)
385+
})
386+
387+
it('should keep the newer registry as complete', async () => {
388+
const newerRegistry = await components.db.getRegistryById(newerEntityId)
389+
390+
expect(newerRegistry).not.toBeNull()
391+
expect(newerRegistry!.status).toBe(Registry.Status.COMPLETE)
392+
})
393+
})
394+
395+
describe('when three rapid deployments happen and none complete before the next arrives', () => {
396+
let entityAId: string
397+
let entityBId: string
398+
let entityCId: string
399+
400+
beforeEach(async () => {
401+
entityAId = `texture-triple-a-${Date.now()}`
402+
entityBId = `texture-triple-b-${Date.now()}`
403+
entityCId = `texture-triple-c-${Date.now()}`
404+
405+
// Deploy all three before any textures complete
406+
const entityA = createEntity({ id: entityAId, pointers: ['907,907'], timestamp: 1000 })
407+
mockGenesisCityDeployment(entityA)
408+
registriesToCleanUp.push(entityAId)
409+
await components.messageProcessor.process(createDeploymentMessage(entityAId))
410+
411+
const entityB = createEntity({ id: entityBId, pointers: ['907,907'], timestamp: 2000 })
412+
mockGenesisCityDeployment(entityB)
413+
registriesToCleanUp.push(entityBId)
414+
await components.messageProcessor.process(createDeploymentMessage(entityBId))
415+
416+
const entityC = createEntity({ id: entityCId, pointers: ['907,907'], timestamp: 3000 })
417+
mockGenesisCityDeployment(entityC)
418+
registriesToCleanUp.push(entityCId)
419+
await components.messageProcessor.process(createDeploymentMessage(entityCId))
420+
421+
// A completes
422+
await components.messageProcessor.process(createTextureEvent(entityAId, 'windows'))
423+
await components.messageProcessor.process(createTextureEvent(entityAId, 'mac'))
424+
425+
// B fails
426+
await components.messageProcessor.process(
427+
createTextureEvent(entityBId, 'mac', {
428+
metadata: {
429+
entityId: entityBId,
430+
platform: 'mac',
431+
statusCode: ManifestStatusCode.ASSET_BUNDLE_BUILD_FAIL,
432+
isLods: false,
433+
isWorld: false,
434+
version: 'v1'
435+
}
436+
})
437+
)
438+
439+
// C fails
440+
await components.messageProcessor.process(
441+
createTextureEvent(entityCId, 'mac', {
442+
metadata: {
443+
entityId: entityCId,
444+
platform: 'mac',
445+
statusCode: ManifestStatusCode.ASSET_BUNDLE_BUILD_FAIL,
446+
isLods: false,
447+
isWorld: false,
448+
version: 'v1'
449+
}
450+
})
451+
)
452+
})
453+
454+
it('should keep entity A as complete since it successfully converted', async () => {
455+
const registryA = await components.db.getRegistryById(entityAId)
456+
457+
expect(registryA).not.toBeNull()
458+
expect(registryA!.status).toBe(Registry.Status.COMPLETE)
459+
})
460+
461+
it('should mark entity B as failed', async () => {
462+
const registryB = await components.db.getRegistryById(entityBId)
463+
464+
expect(registryB).not.toBeNull()
465+
expect(registryB!.status).toBe(Registry.Status.FAILED)
466+
})
467+
468+
it('should mark entity C as failed', async () => {
469+
const registryC = await components.db.getRegistryById(entityCId)
470+
471+
expect(registryC).not.toBeNull()
472+
expect(registryC!.status).toBe(Registry.Status.FAILED)
473+
})
474+
})
282475
})

0 commit comments

Comments
 (0)