fix(studio): harden organize-imports against long-running queries and hangs
CI / check (push) Successful in 2m33s
CI / deploy (push) Successful in 1m26s
CI / publish-container (push) Successful in 50s

The original GET route used a correlated NOT EXISTS / FIND_IN_SET
subquery over the entire catalog_items table for every recent import
audit entry, causing server timeouts when the audit log or catalog
grew large.  The per-item host-page validation inside the create
action also issued one SELECT + one UPDATE per moved offer.

Changes:
- GET /api/admin/import/organize: replace the correlated subquery
  with a bounded candidate list and a JS-side placed-set check, then
  resolve all needed base items in a single indexed SELECT.  This
  bounds the query cost regardless of catalog or audit log size.
- organizeImportFurni action: validate mover ids in one SELECT, then
  batch every move per group into a single UPDATE with a CASE
  expression instead of one UPDATE per item.
- OrganizeImportsDialog: add a 45-second abort timeout on the fetch
  and a distinct load-error state so the UI never silently hangs.
- Add 'loadError' translation key (en + nl).
This commit is contained in:
openhands committed 2026-09-11 20:24:38 +02:00
1 parent 01a85ebcd0
commit 19dc0347df
5 files changed
+181 -66

No files matched your search

@@ -75,6 +75,7 @@ export function OrganizeImportsDialog({
const router = useRouter();
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState(false);
const [items, setItems] = useState<OrganizeItem[]>([]);
const [rootPages, setRootPages] = useState<RootPage[]>([]);
const [importRootPageId, setImportRootPageId] = useState<number | null>(null);
@@ -96,10 +97,17 @@ export function OrganizeImportsDialog({
const load = useCallback(async () => {
setLoading(true);
setLoadError(false);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 45000);
try {
const [data, tree] = await Promise.all([
fetch("/api/admin/import/organize").then((r) => r.json()),
fetch("/api/admin/catalog/tree?parentId=-1").then((r) => r.json()),
fetch("/api/admin/import/organize", { signal: controller.signal }).then(
(r) => r.json(),
),
fetch("/api/admin/catalog/tree?parentId=-1", {
signal: controller.signal,
}).then((r) => r.json()),
]);
setItems(Array.isArray(data?.items) ? data.items : []);
setImportRootPageId(data?.importRootPageId ?? null);
@@ -113,8 +121,10 @@ export function OrganizeImportsDialog({
);
} catch (error) {
console.error("[OrganizeImports] load failed:", error);
toast.error("Failed to load imports");
setLoadError(true);
setItems([]);
} finally {
clearTimeout(timeout);
setLoading(false);
}
}, []);
@@ -347,7 +357,7 @@ export function OrganizeImportsDialog({
</div>
) : items.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
{t("empty")}
{loadError ? t("loadError") : t("empty")}
<Button
type="button"
variant="outline"