feat: catalog shared contracts (Tasks 1-4)

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.
This commit is contained in:
Cline committed 2026-10-13 19:41:03 -05:00
1 parent 6b9fb54b4f
commit a829d6e8c6
21 files changed
+1903 -61

No files matched your search

+3 -16
View File
@@ -5,14 +5,13 @@ declare(strict_types=1);
namespace MeshVault\Indexer;
use MeshVault\Indexer\Contracts\FileEnumeratorInterface;
use MeshVault\Repository\CatalogRepositoryInterface;
use MeshVault\Repository\ImportJobRepositoryInterface;
use Throwable;
/**
* Turns a source tree into a durable import job manifest.
*
* Grouping policy (explicit, never inferred from file contents): each file is
* 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
@@ -24,7 +23,7 @@ final readonly class ImportService
public function __construct(
private FileEnumeratorInterface $enumerator,
private ImportJobRepositoryInterface $jobs,
private CatalogRepositoryInterface $catalog,
private LegacyGuessedImportGroupingAdapter $groupingAdapter,
private SpaceChecker $space,
private string $libraryRoot,
) {
@@ -59,15 +58,12 @@ final readonly class ImportService
/** @return list<array<string, mixed>> */
private function manifest(string $sourcePath, ScanResult $scan): array
{
$models = [];
$items = [];
foreach ($scan->files as $file) {
$group = $this->group($sourcePath, $file);
$models[$group] ??= $this->catalog->ensureModel('/' . $group, $group);
$items[] = [
'source_path' => $file->sourcePath,
'size' => $file->size,
'model_id' => $models[$group],
'model_id' => $this->groupingAdapter->modelFor($sourcePath, $file),
];
}
// Scan failures become terminal items so the job reports them durably.
@@ -82,13 +78,4 @@ final readonly class ImportService
}
return $items;
}
private function group(string $sourcePath, ScannedFile $file): string
{
$parent = trim(str_replace('\\', '/', dirname($file->relativePath)), '/');
if ($parent === '' || $parent === '.') {
return basename(rtrim(str_replace('\\', '/', $sourcePath), '/'));
}
return explode('/', $parent)[0];
}
}
+11 -9
View File
@@ -7,10 +7,11 @@ namespace MeshVault\Indexer;
use MeshVault\Exception\PathSafetyException;
use MeshVault\Indexer\Filesystem\PathSafety;
use MeshVault\Indexer\SpaceChecker;
use MeshVault\Repository\CatalogRepositoryInterface;
use MeshVault\Catalog\CatalogServiceInterface;
use MeshVault\Repository\ImportJobRepositoryInterface;
use Psr\Log\LoggerInterface;
use Throwable;
use DomainException;
/**
* Serializes every managed-library mutation behind one advisory lock file and
@@ -32,7 +33,7 @@ final readonly class ImportWorker
/** @param null|callable(): bool $shouldStop Consulted before each item. */
public function __construct(
private ImportJobRepositoryInterface $jobs,
private CatalogRepositoryInterface $catalog,
private CatalogServiceInterface $catalog,
private StreamingHasher $hasher,
private VerifiedCopier $copier,
private StlValidator $validator,
@@ -112,20 +113,21 @@ final readonly class ImportWorker
$published = $this->publish($plan, $destination);
$relative = $this->relative($published);
// Catalog and progress commit together, so a crash here simply replays.
$this->jobs->atomic(function () use ($item, $itemId, $plan, $published, $relative): void {
// Update managed_path to the actual published path (with collision suffix if any)
// so import_items.managed_path always points at the published file.
$this->jobs->updateManagedPath($itemId, $relative);
if ($item['model_id'] !== null) {
$this->catalog->recordFile(
(int) $item['model_id'],
$this->catalog->atomic(function () use ($item, $itemId, $plan, $relative): void {
// Catalog locks precede item/job locks on the shared PDO connection.
try {
$this->catalog->registerFile(
$item['model_id'] === null ? null : (int) $item['model_id'],
$relative,
$plan['hash'],
$plan['size'],
null,
'Present'
);
} catch (DomainException $error) {
throw new ImportException('CATALOG_CONFLICT', $error->getMessage(), $error);
}
$this->jobs->updateManagedPath($itemId, $relative);
$this->jobs->finishItem($itemId, 'Completed');
});
} catch (Throwable $error) {
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
/**
* Stateless adapter for legacy guessed import grouping.
* Mimics the original grouping logic: top-level directory under source root,
* or source directory name for files in the source root.
*/
final class LegacyGuessedImportGroupingAdapter
{
public function __construct(private readonly \MeshVault\Repository\CatalogRepositoryInterface $catalog)
{
}
public function modelFor(string $sourcePath, ScannedFile $file): int
{
$parent = trim(dirname(str_replace('\\', '/', $file->relativePath)), '/');
$group = $parent === '' || $parent === '.'
? basename(rtrim(str_replace('\\', '/', $sourcePath), '/'))
: explode('/', $parent)[0];
// No retained IDs: rollback and later ambiguity must be visible on every call.
return $this->catalog->ensureModel('/' . $group, $group);
}
}