Files
MeshVault/tests/Integration/PostgresCatalogContractsTest.php
T
Cline a829d6e8c6 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.
2026-10-13 19:41:03 -05:00

307 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
use DomainException;
use MeshVault\Repository\PdoCatalogRepository;
use PDO;
use PDOException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class PostgresCatalogContractsTest extends TestCase
{
private string $database;
private PDO $a;
private PDO $b;
private PdoCatalogRepository $first;
private PdoCatalogRepository $second;
protected function setUp(): void
{
if (!RepositoryDatabase::postgresConfigured()) {
self::markTestSkipped('Requires isolated PostgreSQL.');
}
$this->database = tempnam(sys_get_temp_dir(), 'meshvault-contracts-');
$this->a = RepositoryDatabase::migrate($this->database);
$this->b = RepositoryDatabase::connect($this->database);
foreach ([$this->a, $this->b] as $pdo) {
$pdo->exec("SET lock_timeout = '250ms'");
$pdo->exec("SET statement_timeout = '5s'");
}
self::assertNotSame(
$this->a->query('SELECT pg_backend_pid()')->fetchColumn(),
$this->b->query('SELECT pg_backend_pid()')->fetchColumn(),
);
self::assertSame(
$this->a->query('SELECT current_schema()')->fetchColumn(),
$this->b->query('SELECT current_schema()')->fetchColumn(),
);
$this->first = new PdoCatalogRepository($this->a);
$this->second = new PdoCatalogRepository($this->b);
}
protected function tearDown(): void
{
foreach (['a', 'b'] as $property) {
if (isset($this->$property) && $this->$property->inTransaction()) {
$this->$property->rollBack();
}
}
unset($this->first, $this->second, $this->a, $this->b);
if (isset($this->database)) {
RepositoryDatabase::cleanup($this->database);
}
}
private function timeout(callable $operation): void
{
try {
$operation();
self::fail('Expected PostgreSQL lock timeout; a replay/no-op must not escape the lock.');
} catch (PDOException $error) {
self::assertSame('55P03', $error->getCode());
self::assertStringContainsString('lock timeout', $error->getMessage());
}
}
private function conflict(callable $operation, string $message): void
{
try {
$operation();
self::fail('Expected stale state rejection.');
} catch (DomainException $error) {
self::assertSame($message, $error->getMessage());
}
}
public static function ownershipCases(): iterable
{
foreach (['assign', 'reassign', 'detach', 'noop', 'null-noop'] as $kind) {
foreach ([true, false] as $commit) {
yield $kind . ($commit ? '-commit' : '-rollback') => [$kind, $commit];
}
}
}
#[DataProvider('ownershipCases')]
public function testRegistrationAgainstOwnershipAndReplayRetention(string $kind, bool $commit): void
{
$source = $this->first->createModel('/home', 'Source');
$target = $this->first->createModel('/home', 'Target');
$old = in_array($kind, ['assign', 'null-noop'], true) ? null : $source;
$next = match ($kind) {
'detach', 'null-noop' => null,
'noop' => $source,
default => $target,
};
$file = $this->first->recordFile($old, 'physical/nested/part.stl', 'hash', 1);
$this->a->beginTransaction();
$this->first->changeFileOwner([$file], $old, $next);
$this->timeout(fn () => $this->second->recordFile($old, 'physical/nested/part.stl', 'hash', 1));
$commit ? $this->a->commit() : $this->a->rollBack();
$current = $commit ? $next : $old;
if ($current !== $old) {
$this->conflict(
fn () => $this->second->recordFile($old, 'physical/nested/part.stl', 'hash', 1),
'Managed path already records a different file.',
);
}
$this->b->beginTransaction();
self::assertSame($file, $this->second->recordFile($current, 'physical/nested/part.stl', 'hash', 1));
$this->timeout(fn () => $this->first->changeFileOwner([$file], $current, $current));
$this->timeout(fn () => $this->first->createModel('/unrelated', 'Root retained'));
$this->b->commit();
$this->first->changeFileOwner([$file], $current, $current);
self::assertSame($current, $this->first->file($file)['model_id']);
}
#[DataProvider('ownershipCases')]
public function testMetadataKeyShareComposition(string $kind, bool $commit): void
{
// Reverse backend roles on rollback as well as exercising both transaction outcomes.
[$a, $b, $first, $second] = $commit
? [$this->a, $this->b, $this->first, $this->second]
: [$this->b, $this->a, $this->second, $this->first];
$model = $first->createModel('/home', 'Metadata target');
$source = $first->createModel('/home', 'Other owner');
$owner = match ($kind) {
'assign', 'null-noop' => null,
'noop', 'detach' => $model,
default => $source,
};
$target = in_array($kind, ['detach', 'null-noop'], true) ? null : $model;
$file = $first->recordFile($owner, 'physical/part.stl', 'hash', 1);
$old = $first->modelMetadata($model);
$new = array_replace($old, ['notes' => 'metadata']);
$a->beginTransaction();
$first->updateModelMetadata($model, $old, $new);
$b->beginTransaction();
$second->changeFileOwner([$file], $owner, $owner);
// B holds the file, A retains metadata after its operation savepoint times out.
$this->timeout(fn () => $first->changeFileOwner([$file], $owner, $target));
// Real FK acquisition for assign/reassign, not a simulated KEY SHARE query.
$second->changeFileOwner([$file], $owner, $target);
$this->timeout(fn () => $second->updateModelMetadata($model, $old, $old));
$commit ? $b->commit() : $b->rollBack();
$current = $commit ? $target : $owner;
$first->changeFileOwner([$file], $current, $target);
self::assertSame($new, $first->modelMetadata($model));
self::assertSame($target, $first->file($file)['model_id']);
$a->commit();
self::assertSame($new, $second->modelMetadata($model));
self::assertSame($target, $second->file($file)['model_id']);
}
public function testIndependentStatusOwnershipAndNoUnnecessaryRootLocks(): void
{
$model = $this->first->createModel('/home', 'Model');
$file = $this->first->recordFile($model, 'physical/part.stl', 'hash', 1);
$old = $this->first->modelMetadata($model);
$this->a->beginTransaction();
$this->a->exec("UPDATE folders SET name = name WHERE path = '/'");
$this->second->updateModelMetadata($model, $old, $old);
$this->second->changeFileStatus($file, 'Present', 'Missing');
$this->second->changeFileOwner([$file], $model, null);
$this->a->rollBack();
$this->b->beginTransaction();
$before = $this->second->file($file);
self::assertSame($before, $this->second->changeFileStatus($file, 'Missing', 'Missing'));
$this->timeout(fn () => $this->first->changeFileOwner([$file], null, $model));
$this->b->commit();
$this->first->changeFileOwner([$file], null, $model);
self::assertSame($file, $this->first->recordFile($model, 'physical/part.stl', 'hash', 1));
self::assertSame('Missing', $this->first->file($file)['status']);
self::assertSame($model, $this->first->file($file)['model_id']);
self::assertSame($before['updated_at'], $this->first->file($file)['updated_at']);
$this->b->beginTransaction();
$this->second->updateModelMetadata($model, $old, $old);
$this->timeout(fn () => $this->first->updateModelMetadata($model, $old, $old));
$this->b->rollBack();
self::assertSame($old, $this->first->updateModelMetadata($model, $old, $old));
}
#[DataProvider('outcomes')]
public function testNewPathSerializationAndPhysicalFolders(bool $commit): void
{
$model = $this->first->createModel('/organizational', 'Model');
$this->a->beginTransaction();
$file = $this->first->recordFile($model, 'physical/nested/part.stl', 'hash', 1);
$this->timeout(fn () => $this->second->recordFile(null, 'physical/nested/part.stl', 'hash', 1));
self::assertSame(0, (int) $this->b->query('SELECT COUNT(*) FROM files')->fetchColumn());
$commit ? $this->a->commit() : $this->a->rollBack();
if ($commit) {
$this->conflict(
fn () => $this->second->recordFile(null, 'physical/nested/part.stl', 'hash', 1),
'Managed path already records a different file.',
);
self::assertSame($file, $this->second->recordFile($model, 'physical/nested/part.stl', 'hash', 1));
} else {
self::assertSame(0, (int) $this->b->query(
"SELECT COUNT(*) FROM folders WHERE path LIKE '/physical%'",
)->fetchColumn());
$file = $this->second->recordFile(null, 'physical/nested/part.stl', 'hash', 1);
}
self::assertSame(1, (int) $this->b->query('SELECT COUNT(*) FROM files')->fetchColumn());
$folder = $this->second->file($file)['folder_id'];
self::assertSame('/physical/nested', $this->b->query(
'SELECT path FROM folders WHERE id = ' . $folder,
)->fetchColumn());
self::assertNotSame((int) $this->second->aggregate($model)['model']['folder_id'], $folder);
}
public function testRegistrationTraceAndSqlFailureRollback(): void
{
$model = $this->first->createModel('/home', 'Model');
$trace = new \ArrayObject();
$this->a->setAttribute(PDO::ATTR_STATEMENT_CLASS, [CatalogContractsStatement::class, [$trace]]);
$file = $this->first->recordFile($model, 'physical/nested/part.stl', 'hash', 1);
$sql = $trace->getArrayCopy();
$insert = array_key_last($sql);
self::assertStringContainsString('INSERT INTO files', $sql[$insert]);
self::assertStringContainsString('SELECT id FROM folders', $sql[$insert - 1]);
foreach ([false, true] as $conflict) {
$trace->exchangeArray([]);
if ($conflict) {
$this->conflict(
fn () => $this->first->recordFile(null, 'physical/nested/part.stl', 'hash', 1),
'Managed path already records a different file.',
);
} else {
self::assertSame($file, $this->first->recordFile($model, 'physical/nested/part.stl', 'hash', 1));
}
$sql = $trace->getArrayCopy();
self::assertCount(4, $sql);
self::assertSame("UPDATE folders SET name = name WHERE path = '/'", $sql[2]);
self::assertSame('SELECT * FROM files WHERE managed_path = ? FOR UPDATE', $sql[3]);
}
$this->a->exec("ALTER TABLE files ADD CONSTRAINT reject_test_hash CHECK (hash <> 'rejected')");
$this->a->beginTransaction();
$this->a->exec("INSERT INTO tags (name) VALUES ('sentinel')");
try {
$this->first->recordFile(null, 'rollback/nested/part.stl', 'rejected', 1);
self::fail('Expected actual SQL constraint failure.');
} catch (PDOException $error) {
self::assertSame('23514', $error->getCode());
}
self::assertTrue($this->a->inTransaction());
self::assertSame('sentinel', $this->a->query('SELECT name FROM tags')->fetchColumn());
self::assertSame(0, (int) $this->a->query(
"SELECT COUNT(*) FROM folders WHERE path LIKE '/rollback%'",
)->fetchColumn());
// The operation's root lock was released, while earlier caller work remains uncommitted.
$this->second->createModel('/after-failure', 'Independent');
self::assertSame(0, (int) $this->b->query('SELECT COUNT(*) FROM tags')->fetchColumn());
$this->a->commit();
self::assertSame('sentinel', $this->b->query('SELECT name FROM tags')->fetchColumn());
}
public static function outcomes(): array
{
return ['commit' => [true], 'rollback' => [false]];
}
#[DataProvider('outcomes')]
public function testMetadataAndStatusStaleEditsIncludingEqualTarget(bool $commit): void
{
$model = $this->first->createModel('/home', 'Original');
$file = $this->first->recordFile($model, 'physical/part.stl', 'hash', 1);
$old = $this->first->modelMetadata($model);
$new = array_replace($old, ['notes' => 'winner']);
$this->a->beginTransaction();
$this->first->updateModelMetadata($model, $old, $new);
$this->first->changeFileStatus($file, 'Present', 'Missing');
$this->b->beginTransaction();
$this->b->exec("INSERT INTO tags (name) VALUES ('caller survives')");
foreach ([$old, $new] as $replacement) {
$this->timeout(fn () => $this->second->updateModelMetadata($model, $old, $replacement));
}
foreach (['Present', 'Missing'] as $target) {
$this->timeout(fn () => $this->second->changeFileStatus($file, 'Present', $target));
}
self::assertTrue($this->b->inTransaction());
self::assertSame('caller survives', $this->b->query('SELECT name FROM tags')->fetchColumn());
$commit ? $this->a->commit() : $this->a->rollBack();
if ($commit) {
$this->conflict(
fn () => $this->second->updateModelMetadata($model, $old, $new),
'Model metadata has changed.',
);
$this->conflict(
fn () => $this->second->changeFileStatus($file, 'Present', 'Missing'),
'File status has changed.',
);
} else {
self::assertSame($new, $this->second->updateModelMetadata($model, $old, $new));
self::assertSame('Missing', $this->second->changeFileStatus($file, 'Present', 'Missing')['status']);
}
self::assertTrue($this->b->inTransaction());
$this->b->rollBack();
self::assertSame($commit ? $new : $old, $this->first->modelMetadata($model));
self::assertSame($commit ? 'Missing' : 'Present', $this->first->file($file)['status']);
self::assertSame(0, (int) $this->a->query('SELECT COUNT(*) FROM tags')->fetchColumn());
}
}