Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/main/db/interruptedIndexing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export const INTERRUPTED_INDEXING_ERROR =
'Indexing was interrupted because KnowNote exited. Retry indexing to continue.'

interface RecoveryDatabase {
prepare(sql: string): {
run(error: string, updatedAt: number): { changes: number | bigint }
}
}

/** Run only at desktop startup, before any ingestion jobs or folder watchers start. */
export function markInterruptedIndexingFailed(
database: RecoveryDatabase,
now = new Date()
): number {
const result = database
.prepare(
`UPDATE documents
SET status = 'failed', error_message = ?, updated_at = ?
WHERE status IN ('pending', 'processing')`
)
.run(INTERRUPTED_INDEXING_ERROR, Math.floor(now.getTime() / 1000))
return Number(result.changes)
}
12 changes: 11 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import {
runMigrations,
initVectorStore,
closeDatabase,
executeCheckpoint
executeCheckpoint,
getSqlite
} from './db'
import { markInterruptedIndexingFailed } from './db/interruptedIndexing'
import { ConnectionManager } from './models/ConnectionManager'
import { EmbeddingService } from './services/EmbeddingService'
import { SessionAutoSwitchService } from './services/SessionAutoSwitchService'
Expand Down Expand Up @@ -93,6 +95,14 @@ app.whenReady().then(async () => {
Logger.info('Main', 'Initializing database...')
initDatabase()
runMigrations()
// The ingestion queue is in memory. Leftover live statuses belong to the previous
// process; make them retryable before folder reconciliation can enqueue new jobs.
const sqlite = getSqlite()
if (!sqlite) throw new Error('Database unavailable during indexing recovery')
const interrupted = markInterruptedIndexingFailed(sqlite)
if (interrupted > 0) {
Logger.info('Main', `Marked ${interrupted} interrupted indexing task(s) as failed`)
}
initVectorStore()
// The resolver is lazy on purpose: `knowledgeService` is constructed further down, and
// the protocol only ever answers requests from a renderer that loads after startup.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export default function AnkiConfigDialog({
</div>

<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
{t('cancel')}
</Button>
<Button onClick={handleGenerate}>{t('startGenerate')}</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export default function QuizStartDialog({
</div>

<DialogFooter>
<Button variant="outline" onClick={onClose}>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
{t('cancel')}
</Button>
<Button onClick={handleStart} disabled={!canStart}>
Expand Down
65 changes: 65 additions & 0 deletions test/interruptedIndexing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { DatabaseSync } from 'node:sqlite'
import { readFileSync } from 'node:fs'
import {
INTERRUPTED_INDEXING_ERROR,
markInterruptedIndexingFailed
} from '../src/main/db/interruptedIndexing.ts'

test('startup recovery makes interrupted indexing retryable without touching finished sources', () => {
const db = new DatabaseSync(':memory:')
try {
db.exec(`CREATE TABLE documents (
id TEXT PRIMARY KEY, status TEXT, error_message TEXT, updated_at INTEGER,
content TEXT, local_file_path TEXT, chunk_count INTEGER
)`)
const insert = db.prepare('INSERT INTO documents VALUES (?, ?, ?, ?, ?, ?, ?)')
for (const status of ['pending', 'processing', 'indexed', 'failed']) {
insert.run(
status,
status,
status === 'failed' ? 'Original failure' : null,
100,
'Saved content',
'/saved/source.pdf',
3
)
}
const now = new Date('2026-01-01T00:00:00Z')
assert.equal(markInterruptedIndexingFailed(db, now), 2)
const rows = db.prepare('SELECT * FROM documents ORDER BY id').all()
for (const row of rows) {
assert.equal(row.content, 'Saved content')
assert.equal(row.local_file_path, '/saved/source.pdf')
assert.equal(row.chunk_count, 3)
if (row.id === 'pending' || row.id === 'processing') {
assert.equal(row.status, 'failed')
assert.equal(row.error_message, INTERRUPTED_INDEXING_ERROR)
assert.equal(row.updated_at, now.getTime() / 1000)
} else {
assert.equal(row.status, row.id)
assert.equal(row.updated_at, 100)
assert.equal(row.error_message, row.id === 'failed' ? 'Original failure' : null)
}
}
assert.equal(markInterruptedIndexingFailed(db, new Date()), 0)
} finally {
db.close()
}
})

test('recovery runs after migrations and before new ingestion can start', () => {
const source = readFileSync('src/main/index.ts', 'utf8')
const recovery = source.indexOf('markInterruptedIndexingFailed(sqlite)')
assert.ok(recovery > source.indexOf(' runMigrations()'))
assert.ok(recovery < source.indexOf('new KnowledgeService('))
assert.ok(recovery < source.indexOf('await knowledgeService.reconcileWatchedFolders()'))
})

test('Quiz and Anki Cancel buttons use the same reset path as dismissing the dialog', () => {
for (const path of ['quiz/QuizStartDialog', 'anki/AnkiConfigDialog']) {
const source = readFileSync(`src/renderer/src/components/notebook/${path}.tsx`, 'utf8')
assert.match(source, /variant="outline" onClick=\{\(\) => handleOpenChange\(false\)\}/)
}
})
Loading