feat: establish catalog identity and atomic file ownership

This commit is contained in:
Cline committed 2026-10-12 19:38:38 -05:00
1 parent 390a7a557a
commit 6b9fb54b4f
16 files changed
+1381 -23

No files matched your search

+195
View File
@@ -0,0 +1,195 @@
<?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 PostgresCatalogTest extends TestCase
{
private string $database;
private PDO $pdo;
private PDO $other;
private PdoCatalogRepository $catalog;
private PdoCatalogRepository $contender;
private \ArrayObject $locks;
protected function setUp(): void
{
if (!RepositoryDatabase::postgresConfigured()) {
self::markTestSkipped('Set MESHVAULT_TEST_PG_DSN to run PostgreSQL catalog contention tests.');
}
$this->database = tempnam(sys_get_temp_dir(), 'meshvault-pg-catalog-');
$this->pdo = RepositoryDatabase::migrate($this->database);
$this->other = RepositoryDatabase::connect($this->database);
foreach ([$this->pdo, $this->other] as $pdo) {
$pdo->exec("SET lock_timeout = '250ms'");
$pdo->exec("SET statement_timeout = '5s'");
}
$this->locks = new \ArrayObject();
$this->other->setAttribute(PDO::ATTR_STATEMENT_CLASS, [CatalogLockStatement::class, [$this->locks]]);
$this->catalog = new PdoCatalogRepository($this->pdo);
$this->contender = new PdoCatalogRepository($this->other);
self::assertNotSame(
$this->pdo->query('SELECT pg_backend_pid()')->fetchColumn(),
$this->other->query('SELECT pg_backend_pid()')->fetchColumn(),
);
self::assertSame(
$this->pdo->query('SELECT current_schema()')->fetchColumn(),
$this->other->query('SELECT current_schema()')->fetchColumn(),
);
}
protected function tearDown(): void
{
foreach (['pdo', 'other'] as $property) {
if (isset($this->$property) && $this->$property->inTransaction()) {
$this->$property->rollBack();
}
}
unset($this->catalog, $this->contender, $this->pdo, $this->other);
if (isset($this->database)) {
RepositoryDatabase::cleanup($this->database);
}
}
private function fixture(): array
{
$source = $this->catalog->createModel('/parts', 'Source');
$target = $this->catalog->createModel('/parts', 'Target');
$low = $this->catalog->recordFile($source, 'parts/low.stl', 'low', 3);
$high = $this->catalog->recordFile($source, 'parts/high.stl', 'high', 4);
return [$source, $target, $low, $high];
}
private function timeout(callable $operation): void
{
try {
$operation();
self::fail('Expected a PostgreSQL lock timeout, not a successful write or no-op.');
} catch (PDOException $error) {
self::assertSame('55P03', $error->getCode());
}
}
private function files(): array
{
return $this->other->query('SELECT * FROM files ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
}
#[DataProvider('transactionOutcomes')]
public function testUncommittedOwnerBlocksThenCommitRejectsStaleOrRollbackAllowsRetry(bool $commit): void
{
[$source, $target, $low, $high] = $this->fixture();
$before = $this->files();
$this->pdo->beginTransaction();
$this->catalog->changeFileOwner([$low, $high], $source, $target);
$this->timeout(fn () => $this->contender->changeFileOwner([$high, $low], $source, null));
self::assertFalse($this->other->inTransaction());
self::assertSame($before, $this->files());
if ($commit) {
$this->pdo->commit();
$committed = $this->files();
try {
$this->contender->changeFileOwner([$high, $low], $source, null);
self::fail('Stale expected ownership must not overwrite the winner.');
} catch (DomainException $error) {
self::assertSame('File ownership has changed.', $error->getMessage());
}
self::assertSame($committed, $this->files());
self::assertSame([$target, $target], array_column($committed, 'model_id'));
} else {
$this->pdo->rollBack();
$this->contender->changeFileOwner([$high, $low], $source, null);
self::assertSame([null, null], array_column($this->files(), 'model_id'));
}
self::assertFalse($this->other->inTransaction());
}
#[DataProvider('associationTargets')]
public function testReversedLocksAndNestedFailureReleaseLowerLockWithoutPartialWrites(bool $noop): void
{
[$source, $target, $low, $high] = $this->fixture();
$before = $this->files();
$this->pdo->beginTransaction();
$held = $this->pdo->prepare('SELECT id FROM files WHERE id = ? FOR SHARE');
$held->execute([$high]);
$held->closeCursor();
$this->other->beginTransaction();
$this->other->exec("INSERT INTO tags (name) VALUES ('outer survives')");
$this->timeout(fn () => $this->contender->changeFileOwner(
[$high, $low],
$source,
$noop ? $source : $target,
));
// A timeout alone cannot establish order, especially when no UPDATE would occur.
self::assertSame([[$low], [$high]], $this->locks->getArrayCopy());
self::assertTrue($this->other->inTransaction());
self::assertSame($before, $this->files());
self::assertSame('outer survives', $this->other->query('SELECT name FROM tags')->fetchColumn());
// NOWAIT proves the lower lock was released by B's savepoint rollback, not B's transaction end.
$released = $this->pdo->prepare('SELECT id FROM files WHERE id = ? FOR UPDATE NOWAIT');
$released->execute([$low]);
self::assertSame($low, (int) $released->fetchColumn());
$released->closeCursor();
$this->pdo->rollBack();
$this->contender->changeFileOwner([$high, $low], $source, $noop ? $source : $target);
self::assertTrue($this->other->inTransaction());
self::assertSame(
[$noop ? $source : $target, $noop ? $source : $target],
array_column($this->files(), 'model_id'),
);
$this->other->rollBack();
self::assertSame($before, $this->files());
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM tags')->fetchColumn());
}
public static function associationTargets(): array
{
return ['reassignment' => [false], 'same-owner no-op' => [true]];
}
#[DataProvider('creationMethods')]
public function testRootSerializesCreationAndLegacyLookupAfterRelease(string $method): void
{
$this->catalog->createModel('/', 'Root fixture');
$this->pdo->beginTransaction();
$winner = $this->catalog->$method('/contested', 'Shared title');
foreach (['createModel', 'ensureModel'] as $contenderMethod) {
$this->timeout(fn () => $this->contender->$contenderMethod('/contested', 'Shared title'));
// A disjoint folder/title cannot contend on the winner's new folder or model rows.
$this->timeout(fn () => $this->contender->$contenderMethod('/unrelated', 'Other title'));
self::assertFalse($this->other->inTransaction());
self::assertSame(1, (int) $this->other->query('SELECT COUNT(*) FROM models')->fetchColumn());
self::assertSame(1, (int) $this->other->query('SELECT COUNT(*) FROM folders')->fetchColumn());
}
$this->pdo->commit();
self::assertSame($winner, $this->contender->ensureModel('/contested', 'Shared title'));
$duplicate = $this->contender->createModel('/contested', 'Shared title');
self::assertNotSame($winner, $duplicate);
try {
$this->contender->ensureModel('/contested', 'Shared title');
self::fail('Legacy lookup must reject two explicit identities.');
} catch (DomainException $error) {
self::assertSame('Ambiguous model title in home folder.', $error->getMessage());
}
self::assertFalse($this->other->inTransaction());
self::assertSame(3, (int) $this->other->query('SELECT COUNT(*) FROM models')->fetchColumn());
}
public static function creationMethods(): array
{
return ['explicit winner' => ['createModel'], 'legacy winner' => ['ensureModel']];
}
public static function transactionOutcomes(): array
{
return ['commit' => [true], 'rollback' => [false]];
}
}