Fix duplicate catalog pages from import race condition

A concurrent import could create the same catalog sub-page twice
(getOrCreateCategoryPage / getOrCreateImportedParentPage do a
SELECT-then-INSERT with no unique constraint). Deduplicate right after
insert by keeping the lowest-id page and removing the duplicate, while
never deleting a page that already received catalog items.
This commit is contained in:
openhands committed 2026-08-18 19:30:28 +02:00
1 parent 8a6c596727
commit 7257a7b0f4
1 file changed
+32 -4
+32 -4
View File
@@ -126,6 +126,29 @@ export function buildItemsBaseEnrichmentUpdate(params: {
`;
}
/** Keep the lowest-id row for a catalog page and delete any duplicate created
* concurrently by a racing import. Returns the surviving page id. */
async function dedupeCatalogPage(
captionSave: string,
parentId: number,
justCreatedId: number,
): Promise<number> {
const [rows] = (await db.execute(sql`
SELECT MIN(id) AS id FROM catalog_pages
WHERE caption_save = ${captionSave} AND parent_id = ${parentId}
`)) as unknown as [Array<{ id: number }>, unknown];
const winner = rows[0]?.id ?? justCreatedId;
if (winner !== justCreatedId) {
// Never delete a page that already received catalog items.
await db.execute(sql`
DELETE FROM catalog_pages
WHERE id = ${justCreatedId}
AND NOT EXISTS (SELECT 1 FROM catalog_items WHERE page_id = ${justCreatedId})
`);
}
return winner;
}
/** Get or create the root "Imported Furniture" parent page (no items directly on this page).
* Caches the page ID in-memory for the process lifetime to avoid redundant DB reads
* during batch imports */
@@ -158,8 +181,12 @@ export async function getOrCreateImportedParentPage(): Promise<number> {
'Failed to create "Imported Furniture" parent catalog page',
);
}
parentIdCache = created.id;
return created.id;
parentIdCache = await dedupeCatalogPage(
IMPORTED_PAGE_CAPTION_SAVE,
-1,
created.id,
);
return parentIdCache;
}
/** Determine the catalog category for a furniture item based on its classname and type. */
@@ -246,8 +273,9 @@ export async function getOrCreateCategoryPage(
);
}
if (!categoryPageCache) categoryPageCache = new Map();
categoryPageCache.set(captionSave, created.id);
return created.id;
const winner = await dedupeCatalogPage(captionSave, parentId, created.id);
categoryPageCache.set(captionSave, winner);
return winner;
}
export function resetCatalogPageCache(): void {