fix metasort pipeline

This commit is contained in:
Pierre
2026-04-28 12:21:23 +02:00
parent e3d26549d0
commit f7315cbc3f
8 changed files with 419 additions and 402 deletions
@@ -69,6 +69,18 @@ interface EnvConfig {
/** Concurrency for fast plugin queue. Default: 32 */
FAST_QUEUE_CONCURRENCY: number;
/**
* Maximum number of files concurrently in the light (fast-queue) phase.
* Caps fan-out so the underlying file cache (WebDAV/nginx) only has to
* hold N files at a time. Default: 16.
*/
FILE_LIGHT_SLOTS: number;
/**
* Maximum number of files concurrently in the background phase. Default: 16.
*/
FILE_BG_SLOTS: number;
// ========================================================================
// Container Plugins Configuration
// All plugins run as Docker containers for isolation and flexibility
@@ -139,6 +151,8 @@ export const config: EnvConfig = {
// TaskScheduler Configuration
FAST_QUEUE_CONCURRENCY: parseInt(process.env.FAST_QUEUE_CONCURRENCY || "32", 10),
FILE_LIGHT_SLOTS: parseInt(process.env.FILE_LIGHT_SLOTS || "16", 10),
FILE_BG_SLOTS: parseInt(process.env.FILE_BG_SLOTS || "16", 10),
// Container Plugins Configuration
CONTAINER_PLUGINS_CONFIG: process.env.CONTAINER_PLUGINS_CONFIG || '/app/plugins.yml',
@@ -21,6 +21,7 @@ import type { TaskQueueType } from '../plugin-engine/types.js';
import type { IKVClient } from '../kv/IKVClient.js';
import { type ExtendedContainerTask } from '../types/ExtendedInterfaces.js';
import { config } from '../config/EnvConfig.js';
import { FileSlotGate } from './FileSlotGate.js';
/**
* Scheduler events
@@ -55,6 +56,18 @@ export interface GateStatus {
backgroundRunning: number;
}
/**
* Per-file holder snapshot exposed via getQueueStatus for the dashboard.
* Lets operators see which 16 files are tying up cache slots and what plugin
* each file is currently waiting on (the long-pole per file).
*/
export interface CacheSlotHolder {
fileHash: string;
filePath?: string;
currentPlugin?: string;
tasksRemaining: number;
}
/**
* Container Plugin Scheduler
*/
@@ -73,11 +86,30 @@ export class ContainerPluginScheduler extends EventEmitter {
private kvClient: IKVClient | null = null;
private isAcceptingNewTasks: boolean = true; // Gate control for safe mount/unmount
// File-level cache slots — see FileSlotGate.ts. Caps the number of files
// whose bytes are concurrently being touched in each phase, so the WebDAV
// cache only has to hold N files at a time regardless of plugin fan-out.
private lightFileGate: FileSlotGate;
private bgFileGate: FileSlotGate;
// Per-(file, phase) outstanding task counter — drives slot release.
private lightTasksPerFile: Map<string, number> = new Map();
private bgTasksPerFile: Map<string, number> = new Map();
// Tasks staged while waiting for a slot. Once the gate grants the file a
// slot we flush the buffered tasks into the underlying PQueue.
private lightBufferedTasks: Map<string, ContainerTask[]> = new Map();
private bgBufferedTasks: Map<string, ContainerTask[]> = new Map();
// Currently-dispatched plugin per file in each phase — used by the
// dashboard to show "this file is currently waiting on plugin X".
private lightCurrentPlugin: Map<string, string> = new Map();
private bgCurrentPlugin: Map<string, string> = new Map();
constructor(
containerManager: ContainerManager,
options?: {
fastConcurrency?: number;
backgroundConcurrency?: number;
lightFileSlots?: number;
bgFileSlots?: number;
callbackUrl?: string;
metaCoreUrl?: string;
defaultTimeout?: number;
@@ -98,6 +130,9 @@ export class ContainerPluginScheduler extends EventEmitter {
autoStart: false,
});
this.lightFileGate = new FileSlotGate(options?.lightFileSlots ?? config.FILE_LIGHT_SLOTS);
this.bgFileGate = new FileSlotGate(options?.bgFileSlots ?? config.FILE_BG_SLOTS);
// When fast queue becomes idle, start background queue
this.fastQueue.on('idle', () => {
this.onFastQueueIdle();
@@ -246,6 +281,17 @@ export class ContainerPluginScheduler extends EventEmitter {
/**
* Enqueue a task for execution
* Returns false if the gate is closed and the task was rejected
*
* Tasks pass through the per-phase FileSlotGate before reaching PQueue:
* - If the file already holds a slot in this phase, the task goes
* straight to PQueue.
* - Otherwise the task is buffered, and the file's first task triggers
* a slot acquire. When the slot is granted, all buffered tasks for
* that file are flushed into PQueue.
*
* This caps concurrent files per phase (and therefore concurrent file
* fetches against CORN/SMB) at the gate's capacity, regardless of how
* many plugin tasks each file fans out to.
*/
enqueueTask(task: ContainerTask): boolean {
if (!this.isAcceptingNewTasks) {
@@ -262,20 +308,90 @@ export class ContainerPluginScheduler extends EventEmitter {
return false;
}
const queue = task.queue === 'background' ? this.backgroundQueue : this.fastQueue;
// If adding to fast queue, pause background queue (fast takes priority)
if (task.queue === 'fast' && !this.backgroundQueue.isPaused) {
console.log('[ContainerScheduler] Fast task added, pausing background queue');
this.backgroundQueue.pause();
}
queue.add(async () => {
await this.executeTask(task);
const counterMap = task.queue === 'fast' ? this.lightTasksPerFile : this.bgTasksPerFile;
const bufferMap = task.queue === 'fast' ? this.lightBufferedTasks : this.bgBufferedTasks;
const gate = task.queue === 'fast' ? this.lightFileGate : this.bgFileGate;
// Increment the per-file counter for this phase eagerly. Decrement
// happens in the executeTask finally clause.
counterMap.set(task.fileHash, (counterMap.get(task.fileHash) ?? 0) + 1);
if (gate.holds(task.fileHash)) {
// File already has a slot — dispatch immediately.
this.dispatchToPQueue(task);
return true;
}
// File doesn't hold a slot. Buffer the task. If this is the first
// buffered task for the file, trigger gate.acquire and flush on grant.
const existingBuffer = bufferMap.get(task.fileHash);
if (existingBuffer) {
existingBuffer.push(task);
return true;
}
bufferMap.set(task.fileHash, [task]);
gate.acquire(task.fileHash).then(() => {
const buffered = bufferMap.get(task.fileHash) ?? [];
bufferMap.delete(task.fileHash);
for (const t of buffered) {
this.dispatchToPQueue(t);
}
// Edge case: if every buffered task got cancelled by clear() before
// the slot was granted, release immediately so the slot doesn't
// leak.
if (buffered.length === 0 && (counterMap.get(task.fileHash) ?? 0) === 0) {
gate.release(task.fileHash);
}
});
return true;
}
/**
* Dispatch a task into the underlying PQueue and release its file's gate
* slot when the file's last task in that phase finishes.
*/
private dispatchToPQueue(task: ContainerTask): void {
const queue = task.queue === 'background' ? this.backgroundQueue : this.fastQueue;
queue.add(async () => {
try {
await this.executeTask(task);
} finally {
this.onTaskFinished(task);
}
});
}
/**
* Bookkeeping after a task leaves PQueue (success, failure, or thrown).
* Decrements the per-(file, phase) task counter and releases the gate
* slot when the counter hits zero.
*/
private onTaskFinished(task: ContainerTask): void {
const counterMap = task.queue === 'fast' ? this.lightTasksPerFile : this.bgTasksPerFile;
const currentMap = task.queue === 'fast' ? this.lightCurrentPlugin : this.bgCurrentPlugin;
const gate = task.queue === 'fast' ? this.lightFileGate : this.bgFileGate;
const remaining = (counterMap.get(task.fileHash) ?? 1) - 1;
if (remaining <= 0) {
counterMap.delete(task.fileHash);
currentMap.delete(task.fileHash);
gate.release(task.fileHash);
} else {
counterMap.set(task.fileHash, remaining);
// Clear current-plugin only if it points at this completed task.
if (currentMap.get(task.fileHash) === task.pluginId) {
currentMap.delete(task.fileHash);
}
}
}
/**
* Called when fast queue becomes idle - start background queue if it has work
*/
@@ -314,6 +430,11 @@ export class ContainerPluginScheduler extends EventEmitter {
task.dispatchedAt = Date.now();
task.instanceName = instance.containerName;
// Record which plugin is currently running for this file (per phase)
// so the dashboard can show the long-pole plugin per held cache slot.
const currentMap = task.queue === 'fast' ? this.lightCurrentPlugin : this.bgCurrentPlugin;
currentMap.set(task.fileHash, task.pluginId);
this.emit('task:dispatched', { task });
try {
@@ -581,6 +702,16 @@ export class ContainerPluginScheduler extends EventEmitter {
backgroundRunning: number; // unique files with background tasks running
backgroundWaiting: number; // unique files with background tasks waiting
};
// Per-phase cache-slot view — bounded by FileSlotGate. This is the
// metric operators actually care about: how many distinct files are
// tying up the WebDAV cache right now, and which plugin is the
// long-pole on each. `paused` reflects the underlying PQueue state so
// the UI can distinguish "16 files reserved but bg PQueue paused"
// from "16 files actively dispatching".
cacheSlots: {
fast: { capacity: number; inUse: number; waiting: number; paused: boolean; holders: CacheSlotHolder[] };
background: { capacity: number; inUse: number; waiting: number; paused: boolean; holders: CacheSlotHolder[] };
};
} {
// Count pending callbacks by queue type
// These are tasks dispatched to containers awaiting completion - the real "running" count
@@ -639,6 +770,56 @@ export class ContainerPluginScheduler extends EventEmitter {
backgroundRunning: backgroundRunningFiles.size,
backgroundWaiting: backgroundWaitingFiles.size,
},
cacheSlots: {
fast: this.snapshotCacheSlot('fast'),
background: this.snapshotCacheSlot('background'),
},
};
}
/**
* Build the per-phase cache-slot snapshot for the queue status payload.
* `currentPlugin` is the plugin currently dispatched against the file
* (the long-pole) — falls back to undefined if no task is mid-dispatch.
* `tasksRemaining` is the count of light/bg tasks still outstanding for
* the file in this phase, which is what holds the slot open.
*/
private snapshotCacheSlot(
phase: TaskQueueType
): { capacity: number; inUse: number; waiting: number; paused: boolean; holders: CacheSlotHolder[] } {
const gate = phase === 'fast' ? this.lightFileGate : this.bgFileGate;
const counterMap = phase === 'fast' ? this.lightTasksPerFile : this.bgTasksPerFile;
const currentMap = phase === 'fast' ? this.lightCurrentPlugin : this.bgCurrentPlugin;
const queue = phase === 'fast' ? this.fastQueue : this.backgroundQueue;
const holders: CacheSlotHolder[] = gate.getHolders().map((fileHash) => {
// Find any task for this file in this phase to recover filePath
// (one filePath is enough for the UI; duplicates collapse).
let filePath: string | undefined;
const taskIds = this.fileTasks.get(fileHash);
if (taskIds) {
for (const tid of taskIds) {
const t = this.pendingTasks.get(tid);
if (t && t.queue === phase) {
filePath = t.filePath;
break;
}
}
}
return {
fileHash,
filePath,
currentPlugin: currentMap.get(fileHash),
tasksRemaining: counterMap.get(fileHash) ?? 0,
};
});
return {
capacity: gate.capacity,
inUse: gate.inUse(),
waiting: gate.waiting(),
paused: queue.isPaused,
holders,
};
}
@@ -751,6 +932,22 @@ export class ContainerPluginScheduler extends EventEmitter {
this.fileTasks.clear();
this.fileCompletedPlugins.clear();
this.dependencyWaiters.clear();
// Drop file-slot-gate state. Tasks that were buffered or holding
// slots are no longer interesting once we've cleared the queues; a
// fresh scheduler state implies fresh gate state.
this.lightTasksPerFile.clear();
this.bgTasksPerFile.clear();
this.lightBufferedTasks.clear();
this.bgBufferedTasks.clear();
this.lightCurrentPlugin.clear();
this.bgCurrentPlugin.clear();
for (const fileHash of this.lightFileGate.getHolders()) {
this.lightFileGate.release(fileHash);
}
for (const fileHash of this.bgFileGate.getHolders()) {
this.bgFileGate.release(fileHash);
}
}
/**
@@ -0,0 +1,81 @@
/**
* FileSlotGate — bounds the number of files concurrently in a processing phase.
*
* Motivation: every plugin that runs against a file pulls bytes from CORN/SMB
* via WebDAV. Without a per-file gate, the task queue happily fans 8 plugins
* across hundreds of files at once, multiplying the WebDAV fetch cost by the
* plugin count and overwhelming whatever local cache exists. The gate caps the
* number of files that can be "in flight" in a given phase so the cache only
* has to hold N files at a time.
*
* Semantics:
* - Keyed by fileHash. A file holds at most one slot in a given phase.
* - acquire(fileHash) resolves immediately if the file already holds a slot
* or there is free capacity; otherwise it queues FIFO.
* - release(fileHash) is idempotent for non-holders. When called on a real
* holder it frees the slot and wakes the next waiter.
*
* The gate intentionally knows nothing about tasks or plugins — it is a thin
* file-level semaphore. Counting tasks per (file, phase) and deciding when to
* release is the caller's responsibility.
*/
export class FileSlotGate {
private readonly holders: Set<string> = new Set();
private readonly waiters: Array<{ fileHash: string; resolve: () => void }> = [];
constructor(public readonly capacity: number) {
if (!Number.isFinite(capacity) || capacity < 1) {
throw new Error(`FileSlotGate capacity must be a positive integer, got ${capacity}`);
}
}
/**
* Acquire a slot for fileHash. If the file already holds a slot, resolves
* immediately (re-entrant for the same file). Otherwise queues until a
* slot is released.
*/
acquire(fileHash: string): Promise<void> {
if (this.holders.has(fileHash)) {
return Promise.resolve();
}
if (this.holders.size < this.capacity) {
this.holders.add(fileHash);
return Promise.resolve();
}
return new Promise<void>((resolve) => {
this.waiters.push({ fileHash, resolve });
});
}
/**
* Release the slot held by fileHash. No-op if the file doesn't hold one.
* Wakes the next FIFO waiter, if any, and grants it a slot.
*/
release(fileHash: string): void {
if (!this.holders.delete(fileHash)) {
return;
}
const next = this.waiters.shift();
if (!next) {
return;
}
this.holders.add(next.fileHash);
next.resolve();
}
holds(fileHash: string): boolean {
return this.holders.has(fileHash);
}
inUse(): number {
return this.holders.size;
}
waiting(): number {
return this.waiters.length;
}
getHolders(): string[] {
return Array.from(this.holders);
}
}
@@ -106,7 +106,7 @@ export class WatchedFileProcessor implements FileProcessorInterface {
// Map StreamingPipeline queues to the 3-queue architecture UI expects
// PQueue: .pending = currently running, .size = waiting in queue
// API expects: .pending = running, .size = waiting
const result = {
const result: any = {
preProcessQueue: {
// validation = pre-process (quick extension checks)
size: queues.validation.size, // waiting in queue
@@ -130,6 +130,15 @@ export class WatchedFileProcessor implements FileProcessorInterface {
// File-level counts (what users care about)
files: queues.files
};
// Layer in the per-file cache-slot view from the ContainerPluginScheduler
// when available — this is the metric that reflects WebDAV/cache load
// (number of distinct files concurrently being touched), not just task
// throughput. Older deployments without container plugins simply omit it.
const containerStatus = this.taskScheduler?.getContainerQueueStatus?.() ?? null;
if (containerStatus?.cacheSlots) {
result.cacheSlots = containerStatus.cacheSlots;
}
return result;
}
@@ -584,6 +584,16 @@ export class TaskScheduler extends EventEmitter {
return [];
}
/**
* Pass-through to ContainerPluginScheduler.getQueueStatus so callers like
* WatchedFileProcessor can surface the file-cache-slot view (capacity,
* holders, current plugin per file) without reaching past the scheduler
* boundary themselves.
*/
getContainerQueueStatus(): ReturnType<ContainerPluginScheduler['getQueueStatus']> | null {
return this.containerPluginScheduler?.getQueueStatus() ?? null;
}
// =========================================================================
// Shutdown
// =========================================================================
+80 -11
View File
@@ -5,7 +5,8 @@ import {
RedisStats,
QueueItem,
HashTimingStats,
FailedFile
FailedFile,
CacheSlotPhase
} from '../types';
import {
formatMs,
@@ -14,6 +15,43 @@ import {
getFilename
} from '../utils/format';
/**
* Renders a single phase of the file-cache-slot view: capacity bar, count of
* waiting files, and a list of currently-held files showing which plugin
* each is currently waiting on. The plugin column is the long-pole — it tells
* operators which plugin is the per-file bottleneck.
*/
function CacheSlotPanel({ label, phase }: { label: string; phase: CacheSlotPhase }) {
const fillPct = Math.min(100, (phase.inUse / Math.max(1, phase.capacity)) * 100);
return (
<div style={{ marginTop: '0.75rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<strong>{label}</strong>
<span style={{ color: '#9ca3af', fontSize: '0.85rem' }}>
{phase.inUse}/{phase.capacity} files
{phase.waiting > 0 ? ` • ${phase.waiting} waiting for slot` : ''}
</span>
</div>
<div style={{ height: '6px', background: '#0f3460', borderRadius: '3px', overflow: 'hidden', margin: '0.25rem 0 0.5rem' }}>
<div style={{ width: `${fillPct}%`, height: '100%', background: '#4ade80', transition: 'width 0.5s linear' }} />
</div>
{phase.holders.length > 0 && (
<div style={{ fontSize: '0.78rem', color: '#cbd5e1', maxHeight: '160px', overflow: 'auto' }}>
{phase.holders.map((h) => (
<div key={h.fileHash} style={{ display: 'flex', gap: '0.5rem', padding: '2px 0' }}>
<span style={{ color: '#facc15', minWidth: '8rem' }}>{h.currentPlugin ?? '—'}</span>
<span style={{ color: '#94a3b8', minWidth: '4rem' }}>×{h.tasksRemaining}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={h.filePath ?? h.fileHash}>
{h.filePath ? getFilename(h.filePath) : h.fileHash}
</span>
</div>
))}
</div>
)}
</div>
);
}
function Monitor() {
const [status, setStatus] = useState<ProcessingStatus | null>(null);
const [metrics, setMetrics] = useState<Metrics | null>(null);
@@ -107,31 +145,50 @@ function Monitor() {
// Get queue statistics for display
// StreamingPipeline stages: validation → lightProcessing → hashProcessing
// UI mapping: Validation → Fast Queue → Background Queue
//
// Fast/Background "Running/Max" represents file-cache slot occupancy
// (cacheSlots.inUse / capacity), not raw task throughput. This matches the
// operator mental model: "how many files are currently tying up the WebDAV
// cache?" The task-callback count was misleading because dependency-wait
// and per-plugin HTTP queueing made it under-report by ~5×.
const getQueueStats = () => {
const computed = status?.computed;
const cacheSlots = status?.queueStatus?.cacheSlots;
const fastQueueConcurrency = status?.fastQueueConcurrency || 16;
const backgroundQueueConcurrency = status?.backgroundQueueConcurrency || 8;
return {
// Stage 1: Validation (quick extension checks)
// Concurrency: 2x fast queue workers
// Stage 1: Validation (quick extension checks). Still task-level
// — pre-process is a lightweight midhash256 scan, no cache impact.
preProcess: {
running: computed?.preProcessRunning || 0,
pending: computed?.preProcessPending || 0,
max: fastQueueConcurrency * 2,
paused: computed?.preProcessPaused || false
},
// Stage 2: Fast Queue (midhash256 + FFmpeg + plugins)
// Concurrency: fastQueueConcurrency workers
fast: {
// Stage 2: Fast Queue — files holding a light cache slot.
// running = files in cache, pending = files waiting for a slot.
// paused comes from the cacheSlots payload (true ContainerPluginScheduler
// PQueue state) — the legacy `computed.fastQueuePaused` field is sourced
// from a different StreamingPipeline queue and is unreliable here.
fast: cacheSlots ? {
running: cacheSlots.fast.inUse,
pending: cacheSlots.fast.waiting,
max: cacheSlots.fast.capacity,
paused: cacheSlots.fast.paused
} : {
running: computed?.fastQueueRunning || 0,
pending: computed?.fastQueuePending || 0,
max: fastQueueConcurrency,
paused: computed?.fastQueuePaused || false
},
// Stage 3: Background Queue (SHA-256 full file hash)
// Concurrency: backgroundQueueConcurrency workers
background: {
// Stage 3: Background Queue — files holding a bg cache slot.
background: cacheSlots ? {
running: cacheSlots.background.inUse,
pending: cacheSlots.background.waiting,
max: cacheSlots.background.capacity,
paused: cacheSlots.background.paused
} : {
running: computed?.backgroundQueueRunning || 0,
pending: computed?.backgroundQueuePending || 0,
max: backgroundQueueConcurrency,
@@ -165,9 +222,9 @@ function Monitor() {
{showPipelineHelp && (
<div className="pipeline-help">
<div className="help-item"><span className="help-dot discovered"></span><strong>Discovered:</strong> Files found on disk, waiting to be processed.</div>
<div className="help-item"><span className="help-dot fast"></span><strong>Fast Queue:</strong> Files being processed for quick metadata extraction (hash, ffmpeg, etc.).</div>
<div className="help-item"><span className="help-dot fast"></span><strong>Fast Queue:</strong> Files holding a light cache slot. The badge shows slots-in-use over the gate capacity (e.g. 16/16). Pending = files waiting for a slot to free.</div>
<div className="help-item"><span className="help-dot awaiting-background"></span><strong>Awaiting BG:</strong> Files done with fast processing, queued for slower tasks.</div>
<div className="help-item"><span className="help-dot background"></span><strong>Background:</strong> Files running slow tasks like full file hashing or TMDB lookups.</div>
<div className="help-item"><span className="help-dot background"></span><strong>Background:</strong> Files holding a background cache slot (full-hash, TMDB, etc.). Same slot-occupancy semantics as Fast Queue.</div>
<div className="help-item"><span className="help-dot done"></span><strong>Complete:</strong> Files fully processed and available in the virtual filesystem.</div>
<div className="help-item"><span className="help-dot failed"></span><strong>Failed:</strong> Files that encountered errors during processing.</div>
</div>
@@ -301,6 +358,18 @@ function Monitor() {
</div>
)}
{status?.queueStatus?.cacheSlots && (
<div className="card cache-slot-section" style={{ marginTop: '1rem' }}>
<h2>File cache slots</h2>
<p style={{ color: '#888', fontSize: '0.85rem', marginTop: '-0.5rem' }}>
Number of distinct files concurrently held by the WebDAV cache per phase. Caps how
many file fetches CORN/SMB sees at once, regardless of plugin fan-out.
</p>
<CacheSlotPanel label="Light (fast queue)" phase={status.queueStatus.cacheSlots.fast} />
<CacheSlotPanel label="Background" phase={status.queueStatus.cacheSlots.background} />
</div>
)}
<div className="grid grid-2">
{/* Metrics and Stats */}
<div className="card">
+23
View File
@@ -75,6 +75,29 @@ export interface TaskSchedulerQueueStatus {
preProcessQueue?: TaskQueueStatus;
fastQueue?: TaskQueueStatus;
backgroundQueue?: TaskQueueStatus;
cacheSlots?: CacheSlotsStatus;
}
export interface CacheSlotHolder {
fileHash: string;
filePath?: string;
currentPlugin?: string;
tasksRemaining: number;
}
export interface CacheSlotPhase {
capacity: number;
inUse: number;
waiting: number;
/** True when the underlying PQueue is paused — bg phase is paused while
* fast has work, so its slots can be "reserved" without active dispatch. */
paused: boolean;
holders: CacheSlotHolder[];
}
export interface CacheSlotsStatus {
fast: CacheSlotPhase;
background: CacheSlotPhase;
}
export interface ProcessingStatus {