feat(TRUEREF-0022): complete iteration 0 — worker-thread indexing, parallel jobs, SSE progress
- Move IndexingPipeline.run() into Worker Threads via WorkerPool - Add dedicated embedding worker thread with single model instance - Add stage/stageDetail columns to indexing_jobs schema - Create ProgressBroadcaster for SSE channel management - Add SSE endpoints: GET /api/v1/jobs/:id/stream, GET /api/v1/jobs/stream - Replace UI polling with EventSource on repo detail and admin pages - Add concurrency settings UI and API endpoint - Build worker entries separately via esbuild
This commit is contained in:
@@ -75,6 +75,18 @@
|
||||
error: 'Error'
|
||||
};
|
||||
|
||||
const stageLabels: Record<string, string> = {
|
||||
queued: 'Queued',
|
||||
differential: 'Diff',
|
||||
crawling: 'Crawling',
|
||||
cloning: 'Cloning',
|
||||
parsing: 'Parsing',
|
||||
storing: 'Storing',
|
||||
embedding: 'Embedding',
|
||||
done: 'Done',
|
||||
failed: 'Failed'
|
||||
};
|
||||
|
||||
async function refreshRepo() {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/libs/${encodeURIComponent(repo.id)}`);
|
||||
@@ -105,63 +117,78 @@
|
||||
loadVersions();
|
||||
});
|
||||
|
||||
// Single shared poller — one interval regardless of how many tags are active.
|
||||
// This replaces the N per-version <IndexingProgress> components that each had
|
||||
// their own setInterval, which caused ERR_INSUFFICIENT_RESOURCES and UI lockup
|
||||
// when hundreds of tags were queued simultaneously.
|
||||
// Single shared poller replaced with EventSource SSE stream
|
||||
$effect(() => {
|
||||
const activeIds = new Set(
|
||||
Object.values(activeVersionJobs).filter((id): id is string => !!id)
|
||||
);
|
||||
if (activeIds.size === 0) {
|
||||
versionJobProgress = {};
|
||||
return;
|
||||
}
|
||||
if (!repo.id) return;
|
||||
|
||||
let stopped = false;
|
||||
const es = new EventSource(
|
||||
`/api/v1/jobs/stream?repositoryId=${encodeURIComponent(repo.id)}`
|
||||
);
|
||||
|
||||
async function poll() {
|
||||
es.addEventListener('job-progress', (event) => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/jobs?repositoryId=${encodeURIComponent(repo.id)}&limit=1000`
|
||||
);
|
||||
if (!res.ok || stopped) return;
|
||||
const d = await res.json();
|
||||
|
||||
// Build a jobId → job lookup from the response.
|
||||
const map: Record<string, IndexingJob> = {};
|
||||
for (const job of (d.jobs ?? []) as IndexingJob[]) {
|
||||
map[job.id] = job;
|
||||
}
|
||||
if (!stopped) versionJobProgress = map;
|
||||
|
||||
// Retire completed jobs and trigger a single refresh.
|
||||
let anyCompleted = false;
|
||||
const nextJobs = { ...activeVersionJobs };
|
||||
for (const [tag, jobId] of Object.entries(activeVersionJobs)) {
|
||||
if (!jobId) continue;
|
||||
const job = map[jobId];
|
||||
if (job?.status === 'done' || job?.status === 'failed') {
|
||||
delete nextJobs[tag];
|
||||
anyCompleted = true;
|
||||
}
|
||||
}
|
||||
if (anyCompleted && !stopped) {
|
||||
activeVersionJobs = nextJobs;
|
||||
void loadVersions();
|
||||
void refreshRepo();
|
||||
}
|
||||
const data = JSON.parse(event.data) as IndexingJob;
|
||||
versionJobProgress = { ...versionJobProgress, [data.id]: data };
|
||||
} catch {
|
||||
// ignore transient errors
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener('job-done', (event) => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
const data = JSON.parse(event.data) as IndexingJob;
|
||||
const next = { ...versionJobProgress };
|
||||
delete next[data.id];
|
||||
versionJobProgress = next;
|
||||
void loadVersions();
|
||||
void refreshRepo();
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener('job-failed', (event) => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
const data = JSON.parse(event.data) as IndexingJob;
|
||||
const next = { ...versionJobProgress };
|
||||
delete next[data.id];
|
||||
versionJobProgress = next;
|
||||
void loadVersions();
|
||||
void refreshRepo();
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
if (stopped) return;
|
||||
es.close();
|
||||
// Fall back to a single fetch for resilience
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/jobs?repositoryId=${encodeURIComponent(repo.id)}&limit=1000`
|
||||
);
|
||||
if (!res.ok || stopped) return;
|
||||
const d = await res.json();
|
||||
const map: Record<string, IndexingJob> = {};
|
||||
for (const job of (d.jobs ?? []) as IndexingJob[]) {
|
||||
map[job.id] = job;
|
||||
}
|
||||
if (!stopped) versionJobProgress = map;
|
||||
} catch {
|
||||
// ignore errors
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
void poll();
|
||||
const interval = setInterval(poll, 2000);
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearInterval(interval);
|
||||
es.close();
|
||||
};
|
||||
});
|
||||
|
||||
@@ -620,7 +647,10 @@
|
||||
{@const job = versionJobProgress[activeVersionJobs[version.tag]!]}
|
||||
<div class="mt-2">
|
||||
<div class="flex justify-between text-xs text-gray-500">
|
||||
<span>{(job?.processedFiles ?? 0).toLocaleString()} / {(job?.totalFiles ?? 0).toLocaleString()} files</span>
|
||||
<span>
|
||||
{#if job?.stageDetail}{job.stageDetail}{:else}{(job?.processedFiles ?? 0).toLocaleString()} / {(job?.totalFiles ?? 0).toLocaleString()} files{/if}
|
||||
{#if job?.stage}{' - ' + stageLabels[job.stage] ?? job.stage}{/if}
|
||||
</span>
|
||||
<span>{job?.progress ?? 0}%</span>
|
||||
</div>
|
||||
<div class="mt-1 h-1.5 w-full rounded-full bg-gray-200">
|
||||
|
||||
Reference in New Issue
Block a user