fix(kb): recreate a missing Qdrant container instead of spinning on Start Qdrant

The Knowledge Base's "Start Qdrant" button called start on a container
that no longer existed. The server answered 200 with success: false, the
modal ignored that flag, and the button polled the health check forever.

Starting a dependency service whose container is gone now recreates it
from its stored config through the normal install path. Its data lives on
a host bind mount, so nothing is lost. The modal shows a refused start as
an error, and stops waiting after five minutes if Qdrant never comes
online.

Fixes #1426
This commit is contained in:
Matt Moyles 2026-10-15 13:00:51 -04:00
parent 54ee30a449
commit 634e180edf
No known key found for this signature in database
GPG Key ID: 7746D1F8D7DA0259
4 changed files with 83 additions and 2 deletions

View File

@ -5,6 +5,7 @@ import { inject } from '@adonisjs/core'
import transmit from '@adonisjs/transmit/services/main'
import { doResumableDownloadWithRetry } from '../utils/downloads.js'
import { pickAmdHsaOverride } from '../utils/amd_hsa_override.js'
import { recreatesOnStart } from '../utils/dependency_start.js'
import { join } from 'path'
import os from 'node:os'
import env from '#start/env'
@ -92,7 +93,7 @@ export class DockerService {
): Promise<{ success: boolean; message: string }> {
try {
const service = await Service.query().where('service_name', serviceName).first()
if (!service || !service.installed) {
if (!service) {
return {
success: false,
message: `Service ${serviceName} not found or not installed`,
@ -101,6 +102,24 @@ export class DockerService {
const containers = await this.docker.listContainers({ all: true })
const container = containers.find((c) => c.Names.includes(`/${serviceName}`))
if (recreatesOnStart(action, service.is_dependency_service, !!container)) {
logger.info(`[DockerService] ${serviceName} has no container. Recreating it.`)
// Preflight refuses a row still marked installed; the next sync would reset it anyway.
if (service.installed) {
service.installed = false
await service.save()
}
return await this.createContainerPreflight(serviceName)
}
if (!service.installed) {
return {
success: false,
message: `Service ${serviceName} not found or not installed`,
}
}
if (!container) {
return {
success: false,

View File

@ -0,0 +1,18 @@
/**
* Whether a "start" request should recreate the service's container instead of
* starting it (#1426).
*
* A hidden dependency service (Qdrant) has no card on the Apps page, so the
* Knowledge Base's "Start Qdrant" button is the only control a user has over it.
* When its container is gone there is nothing to start, and the next container sync
* marks the row not installed, so the only way back is to recreate it from the
* stored config. Its data lives on a host bind mount, so recreating it loses nothing.
* Every other service keeps the old answer: the user reinstalls it from the Apps page.
*/
export function recreatesOnStart(
action: 'start' | 'stop' | 'restart',
isDependencyService: boolean,
containerExists: boolean
): boolean {
return action === 'start' && isDependencyService && !containerExists
}

View File

@ -55,6 +55,10 @@ const VIEWABLE_EXTENSIONS = new Set([
'html',
])
// How long "Start Qdrant" waits for the health check to come back online. Long
// enough for a recreate that has to pull the image again.
const QDRANT_START_TIMEOUT_MS = 5 * 60_000
function isViewableExtension(filename: string): boolean {
const ext = filename.split('.').at(-1)?.toLowerCase() ?? ''
return VIEWABLE_EXTENSIONS.has(ext)
@ -228,6 +232,20 @@ export default function KnowledgeBaseModal({
if (!qdrantOffline) setIsStartingQdrant(false)
}, [qdrantOffline])
// Stop waiting on a Qdrant that started and then crashed, or whose recreate failed.
useEffect(() => {
if (!isStartingQdrant) return
const timer = setTimeout(() => {
setIsStartingQdrant(false)
addNotification({
type: 'error',
message:
'Qdrant did not come online. Check its logs under Settings → Service Logs & Metrics.',
})
}, QDRANT_START_TIMEOUT_MS)
return () => clearTimeout(timer)
}, [isStartingQdrant])
const { data: storedFiles = [], isLoading: isLoadingFiles } = useQuery({
queryKey: ['storedFiles'],
queryFn: () => api.getStoredRAGFiles(),
@ -399,7 +417,12 @@ export default function KnowledgeBaseModal({
const startQdrantMutation = useMutation({
mutationFn: () => api.affectService(SERVICE_NAMES.QDRANT, 'start'),
onSuccess: () => {
onSuccess: (result) => {
// A refused start comes back as success: false, not as a thrown error.
if (!result?.success) {
addNotification({ type: 'error', message: result?.message || 'Failed to start Qdrant.' })
return
}
setIsStartingQdrant(true)
queryClient.invalidateQueries({ queryKey: ['qdrantHealth'] })
},

View File

@ -0,0 +1,21 @@
import * as assert from 'node:assert/strict'
import { test } from 'node:test'
import { recreatesOnStart } from '../../app/utils/dependency_start.js'
test('starting a dependency service whose container is gone recreates it', () => {
assert.equal(recreatesOnStart('start', true, false), true)
})
test('starting a dependency service whose container exists just starts it', () => {
assert.equal(recreatesOnStart('start', true, true), false)
})
test('stop and restart never recreate a missing dependency container', () => {
assert.equal(recreatesOnStart('stop', true, false), false)
assert.equal(recreatesOnStart('restart', true, false), false)
})
test('starting a user-facing app with no container does not recreate it', () => {
assert.equal(recreatesOnStart('start', false, false), false)
})