196 lines
8.4 KiB
PHP
196 lines
8.4 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 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]];
|
|
}
|
|
}
|