diff --git a/resources/sql/autopatches/20190412.dashboard.13.rebuild.php b/resources/sql/autopatches/20190412.dashboard.13.rebuild.php index ed32a9137f..04b8d0e275 100644 --- a/resources/sql/autopatches/20190412.dashboard.13.rebuild.php +++ b/resources/sql/autopatches/20190412.dashboard.13.rebuild.php @@ -1,7 +1,9 @@ key = $dict['key']; $this->type = $dict['type']; $this->fullKey = $dict['fullKey']; $this->legacy = $dict['legacy']; $this->name = $dict['name']; $this->after = $dict['after']; $this->dead = $dict['dead']; + $this->phase = $dict['phase']; } public function getLegacy() { return $this->legacy; } public function getAfter() { return $this->after; } public function getType() { return $this->type; } public function getName() { return $this->name; } public function getFullKey() { return $this->fullKey; } public function getKey() { return $this->key; } + public function getPhase() { + return $this->phase; + } + public function isDead() { return $this->dead; } public function getIsGlobalPatch() { return ($this->getType() == 'php'); } + public static function getPhaseList() { + return array_keys(self::getPhaseMap()); + } + + public static function getDefaultPhase() { + return self::PHASE_DEFAULT; + } + + private static function getPhaseMap() { + return array( + self::PHASE_DEFAULT => array( + 'order' => 0, + ), + self::PHASE_WORKER => array( + 'order' => 1, + ), + ); + } + + public function newSortVector() { + $map = self::getPhaseMap(); + $phase = $this->getPhase(); + + return id(new PhutilSortVector()) + ->addInt($map[$phase]['order']); + } + } diff --git a/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementStatusWorkflow.php b/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementStatusWorkflow.php index 88174d22ad..558b18a0c3 100644 --- a/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementStatusWorkflow.php +++ b/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementStatusWorkflow.php @@ -1,69 +1,76 @@ setName('status') ->setExamples('**status** [__options__]') ->setSynopsis(pht('Show patch application status.')); } protected function isReadOnlyWorkflow() { return true; } public function didExecute(PhutilArgumentParser $args) { foreach ($this->getAPIs() as $api) { $patches = $this->getPatches(); $applied = $api->getAppliedPatches(); if ($applied === null) { echo phutil_console_format( "**%s**: %s\n", pht('Database Not Initialized'), pht('Run **%s** to initialize.', './bin/storage upgrade')); return 1; } $ref = $api->getRef(); $table = id(new PhutilConsoleTable()) ->setShowHeader(false) ->addColumn('id', array('title' => pht('ID'))) + ->addColumn('phase', array('title' => pht('Phase'))) ->addColumn('host', array('title' => pht('Host'))) ->addColumn('status', array('title' => pht('Status'))) ->addColumn('duration', array('title' => pht('Duration'))) ->addColumn('type', array('title' => pht('Type'))) ->addColumn('name', array('title' => pht('Name'))); $durations = $api->getPatchDurations(); foreach ($patches as $patch) { $duration = idx($durations, $patch->getFullKey()); if ($duration === null) { $duration = '-'; } else { $duration = pht('%s us', new PhutilNumber($duration)); } - $table->addRow(array( - 'id' => $patch->getFullKey(), - 'host' => $ref->getRefKey(), - 'status' => in_array($patch->getFullKey(), $applied) - ? pht('Applied') - : pht('Not Applied'), - 'duration' => $duration, - 'type' => $patch->getType(), - 'name' => $patch->getName(), - )); + if (in_array($patch->getFullKey(), $applied)) { + $status = pht('Applied'); + } else { + $status = pht('Not Applied'); + } + + $table->addRow( + array( + 'id' => $patch->getFullKey(), + 'phase' => $patch->getPhase(), + 'host' => $ref->getRefKey(), + 'status' => $status, + 'duration' => $duration, + 'type' => $patch->getType(), + 'name' => $patch->getName(), + )); } $table->draw(); } return 0; } } diff --git a/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php b/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php index acbbb4fbda..cc2073d8c9 100644 --- a/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php +++ b/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php @@ -1,1283 +1,1287 @@ apis = $apis; return $this; } final public function getAnyAPI() { return head($this->getAPIs()); } final public function getMasterAPIs() { $apis = $this->getAPIs(); $results = array(); foreach ($apis as $api) { if ($api->getRef()->getIsMaster()) { $results[] = $api; } } if (!$results) { throw new PhutilArgumentUsageException( pht( 'This command only operates on database masters, but the selected '. 'database hosts do not include any masters.')); } return $results; } final public function getSingleAPI() { $apis = $this->getAPIs(); if (count($apis) == 1) { return head($apis); } throw new PhutilArgumentUsageException( pht( 'Phabricator is configured in cluster mode, with multiple database '. 'hosts. Use "--host" to specify which host you want to operate on.')); } final public function getAPIs() { return $this->apis; } final protected function isDryRun() { return $this->dryRun; } final protected function setDryRun($dry_run) { $this->dryRun = $dry_run; return $this; } final protected function isForce() { return $this->force; } final protected function setForce($force) { $this->force = $force; return $this; } public function getPatches() { return $this->patches; } public function setPatches(array $patches) { assert_instances_of($patches, 'PhabricatorStoragePatch'); $this->patches = $patches; return $this; } protected function isReadOnlyWorkflow() { return false; } public function execute(PhutilArgumentParser $args) { $this->setDryRun($args->getArg('dryrun')); $this->setForce($args->getArg('force')); if (!$this->isReadOnlyWorkflow()) { if (PhabricatorEnv::isReadOnly()) { if ($this->isForce()) { PhabricatorEnv::setReadOnly(false, null); } else { throw new PhutilArgumentUsageException( pht( 'Phabricator is currently in read-only mode. Use --force to '. 'override this mode.')); } } } return $this->didExecute($args); } public function didExecute(PhutilArgumentParser $args) {} private function loadSchemata(PhabricatorStorageManagementAPI $api) { $query = id(new PhabricatorConfigSchemaQuery()); $ref = $api->getRef(); $ref_key = $ref->getRefKey(); $query->setAPIs(array($api)); $query->setRefs(array($ref)); $actual = $query->loadActualSchemata(); $expect = $query->loadExpectedSchemata(); $comp = $query->buildComparisonSchemata($expect, $actual); return array( $comp[$ref_key], $expect[$ref_key], $actual[$ref_key], ); } final protected function adjustSchemata( PhabricatorStorageManagementAPI $api, $unsafe) { $lock = $this->lock($api); try { $err = $this->doAdjustSchemata($api, $unsafe); // Analyze tables if we're not doing a dry run and adjustments are either // all clear or have minor errors like surplus tables. if (!$this->dryRun) { $should_analyze = (($err == 0) || ($err == 2)); if ($should_analyze) { $this->analyzeTables($api); } } } catch (Exception $ex) { $lock->unlock(); throw $ex; } $lock->unlock(); return $err; } final private function doAdjustSchemata( PhabricatorStorageManagementAPI $api, $unsafe) { $console = PhutilConsole::getConsole(); $console->writeOut( "%s\n", pht( 'Verifying database schemata on "%s"...', $api->getRef()->getRefKey())); list($adjustments, $errors) = $this->findAdjustments($api); if (!$adjustments) { $console->writeOut( "%s\n", pht('Found no adjustments for schemata.')); return $this->printErrors($errors, 0); } if (!$this->force && !$api->isCharacterSetAvailable('utf8mb4')) { $message = pht( "You have an old version of MySQL (older than 5.5) which does not ". "support the utf8mb4 character set. We strongly recommend upgrading ". "to 5.5 or newer.\n\n". "If you apply adjustments now and later update MySQL to 5.5 or newer, ". "you'll need to apply adjustments again (and they will take a long ". "time).\n\n". "You can exit this workflow, update MySQL now, and then run this ". "workflow again. This is recommended, but may cause a lot of downtime ". "right now.\n\n". "You can exit this workflow, continue using Phabricator without ". "applying adjustments, update MySQL at a later date, and then run ". "this workflow again. This is also a good approach, and will let you ". "delay downtime until later.\n\n". "You can proceed with this workflow, and then optionally update ". "MySQL at a later date. After you do, you'll need to apply ". "adjustments again.\n\n". "For more information, see \"Managing Storage Adjustments\" in ". "the documentation."); $console->writeOut( "\n** %s **\n\n%s\n", pht('OLD MySQL VERSION'), phutil_console_wrap($message)); $prompt = pht('Continue with old MySQL version?'); if (!phutil_console_confirm($prompt, $default_no = true)) { return; } } $table = id(new PhutilConsoleTable()) ->addColumn('database', array('title' => pht('Database'))) ->addColumn('table', array('title' => pht('Table'))) ->addColumn('name', array('title' => pht('Name'))) ->addColumn('info', array('title' => pht('Issues'))); foreach ($adjustments as $adjust) { $info = array(); foreach ($adjust['issues'] as $issue) { $info[] = PhabricatorConfigStorageSchema::getIssueName($issue); } $table->addRow(array( 'database' => $adjust['database'], 'table' => idx($adjust, 'table'), 'name' => idx($adjust, 'name'), 'info' => implode(', ', $info), )); } $console->writeOut("\n\n"); $table->draw(); if ($this->dryRun) { $console->writeOut( "%s\n", pht('DRYRUN: Would apply adjustments.')); return 0; } else if ($this->didInitialize) { // If we just initialized the database, continue without prompting. This // is nicer for first-time setup and there's no reasonable reason any // user would ever answer "no" to the prompt against an empty schema. } else if (!$this->force) { $console->writeOut( "\n%s\n", pht( "Found %s adjustment(s) to apply, detailed above.\n\n". "You can review adjustments in more detail from the web interface, ". "in Config > Database Status. To better understand the adjustment ". "workflow, see \"Managing Storage Adjustments\" in the ". "documentation.\n\n". "MySQL needs to copy table data to make some adjustments, so these ". "migrations may take some time.", phutil_count($adjustments))); $prompt = pht('Apply these schema adjustments?'); if (!phutil_console_confirm($prompt, $default_no = true)) { return 1; } } $console->writeOut( "%s\n", pht('Applying schema adjustments...')); $conn = $api->getConn(null); if ($unsafe) { queryfx($conn, 'SET SESSION sql_mode = %s', ''); } else { queryfx($conn, 'SET SESSION sql_mode = %s', 'STRICT_ALL_TABLES'); } $failed = array(); // We make changes in several phases. $phases = array( // Drop surplus autoincrements. This allows us to drop primary keys on // autoincrement columns. 'drop_auto', // Drop all keys we're going to adjust. This prevents them from // interfering with column changes. 'drop_keys', // Apply all database, table, and column changes. 'main', // Restore adjusted keys. 'add_keys', // Add missing autoincrements. 'add_auto', ); $bar = id(new PhutilConsoleProgressBar()) ->setTotal(count($adjustments) * count($phases)); foreach ($phases as $phase) { foreach ($adjustments as $adjust) { try { switch ($adjust['kind']) { case 'database': if ($phase == 'main') { queryfx( $conn, 'ALTER DATABASE %T CHARACTER SET = %s COLLATE = %s', $adjust['database'], $adjust['charset'], $adjust['collation']); } break; case 'table': if ($phase == 'main') { queryfx( $conn, 'ALTER TABLE %T.%T COLLATE = %s, ENGINE = %s', $adjust['database'], $adjust['table'], $adjust['collation'], $adjust['engine']); } break; case 'column': $apply = false; $auto = false; $new_auto = idx($adjust, 'auto'); if ($phase == 'drop_auto') { if ($new_auto === false) { $apply = true; $auto = false; } } else if ($phase == 'main') { $apply = true; if ($new_auto === false) { $auto = false; } else { $auto = $adjust['is_auto']; } } else if ($phase == 'add_auto') { if ($new_auto === true) { $apply = true; $auto = true; } } if ($apply) { $parts = array(); if ($auto) { $parts[] = qsprintf( $conn, 'AUTO_INCREMENT'); } if ($adjust['charset']) { switch ($adjust['charset']) { case 'binary': $charset_value = qsprintf($conn, 'binary'); break; case 'utf8': $charset_value = qsprintf($conn, 'utf8'); break; case 'utf8mb4': $charset_value = qsprintf($conn, 'utf8mb4'); break; default: throw new Exception( pht( 'Unsupported character set "%s".', $adjust['charset'])); } switch ($adjust['collation']) { case 'binary': $collation_value = qsprintf($conn, 'binary'); break; case 'utf8_general_ci': $collation_value = qsprintf($conn, 'utf8_general_ci'); break; case 'utf8mb4_bin': $collation_value = qsprintf($conn, 'utf8mb4_bin'); break; case 'utf8mb4_unicode_ci': $collation_value = qsprintf($conn, 'utf8mb4_unicode_ci'); break; default: throw new Exception( pht( 'Unsupported collation set "%s".', $adjust['collation'])); } $parts[] = qsprintf( $conn, 'CHARACTER SET %Q COLLATE %Q', $charset_value, $collation_value); } if ($parts) { $parts = qsprintf($conn, '%LJ', $parts); } else { $parts = qsprintf($conn, ''); } if ($adjust['nullable']) { $nullable = qsprintf($conn, 'NULL'); } else { $nullable = qsprintf($conn, 'NOT NULL'); } // TODO: We're using "%Z" here for the column type, which is // technically unsafe. It would be nice to be able to use "%Q" // instead, but this requires a fair amount of legwork to // enumerate all column types. queryfx( $conn, 'ALTER TABLE %T.%T MODIFY %T %Z %Q %Q', $adjust['database'], $adjust['table'], $adjust['name'], $adjust['type'], $parts, $nullable); } break; case 'key': if (($phase == 'drop_keys') && $adjust['exists']) { if ($adjust['name'] == 'PRIMARY') { $key_name = qsprintf($conn, 'PRIMARY KEY'); } else { $key_name = qsprintf($conn, 'KEY %T', $adjust['name']); } queryfx( $conn, 'ALTER TABLE %T.%T DROP %Q', $adjust['database'], $adjust['table'], $key_name); } if (($phase == 'add_keys') && $adjust['keep']) { // Different keys need different creation syntax. Notable // special cases are primary keys and fulltext keys. if ($adjust['name'] == 'PRIMARY') { $key_name = qsprintf($conn, 'PRIMARY KEY'); } else if ($adjust['indexType'] == 'FULLTEXT') { $key_name = qsprintf($conn, 'FULLTEXT %T', $adjust['name']); } else { if ($adjust['unique']) { $key_name = qsprintf( $conn, 'UNIQUE KEY %T', $adjust['name']); } else { $key_name = qsprintf( $conn, '/* NONUNIQUE */ KEY %T', $adjust['name']); } } queryfx( $conn, 'ALTER TABLE %T.%T ADD %Q (%LK)', $adjust['database'], $adjust['table'], $key_name, $adjust['columns']); } break; default: throw new Exception( pht('Unknown schema adjustment kind "%s"!', $adjust['kind'])); } } catch (AphrontQueryException $ex) { $failed[] = array($adjust, $ex); } $bar->update(1); } } $bar->done(); if (!$failed) { $console->writeOut( "%s\n", pht('Completed applying all schema adjustments.')); $err = 0; } else { $table = id(new PhutilConsoleTable()) ->addColumn('target', array('title' => pht('Target'))) ->addColumn('error', array('title' => pht('Error'))); foreach ($failed as $failure) { list($adjust, $ex) = $failure; $pieces = array_select_keys( $adjust, array('database', 'table', 'name')); $pieces = array_filter($pieces); $target = implode('.', $pieces); $table->addRow( array( 'target' => $target, 'error' => $ex->getMessage(), )); } $console->writeOut("\n"); $table->draw(); $console->writeOut( "\n%s\n", pht('Failed to make some schema adjustments, detailed above.')); $console->writeOut( "%s\n", pht( 'For help troubleshooting adjustments, see "Managing Storage '. 'Adjustments" in the documentation.')); $err = 1; } return $this->printErrors($errors, $err); } private function findAdjustments( PhabricatorStorageManagementAPI $api) { list($comp, $expect, $actual) = $this->loadSchemata($api); $issue_charset = PhabricatorConfigStorageSchema::ISSUE_CHARSET; $issue_collation = PhabricatorConfigStorageSchema::ISSUE_COLLATION; $issue_columntype = PhabricatorConfigStorageSchema::ISSUE_COLUMNTYPE; $issue_surpluskey = PhabricatorConfigStorageSchema::ISSUE_SURPLUSKEY; $issue_missingkey = PhabricatorConfigStorageSchema::ISSUE_MISSINGKEY; $issue_columns = PhabricatorConfigStorageSchema::ISSUE_KEYCOLUMNS; $issue_unique = PhabricatorConfigStorageSchema::ISSUE_UNIQUE; $issue_longkey = PhabricatorConfigStorageSchema::ISSUE_LONGKEY; $issue_auto = PhabricatorConfigStorageSchema::ISSUE_AUTOINCREMENT; $issue_engine = PhabricatorConfigStorageSchema::ISSUE_ENGINE; $adjustments = array(); $errors = array(); foreach ($comp->getDatabases() as $database_name => $database) { foreach ($this->findErrors($database) as $issue) { $errors[] = array( 'database' => $database_name, 'issue' => $issue, ); } $expect_database = $expect->getDatabase($database_name); $actual_database = $actual->getDatabase($database_name); if (!$expect_database || !$actual_database) { // If there's a real issue here, skip this stuff. continue; } if ($actual_database->getAccessDenied()) { // If we can't access the database, we can't access the tables either. continue; } $issues = array(); if ($database->hasIssue($issue_charset)) { $issues[] = $issue_charset; } if ($database->hasIssue($issue_collation)) { $issues[] = $issue_collation; } if ($issues) { $adjustments[] = array( 'kind' => 'database', 'database' => $database_name, 'issues' => $issues, 'charset' => $expect_database->getCharacterSet(), 'collation' => $expect_database->getCollation(), ); } foreach ($database->getTables() as $table_name => $table) { foreach ($this->findErrors($table) as $issue) { $errors[] = array( 'database' => $database_name, 'table' => $table_name, 'issue' => $issue, ); } $expect_table = $expect_database->getTable($table_name); $actual_table = $actual_database->getTable($table_name); if (!$expect_table || !$actual_table) { continue; } $issues = array(); if ($table->hasIssue($issue_collation)) { $issues[] = $issue_collation; } if ($table->hasIssue($issue_engine)) { $issues[] = $issue_engine; } if ($issues) { $adjustments[] = array( 'kind' => 'table', 'database' => $database_name, 'table' => $table_name, 'issues' => $issues, 'collation' => $expect_table->getCollation(), 'engine' => $expect_table->getEngine(), ); } foreach ($table->getColumns() as $column_name => $column) { foreach ($this->findErrors($column) as $issue) { $errors[] = array( 'database' => $database_name, 'table' => $table_name, 'name' => $column_name, 'issue' => $issue, ); } $expect_column = $expect_table->getColumn($column_name); $actual_column = $actual_table->getColumn($column_name); if (!$expect_column || !$actual_column) { continue; } $issues = array(); if ($column->hasIssue($issue_collation)) { $issues[] = $issue_collation; } if ($column->hasIssue($issue_charset)) { $issues[] = $issue_charset; } if ($column->hasIssue($issue_columntype)) { $issues[] = $issue_columntype; } if ($column->hasIssue($issue_auto)) { $issues[] = $issue_auto; } if ($issues) { if ($expect_column->getCharacterSet() === null) { // For non-text columns, we won't be specifying a collation or // character set. $charset = null; $collation = null; } else { $charset = $expect_column->getCharacterSet(); $collation = $expect_column->getCollation(); } $adjustment = array( 'kind' => 'column', 'database' => $database_name, 'table' => $table_name, 'name' => $column_name, 'issues' => $issues, 'collation' => $collation, 'charset' => $charset, 'type' => $expect_column->getColumnType(), // NOTE: We don't adjust column nullability because it is // dangerous, so always use the current nullability. 'nullable' => $actual_column->getNullable(), // NOTE: This always stores the current value, because we have // to make these updates separately. 'is_auto' => $actual_column->getAutoIncrement(), ); if ($column->hasIssue($issue_auto)) { $adjustment['auto'] = $expect_column->getAutoIncrement(); } $adjustments[] = $adjustment; } } foreach ($table->getKeys() as $key_name => $key) { foreach ($this->findErrors($key) as $issue) { $errors[] = array( 'database' => $database_name, 'table' => $table_name, 'name' => $key_name, 'issue' => $issue, ); } $expect_key = $expect_table->getKey($key_name); $actual_key = $actual_table->getKey($key_name); $issues = array(); $keep_key = true; if ($key->hasIssue($issue_surpluskey)) { $issues[] = $issue_surpluskey; $keep_key = false; } if ($key->hasIssue($issue_missingkey)) { $issues[] = $issue_missingkey; } if ($key->hasIssue($issue_columns)) { $issues[] = $issue_columns; } if ($key->hasIssue($issue_unique)) { $issues[] = $issue_unique; } // NOTE: We can't really fix this, per se, but we may need to remove // the key to change the column type. In the best case, the new // column type won't be overlong and recreating the key really will // fix the issue. In the worst case, we get the right column type and // lose the key, which is still better than retaining the key having // the wrong column type. if ($key->hasIssue($issue_longkey)) { $issues[] = $issue_longkey; } if ($issues) { $adjustment = array( 'kind' => 'key', 'database' => $database_name, 'table' => $table_name, 'name' => $key_name, 'issues' => $issues, 'exists' => (bool)$actual_key, 'keep' => $keep_key, ); if ($keep_key) { $adjustment += array( 'columns' => $expect_key->getColumnNames(), 'unique' => $expect_key->getUnique(), 'indexType' => $expect_key->getIndexType(), ); } $adjustments[] = $adjustment; } } } } return array($adjustments, $errors); } private function findErrors(PhabricatorConfigStorageSchema $schema) { $result = array(); foreach ($schema->getLocalIssues() as $issue) { $status = PhabricatorConfigStorageSchema::getIssueStatus($issue); if ($status == PhabricatorConfigStorageSchema::STATUS_FAIL) { $result[] = $issue; } } return $result; } private function printErrors(array $errors, $default_return) { if (!$errors) { return $default_return; } $console = PhutilConsole::getConsole(); $table = id(new PhutilConsoleTable()) ->addColumn('target', array('title' => pht('Target'))) ->addColumn('error', array('title' => pht('Error'))); $any_surplus = false; $all_surplus = true; $any_access = false; $all_access = true; foreach ($errors as $error) { $pieces = array_select_keys( $error, array('database', 'table', 'name')); $pieces = array_filter($pieces); $target = implode('.', $pieces); $name = PhabricatorConfigStorageSchema::getIssueName($error['issue']); $issue = $error['issue']; if ($issue === PhabricatorConfigStorageSchema::ISSUE_SURPLUS) { $any_surplus = true; } else { $all_surplus = false; } if ($issue === PhabricatorConfigStorageSchema::ISSUE_ACCESSDENIED) { $any_access = true; } else { $all_access = false; } $table->addRow( array( 'target' => $target, 'error' => $name, )); } $console->writeOut("\n"); $table->draw(); $console->writeOut("\n"); $message = array(); if ($all_surplus) { $message[] = pht( 'You have surplus schemata (extra tables or columns which Phabricator '. 'does not expect). For information on resolving these '. 'issues, see the "Surplus Schemata" section in the "Managing Storage '. 'Adjustments" article in the documentation.'); } else if ($all_access) { $message[] = pht( 'The user you are connecting to MySQL with does not have the correct '. 'permissions, and can not access some databases or tables that it '. 'needs to be able to access. GRANT the user additional permissions.'); } else { $message[] = pht( 'The schemata have errors (detailed above) which the adjustment '. 'workflow can not fix.'); if ($any_access) { $message[] = pht( 'Some of these errors are caused by access control problems. '. 'The user you are connecting with does not have permission to see '. 'all of the database or tables that Phabricator uses. You need to '. 'GRANT the user more permission, or use a different user.'); } if ($any_surplus) { $message[] = pht( 'Some of these errors are caused by surplus schemata (extra '. 'tables or columns which Phabricator does not expect). These are '. 'not serious. For information on resolving these issues, see the '. '"Surplus Schemata" section in the "Managing Storage Adjustments" '. 'article in the documentation.'); } $message[] = pht( 'If you are not developing Phabricator itself, report this issue to '. 'the upstream.'); $message[] = pht( 'If you are developing Phabricator, these errors usually indicate '. 'that your schema specifications do not agree with the schemata your '. 'code actually builds.'); } $message = implode("\n\n", $message); if ($all_surplus) { $console->writeOut( "** %s **\n\n%s\n", pht('SURPLUS SCHEMATA'), phutil_console_wrap($message)); } else if ($all_access) { $console->writeOut( "** %s **\n\n%s\n", pht('ACCESS DENIED'), phutil_console_wrap($message)); } else { $console->writeOut( "** %s **\n\n%s\n", pht('SCHEMATA ERRORS'), phutil_console_wrap($message)); } return 2; } final protected function upgradeSchemata( array $apis, $apply_only = null, $no_quickstart = false, $init_only = false) { $locks = array(); foreach ($apis as $api) { $locks[] = $this->lock($api); } try { $this->doUpgradeSchemata($apis, $apply_only, $no_quickstart, $init_only); } catch (Exception $ex) { foreach ($locks as $lock) { $lock->unlock(); } throw $ex; } foreach ($locks as $lock) { $lock->unlock(); } } final private function doUpgradeSchemata( array $apis, $apply_only, $no_quickstart, $init_only) { $patches = $this->patches; $is_dryrun = $this->dryRun; + // We expect that patches should already be sorted properly. However, + // phase behavior will be wrong if they aren't, so make sure. + $patches = msortv($patches, 'newSortVector'); + $api_map = array(); foreach ($apis as $api) { $api_map[$api->getRef()->getRefKey()] = $api; } foreach ($api_map as $ref_key => $api) { $applied = $api->getAppliedPatches(); $needs_init = ($applied === null); if (!$needs_init) { continue; } if ($is_dryrun) { echo tsprintf( "%s\n", pht( 'DRYRUN: Storage on host "%s" does not exist yet, so it '. 'would be created.', $ref_key)); continue; } if ($apply_only) { throw new PhutilArgumentUsageException( pht( 'Storage on host "%s" has not been initialized yet. You must '. 'initialize storage before selectively applying patches.', $ref_key)); } // If we're initializing storage for the first time on any host, track // it so that we can give the user a nicer experience during the // subsequent adjustment phase. $this->didInitialize = true; $legacy = $api->getLegacyPatches($patches); if ($legacy || $no_quickstart || $init_only) { // If we have legacy patches, we can't quickstart. $api->createDatabase('meta_data'); $api->createTable( 'meta_data', 'patch_status', array( 'patch VARCHAR(255) NOT NULL PRIMARY KEY COLLATE utf8_general_ci', 'applied INT UNSIGNED NOT NULL', )); foreach ($legacy as $patch) { $api->markPatchApplied($patch); } } else { echo tsprintf( "%s\n", pht( 'Loading quickstart template onto "%s"...', $ref_key)); $root = dirname(phutil_get_library_root('phabricator')); $sql = $root.'/resources/sql/quickstart.sql'; $api->applyPatchSQL($sql); } } if ($init_only) { echo pht('Storage initialized.')."\n"; return 0; } $applied_map = array(); $state_map = array(); foreach ($api_map as $ref_key => $api) { $applied = $api->getAppliedPatches(); // If we still have nothing applied, this is a dry run and we didn't // actually initialize storage. Here, just do nothing. if ($applied === null) { if ($is_dryrun) { continue; } else { throw new Exception( pht( 'Database initialization on host "%s" applied no patches!', $ref_key)); } } $applied = array_fuse($applied); $state_map[$ref_key] = $applied; if ($apply_only) { if (isset($applied[$apply_only])) { if (!$this->force && !$is_dryrun) { echo phutil_console_wrap( pht( 'Patch "%s" has already been applied on host "%s". Are you '. 'sure you want to apply it again? This may put your storage '. 'in a state that the upgrade scripts can not automatically '. 'manage.', $apply_only, $ref_key)); if (!phutil_console_confirm(pht('Apply patch again?'))) { echo pht('Cancelled.')."\n"; return 1; } } // Mark this patch as not yet applied on this host. unset($applied[$apply_only]); } } $applied_map[$ref_key] = $applied; } // If we're applying only a specific patch, select just that patch. if ($apply_only) { $patches = array_select_keys($patches, array($apply_only)); } // Apply each patch to each database. We apply patches patch-by-patch, // not database-by-database: for each patch we apply it to every database, // then move to the next patch. // We must do this because ".php" patches may depend on ".sql" patches // being up to date on all masters, and that will work fine if we put each // patch on every host before moving on. If we try to bring database hosts // up to date one at a time we can end up in a big mess. $duration_map = array(); // First, find any global patches which have been applied to ANY database. // We are just going to mark these as applied without actually running // them. Otherwise, adding new empty masters to an existing cluster will // try to apply them against invalid states. foreach ($patches as $key => $patch) { if ($patch->getIsGlobalPatch()) { foreach ($applied_map as $ref_key => $applied) { if (isset($applied[$key])) { $duration_map[$key] = 1; } } } } while (true) { $applied_something = false; foreach ($patches as $key => $patch) { // First, check if any databases need this patch. We can just skip it // if it has already been applied everywhere. $need_patch = array(); foreach ($applied_map as $ref_key => $applied) { if (isset($applied[$key])) { continue; } $need_patch[] = $ref_key; } if (!$need_patch) { unset($patches[$key]); continue; } // Check if we can apply this patch yet. Before we can apply a patch, // all of the dependencies for the patch must have been applied on all // databases. Requiring that all databases stay in sync prevents one // database from racing ahead if it happens to get a patch that nothing // else has yet. $missing_patch = null; foreach ($patch->getAfter() as $after) { foreach ($applied_map as $ref_key => $applied) { if (isset($applied[$after])) { // This database already has the patch. We can apply it to // other databases but don't need to apply it here. continue; } $missing_patch = $after; break 2; } } if ($missing_patch) { if ($apply_only) { echo tsprintf( "%s\n", pht( 'Unable to apply patch "%s" because it depends on patch '. '"%s", which has not been applied on some hosts: %s.', $apply_only, $missing_patch, implode(', ', $need_patch))); return 1; } else { // Some databases are missing the dependencies, so keep trying // other patches instead. If everything goes right, we'll apply the // dependencies and then come back and apply this patch later. continue; } } $is_global = $patch->getIsGlobalPatch(); $patch_apis = array_select_keys($api_map, $need_patch); foreach ($patch_apis as $ref_key => $api) { if ($is_global) { // If this is a global patch which we previously applied, just // read the duration from the map without actually applying // the patch. $duration = idx($duration_map, $key); } else { $duration = null; } if ($duration === null) { if ($is_dryrun) { echo tsprintf( "%s\n", pht( 'DRYRUN: Would apply patch "%s" to host "%s".', $key, $ref_key)); } else { echo tsprintf( "%s\n", pht( 'Applying patch "%s" to host "%s"...', $key, $ref_key)); } $t_begin = microtime(true); if (!$is_dryrun) { $api->applyPatch($patch); } $t_end = microtime(true); $duration = ($t_end - $t_begin); $duration_map[$key] = $duration; } // If we're explicitly reapplying this patch, we don't need to // mark it as applied. if (!isset($state_map[$ref_key][$key])) { if (!$is_dryrun) { $api->markPatchApplied($key, ($t_end - $t_begin)); } $applied_map[$ref_key][$key] = true; } } // We applied this everywhere, so we're done with the patch. unset($patches[$key]); $applied_something = true; } if (!$applied_something) { if ($patches) { throw new Exception( pht( 'Some patches could not be applied: %s', implode(', ', array_keys($patches)))); } else if (!$is_dryrun && !$apply_only) { echo pht( 'Storage is up to date. Use "%s" for details.', 'storage status')."\n"; } break; } } } final protected function getBareHostAndPort($host) { // Split out port information, since the command-line client requires a // separate flag for the port. $uri = new PhutilURI('mysql://'.$host); if ($uri->getPort()) { $port = $uri->getPort(); $bare_hostname = $uri->getDomain(); } else { $port = null; $bare_hostname = $host; } return array($bare_hostname, $port); } /** * Acquires a @{class:PhabricatorGlobalLock}. * * @return PhabricatorGlobalLock */ final protected function lock(PhabricatorStorageManagementAPI $api) { // Although we're holding this lock on different databases so it could // have the same name on each as far as the database is concerned, the // locks would be the same within this process. $parameters = array( 'refKey' => $api->getRef()->getRefKey(), ); // We disable logging for this lock because we may not have created the // log table yet, or may need to adjust it. return PhabricatorGlobalLock::newLock('adjust', $parameters) ->useSpecificConnection($api->getConn(null)) ->setDisableLogging(true) ->lock(); } final protected function analyzeTables( PhabricatorStorageManagementAPI $api) { // Analyzing tables can sometimes have a significant effect on query // performance, particularly for the fulltext ngrams tables. See T12819 // for some specific examples. $conn = $api->getConn(null); $patches = $this->getPatches(); $databases = $api->getDatabaseList($patches, true); $this->logInfo( pht('ANALYZE'), pht('Analyzing tables...')); $targets = array(); foreach ($databases as $database) { queryfx($conn, 'USE %C', $database); $tables = queryfx_all($conn, 'SHOW TABLE STATUS'); foreach ($tables as $table) { $table_name = $table['Name']; $targets[] = array( 'database' => $database, 'table' => $table_name, ); } } $bar = id(new PhutilConsoleProgressBar()) ->setTotal(count($targets)); foreach ($targets as $target) { queryfx( $conn, 'ANALYZE TABLE %T.%T', $target['database'], $target['table']); $bar->update(1); } $bar->done(); $this->logOkay( pht('ANALYZED'), pht( 'Analyzed %d table(s).', count($targets))); } } diff --git a/src/infrastructure/storage/patch/PhabricatorSQLPatchList.php b/src/infrastructure/storage/patch/PhabricatorSQLPatchList.php index 5373762889..765ddae512 100644 --- a/src/infrastructure/storage/patch/PhabricatorSQLPatchList.php +++ b/src/infrastructure/storage/patch/PhabricatorSQLPatchList.php @@ -1,202 +1,345 @@ getPHPPatchAttributes( + $patch, + $patch_full_path); + } + $patches[$patch] = array( - 'type' => $matches[1], - 'name' => rtrim($directory, '/').'/'.$patch, - ); + 'type' => $patch_type, + 'name' => $patch_full_path, + ) + $attributes; } return $patches; } final public static function buildAllPatches() { $patch_lists = id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->setUniqueMethod('getNamespace') ->execute(); $specs = array(); $seen_namespaces = array(); + $phases = PhabricatorStoragePatch::getPhaseList(); + $phases = array_fuse($phases); + + $default_phase = PhabricatorStoragePatch::getDefaultPhase(); + foreach ($patch_lists as $patch_list) { - $last_key = null; + $last_keys = array_fill_keys( + array_keys($phases), + null); + foreach ($patch_list->getPatches() as $key => $patch) { if (!is_array($patch)) { throw new Exception( pht( "%s '%s' has a patch '%s' which is not an array.", __CLASS__, get_class($patch_list), $key)); } $valid = array( 'type' => true, 'name' => true, 'after' => true, 'legacy' => true, 'dead' => true, + 'phase' => true, ); foreach ($patch as $pkey => $pval) { if (empty($valid[$pkey])) { throw new Exception( pht( "%s '%s' has a patch, '%s', with an unknown property, '%s'.". "Patches must have only valid keys: %s.", __CLASS__, get_class($patch_list), $key, $pkey, implode(', ', array_keys($valid)))); } } if (is_numeric($key)) { throw new Exception( pht( "%s '%s' has a patch with a numeric key, '%s'. ". "Patches must use string keys.", __CLASS__, get_class($patch_list), $key)); } if (strpos($key, ':') !== false) { throw new Exception( pht( "%s '%s' has a patch with a colon in the key name, '%s'. ". "Patch keys may not contain colons.", __CLASS__, get_class($patch_list), $key)); } $namespace = $patch_list->getNamespace(); $full_key = "{$namespace}:{$key}"; if (isset($specs[$full_key])) { throw new Exception( pht( "%s '%s' has a patch '%s' which duplicates an ". "existing patch key.", __CLASS__, get_class($patch_list), $key)); } $patch['key'] = $key; $patch['fullKey'] = $full_key; $patch['dead'] = (bool)idx($patch, 'dead', false); if (isset($patch['legacy'])) { if ($namespace != 'phabricator') { throw new Exception( pht( "Only patches in the '%s' namespace may contain '%s' keys.", 'phabricator', 'legacy')); } } else { $patch['legacy'] = false; } + if (!array_key_exists('phase', $patch)) { + $patch['phase'] = $default_phase; + } + + $patch_phase = $patch['phase']; + + if (!isset($phases[$patch_phase])) { + throw new Exception( + pht( + 'Storage patch "%s" specifies it should apply in phase "%s", '. + 'but this phase is unrecognized. Valid phases are: %s.', + $full_key, + $patch_phase, + implode(', ', array_keys($phases)))); + } + + $last_key = $last_keys[$patch_phase]; + if (!array_key_exists('after', $patch)) { - if ($last_key === null) { + if ($last_key === null && $patch_phase === $default_phase) { throw new Exception( pht( "Patch '%s' is missing key 'after', and is the first patch ". "in the patch list '%s', so its application order can not be ". "determined implicitly. The first patch in a patch list must ". "list the patch or patches it depends on explicitly.", $full_key, get_class($patch_list))); } else { - $patch['after'] = array($last_key); + if ($last_key === null) { + $patch['after'] = array(); + } else { + $patch['after'] = array($last_key); + } } } - $last_key = $full_key; + $last_keys[$patch_phase] = $full_key; foreach ($patch['after'] as $after_key => $after) { if (strpos($after, ':') === false) { $patch['after'][$after_key] = $namespace.':'.$after; } } $type = idx($patch, 'type'); if (!$type) { throw new Exception( pht( "Patch '%s' is missing key '%s'. Every patch must have a type.", "{$namespace}:{$key}", 'type')); } switch ($type) { case 'db': case 'sql': case 'php': break; default: throw new Exception( pht( "Patch '%s' has unknown patch type '%s'.", "{$namespace}:{$key}", $type)); } $specs[$full_key] = $patch; } } foreach ($specs as $key => $patch) { foreach ($patch['after'] as $after) { if (empty($specs[$after])) { throw new Exception( pht( "Patch '%s' references nonexistent dependency, '%s'. ". "Patches may only depend on patches which actually exist.", $key, $after)); } + + $patch_phase = $patch['phase']; + $after_phase = $specs[$after]['phase']; + + if ($patch_phase !== $after_phase) { + throw new Exception( + pht( + 'Storage patch "%s" executes in phase "%s", but depends on '. + 'patch "%s" which is in a different phase ("%s"). Patches '. + 'may not have dependencies across phases.', + $key, + $patch_phase, + $after, + $after_phase)); + } } } $patches = array(); foreach ($specs as $full_key => $spec) { $patches[$full_key] = new PhabricatorStoragePatch($spec); } // TODO: Detect cycles? + $patches = msortv($patches, 'newSortVector'); + return $patches; } + private function getPHPPatchAttributes($patch_name, $full_path) { + $data = Filesystem::readFile($full_path); + + $phase_list = PhabricatorStoragePatch::getPhaseList(); + $phase_map = array_fuse($phase_list); + + $attributes = array(); + + $lines = phutil_split_lines($data, false); + foreach ($lines as $line) { + // Skip over the "PHP" line. + if (preg_match('(^<\?)', $line)) { + continue; + } + + // Skip over blank lines. + if (!strlen(trim($line))) { + continue; + } + + // If this is a "//" comment... + if (preg_match('(^\s*//)', $line)) { + $matches = null; + if (preg_match('(^\s*//\s*@(\S+)(?:\s+(.*))?\z)', $line, $matches)) { + $attr_key = $matches[1]; + $attr_value = trim(idx($matches, 2)); + + switch ($attr_key) { + case 'phase': + $phase_name = $attr_value; + + if (!strlen($phase_name)) { + throw new Exception( + pht( + 'Storage patch "%s" specifies a "@phase" attribute with '. + 'no phase value. Phase attributes must specify a value, '. + 'like "@phase default".', + $patch_name)); + } + + if (!isset($phase_map[$phase_name])) { + throw new Exception( + pht( + 'Storage patch "%s" specifies a "@phase" value ("%s"), '. + 'but this is not a recognized phase. Valid phases '. + 'are: %s.', + $patch_name, + $phase_name, + implode(', ', $phase_list))); + } + + if (isset($attributes['phase'])) { + throw new Exception( + pht( + 'Storage patch "%s" specifies a "@phase" value ("%s"), '. + 'but it already has a specified phase ("%s"). Patches '. + 'may not specify multiple phases.', + $patch_name, + $phase_name, + $attributes['phase'])); + } + + $attributes[$attr_key] = $phase_name; + break; + default: + throw new Exception( + pht( + 'Storage patch "%s" specifies attribute "%s", but this '. + 'attribute is unknown.', + $patch_name, + $attr_key)); + } + } + continue; + } + + // If this is anything else, we're all done. Attributes must be marked + // in the header of the file. + break; + } + + + return $attributes; + } + }