Task 1: Establish catalog identity and atomic file ownership - CatalogService, CatalogServiceInterface, ModelMetadata DTO - PdoCatalogRepository with atomic operations, savepoint support - FOR UPDATE file locks in ascending order, FOR NO KEY UPDATE for metadata - FK-compatible lock composition documented (spec §6) Task 2: M3 import & indexing engine delivery layer - ImportService, ImportWorker with durable collision handling - SpaceChecker, StreamingHasher, VerifiedCopier, StlValidator - FileEnumerator, LocalFilesystem, PathSafety - Console command wiring via ContainerWiringTest Task 3: Importer integration and repair - LegacyGuessedImportGroupingAdapter isolates first-directory guessing - ImportService uses adapter; ImportWorker uses CatalogServiceInterface - Null owners remain null; DomainException mapped to CATALOG_CONFLICT - SQL failure after registration/path update with caller sentinel rollback Task 4: PostgreSQL contention evidence and bounded acceptance - 6 new PostgresCatalogContractsTest cases with independent sessions - Generated schemas, 250ms lock timeouts, 5s statement timeouts - Ascending file locks, FOR SHARE contention, stale-owner rejection - Rollback/retry, nested transaction survival, root creation contention - Negative controls: replay FOR UPDATE removal (11 failures), metadata FOR UPDATE (4 FK KEY SHARE timeouts) - Fresh Docker PostgreSQL 17 / PHP 8.4.26: 473 tests, 2718 assertions, 10 skips, exit 0 All local: 474 tests, 2347 assertions, 54 skips, foundation/lint clean.
82 lines
3.0 KiB
PHP
82 lines
3.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace MeshVault\Indexer;
|
|
|
|
use MeshVault\Indexer\Contracts\FileEnumeratorInterface;
|
|
use MeshVault\Repository\ImportJobRepositoryInterface;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Turns a source tree into a durable import job manifest.
|
|
*
|
|
* Legacy guessed grouping (not user-confirmed associations): each file is
|
|
* assigned to the model named after its first directory component below the
|
|
* source root; files sitting directly in the source root join a model named
|
|
* after the source directory itself. Every model lives in a folder of the same
|
|
* name directly under the library root, so scans never silently merge trees.
|
|
* No filesystem mutation happens here; the worker owns all copying.
|
|
*/
|
|
final readonly class ImportService
|
|
{
|
|
public function __construct(
|
|
private FileEnumeratorInterface $enumerator,
|
|
private ImportJobRepositoryInterface $jobs,
|
|
private LegacyGuessedImportGroupingAdapter $groupingAdapter,
|
|
private SpaceChecker $space,
|
|
private string $libraryRoot,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* Scan, preflight free space, then persist the manifest atomically.
|
|
*
|
|
* @param list<string> $extensions Case-insensitive extensions without dots.
|
|
* @throws ImportException SCAN_EMPTY, DISK_FULL, SPACE_CHECK_FAILED, DB_ERROR.
|
|
*/
|
|
public function import(string $sourcePath, array $extensions = FileEnumeratorInterface::DEFAULT_EXTENSIONS): int
|
|
{
|
|
$scan = $this->enumerator->enumerate($sourcePath, $extensions);
|
|
if ($scan->files === []) {
|
|
$detail = $scan->errors === [] ? 'No importable files were found.' : $scan->errors[0]->message;
|
|
throw new ImportException('SCAN_EMPTY', $detail);
|
|
}
|
|
// Preflight before writing any row so a full disk never leaves a job behind.
|
|
$this->space->check($this->libraryRoot, $scan->totalBytes);
|
|
try {
|
|
return $this->jobs->atomic(
|
|
fn (): int => $this->jobs->create($sourcePath, $this->manifest($sourcePath, $scan))
|
|
);
|
|
} catch (ImportException $error) {
|
|
throw $error;
|
|
} catch (Throwable $error) {
|
|
throw new ImportException('DB_ERROR', 'Could not persist the import job.', $error);
|
|
}
|
|
}
|
|
|
|
/** @return list<array<string, mixed>> */
|
|
private function manifest(string $sourcePath, ScanResult $scan): array
|
|
{
|
|
$items = [];
|
|
foreach ($scan->files as $file) {
|
|
$items[] = [
|
|
'source_path' => $file->sourcePath,
|
|
'size' => $file->size,
|
|
'model_id' => $this->groupingAdapter->modelFor($sourcePath, $file),
|
|
];
|
|
}
|
|
// Scan failures become terminal items so the job reports them durably.
|
|
foreach ($scan->errors as $error) {
|
|
$items[] = [
|
|
'source_path' => $error->sourcePath,
|
|
'size' => 0,
|
|
'model_id' => null,
|
|
'status' => 'Failed',
|
|
'failure_reason' => $error->code . ': ' . $error->message,
|
|
];
|
|
}
|
|
return $items;
|
|
}
|
|
}
|