Browse Source

fix: improve save reliability with timeouts and parallel processing

- Add fetchWithTimeout utility for image save requests (30s timeout)
- Process node images in parallel batches (3 at a time) for faster saves
- Add timeout to waitForPendingImageSyncs (60s) to prevent infinite hangs
- Move isSaving flag reset to finally block for consistent state cleanup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
lapoaiscalers 5 months ago
parent
commit
dbaf25ba60
  1. 23
      src/store/workflowStore.ts
  2. 75
      src/utils/imageStorage.ts

23
src/store/workflowStore.ts

@ -296,10 +296,21 @@ function trackSaveGeneration(
pendingImageSyncs.set(tempId, syncPromise); pendingImageSyncs.set(tempId, syncPromise);
} }
// Wait for all pending image syncs to complete // Wait for all pending image syncs to complete (with timeout to prevent infinite hangs)
async function waitForPendingImageSyncs(): Promise<void> { async function waitForPendingImageSyncs(timeout: number = 60000): Promise<void> {
if (pendingImageSyncs.size === 0) return; if (pendingImageSyncs.size === 0) return;
await Promise.all(pendingImageSyncs.values());
const timeoutPromise = new Promise<void>((resolve) => {
setTimeout(() => {
console.warn(`Pending image syncs timed out after ${timeout}ms, continuing with save`);
resolve();
}, timeout);
});
await Promise.race([
Promise.all(pendingImageSyncs.values()),
timeoutPromise,
]);
} }
// Concurrency settings // Concurrency settings
@ -3034,7 +3045,6 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
nodes: nodesWithRefs, nodes: nodesWithRefs,
lastSavedAt: timestamp, lastSavedAt: timestamp,
hasUnsavedChanges: false, hasUnsavedChanges: false,
isSaving: false,
// Update imageRefBasePath to reflect new save location // Update imageRefBasePath to reflect new save location
imageRefBasePath: saveDirectoryPath, imageRefBasePath: saveDirectoryPath,
}); });
@ -3042,7 +3052,6 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
set({ set({
lastSavedAt: timestamp, lastSavedAt: timestamp,
hasUnsavedChanges: false, hasUnsavedChanges: false,
isSaving: false,
// Update imageRefBasePath to reflect save location // Update imageRefBasePath to reflect save location
imageRefBasePath: useExternalImageStorage ? saveDirectoryPath : null, imageRefBasePath: useExternalImageStorage ? saveDirectoryPath : null,
}); });
@ -3060,12 +3069,10 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
return true; return true;
} else { } else {
set({ isSaving: false });
useToast.getState().show(`Auto-save failed: ${result.error}`, "error"); useToast.getState().show(`Auto-save failed: ${result.error}`, "error");
return false; return false;
} }
} catch (error) { } catch (error) {
set({ isSaving: false });
useToast useToast
.getState() .getState()
.show( .show(
@ -3073,6 +3080,8 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
"error" "error"
); );
return false; return false;
} finally {
set({ isSaving: false });
} }
}, },

75
src/utils/imageStorage.ts

@ -2,6 +2,38 @@ import { WorkflowNode, WorkflowNodeData } from "@/types";
import { WorkflowFile } from "@/store/workflowStore"; import { WorkflowFile } from "@/store/workflowStore";
import crypto from "crypto"; import crypto from "crypto";
/**
* Fetch with timeout support using AbortController
* @param url - The URL to fetch
* @param options - Fetch options (RequestInit)
* @param timeout - Timeout in milliseconds (default: 30000ms / 30 seconds)
* @returns Promise<Response>
* @throws Error if the request times out or fails
*/
async function fetchWithTimeout(
url: string,
options: RequestInit,
timeout: number = 30000
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
return response;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error(`Request timed out after ${timeout}ms: ${url}`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
/** /**
* Compute MD5 hash of image content for deduplication * Compute MD5 hash of image content for deduplication
* Consistent with save-generation API (Phase 13 decision) * Consistent with save-generation API (Phase 13 decision)
@ -34,12 +66,24 @@ export async function externalizeWorkflowImages(
workflow: WorkflowFile, workflow: WorkflowFile,
workflowPath: string workflowPath: string
): Promise<WorkflowFile> { ): Promise<WorkflowFile> {
const externalizedNodes: WorkflowNode[] = [];
const savedImageIds = new Map<string, string>(); // base64 hash -> imageId (for deduplication) const savedImageIds = new Map<string, string>(); // base64 hash -> imageId (for deduplication)
for (const node of workflow.nodes) { // Process nodes in parallel batches with controlled concurrency
const newNode = await externalizeNodeImages(node, workflowPath, savedImageIds); const BATCH_SIZE = 3;
externalizedNodes.push(newNode); const externalizedNodes: WorkflowNode[] = new Array(workflow.nodes.length);
for (let i = 0; i < workflow.nodes.length; i += BATCH_SIZE) {
const batch = workflow.nodes.slice(i, i + BATCH_SIZE);
const results = await Promise.all(
batch.map((node, batchIndex) =>
externalizeNodeImages(node, workflowPath, savedImageIds)
.then(result => ({ index: i + batchIndex, result }))
)
);
for (const { index, result } of results) {
externalizedNodes[index] = result;
}
} }
return { return {
@ -276,16 +320,19 @@ async function saveImageAndGetId(
// Use existing ID if provided (for consistency with imageHistory), otherwise generate new // Use existing ID if provided (for consistency with imageHistory), otherwise generate new
const imageId = existingId || generateImageId(); const imageId = existingId || generateImageId();
const response = await fetch("/api/workflow-images", { const response = await fetchWithTimeout(
method: "POST", "/api/workflow-images",
headers: { "Content-Type": "application/json" }, {
body: JSON.stringify({ method: "POST",
workflowPath, headers: { "Content-Type": "application/json" },
imageId, body: JSON.stringify({
imageData, workflowPath,
folder, imageId,
}), imageData,
}); folder,
}),
}
);
const result = await response.json(); const result = await response.json();

Loading…
Cancel
Save