feat(phase8.1-8.2): dynamic worker concurrency via worker_configs

- Migration 054: worker_configs table (queue_name PK, max/min_concurrency,
  enabled, updated_at); seeds step_processing(8/2), thumbnail_rendering(1/1),
  ai_validation(4/1)
- WorkerConfig SQLAlchemy model
- apply_worker_concurrency beat task: reads enabled configs, broadcasts
  pool_grow to all Celery workers every 5min
- GET/PUT /api/worker/configs (admin): list + update per-queue concurrency
- docker-compose.yml: worker uses --autoscale=${MAX_CONCURRENCY:-8},${MIN_CONCURRENCY:-2};
  render-worker uses --autoscale=1,1 --concurrency=1
- WorkerManagement.tsx: "Concurrency Settings" section with +/- steppers
  and Save button per queue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 20:41:57 +01:00
parent b41e70cdad
commit 07e3d1e026
9 changed files with 344 additions and 5 deletions
+31
View File
@@ -166,3 +166,34 @@ export async function scaleWorkers(req: ScaleRequest): Promise<ScaleResponse> {
const res = await api.post<ScaleResponse>('/worker/scale', req)
return res.data
}
// ---------------------------------------------------------------------------
// Worker concurrency configuration
// ---------------------------------------------------------------------------
export interface WorkerConfig {
queue_name: string
max_concurrency: number
min_concurrency: number
enabled: boolean
updated_at: string
}
export interface WorkerConfigUpdate {
max_concurrency?: number
min_concurrency?: number
enabled?: boolean
}
export async function getWorkerConfigs(): Promise<WorkerConfig[]> {
const res = await api.get<WorkerConfig[]>('/worker/configs')
return res.data
}
export async function updateWorkerConfig(
queueName: string,
update: WorkerConfigUpdate,
): Promise<WorkerConfig> {
const res = await api.put<WorkerConfig>(`/worker/configs/${queueName}`, update)
return res.data
}
+124 -1
View File
@@ -1,13 +1,16 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { RefreshCw, ChevronDown, ChevronRight, Cpu, Layers, Minus, Plus } from 'lucide-react'
import { RefreshCw, ChevronDown, ChevronRight, Cpu, Layers, Minus, Plus, Settings2 } from 'lucide-react'
import {
getCeleryWorkers,
getQueueStatus,
scaleWorkers,
getWorkerConfigs,
updateWorkerConfig,
type CeleryWorker,
type ScaleRequest,
type WorkerConfig,
} from '../api/worker'
// ---------------------------------------------------------------------------
@@ -161,6 +164,93 @@ function QueueDepthRow({ queue, depth }: { queue: string; depth: number }) {
)
}
// ---------------------------------------------------------------------------
// Concurrency config row
// ---------------------------------------------------------------------------
function ConcurrencyConfigRow({ config }: { config: WorkerConfig }) {
const qc = useQueryClient()
const [minVal, setMinVal] = useState(config.min_concurrency)
const [maxVal, setMaxVal] = useState(config.max_concurrency)
const saveMut = useMutation({
mutationFn: () =>
updateWorkerConfig(config.queue_name, {
min_concurrency: minVal,
max_concurrency: maxVal,
}),
onSuccess: () => {
toast.success(`Saved concurrency for ${config.queue_name}`)
qc.invalidateQueries({ queryKey: ['worker-configs'] })
},
onError: (e: unknown) => {
const detail = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail
toast.error(detail ?? `Failed to save ${config.queue_name}`)
},
})
const isDirty = minVal !== config.min_concurrency || maxVal !== config.max_concurrency
return (
<div className="rounded-xl border border-border-default p-4 flex items-center justify-between gap-4 flex-wrap">
<div className="min-w-0">
<p className="text-sm font-medium text-content font-mono">{config.queue_name}</p>
<p className="text-xs text-content-muted mt-0.5">
{config.enabled ? 'enabled' : 'disabled'} · updated{' '}
{new Date(config.updated_at).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-6 shrink-0">
{/* Min concurrency */}
<div className="flex flex-col items-center gap-1">
<span className="text-xs text-content-muted">Min</span>
<div className="flex items-center gap-1">
<button
onClick={() => setMinVal((v) => Math.max(1, v - 1))}
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
>
<Minus size={12} />
</button>
<span className="w-6 text-center text-sm font-semibold text-content">{minVal}</span>
<button
onClick={() => setMinVal((v) => Math.min(maxVal, v + 1))}
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
>
<Plus size={12} />
</button>
</div>
</div>
{/* Max concurrency */}
<div className="flex flex-col items-center gap-1">
<span className="text-xs text-content-muted">Max</span>
<div className="flex items-center gap-1">
<button
onClick={() => setMaxVal((v) => Math.max(minVal, v - 1))}
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
>
<Minus size={12} />
</button>
<span className="w-6 text-center text-sm font-semibold text-content">{maxVal}</span>
<button
onClick={() => setMaxVal((v) => Math.min(64, v + 1))}
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
>
<Plus size={12} />
</button>
</div>
</div>
<button
onClick={() => saveMut.mutate()}
disabled={saveMut.isPending || !isDirty}
className={`btn-primary text-xs px-3 py-1.5 ${!isDirty ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{saveMut.isPending ? 'Saving…' : 'Save'}
</button>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
@@ -180,9 +270,15 @@ export default function WorkerManagement() {
refetchInterval: 5_000,
})
const { data: workerConfigs, isLoading: configsLoading } = useQuery({
queryKey: ['worker-configs'],
queryFn: getWorkerConfigs,
})
function refresh() {
qc.invalidateQueries({ queryKey: ['celery-workers'] })
qc.invalidateQueries({ queryKey: ['queue-status'] })
qc.invalidateQueries({ queryKey: ['worker-configs'] })
}
const workers = workerData?.workers ?? []
@@ -263,6 +359,33 @@ export default function WorkerManagement() {
)}
</section>
{/* Concurrency settings */}
<section>
<div className="flex items-center gap-2 mb-3">
<Settings2 size={16} className="text-accent" />
<h2 className="text-base font-semibold text-content">Concurrency Settings</h2>
</div>
<p className="text-xs text-content-muted mb-4">
Configure min/max concurrency per queue. The beat scheduler applies these settings
every 5 minutes via Celery pool signals. Changes are persisted in the database.
</p>
{configsLoading ? (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i} className="h-16 rounded-xl bg-surface-muted animate-pulse" />
))}
</div>
) : !workerConfigs || workerConfigs.length === 0 ? (
<p className="text-sm text-content-muted">No worker configs available.</p>
) : (
<div className="space-y-2">
{workerConfigs.map((cfg) => (
<ConcurrencyConfigRow key={cfg.queue_name} config={cfg} />
))}
</div>
)}
</section>
{/* Scale controls */}
<section>
<h2 className="text-base font-semibold text-content mb-3">Scale Services</h2>