feat: establish catalog identity and atomic file ownership
This commit is contained in:
1 parent
390a7a557a
commit
6b9fb54b4f
16 files changed
+1381
-23
No files matched your search
@@ -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]];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user