$item) { if (!isset($item['source_path']) || !isset($item['size'])) { throw new InvalidArgumentException("Item $index requires source_path and size."); } if ($item['size'] < 0) { throw new InvalidArgumentException("Item $index size must be non-negative."); } $allowed = ['Pending', 'Copying', 'Completed', 'Failed', 'Skipped']; if (isset($item['status']) && !in_array($item['status'], $allowed, true)) { throw new InvalidArgumentException("Item $index has invalid status."); } } return $this->transaction(function () use ($sourcePath, $items): int { $job = $this->row( 'INSERT INTO import_jobs (source_path, total_files, total_bytes) VALUES (?, ?, ?) RETURNING id', [$sourcePath, count($items), array_sum(array_column($items, 'size'))] ); $jobId = (int) $job['id']; if ($items === []) { // Empty manifests stay claimable; completion is an explicit caller decision. return $jobId; } $statement = $this->pdo->prepare( 'INSERT INTO import_items (job_id, source_path, size, model_id, status, failure_reason)' . ' VALUES (?, ?, ?, ?, ?, ?)' ); foreach ($items as $item) { $status = $item['status'] ?? 'Pending'; $failureReason = $item['failure_reason'] ?? null; $statement->execute([ $jobId, $item['source_path'], $item['size'], $item['model_id'] ?? null, $status, $failureReason, ]); } // Scan-time failures arrive terminal, so publish progress immediately. $this->refresh($jobId); return $jobId; }); } public function get(int $jobId): ?array { return $this->row('SELECT * FROM import_jobs WHERE id = ?', [$jobId]); } public function items(int $jobId): array { return $this->execute('SELECT * FROM import_items WHERE job_id = ? ORDER BY id', [$jobId]) ->fetchAll(PDO::FETCH_ASSOC); } public function claimNext(): ?array { return $this->transaction(function (): ?array { if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql') { // SKIP LOCKED lets concurrent workers claim distinct jobs without blocking. $candidate = $this->row( "SELECT id FROM import_jobs WHERE status = 'Pending'" . ' ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1' ); if ($candidate === null) { return null; } return $this->row( "UPDATE import_jobs SET status = 'Copying', started_at = CURRENT_TIMESTAMP" . ' WHERE id = ? RETURNING *', [$candidate['id']] ); } // SQLite serializes writers, so a single conditional UPDATE is already atomic. return $this->row( "UPDATE import_jobs SET status = 'Copying', started_at = CURRENT_TIMESTAMP" . " WHERE id = (SELECT id FROM import_jobs WHERE status = 'Pending' ORDER BY id LIMIT 1)" . ' RETURNING *' ); }); } public function resumeInterrupted(): void { $this->transaction(function (): void { // Plans (managed_path/hash) are deliberately preserved for idempotent recovery. $this->execute( "UPDATE import_items SET status = 'Pending', started_at = NULL WHERE status = 'Copying'" . " AND job_id IN (SELECT id FROM import_jobs WHERE status IN ('Scanning', 'Copying'))" ); $this->execute( "UPDATE import_jobs SET status = 'Pending' WHERE status IN ('Scanning', 'Copying')" ); }); } public function planItem(int $itemId, string $managedPath, string $hash, int $size): void { if ($managedPath === '' || $hash === '' || $size < 0) { throw new InvalidArgumentException('A plan requires a path, hash and non-negative size.'); } $this->transaction(function () use ($itemId, $managedPath, $hash, $size): void { $item = $this->lockedItem($itemId); if (in_array($item['status'], ['Completed', 'Failed', 'Skipped'], true)) { throw new DomainException('Cannot plan a terminal import item.'); } if ($item['managed_path'] !== null) { if ( $item['managed_path'] !== $managedPath || $item['hash'] !== $hash || (int) $item['size'] !== $size ) { throw new DomainException('An import plan is immutable once recorded.'); } return; } $this->execute( "UPDATE import_items SET status = 'Copying', managed_path = ?, hash = ?, size = ?," . ' started_at = CURRENT_TIMESTAMP WHERE id = ?', [$managedPath, $hash, $size, $itemId] ); }); } public function updateManagedPath(int $itemId, string $managedPath): void { if ($managedPath === '') { throw new InvalidArgumentException('A managed path must not be empty.'); } $this->transaction(function () use ($itemId, $managedPath): void { $item = $this->lockedItem($itemId); if (in_array($item['status'], ['Completed', 'Failed', 'Skipped'], true)) { throw new DomainException('Cannot update a terminal import item.'); } $this->execute( 'UPDATE import_items SET managed_path = ? WHERE id = ?', [$managedPath, $itemId] ); }); } public function finishItem(int $itemId, string $status, ?string $failureReason = null): void { if (!in_array($status, ['Completed', 'Failed', 'Skipped'], true)) { throw new InvalidArgumentException('Only terminal item statuses may finish an item.'); } $this->transaction(function () use ($itemId, $status, $failureReason): void { $item = $this->lockedItem($itemId); if (in_array($item['status'], ['Completed', 'Failed', 'Skipped'], true)) { if ($item['status'] !== $status) { throw new DomainException('Import item already reached a different terminal status.'); } return; } $this->execute( 'UPDATE import_items SET status = ?, failure_reason = ?, completed_at = CURRENT_TIMESTAMP' . ' WHERE id = ?', [$status, $failureReason, $itemId] ); $this->refresh((int) $item['job_id']); }); } public function release(int $jobId): void { $this->transaction(function () use ($jobId): void { $this->execute( "UPDATE import_items SET status = 'Pending', started_at = NULL" . " WHERE job_id = ? AND status = 'Copying'", [$jobId] ); $this->execute( "UPDATE import_jobs SET status = 'Pending' WHERE id = ? AND status IN ('Scanning', 'Copying')", [$jobId] ); }); } public function cancel(int $jobId): void { $this->terminate($jobId, 'Cancelled', 'Skipped', null); } public function fail(int $jobId, string $failureReason): void { if ($failureReason === '') { throw new InvalidArgumentException('A failure reason is required.'); } $this->terminate($jobId, 'Failed', 'Failed', $failureReason); } public function completeEmpty(int $jobId): void { $this->transaction(function () use ($jobId): void { $job = $this->row('SELECT * FROM import_jobs WHERE id = ?', [$jobId]); if ($job === null) { throw new DomainException('Unknown import job.'); } if ($this->items($jobId) !== []) { throw new DomainException('Import job still has items.'); } if (in_array($job['status'], ['Completed', 'Failed', 'Cancelled'], true)) { return; } $this->execute( "UPDATE import_jobs SET status = 'Completed', processed_files = 0," . ' completed_at = CURRENT_TIMESTAMP WHERE id = ?', [$jobId] ); }); } public function atomic(callable $callback): mixed { return $this->transaction($callback); } /** Terminal job transitions share one shape: finish open items, then close the job. */ private function terminate(int $jobId, string $jobStatus, string $itemStatus, ?string $reason): void { $this->transaction(function () use ($jobId, $jobStatus, $itemStatus, $reason): void { $job = $this->row('SELECT * FROM import_jobs WHERE id = ?', [$jobId]); if ($job === null) { throw new DomainException('Unknown import job.'); } if (in_array($job['status'], ['Completed', 'Failed', 'Cancelled'], true)) { return; } $this->execute( 'UPDATE import_items SET status = ?, failure_reason = ?, completed_at = CURRENT_TIMESTAMP' . " WHERE job_id = ? AND status IN ('Pending', 'Copying')", [$itemStatus, $reason, $jobId] ); $this->execute( 'UPDATE import_jobs SET status = ?, processed_files = ?, copied_bytes = ?,' . ' completed_at = CURRENT_TIMESTAMP WHERE id = ?', [$jobStatus, $this->processedFiles($jobId), $this->copiedBytes($jobId), $jobId] ); }); } /** Progress is recomputed from items so repeated or crashed updates cannot drift. */ private function refresh(int $jobId): void { $job = $this->row('SELECT * FROM import_jobs WHERE id = ?', [$jobId]); if ($job === null || in_array($job['status'], ['Completed', 'Failed', 'Cancelled'], true)) { return; } $total = (int) $this->row('SELECT COUNT(*) AS total FROM import_items WHERE job_id = ?', [$jobId])['total']; $processed = $this->processedFiles($jobId); $failed = (int) $this->row( "SELECT COUNT(*) AS failed FROM import_items WHERE job_id = ? AND status = 'Failed'", [$jobId] )['failed']; if ($total === 0 || $processed < $total) { $this->execute( 'UPDATE import_jobs SET processed_files = ?, copied_bytes = ? WHERE id = ?', [$processed, $this->copiedBytes($jobId), $jobId] ); return; } $this->execute( 'UPDATE import_jobs SET status = ?, processed_files = ?, copied_bytes = ?,' . ' completed_at = CURRENT_TIMESTAMP WHERE id = ?', [$failed > 0 ? 'Failed' : 'Completed', $processed, $this->copiedBytes($jobId), $jobId] ); } private function processedFiles(int $jobId): int { return (int) $this->row( 'SELECT COUNT(*) AS processed FROM import_items WHERE job_id = ?' . " AND status IN ('Completed', 'Failed', 'Skipped')", [$jobId] )['processed']; } private function copiedBytes(int $jobId): int { return (int) $this->row( "SELECT COALESCE(SUM(size), 0) AS copied FROM import_items WHERE job_id = ? AND status = 'Completed'", [$jobId] )['copied']; } private function lockedItem(int $itemId): array { $suffix = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql' ? ' FOR UPDATE' : ''; $item = $this->row('SELECT * FROM import_items WHERE id = ?' . $suffix, [$itemId]); if ($item === null) { throw new DomainException('Unknown import item.'); } return $item; } }