From 1c5b634ea4c5170b612f223ba53d7daa6f4c4a6d Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Sat, 28 Mar 2026 09:32:27 +0100 Subject: [PATCH] =?UTF-8?q?fix(MULTIVERSION-0001):=20fix=20multi-version?= =?UTF-8?q?=20indexing=20=E2=80=94=20jobs=20never=20created=20or=20trigger?= =?UTF-8?q?ed=20for=20secondary=20versions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs prevented secondary versions from ever being indexed: 1. JobQueue.enqueue() and RepositoryService.createIndexingJob() deduplication only checked repository_id, so a queued default-branch job blocked all version-specific jobs for the same repo. Fix: include version_id in the WHERE clause so only exact (repository_id, version_id) pairs are deduped. 2. POST /api/v1/libs/:id/versions used repoService.createIndexingJob() which inserts a job record but never triggers queue processing. Fix: use queue.enqueue() (same fallback pattern as the libs endpoint) so setImmediate fires processNext() after the job is inserted. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/server/pipeline/job-queue.ts | 11 ++++--- .../services/repository.service.test.ts | 20 ++++++++++++ src/lib/server/services/repository.service.ts | 9 ++++-- .../api/v1/api-contract.integration.test.ts | 31 +++++++++++++++++++ .../api/v1/libs/[id]/versions/+server.ts | 6 +++- 5 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/lib/server/pipeline/job-queue.ts b/src/lib/server/pipeline/job-queue.ts index 6f8febe..587849b 100644 --- a/src/lib/server/pipeline/job-queue.ts +++ b/src/lib/server/pipeline/job-queue.ts @@ -36,14 +36,17 @@ export class JobQueue { * existing job instead of creating a duplicate. */ enqueue(repositoryId: string, versionId?: string): IndexingJob { - // Return early if there's already an active job for this repo. + // Return early if there's already an active job for this exact (repo, version) pair. + const resolvedVersionId = versionId ?? null; const activeRaw = this.db - .prepare<[string], IndexingJobEntity>( + .prepare<[string, string | null, string | null], IndexingJobEntity>( `${JOB_SELECT} - WHERE repository_id = ? AND status IN ('queued', 'running') + WHERE repository_id = ? + AND (version_id = ? OR (version_id IS NULL AND ? IS NULL)) + AND status IN ('queued', 'running') ORDER BY created_at DESC LIMIT 1` ) - .get(repositoryId); + .get(repositoryId, resolvedVersionId, resolvedVersionId); if (activeRaw) { // Ensure the queue is draining even if enqueue was called concurrently. diff --git a/src/lib/server/services/repository.service.test.ts b/src/lib/server/services/repository.service.test.ts index e5e4aa8..4b420bc 100644 --- a/src/lib/server/services/repository.service.test.ts +++ b/src/lib/server/services/repository.service.test.ts @@ -529,4 +529,24 @@ describe('RepositoryService.createIndexingJob()', () => { const job = service.createIndexingJob('/facebook/react', '/facebook/react/v18.3.0'); expect(job.versionId).toBe('/facebook/react/v18.3.0'); }); + + it('allows separate jobs for the same repo but different versions', () => { + const defaultJob = service.createIndexingJob('/facebook/react'); + const versionJob = service.createIndexingJob('/facebook/react', '/facebook/react/v18.3.0'); + expect(versionJob.id).not.toBe(defaultJob.id); + expect(defaultJob.versionId).toBeNull(); + expect(versionJob.versionId).toBe('/facebook/react/v18.3.0'); + }); + + it('returns the existing job when the same (repo, version) pair is already queued', () => { + const job1 = service.createIndexingJob('/facebook/react', '/facebook/react/v18.3.0'); + const job2 = service.createIndexingJob('/facebook/react', '/facebook/react/v18.3.0'); + expect(job2.id).toBe(job1.id); + }); + + it('returns the existing default-branch job when called again without a versionId', () => { + const job1 = service.createIndexingJob('/facebook/react'); + const job2 = service.createIndexingJob('/facebook/react'); + expect(job2.id).toBe(job1.id); + }); }); diff --git a/src/lib/server/services/repository.service.ts b/src/lib/server/services/repository.service.ts index b8e5e16..b9cac2b 100644 --- a/src/lib/server/services/repository.service.ts +++ b/src/lib/server/services/repository.service.ts @@ -319,14 +319,17 @@ export class RepositoryService { * If a job is already running, returns the existing job. */ createIndexingJob(repositoryId: string, versionId?: string): IndexingJob { - // Check for running job + // Check for an existing queued/running job for this exact (repo, version) pair. + const resolvedVersionId = versionId ?? null; const runningJob = this.db .prepare( `SELECT * FROM indexing_jobs - WHERE repository_id = ? AND status IN ('queued', 'running') + WHERE repository_id = ? + AND (version_id = ? OR (version_id IS NULL AND ? IS NULL)) + AND status IN ('queued', 'running') ORDER BY created_at DESC LIMIT 1` ) - .get(repositoryId) as IndexingJobEntity | undefined; + .get(repositoryId, resolvedVersionId, resolvedVersionId) as IndexingJobEntity | undefined; if (runningJob) return IndexingJobMapper.fromEntity(new IndexingJobEntity(runningJob)); diff --git a/src/routes/api/v1/api-contract.integration.test.ts b/src/routes/api/v1/api-contract.integration.test.ts index 5f6bce0..e509f6c 100644 --- a/src/routes/api/v1/api-contract.integration.test.ts +++ b/src/routes/api/v1/api-contract.integration.test.ts @@ -348,6 +348,37 @@ describe('API contract integration', () => { expect(getBody.versions[0]).not.toHaveProperty('total_snippets'); }); + it('POST /api/v1/libs/:id/versions creates distinct jobs for different versions of the same repo', async () => { + const repoService = new RepositoryService(db); + repoService.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' }); + + const postV1 = await postVersions({ + params: { id: encodeURIComponent('/facebook/react') }, + request: new Request('http://test/api/v1/libs/%2Ffacebook%2Freact/versions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ tag: 'v18.3.0', autoIndex: true }) + }) + } as never); + const bodyV1 = await postV1.json(); + + const postV2 = await postVersions({ + params: { id: encodeURIComponent('/facebook/react') }, + request: new Request('http://test/api/v1/libs/%2Ffacebook%2Freact/versions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ tag: 'v17.0.2', autoIndex: true }) + }) + } as never); + const bodyV2 = await postV2.json(); + + expect(postV1.status).toBe(201); + expect(postV2.status).toBe(201); + expect(bodyV1.job.id).not.toBe(bodyV2.job.id); + expect(bodyV1.job.versionId).toBe('/facebook/react/v18.3.0'); + expect(bodyV2.job.versionId).toBe('/facebook/react/v17.0.2'); + }); + it('GET /api/v1/context returns informative txt output for empty results', async () => { const repositoryId = seedRepo(db); diff --git a/src/routes/api/v1/libs/[id]/versions/+server.ts b/src/routes/api/v1/libs/[id]/versions/+server.ts index 0046d06..60a6a6f 100644 --- a/src/routes/api/v1/libs/[id]/versions/+server.ts +++ b/src/routes/api/v1/libs/[id]/versions/+server.ts @@ -10,6 +10,7 @@ import { RepositoryVersionMapper } from '$lib/server/mappers/repository-version. import { IndexingJobMapper } from '$lib/server/mappers/indexing-job.mapper.js'; import { RepositoryService } from '$lib/server/services/repository.service'; import { VersionService } from '$lib/server/services/version.service'; +import { getQueue } from '$lib/server/pipeline/startup'; import { handleServiceError, NotFoundError, InvalidInputError } from '$lib/server/utils/validation'; function getServices() { @@ -78,7 +79,10 @@ export const POST: RequestHandler = async ({ params, request }) => { let job: ReturnType | undefined; if (autoIndex) { - const indexingJob = repoService.createIndexingJob(repositoryId, version.id); + const queue = getQueue(); + const indexingJob = queue + ? queue.enqueue(repositoryId, version.id) + : repoService.createIndexingJob(repositoryId, version.id); job = IndexingJobMapper.toDto(indexingJob); }