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:
1 parent
6b9fb54b4f
commit
a829d6e8c6
21 files changed
+1903
-61
No files matched your search
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Tests\Integration;
|
||||
|
||||
use DomainException;
|
||||
use MeshVault\Indexer\Contracts\FileEnumeratorInterface;
|
||||
use MeshVault\Indexer\ImportException;
|
||||
use MeshVault\Indexer\ImportService;
|
||||
use MeshVault\Indexer\LegacyGuessedImportGroupingAdapter;
|
||||
use MeshVault\Indexer\ScannedFile;
|
||||
use MeshVault\Indexer\ScanResult;
|
||||
use MeshVault\Indexer\SpaceChecker;
|
||||
use MeshVault\Repository\PdoCatalogRepository;
|
||||
use MeshVault\Repository\PdoImportJobRepository;
|
||||
use PDO;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
final class LegacyGroupingAdapterTest extends TestCase
|
||||
{
|
||||
private PDO $pdo;
|
||||
private string $database;
|
||||
private string $library;
|
||||
private string $source;
|
||||
private PdoCatalogRepository $catalog;
|
||||
private PdoImportJobRepository $jobs;
|
||||
private LegacyGuessedImportGroupingAdapter $adapter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->database = tempnam(sys_get_temp_dir(), 'meshvault-grouping-');
|
||||
$this->pdo = RepositoryDatabase::migrate($this->database);
|
||||
$this->catalog = new PdoCatalogRepository($this->pdo);
|
||||
$this->jobs = new PdoImportJobRepository($this->pdo);
|
||||
$this->adapter = new LegacyGuessedImportGroupingAdapter($this->catalog);
|
||||
$this->library = RepositoryDatabase::directory('library');
|
||||
$this->source = RepositoryDatabase::directory('source');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
unset($this->adapter, $this->catalog, $this->jobs, $this->pdo);
|
||||
RepositoryDatabase::cleanup($this->database);
|
||||
RepositoryDatabase::remove($this->library);
|
||||
RepositoryDatabase::remove($this->source);
|
||||
}
|
||||
|
||||
public function testSameAdapterResolvesPersistedModelAfterRollback(): void
|
||||
{
|
||||
$file = $this->file('planes/wing.stl');
|
||||
$before = $this->snapshot();
|
||||
$failure = new RuntimeException('Force rollback after grouping.');
|
||||
try {
|
||||
$this->jobs->atomic(function () use ($file, $failure): void {
|
||||
$id = $this->adapter->modelFor($this->source, $file);
|
||||
self::assertSame($id, (int) $this->pdo->query('SELECT id FROM models')->fetchColumn());
|
||||
throw $failure;
|
||||
});
|
||||
self::fail('The transaction must roll back.');
|
||||
} catch (RuntimeException $error) {
|
||||
self::assertSame($failure, $error);
|
||||
}
|
||||
self::assertSame($before, $this->snapshot());
|
||||
|
||||
// SQLite can reuse rolled-back IDs: require an actual persisted row, not a different ID.
|
||||
$id = $this->adapter->modelFor($this->source, $file);
|
||||
$rows = $this->pdo->query(
|
||||
'SELECT m.id, m.name, f.path FROM models m JOIN folders f ON f.id = m.folder_id'
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
self::assertCount(1, $rows);
|
||||
self::assertSame($id, (int) $rows[0]['id']);
|
||||
self::assertSame('planes', $rows[0]['name']);
|
||||
self::assertSame('/planes', $rows[0]['path']);
|
||||
self::assertSame($id, $this->adapter->modelFor($this->source, $file));
|
||||
}
|
||||
|
||||
public function testSameAdapterRejectsAmbiguityIntroducedBetweenCalls(): void
|
||||
{
|
||||
$file = $this->file('planes/wing.stl');
|
||||
$first = $this->adapter->modelFor($this->source, $file);
|
||||
$second = $this->catalog->createModel('/planes', 'planes');
|
||||
self::assertNotSame($first, $second);
|
||||
$before = $this->snapshot();
|
||||
|
||||
try {
|
||||
$this->adapter->modelFor($this->source, $file);
|
||||
self::fail('A previously resolved title must not hide new ambiguity.');
|
||||
} catch (DomainException $error) {
|
||||
self::assertSame('Ambiguous model title in home folder.', $error->getMessage());
|
||||
}
|
||||
self::assertSame($before, $this->snapshot());
|
||||
}
|
||||
|
||||
public function testRepeatedImportsReuseModelsWithTheSameServiceAndAdapter(): void
|
||||
{
|
||||
$service = $this->service([$this->file('planes/wing.stl'), $this->file('planes/nested/body.stl')]);
|
||||
$first = $service->import($this->source);
|
||||
$before = $this->snapshot();
|
||||
$second = $service->import($this->source);
|
||||
|
||||
self::assertNotSame($first, $second);
|
||||
$firstItems = $this->jobs->items($first);
|
||||
$secondItems = $this->jobs->items($second);
|
||||
self::assertCount(2, $firstItems);
|
||||
self::assertCount(2, $secondItems);
|
||||
$model = (int) $firstItems[0]['model_id'];
|
||||
self::assertGreaterThan(0, $model);
|
||||
foreach (array_merge($firstItems, $secondItems) as $item) {
|
||||
self::assertSame($model, (int) $item['model_id']);
|
||||
self::assertSame('Pending', $item['status']);
|
||||
}
|
||||
$after = $this->snapshot();
|
||||
self::assertSame($before['models'], $after['models']);
|
||||
self::assertSame($before['folders'], $after['folders']);
|
||||
self::assertCount(1, $after['models']);
|
||||
self::assertCount(2, $after['import_jobs']);
|
||||
self::assertCount(4, $after['import_items']);
|
||||
}
|
||||
|
||||
|
||||
public function testAmbiguityBetweenImportsRollsBackEarlierManifestModelCreation(): void
|
||||
{
|
||||
$plane = $this->file('planes/wing.stl');
|
||||
$service = $this->service([$plane], [$this->file('new-group/part.stl'), $plane]);
|
||||
$firstJob = $service->import($this->source);
|
||||
$firstModel = (int) $this->jobs->items($firstJob)[0]['model_id'];
|
||||
$duplicate = $this->catalog->createModel('/planes', 'planes');
|
||||
self::assertNotSame($firstModel, $duplicate);
|
||||
$before = $this->snapshot();
|
||||
|
||||
try {
|
||||
// The ordered scan creates /new-group before encountering the now-ambiguous title.
|
||||
$service->import($this->source);
|
||||
self::fail('An ambiguous title must reject the entire import.');
|
||||
} catch (ImportException $error) {
|
||||
self::assertSame('DB_ERROR', $error->reason);
|
||||
self::assertInstanceOf(DomainException::class, $error->getPrevious());
|
||||
self::assertSame('Ambiguous model title in home folder.', $error->getPrevious()->getMessage());
|
||||
}
|
||||
|
||||
self::assertFalse($this->pdo->inTransaction());
|
||||
self::assertSame($before, $this->snapshot(), 'No model, folder, job or item may survive the failed import.');
|
||||
self::assertSame(0, (int) $this->pdo->query(
|
||||
"SELECT COUNT(*) FROM folders WHERE path = '/new-group'"
|
||||
)->fetchColumn());
|
||||
}
|
||||
|
||||
private function file(string $relative): ScannedFile
|
||||
{
|
||||
return new ScannedFile($this->source . '/' . $relative, $relative, 12, 'model/stl');
|
||||
}
|
||||
|
||||
/** @param list<ScannedFile> $files @param list<ScannedFile>|null $nextFiles */
|
||||
private function service(array $files, ?array $nextFiles = null): ImportService
|
||||
{
|
||||
// Control scan order so creation precedes ambiguity. Grouping and persistence remain real.
|
||||
$enumerator = new class ($files, $nextFiles) implements FileEnumeratorInterface {
|
||||
public function __construct(private array $files, private ?array $nextFiles)
|
||||
{
|
||||
}
|
||||
|
||||
public function enumerate(string $sourceRoot, array $extensions = self::DEFAULT_EXTENSIONS): ScanResult
|
||||
{
|
||||
$files = $this->files;
|
||||
$this->files = $this->nextFiles ?? $files;
|
||||
$bytes = array_sum(array_map(static fn (ScannedFile $file): int => $file->size, $files));
|
||||
return new ScanResult($files, $bytes, []);
|
||||
}
|
||||
};
|
||||
|
||||
return new ImportService(
|
||||
$enumerator,
|
||||
$this->jobs,
|
||||
$this->adapter,
|
||||
new SpaceChecker(0.05, static fn (string $path): float => 1000000.0),
|
||||
$this->library,
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, list<array<string, mixed>>> */
|
||||
private function snapshot(): array
|
||||
{
|
||||
$snapshot = [];
|
||||
foreach (['folders', 'models', 'import_jobs', 'import_items'] as $table) {
|
||||
$snapshot[$table] = $this->pdo->query('SELECT * FROM ' . $table . ' ORDER BY id')
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
return $snapshot;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user