Skip to content

Commit 498cac4

Browse files
fix(registry): bound repository purpose candidate blocking reads (#582)
Co-authored-by: Harlan GitHub Agent <harlan-github-agent[bot]@users.noreply.github.com>
1 parent 430914d commit 498cac4

2 files changed

Lines changed: 89 additions & 15 deletions

File tree

‎layers/registry/server/utils/repository-purpose-effect.ts‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,31 @@ export type RepositoryPurposeJudge = (state: RepositoryPurposeEvidence) => Promi
1414
export interface RepositoryIdentity { owner: string, repo: string }
1515

1616
export async function listRepositoryPurposeCandidates(db: D1Database, now: number): Promise<RepositoryIdentity[]> {
17+
// Completed jobs are retained in `jobs` forever, so a correlated read over
18+
// that table walks every finished classification for every candidate. Active
19+
// jobs are bounded by in-flight work and reachable through the partial
20+
// idx_jobs_active index, and terminal failures are moved to `failed_jobs`,
21+
// where the one day retry wait is a failed_at index seek.
22+
const blocking = await db.prepare(`
23+
SELECT json_extract(payload,'$.owner') AS owner, json_extract(payload,'$.repo') AS repo
24+
FROM jobs INDEXED BY idx_jobs_active
25+
WHERE job_type='registry/repository-purpose' AND completed_at IS NULL AND failed_at IS NULL
26+
UNION ALL
27+
SELECT json_extract(payload,'$.owner') AS owner, json_extract(payload,'$.repo') AS repo
28+
FROM failed_jobs INDEXED BY idx_failed_jobs_failed_at
29+
WHERE job_type='registry/repository-purpose' AND failed_at>?1
30+
`).bind(now - 86400).all<RepositoryIdentity>()
31+
const blocked = JSON.stringify([...new Set(blocking.results.map(row => `${row.owner}/${row.repo}`))])
1732
const result = await db.prepare(`
1833
SELECT r.owner,r.repo FROM repos r
1934
LEFT JOIN repository_purpose p ON p.owner=r.owner AND p.repo=r.repo
2035
WHERE r.broken_since IS NULL AND r.repo_skill_count>0
21-
AND (p.owner IS NULL OR p.prompt_version!=?1 OR p.model_id!=?4 OR p.evaluated_at<?2 OR r.pushed_at>p.evaluated_at)
22-
AND NOT EXISTS (
23-
SELECT 1 FROM jobs j WHERE j.job_type='registry/repository-purpose'
24-
AND json_extract(j.payload,'$.owner')=r.owner AND json_extract(j.payload,'$.repo')=r.repo
25-
AND ((j.completed_at IS NULL AND j.failed_at IS NULL) OR j.failed_at>?3)
26-
)
36+
AND (p.owner IS NULL OR p.prompt_version!=?1 OR p.model_id!=?3 OR p.evaluated_at<?2 OR r.pushed_at>p.evaluated_at)
37+
AND NOT EXISTS (SELECT 1 FROM json_each(?4) AS b WHERE b.value=r.owner||'/'||r.repo)
2738
ORDER BY r.tree_truncated_at IS NOT NULL DESC, r.repo_skill_count>100 DESC,
2839
EXISTS(SELECT 1 FROM skills s WHERE s.owner=r.owner AND s.repo=r.repo) DESC,
2940
COALESCE(p.evaluated_at,0),r.owner,r.repo LIMIT 25
30-
`).bind(REPOSITORY_PURPOSE_PROMPT_VERSION, now - REPOSITORY_PURPOSE_REFRESH_SECONDS, now - 86400, REPOSITORY_PURPOSE_MODEL).all<RepositoryIdentity>()
41+
`).bind(REPOSITORY_PURPOSE_PROMPT_VERSION, now - REPOSITORY_PURPOSE_REFRESH_SECONDS, REPOSITORY_PURPOSE_MODEL, blocked).all<RepositoryIdentity>()
3142
return result.results
3243
}
3344

‎test/unit/repository-purpose-effect.test.ts‎

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { SqliteD1 } from './helpers/d1-sqlite'
12
import { describe, expect, it, vi } from 'vitest'
23
import { classifyRepositoryPurpose, REPOSITORY_PURPOSE_REFRESH_SECONDS } from '../../layers/registry/server/utils/repository-purpose'
34
import { checkRepositoryPurposeAdmission, listRepositoryPurposeCandidates, persistRepositoryPurpose, refreshRepositoryPurpose } from '../../layers/registry/server/utils/repository-purpose-effect'
@@ -52,22 +53,55 @@ describe('purpose records', () => {
5253
finally { sqlite.close() }
5354
})
5455

56+
/**
57+
* The runtime holds active jobs in `jobs` and moves terminal failures to
58+
* `failed_jobs`, deleting them from `jobs`. A dead-lettered classification
59+
* leaves no `jobs` row at all, so the one day retry wait has to read
60+
* `failed_jobs` or it never applies to a real failure.
61+
*/
5562
it('checks large inventories first and avoids active or recently failed jobs', async () => {
5663
const sqlite = createSqliteD1(allMigrations())
5764
try {
5865
sqlite.raw.exec(`
59-
INSERT INTO repos(owner,repo,repo_skill_count) VALUES ('org','small',3),('org','large',400),('org','busy',200);
60-
INSERT INTO jobs(id,queue,job_type,payload,available_at,failed_at)
61-
VALUES ('busy','repo-review-sync','registry/repository-purpose','{"owner":"org","repo":"busy"}',0,NULL);
66+
INSERT INTO repos(owner,repo,repo_skill_count) VALUES ('org','small',3),('org','large',400),('org','busy',200),('org','retry',100);
67+
INSERT INTO jobs(id,queue,job_type,payload,available_at)
68+
VALUES ('busy','repo-review-sync','registry/repository-purpose','{"owner":"org","repo":"busy"}',0);
69+
INSERT INTO failed_jobs(id,queue,job_type,payload,exception,attempts,max_attempts,failed_at)
70+
VALUES ('retry','repo-review-sync','registry/repository-purpose','{"owner":"org","repo":"retry"}','AI binding unavailable',5,5,99999);
6271
`)
6372
expect(await listRepositoryPurposeCandidates(sqlite.db, 100000))
6473
.toEqual([{ owner: 'org', repo: 'large' }, { owner: 'org', repo: 'small' }])
65-
sqlite.raw.exec('UPDATE jobs SET failed_at=99999 WHERE id=\'busy\'')
74+
sqlite.raw.exec('UPDATE failed_jobs SET failed_at=1 WHERE id=\'retry\'')
6675
expect(await listRepositoryPurposeCandidates(sqlite.db, 100000))
67-
.toEqual([{ owner: 'org', repo: 'large' }, { owner: 'org', repo: 'small' }])
68-
sqlite.raw.exec('UPDATE jobs SET failed_at=1 WHERE id=\'busy\'')
69-
expect(await listRepositoryPurposeCandidates(sqlite.db, 100000))
70-
.toEqual([{ owner: 'org', repo: 'busy' }, { owner: 'org', repo: 'large' }, { owner: 'org', repo: 'small' }])
76+
.toEqual([{ owner: 'org', repo: 'large' }, { owner: 'org', repo: 'retry' }, { owner: 'org', repo: 'small' }])
77+
}
78+
finally { sqlite.close() }
79+
})
80+
81+
/**
82+
* Completed classification jobs stay in `jobs` forever, so a candidate read
83+
* that ranges over that table walks every finished job for every outer row.
84+
* Blocking reads must reach `jobs` only through the partial active index and
85+
* `failed_jobs` only through the failed_at window.
86+
*/
87+
it('never ranges over retained job history when blocking candidates', async () => {
88+
const sqlite = createSqliteD1(allMigrations())
89+
try {
90+
sqlite.raw.exec(`
91+
INSERT INTO repos(owner,repo,repo_skill_count) VALUES ('org','small',3);
92+
INSERT INTO jobs(id,queue,job_type,payload,available_at,completed_at) VALUES
93+
('done-1','repo-review-sync','registry/repository-purpose','{"owner":"other","repo":"past"}',0,5000),
94+
('done-2','repo-review-sync','registry/repository-purpose','{"owner":"other","repo":"older"}',0,4000);
95+
`)
96+
const { db, statements } = recordingDb(sqlite)
97+
await listRepositoryPurposeCandidates(db, 100000)
98+
expect(statements.length).toBeGreaterThan(0)
99+
const details = statements.flatMap(({ sql, values }) => planDetails(sqlite, sql, values))
100+
const text = details.join('\n')
101+
expect(text).not.toContain('idx_jobs_type')
102+
expect(details.some(detail => detail.includes('idx_jobs_active'))).toBe(true)
103+
expect(details.some(detail => detail.includes('idx_failed_jobs_failed_at'))).toBe(true)
104+
expect(details.filter(detail => /^SCAN jobs(?! USING)/.test(detail))).toEqual([])
71105
}
72106
finally { sqlite.close() }
73107
})
@@ -137,3 +171,32 @@ describe('purpose records', () => {
137171
finally { sqlite.close() }
138172
})
139173
})
174+
175+
/** Wraps the D1 facade so every bound statement can be explained after the call. */
176+
function recordingDb(sqlite: SqliteD1): { db: D1Database, statements: Array<{ sql: string, values: unknown[] }> } {
177+
const statements: Array<{ sql: string, values: unknown[] }> = []
178+
const prepare = sqlite.db.prepare.bind(sqlite.db)
179+
const db = {
180+
prepare(sql: string) {
181+
const statement = prepare(sql)
182+
return {
183+
...statement,
184+
bind: (...values: unknown[]) => {
185+
statements.push({ sql, values })
186+
return statement.bind(...values)
187+
},
188+
}
189+
},
190+
} as unknown as D1Database
191+
return { db, statements }
192+
}
193+
194+
function planDetails(sqlite: SqliteD1, sql: string, values: unknown[]): string[] {
195+
const expanded: unknown[] = []
196+
const normalized = sql.replace(/\?(\d+)/g, (_, raw: string) => {
197+
expanded.push(values[Number(raw) - 1])
198+
return '?'
199+
})
200+
return (sqlite.raw.prepare(`EXPLAIN QUERY PLAN ${normalized}`).all(...expanded) as Array<{ detail: string }>)
201+
.map(row => row.detail)
202+
}

0 commit comments

Comments
 (0)