index-sync.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import * as fs from 'node:fs'
  3. import * as os from 'node:os'
  4. import * as path from 'node:path'
  5. import { createRequire } from 'node:module'
  6. import { spawn } from 'node:child_process'
  7. import { pathToFileURL } from 'node:url'
  8. import { changeIndexState, embeddingRevision, readIndexState, searchFinalized, syncFinalizedIndex, withBookWrite, writeBatchAtomic, type EmbeddingProvider } from '../src'
  9. import { removeSync } from '../src/repo/remove'
  10. import { scanFinalized } from '../src/retrieval/source'
  11. import { vectorBytes, normalizedVector } from '../src/retrieval/vectors'
  12. import { retryDelayMs, DEFAULT_INDEX_RETRY } from '../src/retrieval/retry'
  13. let root: string
  14. beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-index-sync-')) })
  15. afterEach(() => { removeSync(root) })
  16. function chapter(number: number, body: string, title = `章${number}`) {
  17. const relative = `定稿/卷01/${String(number).padStart(4, '0')}-${title}.md`
  18. fs.mkdirSync(path.dirname(path.join(root, relative)), { recursive: true })
  19. fs.writeFileSync(path.join(root, relative), `---\n版本: 1\n角色: 已定稿\n---\n${body}\n`)
  20. return relative
  21. }
  22. function provider(run: EmbeddingProvider['embed'] = async inputs => inputs.map(() => [1, 0]), batchSize = 1): EmbeddingProvider {
  23. return { metadata: { provider: 'fixture', model: 'fixture', dimensions: 2, revision: 'v1', batchSize }, embed: run, embedBatch: run }
  24. }
  25. function deferred<T>() {
  26. let resolve!: (value: T) => void
  27. const promise = new Promise<T>(done => { resolve = done })
  28. return { promise, resolve }
  29. }
  30. describe('程序维护的向量索引', () => {
  31. it('查询不生成正文向量;后台就绪后查询只请求问句', async () => {
  32. chapter(1, '主角带信归来。')
  33. const embed = vi.fn<EmbeddingProvider['embed']>(async inputs => inputs.map(() => [1, 0]))
  34. const client = provider(embed)
  35. const cold = await searchFinalized(root, { query: '带信', provider: client })
  36. expect(cold).toMatchObject({ ok: true, mode: 'keyword', index: { ready: false, missing: 1 } })
  37. expect(embed).not.toHaveBeenCalled()
  38. expect(await syncFinalizedIndex(root, { getProvider: () => client })).toMatchObject({ ok: true, state: { phase: 'ready', generated: 1, completedChunks: 1 } })
  39. expect(await searchFinalized(root, { query: '带信', provider: client })).toMatchObject({ ok: true, mode: 'hybrid', index: { embedded: 0, reused: 1, ready: true } })
  40. expect(embed.mock.calls.map(call => call[1])).toEqual(['document', 'query'])
  41. })
  42. it('每批持久化,失败后的新调用只补未完成部分', async () => {
  43. for (let number = 1; number <= 3; number++) chapter(number, `正文${number}`)
  44. let calls = 0
  45. const failed = provider(async () => {
  46. if (++calls === 2) throw { code: 'http-error', httpStatus: 503 }
  47. return [[1, 0]]
  48. })
  49. expect(await syncFinalizedIndex(root, { getProvider: () => failed, retry: { maxRetries: 0 } })).toMatchObject({ ok: false, state: { phase: 'failed', completedChunks: 1, failed: 2 } })
  50. const requested: string[] = []
  51. const restored = provider(async inputs => { requested.push(...inputs.map(input => input.text)); return inputs.map(() => [1, 0]) })
  52. expect(await syncFinalizedIndex(root, { getProvider: () => restored })).toMatchObject({ ok: true, state: { generated: 2, reused: 1, completedChunks: 3 } })
  53. expect(requested).toEqual(['正文2', '正文3'])
  54. })
  55. it('相同输入去重,单章内容小改仍复用未变片段与其他章', async () => {
  56. const relative = chapter(1, Array.from({ length: 20 }, (_, index) => `${index}段${'山'.repeat(96)}`).join('\n\n') + '\n\n旧结尾')
  57. chapter(2, '完全不动的章节')
  58. const embed = vi.fn<EmbeddingProvider['embed']>(async inputs => inputs.map(() => [1, 0]))
  59. const client = provider(embed, 32)
  60. const first = await syncFinalizedIndex(root, { getProvider: () => client })
  61. expect(first.ok).toBe(true)
  62. fs.writeFileSync(path.join(root, relative), fs.readFileSync(path.join(root, relative), 'utf8').replace('旧结尾', '新结尾'))
  63. embed.mockClear()
  64. const next = await syncFinalizedIndex(root, { getProvider: () => client })
  65. expect(next.ok).toBe(true)
  66. if (!next.ok) return
  67. expect(next.state.generated).toBeLessThan(next.state.chunks)
  68. expect(next.state.reused).toBeGreaterThan(1)
  69. expect(embed.mock.calls.flatMap(call => call[0]).every(input => input.title !== '章2')).toBe(true)
  70. const content = fs.readFileSync(path.join(root, relative), 'utf8')
  71. fs.writeFileSync(path.join(root, relative), content.replace('版本: 1', '版本: 2'))
  72. embed.mockClear()
  73. expect(await syncFinalizedIndex(root, { getProvider: () => client })).toMatchObject({ ok: true, state: { generated: 0 } })
  74. expect(embed).not.toHaveBeenCalled()
  75. })
  76. it('同正文同标题在不同章节只请求一次,来源仍分别定位', async () => {
  77. chapter(1, '相同原文', '相同标题'); chapter(2, '相同原文', '相同标题')
  78. const embed = vi.fn<EmbeddingProvider['embed']>(async inputs => inputs.map(() => [1, 0]))
  79. const client = provider(embed)
  80. expect(await syncFinalizedIndex(root, { getProvider: () => client })).toMatchObject({ ok: true, state: { chunks: 2, completedChunks: 2 } })
  81. expect(embed).toHaveBeenCalledTimes(1)
  82. const result = await searchFinalized(root, { query: '相同原文', provider: client })
  83. expect(result.ok && result.hits.map(hit => hit.chapter)).toEqual([1, 2])
  84. })
  85. it('取消保留已提交批次,并在未配合的 API 返回前释放任务租约', async () => {
  86. chapter(1, '第一份'); chapter(2, '第二份')
  87. const entered = deferred<void>(), late = deferred<readonly (readonly number[])[]>()
  88. let calls = 0
  89. const client = provider(async () => { if (++calls === 2) { entered.resolve(); return late.promise }; return [[1, 0]] })
  90. const controller = new AbortController()
  91. const pending = syncFinalizedIndex(root, { getProvider: () => client, signal: controller.signal })
  92. await entered.promise
  93. controller.abort()
  94. expect(await pending).toMatchObject({ ok: false, failure: { code: 'cancelled' }, state: { completedChunks: 1 } })
  95. late.resolve([[1, 0]])
  96. expect(fs.existsSync(path.join(root, '.webnovel/finalized-search.worker.lock'))).toBe(false)
  97. const next = provider()
  98. expect(await syncFinalizedIndex(root, { getProvider: () => next })).toMatchObject({ ok: true, state: { generated: 1, reused: 1 } })
  99. })
  100. it('API 等待不占书仓锁,源变更拒绝迟到批次', async () => {
  101. const relative = chapter(1, '旧正文')
  102. const entered = deferred<void>(), late = deferred<readonly (readonly number[])[]>()
  103. const client = provider(async () => { entered.resolve(); return late.promise })
  104. const pending = syncFinalizedIndex(root, { getProvider: () => client })
  105. await entered.promise
  106. expect(fs.existsSync(path.join(root, '.webnovel/book.lock'))).toBe(false)
  107. withBookWrite(root, () => writeBatchAtomic(root, [{ relPath: relative, content: '新正文' }]))
  108. late.resolve([[1, 0]])
  109. expect(await pending).toMatchObject({ ok: false, failure: { code: 'source-changed' }, state: { phase: 'queued', completedChunks: 0 } })
  110. expect((await searchFinalized(root, { query: '旧正文' }))).toMatchObject({ ok: true, hits: [] })
  111. })
  112. it('提供方换实例不发布旧向量,同书不会启动第二个工作者', async () => {
  113. chapter(1, '原文')
  114. const entered = deferred<void>(), late = deferred<readonly (readonly number[])[]>()
  115. let current = provider(async () => { entered.resolve(); return late.promise })
  116. const pending = syncFinalizedIndex(root, { getProvider: () => current })
  117. await entered.promise
  118. expect(await syncFinalizedIndex(root, { getProvider: () => current })).toMatchObject({ ok: false, failure: { code: 'index-busy' } })
  119. current = provider()
  120. late.resolve([[1, 0]])
  121. expect(await pending).toMatchObject({ ok: false, failure: { code: 'provider-changed' } })
  122. expect(await syncFinalizedIndex(root, { getProvider: () => current })).toMatchObject({ ok: true, state: { generated: 1 } })
  123. })
  124. it('指数退避、Retry-After 和预算由一层控制,瞬时错误不生成最终通知', async () => {
  125. chapter(1, '重试原文')
  126. let now = 1000, calls = 0
  127. const sleeps: number[] = []
  128. const client = provider(async () => {
  129. if (++calls === 1) throw { code: 'http-error', httpStatus: 429, retryAfterMs: 7000 }
  130. if (calls === 2) throw { code: 'http-error', httpStatus: 503 }
  131. return [[1, 0]]
  132. })
  133. const result = await syncFinalizedIndex(root, { getProvider: () => client,
  134. clock: { now: () => now, random: () => 1, sleep: async ms => { sleeps.push(ms); now += ms } } })
  135. expect(result).toMatchObject({ ok: true, state: { phase: 'ready', completedChunks: 1 } })
  136. expect(sleeps).toEqual([7000, 4000])
  137. expect(calls).toBe(3)
  138. expect(readIndexState(root).errors).toHaveLength(2)
  139. expect(readIndexState(root).notice).toBeUndefined()
  140. expect(retryDelayMs(9, DEFAULT_INDEX_RETRY, 120_000, () => 1)).toBe(120_000)
  141. })
  142. it('鉴权与格式错误不重试,安全原因和通知绑定原会话', async () => {
  143. chapter(1, '私有原文')
  144. await changeIndexState(root, state => ({ ...state, recipientSession: 'owner-session' }))
  145. const embed = vi.fn(async () => { throw { code: 'http-error', httpStatus: 401, message: 'SECRET key and prompt' } })
  146. const client = provider(embed)
  147. const sleep = vi.fn(async () => {})
  148. const result = await syncFinalizedIndex(root, { getProvider: () => client, clock: { now: Date.now, random: () => 1, sleep } })
  149. expect(result).toMatchObject({ ok: false, failure: { httpStatus: 401, retryable: false }, state: { notice: { sessionId: 'owner-session' } } })
  150. expect(embed).toHaveBeenCalledTimes(1)
  151. expect(sleep).not.toHaveBeenCalled()
  152. expect(JSON.stringify(result)).not.toContain('SECRET')
  153. })
  154. it('损坏缓存重建保留自动更新与通知设置', async () => {
  155. chapter(1, '原文')
  156. await changeIndexState(root, state => ({ ...state, auto: true, recipientSession: 'owner-session' }))
  157. const client = provider()
  158. await syncFinalizedIndex(root, { getProvider: () => client })
  159. fs.writeFileSync(path.join(root, '.webnovel/finalized-search.sqlite'), 'broken')
  160. expect(await syncFinalizedIndex(root, { getProvider: () => client })).toMatchObject({ ok: true, state: { auto: true, recipientSession: 'owner-session', generated: 1 } })
  161. })
  162. it('v1 缓存只迁移能与当前来源、模型对应的向量', async () => {
  163. chapter(1, '已有向量')
  164. const client = provider(vi.fn(async () => { throw new Error('should not call API') }))
  165. const snapshot = await scanFinalized(root)
  166. const chunk = snapshot.documents[0]!.chunks[0]!
  167. fs.mkdirSync(path.join(root, '.webnovel'), { recursive: true })
  168. const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite') as typeof import('node:sqlite')
  169. const db = new DatabaseSync(path.join(root, '.webnovel/finalized-search.sqlite'))
  170. try {
  171. db.exec("CREATE TABLE chunks(id TEXT PRIMARY KEY,body TEXT,vector BLOB,revision TEXT,dimensions INTEGER); CREATE VIRTUAL TABLE terms USING fts5(id UNINDEXED,body,tokenize='trigram case_sensitive 1'); CREATE TABLE metadata(key TEXT PRIMARY KEY,value TEXT); PRAGMA user_version=1;")
  172. db.exec('PRAGMA application_id=' + 0x57565336)
  173. db.prepare('INSERT INTO chunks VALUES (?,?,?,?,?)').run(chunk.id, chunk.text, vectorBytes(normalizedVector([1, 0])), embeddingRevision(client)!, 2)
  174. db.prepare('INSERT INTO terms(id,body) VALUES (?,?)').run(chunk.id, chunk.text)
  175. } finally { db.close() }
  176. expect(await syncFinalizedIndex(root, { getProvider: () => client })).toMatchObject({ ok: true, state: { generated: 0, reused: 1 } })
  177. expect(client.embedBatch).not.toHaveBeenCalled()
  178. })
  179. it('实际进程中断后回收死亡租约,重启只补未落库批次', async () => {
  180. chapter(1, '已保存批次'); chapter(2, '尚未保存批次')
  181. const loader = pathToFileURL(path.resolve(__dirname, '../../../scripts/strip-types-loader.mjs')).href
  182. const module = pathToFileURL(path.resolve(__dirname, '../src/retrieval/sync.ts')).href
  183. const script = `import { register } from 'node:module'; register(${JSON.stringify(loader)});
  184. const { syncFinalizedIndex } = await import(${JSON.stringify(module)});
  185. let calls = 0;
  186. // Announce from inside the hanging second request: batch one is committed and no book
  187. // lock or acquire guard is held, so killing here orphans only the worker lease.
  188. const embed = async () => { if (++calls > 1) { process.stdout.write('CHECKPOINT\\n'); return new Promise(() => { setInterval(() => {}, 1000) }) } return [[1,0]] };
  189. const provider = { metadata: { provider:'fixture', model:'fixture', dimensions:2, revision:'v1', batchSize:1 }, embed, embedBatch:embed };
  190. await syncFinalizedIndex(${JSON.stringify(root)}, { getProvider: () => provider });`
  191. const child = spawn(process.execPath, ['--experimental-transform-types', '--input-type=module', '-e', script], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
  192. const exited = new Promise<void>(resolve => child.once('exit', () => resolve()))
  193. try {
  194. await new Promise<void>((resolve, reject) => {
  195. const timer = setTimeout(() => reject(new Error('child checkpoint timeout')), 10_000)
  196. child.stdout.on('data', data => { if (String(data).includes('CHECKPOINT')) { clearTimeout(timer); resolve() } })
  197. child.once('error', reject)
  198. })
  199. child.kill()
  200. await exited
  201. expect(readIndexState(root).completedChunks).toBe(1)
  202. const requested: string[] = []
  203. const restored = provider(async inputs => { requested.push(...inputs.map(input => input.text)); return inputs.map(() => [1, 0]) })
  204. expect(await syncFinalizedIndex(root, { getProvider: () => restored })).toMatchObject({ ok: true, state: { generated: 1, reused: 1 } })
  205. expect(requested).toEqual(['尚未保存批次'])
  206. } finally { if (child.exitCode === null) child.kill(); await exited }
  207. }, 15_000)
  208. })