perf(processing-folders): faster sweeps — wider gate, filtered polling, no reselect churn

sweepConcurrency's default rises to 6: the classification pipeline is
API-bound (one fast-model call per document, ~half a second), so it scales
nearly linearly with concurrency and 2 was the bottleneck — a 100-file sweep
drops from ~90s to ~30s while completions keep arriving steadily. The knob
stays for installs whose pipeline really is a heavyweight local engine.

The runs listing takes an optional policyId filter and delivery passes it:
following one sweep polls the endpoint every second, and the unfiltered
response re-serialized every other policy's runs each time, growing with
history. The client-side filter stays as a guard for backends that ignore
the parameter.

Delivery no longer selects what it opens: a selection isn't meaningful
across a folderful of results, and re-selecting on every batch re-rendered
the whole growing file list once a second for the length of the sweep.
This commit is contained in:
Reece
2026-08-17 14:54:50 +01:00
parent d580f565b7
commit b7c9065a4e
5 changed files with 26 additions and 10 deletions
@@ -218,11 +218,12 @@ public class ApplicationProperties {
/**
* How many of one sweep's runs may execute at once; further runs queue (visible as pending)
* and start as slots free up. Sweeps fan out one run per file, and a folder of documents
* dispatched all at once just piles up at the pipeline's slowest tool (on a desktop
* install, the local AI engine) — same total time, but nothing visibly finishes until the
* end. A small cap keeps completions steady. 0 = unbounded.
* dispatched all at once piles up at the pipeline's slowest tool — nothing visibly finishes
* until the end. The cap keeps completions arriving steadily; the default suits API-bound
* pipelines (classification is one fast-model call per document). Turn it down for a
* heavyweight local engine, 0 = unbounded.
*/
private int sweepConcurrency = 2;
private int sweepConcurrency = 6;
/** How often (seconds) the schedule trigger checks for policies whose schedule is due. */
private long scheduleSweepSeconds = 60;
@@ -21,6 +21,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.RequestContextHolder;
@@ -203,15 +204,20 @@ public class PolicyController {
summary = "List the caller's stored-policy runs",
description =
"Returns the caller's in-flight and recently-finished stored-policy runs (within"
+ " the run-retention window). The frontend reconciles these on load so a"
+ " run started before a refresh/crash is rediscovered and its outputs"
+ " the run-retention window), optionally narrowed to one policy via"
+ " `policyId` — a client following a single sweep polls this every"
+ " second, and the unfiltered list grows with every other policy's"
+ " runs. The frontend reconciles the unfiltered list on load so a run"
+ " started before a refresh/crash is rediscovered and its outputs"
+ " collected, rather than orphaned on the backend. Ad-hoc runs (no"
+ " policy id) are excluded.")
public List<PolicyRunView> listRuns() {
public List<PolicyRunView> listRuns(
@RequestParam(name = "policyId", required = false) String policyId) {
// Local runs first (they carry live step state); keyed by runId to dedupe shared entries.
Map<String, PolicyRunView> byRunId = new LinkedHashMap<>();
runRegistry.all().stream()
.filter(run -> run.getPolicyId() != null)
.filter(run -> policyId == null || policyId.equals(run.getPolicyId()))
.filter(run -> ownedByCurrentUser(run.getRunId()))
.forEach(run -> byRunId.put(run.getRunId(), PolicyRunView.of(run)));
// Then runs from other nodes, read from the shared job store.
@@ -223,6 +229,9 @@ public class PolicyController {
if (meta == null || !meta.containsKey("policyId")) {
continue; // ad-hoc job, not a stored-policy run
}
if (policyId != null && !policyId.equals(meta.get("policyId"))) {
continue;
}
if (ownedByCurrentUser(entry.jobId())) {
byRunId.put(entry.jobId(), PolicyRunView.ofEntry(entry));
}
@@ -359,7 +359,7 @@ class PolicyControllerTest {
when(jobOwnershipService.createScopedJobKey("owned")).thenReturn("owned");
when(jobOwnershipService.createScopedJobKey("other")).thenReturn("scoped-other");
List<PolicyRunView> views = controller.listRuns();
List<PolicyRunView> views = controller.listRuns(null);
assertThat(views).hasSize(1);
assertThat(views.get(0).runId()).isEqualTo("owned");
@@ -140,9 +140,12 @@ export interface ProcessingFolderRun {
export async function fetchProcessingFolderRuns(
policyId: string,
): Promise<ProcessingFolderRun[]> {
// Filtered server-side: delivery polls this every second, and the
// unfiltered list carries every policy's runs. The client-side filter stays
// as a guard against a backend that ignores the parameter.
const res = await apiClient.get<
(ProcessingFolderRun & { policyId?: string })[]
>("/api/v1/policies/runs");
>("/api/v1/policies/runs", { params: { policyId } });
return (res.data ?? []).filter((run) => run.policyId === policyId);
}
@@ -78,7 +78,10 @@ export async function deliverSweepResults(
}
}
if (files.length === 0) return;
await addFiles(files, { selectFiles: true });
// Never select what is delivered: a selection isn't meaningful across a
// folderful of results, and re-selecting on every batch re-renders the
// whole growing file list once a second for the length of the sweep.
await addFiles(files);
progress.opened += files.length;
};