Make batch source search sequential per source and independent across sources

Refactor searchMissingBatch to schedule each source independently via a
per-source promise chain (scheduleSource) instead of processing all targets
in a single serial loop. This keeps source ordering intact while allowing
different sources to advance in parallel across models.

Also update searchOnline to accept scheduleSource and emit a pending UI
state for queued searches, split pending/running source colors in CSS, and
add tests covering independent batch source progression plus error/cancel
survival.
This commit is contained in:
Dariusz L
2026-09-10 14:49:28 +02:00
parent 9f9b3da12c
commit cf5c73c2c8
3 changed files with 131 additions and 30 deletions
+68 -1
View File
@@ -7058,7 +7058,7 @@ test('search and URN completion ignore stale UI runs', () => {
assert.match(searchOnline, /isCurrentSearchRun/);
assert.match(searchOnline, /currentSearchRun/);
assert.match(searchOnline, /if \(searchRunId && !isCurrentSearchRun\(\)\) return/);
assert.match(searchOnline, /currentSearchRun && searchBtn\?\.isConnected !== false/);
assert.match(searchOnline, /currentSearchRun && searchBtn && searchBtn\.isConnected !== false/);
assert.match(resolveUrnAsync, /urnResolveUiTokens/);
assert.match(resolveUrnAsync, /isCurrentUrnUi/);
assert.match(resolveUrnAsync, /else if \(data\) \{\s*return;/);
@@ -8195,3 +8195,70 @@ test('applying source model details selection updates current download source ha
assert.equal(state.results.civitai.file_info.hashes.SHA256, sha256);
assert.equal(refreshCalls.length, 1);
});
test('batch sources advance independently while each source stays sequential', async () => {
const runBatch = eval(`(${extractMethod(resolveDownloadMethodsSource, 'searchMissingBatch')})`);
const started = [];
const releases = new Map();
const dialog = {
getBatchSearchTargets: () => [{ id: 1 }, { id: 2 }],
getWorkflowScopedQueueKey: () => 'workflow-a',
getSearchStateForWorkflow: () => ({}),
showNotification() {},
updateBatchFooterButtons() {},
closeFooterMenus() {},
persistSearchStateForActiveWorkflow() {},
async searchOnline(model, { scheduleSource }) {
await Promise.all(['fast', 'slow'].map(source => scheduleSource(source, async () => {
const key = source + model.id;
started.push(key);
await new Promise(resolve => releases.set(key, resolve));
})));
}
};
const flush = async () => { for (let i = 0; i < 15; i++) await Promise.resolve(); };
const pending = runBatch.call(dialog, 'all');
await flush();
assert.deepEqual(started, ['fast1', 'slow1']);
releases.get('fast1')();
await flush();
assert.deepEqual(started, ['fast1', 'slow1', 'fast2']);
assert.equal(dialog.batchSearchRunning, true);
releases.get('fast2')();
releases.get('slow1')();
await flush();
assert.deepEqual(started, ['fast1', 'slow1', 'fast2', 'slow2']);
releases.get('slow2')();
await pending;
assert.equal(dialog.batchSearchRunning, false);
});
test('batch source queue survives errors and skips waiting requests on stop', async () => {
const runBatch = eval(`(${extractMethod(resolveDownloadMethodsSource, 'searchMissingBatch')})`);
const calls = [];
let release;
const dialog = {
getBatchSearchTargets: () => [{ id: 1 }, { id: 2 }, { id: 3 }],
getWorkflowScopedQueueKey: () => 'workflow-a',
getSearchStateForWorkflow: () => ({}),
showNotification() {},
updateBatchFooterButtons() {},
closeFooterMenus() {},
persistSearchStateForActiveWorkflow() {},
async searchOnline(model, { scheduleSource }) {
await scheduleSource('civitai', async () => {
calls.push(model.id);
if (model.id === 1) throw new Error('provider failed');
await new Promise(resolve => { release = resolve; });
});
}
};
const pending = runBatch.call(dialog, 'all');
for (let i = 0; i < 20; i++) await Promise.resolve();
assert.deepEqual(calls, [1, 2]);
dialog.batchSearchCancelRequested = true;
release();
await pending;
assert.deepEqual(calls, [1, 2]);
assert.equal(dialog.batchSearchRunning, false);
});
+12 -2
View File
@@ -1103,14 +1103,20 @@
--mr-source-border-alpha: 0.22;
}
.mr-missing-source-running,
.mr-missing-source-pending {
.mr-missing-source-running {
--mr-source-rgb: 96, 190, 255;
--mr-source-fill-alpha: 0.16;
--mr-source-border-hi-alpha: 0.78;
--mr-source-border-alpha: 0.6;
}
.mr-missing-source-pending {
--mr-source-rgb: 175, 180, 188;
--mr-source-fill-alpha: 0.10;
--mr-source-border-hi-alpha: 0.52;
--mr-source-border-alpha: 0.38;
}
.mr-missing-source-error {
--mr-source-rgb: 255, 138, 128;
--mr-source-fill-alpha: 0.16;
@@ -3675,6 +3681,10 @@
display: none;
}
.mr-search-progress-pending .mr-search-progress-fill {
background: var(--mr-text-muted);
}
.mr-footer:not(.mr-footer-is-missing) .mr-footer-star-link {
margin-right: auto;
}
@@ -210,22 +210,36 @@ export const resolveDownloadMethods = {
let cancelled;
let batchError;
let hasBatchError = false;
const sourceTails = new Map();
const scheduleSource = (sourceId, task) => {
const previous = sourceTails.get(sourceId) || Promise.resolve();
const next = previous.then(() => this.batchSearchCancelRequested
? { source: sourceId, cancelled: true }
: task());
// One failed request must not block the remaining models.
sourceTails.set(sourceId, next.catch(() => {}));
return next;
};
try {
for (const missing of targets) {
if (this.batchSearchCancelRequested) {
break;
}
const outcomes = await Promise.allSettled(targets.map(async missing => {
if (this.batchSearchCancelRequested) return;
const state = this.getSearchStateForWorkflow(batchWorkflowKey, missing);
state.selectedSource = source || 'all';
try {
await this.searchOnline(missing, { workflowKey: batchWorkflowKey, forceSearch });
await this.searchOnline(missing, {
workflowKey: batchWorkflowKey,
forceSearch,
scheduleSource
});
} catch (error) {
failed += 1;
console.error('Model Resolver: batch search item failed:', error);
}
completed += 1;
if (!this.batchSearchCancelRequested) completed += 1;
this.updateBatchFooterButtons();
}
}));
const rejected = outcomes.find(outcome => outcome.status === 'rejected');
if (rejected) throw rejected.reason;
} catch (error) {
batchError = error;
hasBatchError = true;
@@ -2581,7 +2595,7 @@ export const resolveDownloadMethods = {
/**
* Search online for a model
*/
async searchOnline(missing, { workflowKey = this.getWorkflowScopedQueueKey(), forceSearch = false, source = null, searchQuery = '' } = {}) {
async searchOnline(missing, { workflowKey = this.getWorkflowScopedQueueKey(), forceSearch = false, source = null, searchQuery = '', scheduleSource = null } = {}) {
const providedSearchQuery = String(searchQuery || '').trim();
const searchSha256 = normalizeSha256(providedSearchQuery);
const state = this.getSearchStateForWorkflow(workflowKey, missing);
@@ -2646,7 +2660,8 @@ export const resolveDownloadMethods = {
const isUrn = Boolean(missing.is_urn && !requestedSearchQuery && !searchSha256);
const resultsId = `search-results-${this.getMissingModelDomKey(missing)}`;
const canUpdateCurrentWorkflow = workflowKey === this.getWorkflowScopedQueueKey();
const canUpdateCurrentWorkflow = workflowKey === this.getWorkflowScopedQueueKey()
&& (!this.selectedMissingModelKey || this.getMissingModelKey(missing) === this.selectedMissingModelKey);
const resultsDiv = canUpdateCurrentWorkflow
? this.contentElement?.querySelector(`#${resultsId}`)
: null;
@@ -2695,9 +2710,10 @@ export const resolveDownloadMethods = {
state.sourceProgress = {};
for (const source of sourceIds) {
this.setSourceProgress(state, source, {
status: 'running',
percent: 6,
startedAt: Date.now(),
status: scheduleSource ? 'pending' : 'running',
percent: scheduleSource ? 0 : 6,
message: scheduleSource ? 'Queued' : 'Searching...',
startedAt: scheduleSource ? null : Date.now(),
estimateMs: this.getSearchSourceEstimateMs(source, isUrn)
}, missing, { workflowKey });
}
@@ -2707,22 +2723,13 @@ export const resolveDownloadMethods = {
searchBtn.disabled = true;
searchBtn.innerHTML = this.renderSearchButtonContent(`Searching ${selectedSourceLabel}...`);
}
this.syncLinkNameActionUi?.(missing, this.contentElement, state);
if (canUpdateCurrentWorkflow) this.syncLinkNameActionUi?.(missing, this.contentElement, state);
if (resultsDiv) {
resultsDiv.classList.remove('mr-is-hidden');
resultsDiv.classList.add('mr-is-visible');
this.displaySearchResults(missing, state, resultsDiv);
}
for (const source of sourceIds) {
this.startEstimatedSearchProgress(
state,
missing,
resultsDiv,
source,
searchRunId,
{ workflowKey }
);
}
// For URNs, include model_id and version_id for direct download
const tokens = this.getStoredTokens();
@@ -2757,7 +2764,19 @@ export const resolveDownloadMethods = {
let anyFound = false;
let hadError = false;
const searchPromises = sourceIds.map(async (source) => {
const runSource = async (source) => {
if (!this.isBackgroundSearchRunActive(workflowKey, missingSearchKey, searchRunId)
|| this.isSearchSourceCancelled?.(workflowKey, missingSearchKey, searchRunId, source)) {
return { source, cancelled: true };
}
this.setSourceProgress(state, source, {
status: 'running',
percent: 6,
message: 'Searching...',
startedAt: Date.now()
}, missing, { workflowKey });
this.refreshSearchUiForMissing(missing, state, { workflowKey });
this.startEstimatedSearchProgress(state, missing, resultsDiv, source, searchRunId, { workflowKey });
const sourceIsUrn = isUrn && source !== 'lora_manager_archive';
const progressId = `${searchRunId}-${source}-${Math.random().toString(36).slice(2, 8)}`;
const searchData = {
@@ -2940,7 +2959,12 @@ export const resolveDownloadMethods = {
cleanupJob.sourceProgressIds?.delete(source);
}
}
});
};
const searchPromises = sourceIds.map(source => (
scheduleSource
? scheduleSource(source, () => runSource(source))
: runSource(source)
));
const currentJob = this.backgroundSearchJobs.get(backgroundJobKey);
if (currentJob?.runId === searchRunId) {
@@ -2982,7 +3006,7 @@ export const resolveDownloadMethods = {
if (searchRunId && !isCurrentSearchRun()) return;
state.lastAttemptError = error.message;
this.persistSearchStateForWorkflow(workflowKey, missing, state);
if (resultsDiv?.isConnected !== false) {
if (resultsDiv && resultsDiv.isConnected !== false) {
this.patchSearchResultsContainer?.(
resultsDiv,
this.renderStatusMessage(`Search failed: ${error.message}`, 'error')
@@ -3012,7 +3036,7 @@ export const resolveDownloadMethods = {
refresh: false
});
}
if (currentSearchRun && searchBtn?.isConnected !== false) {
if (currentSearchRun && searchBtn && searchBtn.isConnected !== false) {
searchBtn.disabled = false;
searchBtn.innerHTML = this.renderSearchButtonContent('Search Again');
}