fix: publish knowledge rebuilds atomically

This commit is contained in:
lofyer
2026-08-13 06:35:07 +08:00
parent 0c46afba59
commit 7a078c6ffe
7 changed files with 1618 additions and 254 deletions
+452 -19
View File
@@ -80,7 +80,7 @@ import type {
VectorSearchOptions
} from './types'
const DATABASE_VERSION = 10
const DATABASE_VERSION = 11
const MAX_ID_LENGTH = 128
const MAX_NAME_LENGTH = 512
const MAX_LOCATION_LENGTH = 8192
@@ -118,6 +118,22 @@ export type HybridSearchResultPage = {
vectorScannedCount: number
}
export type PreparedEmbeddingReplacement = {
replacementId: string
provider: string
model: string
}
export type DocumentPublicationOptions = {
embeddingReplacement?: PreparedEmbeddingReplacement
embeddingError?: {
provider: string
model: string
message: string
}
afterChunksInserted?: (document: Document) => void
}
type Row = Record<string, null | number | bigint | string | Uint8Array>
const taskScopes = knowledgeTaskScopeSchema.options
@@ -1635,6 +1651,22 @@ export class KnowledgeDatabase {
.map(mapSource)
}
listSourcesForSnapshot(knowledgeBaseId: string): KnowledgeSource[] {
const normalizedId = requiredString(
knowledgeBaseId,
'knowledgeBaseId',
MAX_ID_LENGTH
)
return this.requireDatabase()
.prepare(
`SELECT * FROM knowledge_sources
WHERE knowledge_base_id = ?
ORDER BY created_at ASC, id ASC`
)
.all(normalizedId)
.map(mapSource)
}
getSource(id: string): KnowledgeSource | undefined {
const row = this.requireDatabase()
.prepare('SELECT * FROM knowledge_sources WHERE id = ?')
@@ -1654,6 +1686,14 @@ export class KnowledgeDatabase {
upsertDocument(
input: UpsertDocumentInput,
chunks: ReplaceChunkInput[]
): Document {
return this.publishDocument(input, chunks)
}
publishDocument(
input: UpsertDocumentInput,
chunks: ReplaceChunkInput[],
options: DocumentPublicationOptions = {}
): Document {
if (!Array.isArray(chunks) || chunks.length > MAX_CHUNKS) {
throw new RangeError(`chunks must contain at most ${MAX_CHUNKS} items`)
@@ -1716,6 +1756,58 @@ export class KnowledgeDatabase {
)
const checksum = optionalString(input.checksum, 'checksum', 512)
const metadata = jsonObject(input.metadata, 'metadata')
const embeddingReplacement = options.embeddingReplacement
? {
replacementId: requiredString(
options.embeddingReplacement.replacementId,
'embeddingReplacement.replacementId',
MAX_ID_LENGTH
),
provider: requiredString(
options.embeddingReplacement.provider,
'embeddingReplacement.provider',
MAX_EMBEDDING_PROVIDER_LENGTH
),
model: requiredString(
options.embeddingReplacement.model,
'embeddingReplacement.model',
MAX_EMBEDDING_MODEL_LENGTH
)
}
: undefined
const embeddingError = options.embeddingError
? {
provider: requiredString(
options.embeddingError.provider,
'embeddingError.provider',
MAX_EMBEDDING_PROVIDER_LENGTH
),
model: requiredString(
options.embeddingError.model,
'embeddingError.model',
MAX_EMBEDDING_MODEL_LENGTH
),
message: requiredString(
options.embeddingError.message,
'embeddingError.message',
MAX_EMBEDDING_ERROR_LENGTH,
false
)
}
: undefined
if (embeddingReplacement && embeddingError) {
throw new Error(
'Document publication cannot include embeddings and an embedding error'
)
}
if (embeddingReplacement) {
this.validatePreparedDocumentEmbeddings(
database,
embeddingReplacement,
id,
normalizedChunks
)
}
const now = new Date().toISOString()
this.transaction(database, () => {
@@ -1749,7 +1841,7 @@ export class KnowledgeDatabase {
now
)
database
.prepare('DELETE FROM embedding_index_state WHERE document_id = ?')
.prepare('DELETE FROM graph_evidence WHERE document_id = ?')
.run(id)
database.prepare('DELETE FROM chunks WHERE document_id = ?').run(id)
const insertChunk = database.prepare(
@@ -1794,6 +1886,29 @@ export class KnowledgeDatabase {
indexContent
)
}
options.afterChunksInserted?.(this.requiredDocument(id))
database
.prepare(
'DELETE FROM embedding_index_state WHERE document_id = ?'
)
.run(id)
if (embeddingReplacement) {
this.publishPreparedDocumentEmbeddings(
database,
embeddingReplacement,
id,
knowledgeBaseId,
now
)
} else if (embeddingError) {
this.recordEmbeddingIndexError(
id,
embeddingError.provider,
embeddingError.model,
embeddingError.message
)
}
this.pruneUnreferencedGeneratedGraph(knowledgeBaseId)
})
return this.requiredDocument(id)
}
@@ -1851,6 +1966,10 @@ export class KnowledgeDatabase {
.map(mapDocument)
}
listDocumentsForSnapshot(knowledgeBaseId: string): Document[] {
return this.listDocumentsForLibraryRebuild(knowledgeBaseId)
}
getDocumentChunkCounts(knowledgeBaseId: string): Map<string, number> {
const normalizedId = requiredString(
knowledgeBaseId,
@@ -2365,6 +2484,17 @@ export class KnowledgeDatabase {
return randomUUID()
}
beginPreparedDocumentEmbeddingReplacement(
documentId: string,
provider: string,
model: string
): string {
requiredString(documentId, 'documentId', MAX_ID_LENGTH)
requiredString(provider, 'provider', MAX_EMBEDDING_PROVIDER_LENGTH)
requiredString(model, 'model', MAX_EMBEDDING_MODEL_LENGTH)
return randomUUID()
}
appendDocumentEmbeddingBatch(
replacementId: string,
documentId: string,
@@ -2465,26 +2595,95 @@ export class KnowledgeDatabase {
}
return { chunkId, checksum, ...vector }
})
const insert = database.prepare(
`INSERT INTO embedding_rebuild_staging
(replacement_id, document_id, provider, model, chunk_id,
dimensions, content_checksum, vector, magnitude)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
this.insertEmbeddingReplacementBatch(database, {
replacementId: normalizedReplacementId,
documentId: normalizedDocumentId,
provider: normalizedProvider,
model: normalizedModel,
embeddings: normalized
})
}
appendPreparedDocumentEmbeddingBatch(
replacementId: string,
documentId: string,
provider: string,
model: string,
embeddings: readonly ChunkEmbeddingInput[]
): void {
const normalizedReplacementId = requiredString(
replacementId,
'replacementId',
MAX_ID_LENGTH
)
this.transaction(database, () => {
for (const item of normalized) {
insert.run(
normalizedReplacementId,
normalizedDocumentId,
normalizedProvider,
normalizedModel,
item.chunkId,
item.dimensions,
item.checksum,
item.bytes,
item.magnitude
const normalizedDocumentId = requiredString(
documentId,
'documentId',
MAX_ID_LENGTH
)
const normalizedProvider = requiredString(
provider,
'provider',
MAX_EMBEDDING_PROVIDER_LENGTH
)
const normalizedModel = requiredString(
model,
'model',
MAX_EMBEDDING_MODEL_LENGTH
)
if (
!Array.isArray(embeddings) ||
embeddings.length < 1 ||
embeddings.length > MAX_EMBEDDING_BATCH
) {
throw new RangeError(
`embeddings must contain between 1 and ${MAX_EMBEDDING_BATCH} items`
)
}
const database = this.requireDatabase()
const existingDimensions = database
.prepare(
`SELECT dimensions FROM embedding_rebuild_staging
WHERE replacement_id = ? LIMIT 1`
)
.get(normalizedReplacementId)
let dimensions = existingDimensions
? asNumber(existingDimensions, 'dimensions')
: undefined
const seen = new Set<string>()
const normalized = embeddings.map((embedding, index) => {
const chunkId = requiredString(
embedding.chunkId,
`embeddings[${index}].chunkId`,
MAX_ID_LENGTH
)
if (seen.has(chunkId)) {
throw new Error('Embedding chunk IDs must be unique within a batch')
}
seen.add(chunkId)
const checksum = normalizedChecksum(
embedding.contentChecksum,
`embeddings[${index}].contentChecksum`
)
const vector = normalizeVector(
embedding.vector,
`embeddings[${index}].vector`
)
if (dimensions === undefined) {
dimensions = vector.dimensions
} else if (dimensions !== vector.dimensions) {
throw new Error(
'Document embeddings must have consistent dimensions'
)
}
return { chunkId, checksum, ...vector }
})
this.insertEmbeddingReplacementBatch(database, {
replacementId: normalizedReplacementId,
documentId: normalizedDocumentId,
provider: normalizedProvider,
model: normalizedModel,
embeddings: normalized
})
}
@@ -4324,6 +4523,14 @@ export class KnowledgeDatabase {
)
.run(10, new Date().toISOString())
}
if (currentVersion < 11) {
this.migrateToVersion11(database)
database
.prepare(
'INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)'
)
.run(11, new Date().toISOString())
}
database.exec(`PRAGMA user_version = ${DATABASE_VERSION}`)
database.exec('COMMIT')
} catch (error) {
@@ -4947,6 +5154,228 @@ export class KnowledgeDatabase {
`)
}
private migrateToVersion11(database: DatabaseSync): void {
database.exec('DROP TABLE embedding_rebuild_staging;')
database.exec(`
CREATE TABLE embedding_rebuild_staging (
replacement_id TEXT NOT NULL,
document_id TEXT NOT NULL,
provider TEXT NOT NULL,
model TEXT NOT NULL,
chunk_id TEXT NOT NULL,
dimensions INTEGER NOT NULL
CHECK (dimensions >= 1 AND dimensions <= 8192),
content_checksum TEXT NOT NULL
CHECK (length(content_checksum) = 64),
vector BLOB NOT NULL,
magnitude REAL NOT NULL CHECK (magnitude > 0),
PRIMARY KEY (replacement_id, chunk_id)
);
CREATE INDEX embedding_rebuild_staging_document_idx
ON embedding_rebuild_staging(
document_id, provider, model, replacement_id
);
`)
}
private validatePreparedDocumentEmbeddings(
database: DatabaseSync,
replacement: PreparedEmbeddingReplacement,
documentId: string,
chunks: ReturnType<KnowledgeDatabase['normalizeChunks']>
): void {
const indexableChunks = chunks.filter(
(chunk) => chunk.enabled && chunk.role !== 'parent'
)
if (indexableChunks.length === 0) {
throw new Error(
'Prepared embedding replacement requires indexable chunks'
)
}
const stagedRows = database
.prepare(
`SELECT chunk_id, content_checksum, dimensions
FROM embedding_rebuild_staging
WHERE replacement_id = ? AND document_id = ?
AND provider = ? AND model = ?
ORDER BY chunk_id`
)
.all(
replacement.replacementId,
documentId,
replacement.provider,
replacement.model
)
if (stagedRows.length !== indexableChunks.length) {
throw new Error('Embeddings must cover every candidate document chunk')
}
const chunksById = new Map(
indexableChunks.map((chunk) => [
chunk.id,
contentChecksum(
chunkIndexContent(chunk.content, parseObject(chunk.metadata))
)
])
)
let dimensions: number | undefined
for (const row of stagedRows) {
const chunkId = asString(row, 'chunk_id')
const checksum = chunksById.get(chunkId)
if (
checksum === undefined ||
checksum !== asString(row, 'content_checksum')
) {
throw new Error('Prepared embedding content does not match the chunk')
}
chunksById.delete(chunkId)
const rowDimensions = asNumber(row, 'dimensions')
if (dimensions === undefined) {
dimensions = rowDimensions
} else if (dimensions !== rowDimensions) {
throw new Error(
'Document embeddings must have consistent dimensions'
)
}
}
if (chunksById.size > 0) {
throw new Error('Embeddings must cover every candidate document chunk')
}
}
private publishPreparedDocumentEmbeddings(
database: DatabaseSync,
replacement: PreparedEmbeddingReplacement,
documentId: string,
knowledgeBaseId: string,
now: string
): void {
let dimensions: number | undefined
const indexHash = createHash('sha256')
let firstChecksum = true
for (const row of database
.prepare(
`SELECT chunk_id, content_checksum, dimensions
FROM embedding_rebuild_staging
WHERE replacement_id = ? AND document_id = ?
AND provider = ? AND model = ?
ORDER BY chunk_id`
)
.iterate(
replacement.replacementId,
documentId,
replacement.provider,
replacement.model
)) {
const chunkId = asString(row, 'chunk_id')
const checksum = asString(row, 'content_checksum')
if (!firstChecksum) {
indexHash.update('\n')
}
indexHash.update(`${chunkId}\0${checksum}`)
firstChecksum = false
dimensions ??= asNumber(row, 'dimensions')
}
database
.prepare(
`INSERT INTO chunk_embeddings
(chunk_id, knowledge_base_id, provider, model, dimensions,
content_checksum, vector, magnitude, created_at, updated_at)
SELECT chunk_id, ?, provider, model, dimensions,
content_checksum, vector, magnitude, ?, ?
FROM embedding_rebuild_staging
WHERE replacement_id = ? AND document_id = ?
AND provider = ? AND model = ?`
)
.run(
knowledgeBaseId,
now,
now,
replacement.replacementId,
documentId,
replacement.provider,
replacement.model
)
database
.prepare(
`INSERT INTO embedding_index_state
(document_id, knowledge_base_id, provider, model, dimensions,
content_checksum, status, last_error, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'ready', NULL, ?)`
)
.run(
documentId,
knowledgeBaseId,
replacement.provider,
replacement.model,
dimensions ?? null,
indexHash.digest('hex'),
now
)
database
.prepare(
'DELETE FROM embedding_rebuild_staging WHERE replacement_id = ?'
)
.run(replacement.replacementId)
}
private insertEmbeddingReplacementBatch(
database: DatabaseSync,
input: {
replacementId: string
documentId: string
provider: string
model: string
embeddings: ReadonlyArray<{
chunkId: string
checksum: string
bytes: Buffer
dimensions: number
magnitude: number
}>
}
): void {
const insert = database.prepare(
`INSERT INTO embedding_rebuild_staging
(replacement_id, document_id, provider, model, chunk_id,
dimensions, content_checksum, vector, magnitude)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
this.transaction(database, () => {
const existingTarget = database
.prepare(
`SELECT document_id, provider, model
FROM embedding_rebuild_staging
WHERE replacement_id = ? LIMIT 1`
)
.get(input.replacementId)
if (
existingTarget &&
(
asString(existingTarget, 'document_id') !== input.documentId ||
asString(existingTarget, 'provider') !== input.provider ||
asString(existingTarget, 'model') !== input.model
)
) {
throw new Error(
'Embedding replacement target cannot change between batches'
)
}
for (const item of input.embeddings) {
insert.run(
input.replacementId,
input.documentId,
input.provider,
input.model,
item.chunkId,
item.dimensions,
item.checksum,
item.bytes,
item.magnitude
)
}
})
}
private normalizeChunks(chunks: ReplaceChunkInput[]): Array<{
id: string
ordinal: number
@@ -5175,6 +5604,10 @@ export class KnowledgeDatabase {
}
private transaction(database: DatabaseSync, operation: () => void): void {
if (database.isTransaction) {
operation()
return
}
database.exec('BEGIN IMMEDIATE')
try {
operation()