Skip to content

Commit e00fa84

Browse files
fix: Atomic persisting and rotating (#107)
1 parent 7caf25f commit e00fa84

9 files changed

Lines changed: 1395 additions & 358 deletions

File tree

src/adapters/db.ts

Lines changed: 300 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1174,6 +1174,304 @@ export function createDbAdapter({ pg }: Pick<AppComponents, 'pg'>): IDbComponent
11741174
})
11751175
}
11761176

1177+
/**
1178+
* Internal: Updates the bundle status for a specific platform within a transaction.
1179+
* Sets the simplified status (PENDING, COMPLETE, FAILED) for either the assets or lods bundle type.
1180+
*
1181+
* @param id - The entity ID to update
1182+
* @param platform - The platform to update (e.g., 'windows', 'mac', 'webgl')
1183+
* @param lods - Whether to update the LODs bundle (true) or assets bundle (false)
1184+
* @param status - The new simplified status for the bundle
1185+
* @param executor - The transaction client to use for the query
1186+
* @returns The updated registry entity, or null if the entity was not found
1187+
*/
1188+
async function _upsertRegistryBundle(
1189+
id: string,
1190+
platform: string,
1191+
lods: boolean,
1192+
status: Registry.SimplifiedStatus,
1193+
executor: QueryExecutor
1194+
): Promise<Registry.DbEntity | null> {
1195+
const bundleType = lods ? 'lods' : 'assets'
1196+
1197+
const query: SQLStatement = SQL`
1198+
UPDATE registries
1199+
SET
1200+
bundles = jsonb_set(
1201+
registries.bundles,
1202+
ARRAY[${bundleType}::text, ${platform}::text],
1203+
to_jsonb(${status}::text)
1204+
)
1205+
WHERE LOWER(registries.id) = ${id.toLocaleLowerCase()}
1206+
RETURNING *
1207+
`
1208+
1209+
const result = await executor.query<Registry.DbEntity>(query)
1210+
return result.rows[0] || null
1211+
}
1212+
1213+
/**
1214+
* Internal: Updates the version and build date for a specific platform's asset bundle within a transaction.
1215+
*
1216+
* @param id - The entity ID to update
1217+
* @param platform - The platform to update (e.g., 'windows', 'mac', 'webgl')
1218+
* @param version - The asset bundle version string
1219+
* @param buildDate - The ISO date string of when the bundle was built
1220+
* @param executor - The transaction client to use for the query
1221+
* @returns The updated registry entity, or null if the entity was not found
1222+
*/
1223+
async function _updateRegistryVersionWithBuildDate(
1224+
id: string,
1225+
platform: string,
1226+
version: string,
1227+
buildDate: string,
1228+
executor: QueryExecutor
1229+
): Promise<Registry.DbEntity | null> {
1230+
const bundleType = 'assets'
1231+
const versionData = { version, buildDate }
1232+
const query: SQLStatement = SQL`
1233+
UPDATE registries
1234+
SET
1235+
versions = jsonb_set(
1236+
COALESCE(registries.versions, '{}'::jsonb),
1237+
ARRAY[${bundleType}::text, ${platform}::text],
1238+
to_jsonb(${versionData}::jsonb)
1239+
)
1240+
WHERE LOWER(registries.id) = ${id.toLocaleLowerCase()}
1241+
RETURNING *
1242+
`
1243+
1244+
const result = await executor.query<Registry.DbEntity>(query)
1245+
return result.rows[0] || null
1246+
}
1247+
1248+
/**
1249+
* Internal: Gets registries related to a given registry by overlapping pointers within a transaction.
1250+
* Filters by world name context (same as the public getRelatedRegistries) and excludes OBSOLETE entities.
1251+
*
1252+
* @param registry - The registry to find related entities for (uses pointers, id, and metadata)
1253+
* @param executor - The transaction client to use for the query
1254+
* @returns Related registries that share pointers within the same context (world or Genesis City)
1255+
*/
1256+
async function _getRelatedRegistries(
1257+
registry: Pick<Registry.DbEntity, 'pointers' | 'id' | 'metadata'>,
1258+
executor: QueryExecutor
1259+
): Promise<Registry.PartialDbEntity[]> {
1260+
const worldName = (registry.metadata as any)?.worldConfiguration?.name as string | undefined
1261+
const lowerCasePointers = registry.pointers.map((p) => p.toLowerCase())
1262+
1263+
const query: SQLStatement = SQL`
1264+
SELECT
1265+
id, pointers, timestamp, status, bundles, versions
1266+
FROM
1267+
registries
1268+
WHERE
1269+
pointers && ${lowerCasePointers}::varchar(255)[]
1270+
AND LOWER(id) != ${registry.id.toLocaleLowerCase()}
1271+
AND status != ${Registry.Status.OBSOLETE}
1272+
`
1273+
1274+
if (worldName) {
1275+
const normalizedWorldName = worldName.toLowerCase()
1276+
query.append(SQL`
1277+
AND LOWER(metadata->'worldConfiguration'->>'name') = ${normalizedWorldName}
1278+
`)
1279+
} else {
1280+
query.append(SQL`
1281+
AND metadata->'worldConfiguration'->>'name' IS NULL
1282+
`)
1283+
}
1284+
1285+
query.append(SQL`
1286+
ORDER BY timestamp DESC
1287+
`)
1288+
1289+
const result = await executor.query<Registry.PartialDbEntity>(query)
1290+
return result.rows
1291+
}
1292+
1293+
/**
1294+
* Internal: Inserts or updates a registry entity within a transaction.
1295+
* Uses an upsert (INSERT ... ON CONFLICT DO UPDATE) to handle both new and existing entities.
1296+
* Preserves the existing deployer if the new deployer is empty.
1297+
*
1298+
* @param registry - The full registry entity to insert or update
1299+
* @param executor - The transaction client to use for the query
1300+
* @returns The inserted or updated registry entity
1301+
*/
1302+
async function _insertRegistry(registry: Registry.DbEntity, executor: QueryExecutor): Promise<Registry.DbEntity> {
1303+
const query: SQLStatement = SQL`
1304+
INSERT INTO registries (
1305+
id, type, timestamp, deployer, pointers, content, metadata, status, bundles, versions
1306+
)
1307+
VALUES (
1308+
${registry.id},
1309+
${registry.type},
1310+
${registry.timestamp},
1311+
${registry.deployer.toLocaleLowerCase()},
1312+
${registry.pointers}::varchar(255)[],
1313+
${JSON.stringify(registry.content)}::jsonb,
1314+
${JSON.stringify(registry.metadata)}::jsonb,
1315+
${registry.status},
1316+
${JSON.stringify(registry.bundles)}::jsonb,
1317+
${JSON.stringify(registry.versions)}::jsonb
1318+
)
1319+
ON CONFLICT (id) DO UPDATE
1320+
SET
1321+
type = EXCLUDED.type,
1322+
timestamp = EXCLUDED.timestamp,
1323+
pointers = EXCLUDED.pointers,
1324+
content = EXCLUDED.content,
1325+
metadata = EXCLUDED.metadata,
1326+
status = EXCLUDED.status,
1327+
bundles = EXCLUDED.bundles,
1328+
deployer = CASE
1329+
WHEN EXCLUDED.deployer != '' THEN EXCLUDED.deployer
1330+
ELSE registries.deployer
1331+
END,
1332+
versions = EXCLUDED.versions
1333+
RETURNING
1334+
id,
1335+
type,
1336+
timestamp,
1337+
deployer,
1338+
pointers,
1339+
content,
1340+
metadata,
1341+
status,
1342+
bundles,
1343+
versions
1344+
`
1345+
1346+
const result = await executor.query<Registry.DbEntity>(query)
1347+
return result.rows[0]
1348+
}
1349+
1350+
/**
1351+
* Internal: Updates the status of multiple registries by their IDs within a transaction.
1352+
*
1353+
* @param ids - Array of entity IDs to update
1354+
* @param status - The new status to set
1355+
* @param executor - The transaction client to use for the query
1356+
* @returns The updated registry entities
1357+
*/
1358+
async function _updateRegistriesStatus(
1359+
ids: string[],
1360+
status: Registry.Status,
1361+
executor: QueryExecutor
1362+
): Promise<Registry.DbEntity[]> {
1363+
const parsedIds = ids.map((id) => id.toLocaleLowerCase())
1364+
1365+
const query: SQLStatement = SQL`
1366+
UPDATE registries
1367+
SET status = ${status}
1368+
WHERE LOWER(id) = ANY(${parsedIds}::varchar(255)[])
1369+
RETURNING
1370+
id,
1371+
type,
1372+
timestamp,
1373+
pointers,
1374+
deployer,
1375+
content,
1376+
metadata,
1377+
status,
1378+
bundles,
1379+
versions
1380+
`
1381+
1382+
const result = await executor.query<Registry.DbEntity>(query)
1383+
return result.rows || null
1384+
}
1385+
1386+
/**
1387+
* Atomically updates a bundle, optionally updates the version, reads the current entity state and
1388+
* related registries, then persists the registry with rotated statuses — all within a single transaction.
1389+
*
1390+
* This prevents race conditions where concurrent texture events for the same entity could result in
1391+
* a stale thread overwriting a correct COMPLETE status back to PENDING, because the status determination
1392+
* always uses the current DB state (after the bundle update) rather than the caller's potentially stale snapshot.
1393+
*
1394+
* The transaction executes the following steps:
1395+
* 1. Updates the bundle status for the specified platform
1396+
* 2. Optionally updates the version and build date
1397+
* 3. Reads related registries (consistent within the transaction)
1398+
* 4. Calls the provided callback to determine the registry status and rotation actions
1399+
* 5. Inserts/updates the registry with the determined status
1400+
* 6. Marks older entities as OBSOLETE
1401+
* 7. Updates the fallback entity's status
1402+
*
1403+
* @param params.bundleUpdate - The bundle status update to apply (entityId, platform, isLods, status)
1404+
* @param params.versionUpdate - Optional version and build date update
1405+
* @param params.determineStatusAndRotate - Callback that receives the current entity and related registries,
1406+
* and returns the status, older entity IDs to mark as OBSOLETE, and an optional fallback update
1407+
* @returns The persisted registry entity with its final status, or null if the entity was not found
1408+
*/
1409+
async function persistRegistryInTransaction(params: {
1410+
bundleUpdate: { entityId: string; platform: string; isLods: boolean; status: Registry.SimplifiedStatus }
1411+
versionUpdate?: { entityId: string; platform: string; version: string; buildDate: string }
1412+
determineStatusAndRotate: (
1413+
currentEntity: Registry.DbEntity,
1414+
relatedRegistries: Registry.PartialDbEntity[]
1415+
) => {
1416+
status: Registry.Status
1417+
olderEntityIds: string[]
1418+
fallbackUpdate: { id: string; status: Registry.Status } | null
1419+
}
1420+
}): Promise<Registry.DbEntity | null> {
1421+
return pg.withTransaction(async (client) => {
1422+
const { bundleUpdate, versionUpdate, determineStatusAndRotate } = params
1423+
1424+
// 1. Update the bundle status
1425+
let entity = await _upsertRegistryBundle(
1426+
bundleUpdate.entityId,
1427+
bundleUpdate.platform,
1428+
bundleUpdate.isLods,
1429+
bundleUpdate.status,
1430+
client
1431+
)
1432+
1433+
if (!entity) {
1434+
return null
1435+
}
1436+
1437+
// 2. Optionally update version
1438+
if (versionUpdate) {
1439+
entity = await _updateRegistryVersionWithBuildDate(
1440+
versionUpdate.entityId,
1441+
versionUpdate.platform,
1442+
versionUpdate.version,
1443+
versionUpdate.buildDate,
1444+
client
1445+
)
1446+
1447+
if (!entity) {
1448+
return null
1449+
}
1450+
}
1451+
1452+
// 3. Read related registries (within the same transaction for consistency)
1453+
const relatedRegistries = await _getRelatedRegistries(entity, client)
1454+
1455+
// 4. Let the caller determine status and rotation
1456+
const { status, olderEntityIds, fallbackUpdate } = determineStatusAndRotate(entity, relatedRegistries)
1457+
1458+
// 5. Insert/update the registry with the determined status
1459+
const insertedRegistry = await _insertRegistry({ ...entity, status }, client)
1460+
1461+
// 6. Update older entities
1462+
if (olderEntityIds.length > 0) {
1463+
await _updateRegistriesStatus(olderEntityIds, Registry.Status.OBSOLETE, client)
1464+
}
1465+
1466+
// 7. Update fallback
1467+
if (fallbackUpdate) {
1468+
await _updateRegistriesStatus([fallbackUpdate.id], fallbackUpdate.status, client)
1469+
}
1470+
1471+
return insertedRegistry
1472+
})
1473+
}
1474+
11771475
return {
11781476
insertRegistry,
11791477
updateRegistriesStatus,
@@ -1210,6 +1508,7 @@ export function createDbAdapter({ pg }: Pick<AppComponents, 'pg'>): IDbComponent
12101508
setSpawnCoordinate,
12111509
recalculateSpawnCoordinate,
12121510
undeployWorldScenes,
1213-
undeployWorldByName
1511+
undeployWorldByName,
1512+
persistRegistryInTransaction
12141513
}
12151514
}

0 commit comments

Comments
 (0)