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
@@ -57,7 +57,7 @@ final class ImportPipelineTest extends TestCase
|
||||
return new ImportService(
|
||||
new FileEnumerator(new LocalFilesystem(), $safety),
|
||||
$this->jobs,
|
||||
$this->catalog,
|
||||
new \MeshVault\Indexer\LegacyGuessedImportGroupingAdapter($this->catalog),
|
||||
$space ?? new SpaceChecker(),
|
||||
$this->library,
|
||||
);
|
||||
@@ -69,7 +69,7 @@ final class ImportPipelineTest extends TestCase
|
||||
$sourceSafety = new PathSafety($this->source);
|
||||
return new ImportWorker(
|
||||
$this->jobs,
|
||||
$this->catalog,
|
||||
new \MeshVault\Catalog\CatalogService($this->catalog),
|
||||
new StreamingHasher($sourceSafety),
|
||||
new VerifiedCopier($safety),
|
||||
new StlValidator($sourceSafety),
|
||||
@@ -93,7 +93,113 @@ final class ImportPipelineTest extends TestCase
|
||||
return $path;
|
||||
}
|
||||
|
||||
public function testImportCreatesJobWithExplicitSubfolderGrouping(): void
|
||||
public function testNullOwnerIsRegisteredAndScanFailuresAreNot(): void
|
||||
{
|
||||
$source = $this->writeStl('loose.stl');
|
||||
$job = $this->jobs->create($this->source, [
|
||||
['source_path' => $source, 'size' => filesize($source), 'model_id' => null],
|
||||
['source_path' => 'unreadable', 'size' => 0, 'model_id' => null,
|
||||
'status' => 'Failed', 'failure_reason' => 'SCAN_READ_ERROR'],
|
||||
]);
|
||||
$this->worker()->runOnce();
|
||||
$files = $this->pdo->query('SELECT * FROM files')->fetchAll(PDO::FETCH_ASSOC);
|
||||
self::assertCount(1, $files);
|
||||
self::assertNull($files[0]['model_id']);
|
||||
self::assertSame('Completed', $this->jobs->items($job)[0]['status']);
|
||||
}
|
||||
|
||||
public function testRegistrationConflictReportsCatalogConflictAndPreservesBytes(): void
|
||||
{
|
||||
$source = $this->writeStl('planes/wing.stl');
|
||||
$job = $this->service()->import($this->source);
|
||||
$other = $this->catalog->ensureModel('/other', 'other');
|
||||
$id = $this->catalog->recordFile($other, 'planes/wing.stl', hash_file('sha256', $source), filesize($source));
|
||||
$sourceBefore = PhysicalFileSnapshot::capture($this->source);
|
||||
$this->worker()->runOnce();
|
||||
$item = $this->jobs->items($job)[0];
|
||||
self::assertSame('Failed', $item['status']);
|
||||
self::assertStringStartsWith('CATALOG_CONFLICT:', $item['failure_reason']);
|
||||
self::assertSame($other, (int) $this->catalog->file($id)['model_id']);
|
||||
self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source));
|
||||
self::assertSame(
|
||||
$sourceBefore['planes/wing.stl']['bytes'],
|
||||
file_get_contents($this->library . '/planes/wing.stl')
|
||||
);
|
||||
}
|
||||
|
||||
public function testCompletionRegistersBeforeUpdatingItemPath(): void
|
||||
{
|
||||
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
|
||||
self::markTestSkipped('SQLite ordering assertion trigger.');
|
||||
}
|
||||
$this->writeStl('planes/wing.stl');
|
||||
$job = $this->service()->import($this->source);
|
||||
$this->pdo->exec("CREATE TRIGGER catalog_first BEFORE UPDATE OF managed_path ON import_items"
|
||||
. " WHEN OLD.hash IS NOT NULL AND NOT EXISTS (SELECT 1 FROM files WHERE managed_path = NEW.managed_path)"
|
||||
. " BEGIN SELECT RAISE(ABORT, 'catalog must precede progress'); END");
|
||||
$this->worker()->runOnce();
|
||||
self::assertSame('Completed', $this->jobs->items($job)[0]['status']);
|
||||
}
|
||||
|
||||
public function testMissingNonNullOwnerFailsRatherThanBecomingUnassigned(): void
|
||||
{
|
||||
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
|
||||
self::markTestSkipped('SQLite fixture deliberately simulates a stale manifest owner.');
|
||||
}
|
||||
$source = $this->writeStl('loose.stl');
|
||||
$this->pdo->exec('PRAGMA foreign_keys = OFF');
|
||||
$job = $this->jobs->create($this->source, [
|
||||
['source_path' => $source, 'size' => filesize($source), 'model_id' => 999999],
|
||||
]);
|
||||
$this->pdo->exec('PRAGMA foreign_keys = ON');
|
||||
$sourceBefore = PhysicalFileSnapshot::capture($this->source);
|
||||
$this->worker()->runOnce();
|
||||
$item = $this->jobs->items($job)[0];
|
||||
self::assertSame('Failed', $item['status']);
|
||||
self::assertStringStartsWith('CATALOG_CONFLICT:', $item['failure_reason']);
|
||||
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn());
|
||||
self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source));
|
||||
self::assertSame($sourceBefore['loose.stl']['bytes'], file_get_contents($this->library . '/unfiled/loose.stl'));
|
||||
}
|
||||
|
||||
public function testCompletionSqlFailureRollsBackCatalogPathAndProgressInsideCallerTransaction(): void
|
||||
{
|
||||
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
|
||||
self::markTestSkipped('SQLite failure-injection trigger.');
|
||||
}
|
||||
$source = $this->writeStl('planes/wing.stl');
|
||||
$job = $this->service()->import($this->source);
|
||||
mkdir($this->library . '/planes');
|
||||
file_put_contents($this->library . '/planes/wing.stl', 'different bytes');
|
||||
$sourceBefore = PhysicalFileSnapshot::capture($this->source);
|
||||
$libraryBefore = PhysicalFileSnapshot::capture($this->library);
|
||||
// Fail after registration and item-path update, during progress refresh.
|
||||
$this->pdo->exec("CREATE TRIGGER reject_completion BEFORE UPDATE ON import_jobs"
|
||||
. " WHEN NEW.status = 'Completed' BEGIN SELECT RAISE(ABORT, 'injected completion failure'); END");
|
||||
$this->pdo->beginTransaction();
|
||||
$sentinel = $this->catalog->createModel('/sentinel', 'sentinel');
|
||||
$this->worker()->runOnce();
|
||||
self::assertTrue($this->pdo->inTransaction());
|
||||
self::assertNotNull($this->catalog->aggregate($sentinel));
|
||||
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn());
|
||||
$item = $this->jobs->items($job)[0];
|
||||
self::assertSame('planes/wing.stl', $item['managed_path']);
|
||||
self::assertSame('Failed', $item['status']);
|
||||
self::assertStringStartsWith('COPY_FAILED:', $item['failure_reason']);
|
||||
self::assertSame('Failed', $this->jobs->get($job)['status']);
|
||||
self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source));
|
||||
$published = PhysicalFileSnapshot::capture($this->library);
|
||||
self::assertSame($libraryBefore['planes/wing.stl'], $published['planes/wing.stl']);
|
||||
self::assertSame($sourceBefore['planes/wing.stl']['bytes'], $published['planes/wing_001.stl']['bytes']);
|
||||
self::assertSame($sourceBefore['planes/wing.stl']['size'], $published['planes/wing_001.stl']['size']);
|
||||
$this->pdo->rollBack();
|
||||
self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source));
|
||||
self::assertSame($published, PhysicalFileSnapshot::capture($this->library));
|
||||
self::assertNull($this->catalog->aggregate($sentinel));
|
||||
self::assertSame('Pending', $this->jobs->items($job)[0]['status']);
|
||||
}
|
||||
|
||||
public function testImportCreatesJobWithLegacyGuessedSubfolderGrouping(): void
|
||||
{
|
||||
$this->writeStl('planes/wing.stl');
|
||||
$this->writeStl('planes/body.stl');
|
||||
@@ -108,7 +214,7 @@ final class ImportPipelineTest extends TestCase
|
||||
$aggregate = $this->catalog->aggregate((int) $item['model_id']);
|
||||
$models[$aggregate['model']['name']] = $aggregate['folder']['path'];
|
||||
}
|
||||
// Explicit policy: one model per immediate source subfolder; root files join
|
||||
// Legacy guessed grouping: one model per immediate source subfolder; root files join
|
||||
// a model named after the source directory itself.
|
||||
self::assertSame('/planes', $models['planes']);
|
||||
self::assertSame('/' . basename($this->source), $models[basename($this->source)]);
|
||||
@@ -353,7 +459,7 @@ final class ImportPipelineTest extends TestCase
|
||||
$sourceSafety = new PathSafety($this->source);
|
||||
$worker = new ImportWorker(
|
||||
$this->jobs,
|
||||
$this->catalog,
|
||||
new \MeshVault\Catalog\CatalogService($this->catalog),
|
||||
new StreamingHasher($sourceSafety),
|
||||
new VerifiedCopier($safety),
|
||||
new StlValidator($sourceSafety),
|
||||
@@ -390,7 +496,7 @@ final class ImportPipelineTest extends TestCase
|
||||
$sourceSafety = new PathSafety($this->source);
|
||||
$worker = new ImportWorker(
|
||||
$this->jobs,
|
||||
$this->catalog,
|
||||
new \MeshVault\Catalog\CatalogService($this->catalog),
|
||||
new StreamingHasher($sourceSafety),
|
||||
new VerifiedCopier($safety),
|
||||
new StlValidator($sourceSafety),
|
||||
|
||||
Reference in new issue
Block a user