|
| 1 | +import type { SqliteD1 } from './helpers/d1-sqlite' |
1 | 2 | import { describe, expect, it, vi } from 'vitest' |
2 | 3 | import { classifyRepositoryPurpose, REPOSITORY_PURPOSE_REFRESH_SECONDS } from '../../layers/registry/server/utils/repository-purpose' |
3 | 4 | import { checkRepositoryPurposeAdmission, listRepositoryPurposeCandidates, persistRepositoryPurpose, refreshRepositoryPurpose } from '../../layers/registry/server/utils/repository-purpose-effect' |
@@ -52,22 +53,55 @@ describe('purpose records', () => { |
52 | 53 | finally { sqlite.close() } |
53 | 54 | }) |
54 | 55 |
|
| 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 | + */ |
55 | 62 | it('checks large inventories first and avoids active or recently failed jobs', async () => { |
56 | 63 | const sqlite = createSqliteD1(allMigrations()) |
57 | 64 | try { |
58 | 65 | 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); |
62 | 71 | `) |
63 | 72 | expect(await listRepositoryPurposeCandidates(sqlite.db, 100000)) |
64 | 73 | .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\'') |
66 | 75 | 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([]) |
71 | 105 | } |
72 | 106 | finally { sqlite.close() } |
73 | 107 | }) |
@@ -137,3 +171,32 @@ describe('purpose records', () => { |
137 | 171 | finally { sqlite.close() } |
138 | 172 | }) |
139 | 173 | }) |
| 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