diff --git a/Neos.ContentRepositoryRegistry/Classes/Command/NeosBetaMigrationCommandController.php b/Neos.ContentRepositoryRegistry/Classes/Command/NeosBetaMigrationCommandController.php new file mode 100644 index 00000000000..aa2ff5c65f1 --- /dev/null +++ b/Neos.ContentRepositoryRegistry/Classes/Command/NeosBetaMigrationCommandController.php @@ -0,0 +1,152 @@ +contentRepositoryRegistry->get($contentRepositoryId); + + $this->backup($contentRepositoryId); + + + $progressBar = new ProgressBar($this->output->getOutput()); + + $progressBar->start($this->highestSequenceNumber($contentRepositoryId)->value, 1); + $options = CatchUpOptions::create(progressCallback: fn () => $progressBar->advance()); + + if ($resetProjection) { + $contentRepository->resetProjectionState(DoctrineDbalContentGraphProjection::class); + } + + $automaticRemovedSequenceNumbers = []; + $manualRemovedSequenceNumbers = []; + + do { + try { + $contentRepository->catchUpProjection(DoctrineDbalContentGraphProjection::class, $options); + } catch (\Throwable $e) { + $this->outputLine(); + + if (preg_match('/^Exception while catching up to sequence number (\d+)/', $e->getMessage(), $matches) !== 1) { + $this->outputLine('Could not replay because of unexpected error'); + $this->outputLine('Removed %d other events: %s', [count($automaticRemovedSequenceNumbers), join(', ', $automaticRemovedSequenceNumbers)]); + throw $e; + } + $failedSequenceNumber = SequenceNumber::fromInteger((int)$matches[1]); + + $eventRow = $this->getEventEnvelopeData($failedSequenceNumber, $contentRepositoryId); + + if ($eventRow['metadata'] !== null) { + $this->outputLine('Did not delete event %s because it doesnt seem to be auto generated', [$failedSequenceNumber->value]); + $this->outputLine('The exception: %s', [$e->getMessage()]); + $this->outputLine(json_encode($eventRow, JSON_PRETTY_PRINT)); + $this->outputLine(); + if ($this->output->askConfirmation(sprintf('> still delete it %d? (y/n) ', $failedSequenceNumber->value), false)) { + $manualRemovedSequenceNumbers[] = $failedSequenceNumber->value; + $this->deleteEvent($failedSequenceNumber, $contentRepositoryId); + continue; + } + $this->outputLine('Removed %d other events: %s', [count($automaticRemovedSequenceNumbers), join(', ', $automaticRemovedSequenceNumbers)]); + throw $e; + } + + $this->outputLine('Deleted event %s because it seems to be invalid and auto generated', [$failedSequenceNumber->value]); + $this->outputLine(json_encode($eventRow)); + + $automaticRemovedSequenceNumbers[] = $failedSequenceNumber->value; + $this->deleteEvent($failedSequenceNumber, $contentRepositoryId); + + $this->outputLine(); + continue; + } + + $progressBar->finish(); + + $this->outputLine(); + $this->outputLine('Replay was successfully.'); + $this->outputLine('Removed %d automatic events: %s', [count($automaticRemovedSequenceNumbers), join(', ', $automaticRemovedSequenceNumbers)]); + if ($manualRemovedSequenceNumbers) { + $this->outputLine('Also removed %d events manually: %s', [count($manualRemovedSequenceNumbers), join(', ', $manualRemovedSequenceNumbers)]); + } + + return; + + } while (true); + } + + public function highestSequenceNumber(ContentRepositoryId $contentRepositoryId): SequenceNumber + { + $eventTableName = DoctrineEventStoreFactory::databaseTableName($contentRepositoryId); + return SequenceNumber::fromInteger((int)$this->connection->fetchOne( + 'SELECT sequencenumber FROM ' . $eventTableName . ' ORDER BY sequencenumber ASC' + )); + } + + + private function backup(ContentRepositoryId $contentRepositoryId): void + { + $backupEventTableName = DoctrineEventStoreFactory::databaseTableName($contentRepositoryId) + . '_bkp_' . date('Y_m_d_H_i_s'); + $this->copyEventTable($backupEventTableName, $contentRepositoryId); + $this->outputLine(sprintf('Copied events table to %s', $backupEventTableName)); + } + + /** + * @return array + */ + private function getEventEnvelopeData(SequenceNumber $sequenceNumber, ContentRepositoryId $contentRepositoryId): array + { + $eventTableName = DoctrineEventStoreFactory::databaseTableName($contentRepositoryId); + return $this->connection->fetchAssociative( + 'SELECT * FROM ' . $eventTableName . ' WHERE sequencenumber=:sequenceNumber', + [ + 'sequenceNumber' => $sequenceNumber->value, + ] + ); + } + + private function deleteEvent(SequenceNumber $sequenceNumber, ContentRepositoryId $contentRepositoryId): void + { + $eventTableName = DoctrineEventStoreFactory::databaseTableName($contentRepositoryId); + $this->connection->beginTransaction(); + $this->connection->executeStatement( + 'DELETE FROM ' . $eventTableName . ' WHERE sequencenumber=:sequenceNumber', + [ + 'sequenceNumber' => $sequenceNumber->value + ] + ); + $this->connection->commit(); + } + + private function copyEventTable(string $backupEventTableName, ContentRepositoryId $contentRepositoryId): void + { + $eventTableName = DoctrineEventStoreFactory::databaseTableName($contentRepositoryId); + $this->connection->executeStatement( + 'CREATE TABLE ' . $backupEventTableName . ' AS + SELECT * + FROM ' . $eventTableName + ); + } +}