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]]; } }