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()); } }