sibling explicit-grouping implementation: commit all work, removal of stray scratch files; update docs and status; keep P1 fix and other senior details
This commit is contained in:
1 parent
9dd8c07e66
commit
99f19e1052
16 files changed
+1244
-44
No files matched your search
@@ -63,7 +63,7 @@ final class ImportPipelineTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
private function worker(?callable $shouldStop = null): ImportWorker
|
||||
private function worker(?callable $shouldStop = null, array $operations = []): ImportWorker
|
||||
{
|
||||
$safety = new PathSafety($this->library);
|
||||
$sourceSafety = new PathSafety($this->source);
|
||||
@@ -71,7 +71,7 @@ final class ImportPipelineTest extends TestCase
|
||||
$this->jobs,
|
||||
new \MeshVault\Catalog\CatalogService($this->catalog),
|
||||
new StreamingHasher($sourceSafety),
|
||||
new VerifiedCopier($safety),
|
||||
new VerifiedCopier($safety, $operations),
|
||||
new StlValidator($sourceSafety),
|
||||
$safety,
|
||||
new SpaceChecker(),
|
||||
@@ -121,10 +121,7 @@ final class ImportPipelineTest extends TestCase
|
||||
self::assertStringStartsWith('CATALOG_CONFLICT:', $item['failure_reason']);
|
||||
self::assertSame($other, (int) $this->catalog->file($id)['model_id']);
|
||||
self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source));
|
||||
self::assertSame(
|
||||
$sourceBefore['planes/wing.stl']['bytes'],
|
||||
file_get_contents($this->library . '/planes/wing.stl')
|
||||
);
|
||||
self::assertFileDoesNotExist($this->library . '/planes/wing.stl');
|
||||
}
|
||||
|
||||
public function testCompletionRegistersBeforeUpdatingItemPath(): void
|
||||
@@ -159,10 +156,97 @@ final class ImportPipelineTest extends TestCase
|
||||
self::assertStringStartsWith('CATALOG_CONFLICT:', $item['failure_reason']);
|
||||
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn());
|
||||
self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source));
|
||||
self::assertSame($sourceBefore['loose.stl']['bytes'], file_get_contents($this->library . '/unfiled/loose.stl'));
|
||||
self::assertFileDoesNotExist($this->library . '/unfiled/loose.stl');
|
||||
}
|
||||
|
||||
public function testCompletionSqlFailureRollsBackCatalogPathAndProgressInsideCallerTransaction(): void
|
||||
public static function invalidExplicitOwners(): array
|
||||
{
|
||||
return [
|
||||
'deleted before execution' => [true, false, false],
|
||||
'deleted before recovery' => [true, true, false],
|
||||
'different manifest owner' => [false, false, true],
|
||||
'missing historical owner with replacement' => [true, false, true],
|
||||
];
|
||||
}
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('invalidExplicitOwners')]
|
||||
public function testExplicitOwnershipFailsClosed(bool $delete, bool $recover, bool $replace): void
|
||||
{
|
||||
$source = $this->writeStl('part.stl');
|
||||
$adapter = new \MeshVault\Indexer\ExplicitImportGroupingAdapter($this->catalog, $this->jobs);
|
||||
$service = new ImportService(
|
||||
new FileEnumerator(new LocalFilesystem(), new PathSafety($this->source)),
|
||||
$this->jobs,
|
||||
$adapter,
|
||||
new SpaceChecker(),
|
||||
$this->library,
|
||||
);
|
||||
$model = $this->catalog->createModel('/confirmed', 'Part');
|
||||
$job = $service->import($this->source, ['stl'], [
|
||||
'*' => ['confirmed' => true, 'model_id' => $model],
|
||||
]);
|
||||
$item = $this->jobs->items($job)[0];
|
||||
$destination = $this->library . '/confirmed/part.stl';
|
||||
if ($recover) {
|
||||
$this->jobs->claimNext();
|
||||
$this->jobs->planItem(
|
||||
(int) $item['id'],
|
||||
'confirmed/part.stl',
|
||||
hash_file('sha256', $source),
|
||||
filesize($source)
|
||||
);
|
||||
mkdir(dirname($destination));
|
||||
copy($source, $destination);
|
||||
unlink($source); // Recovery must validate ownership even without the source.
|
||||
}
|
||||
if ($delete) {
|
||||
$this->pdo->exec('DELETE FROM models WHERE id = ' . $model);
|
||||
self::assertNull($this->jobs->items($job)[0]['model_id']);
|
||||
}
|
||||
if ($replace) {
|
||||
$other = $this->catalog->createModel('/replacement', 'Other');
|
||||
$this->pdo->exec('UPDATE import_items SET model_id = ' . $other . ' WHERE id = ' . (int) $item['id']);
|
||||
}
|
||||
self::assertSame([$source => $model], $adapter->recoverDecisions($job));
|
||||
$sourceBefore = PhysicalFileSnapshot::capture($this->source);
|
||||
$this->worker()->runOnce();
|
||||
$item = $this->jobs->items($job)[0];
|
||||
self::assertSame('Failed', $item['status']);
|
||||
self::assertStringStartsWith('CATALOG_CONFLICT:', $item['failure_reason']);
|
||||
self::assertSame('Failed', $this->jobs->get($job)['status']);
|
||||
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn());
|
||||
self::assertSame([$source => $model], $adapter->recoverDecisions($job));
|
||||
self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source));
|
||||
self::assertFileDoesNotExist($this->library . '/unfiled/part.stl');
|
||||
self::assertFileDoesNotExist($this->library . '/replacement/part.stl');
|
||||
if ($recover) {
|
||||
self::assertSame($item['hash'], hash_file('sha256', $destination));
|
||||
self::assertSame('confirmed/part.stl', $item['managed_path']);
|
||||
} else {
|
||||
self::assertNull($item['managed_path']);
|
||||
self::assertFileDoesNotExist($destination);
|
||||
}
|
||||
}
|
||||
|
||||
public function testCallerTransactionIsRejectedBeforePublication(): void
|
||||
{
|
||||
$this->writeStl('planes/wing.stl');
|
||||
$job = $this->service()->import($this->source);
|
||||
$this->pdo->beginTransaction();
|
||||
try {
|
||||
$this->worker()->runOnce();
|
||||
self::fail('Uncommitted publication intent accepted');
|
||||
} catch (\LogicException $error) {
|
||||
self::assertStringContainsString('transaction', $error->getMessage());
|
||||
} finally {
|
||||
self::assertTrue($this->pdo->inTransaction());
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
self::assertSame('Pending', $this->jobs->items($job)[0]['status']);
|
||||
self::assertFileDoesNotExist($this->library . '/planes/wing.stl');
|
||||
}
|
||||
|
||||
public function testCompletionSqlFailureCompensatesOwnedSuffix(): void
|
||||
{
|
||||
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
|
||||
self::markTestSkipped('SQLite failure-injection trigger.');
|
||||
@@ -176,27 +260,17 @@ final class ImportPipelineTest extends TestCase
|
||||
// Fail after registration and item-path update, during progress refresh.
|
||||
$this->pdo->exec("CREATE TRIGGER reject_completion BEFORE UPDATE ON import_jobs"
|
||||
. " WHEN NEW.status = 'Completed' BEGIN SELECT RAISE(ABORT, 'injected completion failure'); END");
|
||||
$this->pdo->beginTransaction();
|
||||
$sentinel = $this->catalog->createModel('/sentinel', 'sentinel');
|
||||
$this->worker()->runOnce();
|
||||
self::assertTrue($this->pdo->inTransaction());
|
||||
self::assertNotNull($this->catalog->aggregate($sentinel));
|
||||
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn());
|
||||
$item = $this->jobs->items($job)[0];
|
||||
self::assertSame('planes/wing.stl', $item['managed_path']);
|
||||
self::assertSame('planes/wing_001.stl', $item['managed_path']);
|
||||
self::assertSame('Failed', $item['status']);
|
||||
self::assertStringStartsWith('COPY_FAILED:', $item['failure_reason']);
|
||||
self::assertSame('Failed', $this->jobs->get($job)['status']);
|
||||
self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source));
|
||||
$published = PhysicalFileSnapshot::capture($this->library);
|
||||
self::assertSame($libraryBefore['planes/wing.stl'], $published['planes/wing.stl']);
|
||||
self::assertSame($sourceBefore['planes/wing.stl']['bytes'], $published['planes/wing_001.stl']['bytes']);
|
||||
self::assertSame($sourceBefore['planes/wing.stl']['size'], $published['planes/wing_001.stl']['size']);
|
||||
$this->pdo->rollBack();
|
||||
self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source));
|
||||
self::assertSame($published, PhysicalFileSnapshot::capture($this->library));
|
||||
self::assertNull($this->catalog->aggregate($sentinel));
|
||||
self::assertSame('Pending', $this->jobs->items($job)[0]['status']);
|
||||
self::assertArrayNotHasKey('planes/wing_001.stl', $published);
|
||||
}
|
||||
|
||||
public function testImportCreatesJobWithLegacyGuessedSubfolderGrouping(): void
|
||||
@@ -300,6 +374,60 @@ final class ImportPipelineTest extends TestCase
|
||||
self::assertCount(1, $this->catalog->aggregate((int) $item['model_id'])['files']);
|
||||
}
|
||||
|
||||
public function testSuffixIntentIsCommittedBeforeLinkAndRecoveryNeedsNoSource(): void
|
||||
{
|
||||
$source = $this->writeStl('planes/wing.stl');
|
||||
$job = $this->service()->import($this->source);
|
||||
mkdir($this->library . '/planes');
|
||||
file_put_contents($this->library . '/planes/wing.stl', 'occupied');
|
||||
$observed = null;
|
||||
$this->worker(null, ['link' => function (string $temp, string $dest) use ($job, &$observed): bool {
|
||||
$reader = RepositoryDatabase::connect($this->database);
|
||||
$observed = $reader->query('SELECT managed_path FROM import_items WHERE job_id = ' . $job)->fetchColumn();
|
||||
return link($temp, $dest);
|
||||
}])->runOnce();
|
||||
self::assertSame('planes/wing_001.stl', $observed);
|
||||
$this->pdo->exec("UPDATE import_items SET status = 'Copying', completed_at = NULL");
|
||||
$this->pdo->exec("UPDATE import_jobs SET status = 'Copying', processed_files = 0");
|
||||
$this->pdo->exec('DELETE FROM files');
|
||||
unlink($source);
|
||||
$this->worker()->runOnce();
|
||||
self::assertSame('Completed', $this->jobs->items($job)[0]['status']);
|
||||
self::assertFileDoesNotExist($this->library . '/planes/wing_002.stl');
|
||||
}
|
||||
|
||||
public function testPublicationRaceFailsWithoutSilentSuffix(): void
|
||||
{
|
||||
$this->writeStl('planes/wing.stl');
|
||||
$job = $this->service()->import($this->source);
|
||||
$this->worker(null, ['link' => static function (string $temp, string $dest): bool {
|
||||
file_put_contents($dest, 'race winner');
|
||||
return false;
|
||||
}])->runOnce();
|
||||
self::assertSame('Failed', $this->jobs->items($job)[0]['status']);
|
||||
self::assertSame('race winner', file_get_contents($this->library . '/planes/wing.stl'));
|
||||
self::assertFileDoesNotExist($this->library . '/planes/wing_001.stl');
|
||||
}
|
||||
|
||||
public function testAdoptedBytesSurviveCompletionFailure(): void
|
||||
{
|
||||
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
|
||||
self::markTestSkipped('SQLite failure-injection trigger.');
|
||||
}
|
||||
$source = $this->writeStl('planes/wing.stl');
|
||||
$job = $this->service()->import($this->source);
|
||||
$item = $this->jobs->items($job)[0];
|
||||
$this->jobs->planItem((int) $item['id'], 'planes/wing.stl', hash_file('sha256', $source), filesize($source));
|
||||
mkdir($this->library . '/planes');
|
||||
copy($source, $this->library . '/planes/wing.stl');
|
||||
$this->pdo->exec("CREATE TRIGGER reject_adoption BEFORE UPDATE ON import_jobs"
|
||||
. " WHEN NEW.status = 'Completed' BEGIN SELECT RAISE(ABORT, 'completion failure'); END");
|
||||
$this->worker()->runOnce();
|
||||
self::assertSame('Failed', $this->jobs->items($job)[0]['status']);
|
||||
self::assertSame(file_get_contents($source), file_get_contents($this->library . '/planes/wing.stl'));
|
||||
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn());
|
||||
}
|
||||
|
||||
public function testGracefulStopLeavesJobResumable(): void
|
||||
{
|
||||
$this->writeStl('planes/wing.stl');
|
||||
|
||||
Reference in new issue
Block a user