Task 1: Establish catalog identity and atomic file ownership - CatalogService, CatalogServiceInterface, ModelMetadata DTO - PdoCatalogRepository with atomic operations, savepoint support - FOR UPDATE file locks in ascending order, FOR NO KEY UPDATE for metadata - FK-compatible lock composition documented (spec §6) Task 2: M3 import & indexing engine delivery layer - ImportService, ImportWorker with durable collision handling - SpaceChecker, StreamingHasher, VerifiedCopier, StlValidator - FileEnumerator, LocalFilesystem, PathSafety - Console command wiring via ContainerWiringTest Task 3: Importer integration and repair - LegacyGuessedImportGroupingAdapter isolates first-directory guessing - ImportService uses adapter; ImportWorker uses CatalogServiceInterface - Null owners remain null; DomainException mapped to CATALOG_CONFLICT - SQL failure after registration/path update with caller sentinel rollback Task 4: PostgreSQL contention evidence and bounded acceptance - 6 new PostgresCatalogContractsTest cases with independent sessions - Generated schemas, 250ms lock timeouts, 5s statement timeouts - Ascending file locks, FOR SHARE contention, stale-owner rejection - Rollback/retry, nested transaction survival, root creation contention - Negative controls: replay FOR UPDATE removal (11 failures), metadata FOR UPDATE (4 FK KEY SHARE timeouts) - Fresh Docker PostgreSQL 17 / PHP 8.4.26: 473 tests, 2718 assertions, 10 skips, exit 0 All local: 474 tests, 2347 assertions, 54 skips, foundation/lint clean.
307 lines
14 KiB
PHP
307 lines
14 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 PostgresCatalogContractsTest extends TestCase
|
|
{
|
|
private string $database;
|
|
private PDO $a;
|
|
private PDO $b;
|
|
private PdoCatalogRepository $first;
|
|
private PdoCatalogRepository $second;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
if (!RepositoryDatabase::postgresConfigured()) {
|
|
self::markTestSkipped('Requires isolated PostgreSQL.');
|
|
}
|
|
$this->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());
|
|
}
|
|
}
|