Commit ba361554 authored by catch's avatar catch
Browse files

Issue #3108658 by alexpott, mikelutz, catch, xjm, nicxvan, longwave: Handling...

Issue #3108658 by alexpott, mikelutz, catch, xjm, nicxvan, longwave: Handling update path divergence between 11.x and 10.x

(cherry picked from commit df700a8d)
parent f932d803
Loading
Loading
Loading
Loading
Loading
+8 −1
Original line number Diff line number Diff line
@@ -11,6 +11,7 @@
use Drupal\Component\Graph\Graph;
use Drupal\Core\Extension\Exception\UnknownExtensionException;
use Drupal\Core\Utility\Error;
use Drupal\Core\Update\EquivalentUpdate;

/**
 * Returns whether the minimum schema requirement has been satisfied.
@@ -166,7 +167,13 @@ function update_do_one($module, $number, $dependency_map, &$context) {
  }

  $ret = [];
  if (function_exists($function)) {
  $equivalent_update = \Drupal::service('update.update_hook_registry')->getEquivalentUpdate($module, $number);
  if ($equivalent_update instanceof EquivalentUpdate) {
    $ret['results']['query'] = $equivalent_update->toSkipMessage();
    $ret['results']['success'] = TRUE;
    $context['sandbox']['#finished'] = TRUE;
  }
  elseif (function_exists($function)) {
    try {
      $ret['results']['query'] = $function($context['sandbox']);
      $ret['results']['success'] = TRUE;
+76 −0
Original line number Diff line number Diff line
@@ -667,6 +667,82 @@ function hook_install_tasks_alter(&$tasks, $install_state) {
 * See the @link batch Batch operations topic @endlink for more information on
 * how to use the Batch API.
 *
 * @section sec_equivalent_updates Multiple upgrade paths
 * There are situations where changes require a hook_update_N() to be applied to
 * different major branches. This results in more than one upgrade path from the
 * current major version to the next major version.
 *
 * For example, if an update is added to 11.1.0 and 10.4.0, then a site on
 * 10.3.7 can update either to 10.4.0 and from there to 11.1.0, or directly from
 * 10.3.7 to 11.1.1. In one case, the update will run on the 10.x code base, and
 * in another on the 11.x code base, but the update system needs to ensure that
 * it doesn't run twice on the same site.
 *
 * hook_update_N() numbers are sequential integers, and numbers lower than the
 * modules current schema version will never be run. This means once a site has
 * run an update, for example, 11100, it will not run a later update added as
 * 10400. Backporting of updates therefore needs to allow 'space' for the 10.4.x
 * codebase to include updates which don't exist in 11.x (for example to ensure
 * a later 11.x update goes smoothly).
 *
 * To resolve this, when handling potential backports of updates between major
 * branches, we use different update numbers for each branch, but record the
 * relationship between those updates in the older branches. This is best
 * explained by an example showing the different branches updates could be
 * applied to:
 * - The first update, system_update_10300 is applied to 10.3.0.
 * - Then, 11.0.0 is released with the update removed,
 *   system_update_last_removed() is added which returns 10300.
 * - The next update, system_update_11100, is applied to 11.1.x only.
 * - Then 10.4.0 and 11.1.0 are released. system_update_11100 is not backported
 *   to 11.0.x or any 10.x branch.
 * - Finally, a critical data loss update is necessary. The bug-fix supported
 *   branches are 11.1.x, 11.0.x, and 10.4.x. This results in adding the updates
 *   system_update_10400 (10.4.x), system_update_11000 (11.0.x) and
 *   system_update_11101 (11.1.x) and making the 10.4.1, 11.0.1 and 11.1.1
 *   releases.
 *
 * This is a list of the example releases and the updates they contain:
 * - 10.3.0: system_update_10300
 * - 10.4.1: system_update_10300 and system_update_10400 (equivalent to
 *   system_update_11101)
 * - 11.0.0: No updates
 * - 11.0.1: system_update_11000 (equivalent to system_update_11101)
 * - 11.1.0: system_update_11100
 * - 11.1.1: system_update_11100 and system_update_11101
 *
 * In this situation, sites on 10.4.1 or 11.0.1 will be required to update to
 * versions that contain system_update_11101. For example, a site on 10.4.1
 * would not be able to update to 11.0.0, because that would result in it going
 * 'backwards' in terms of database schema, but it would be able to update to
 * 11.1.1. The same is true for a site on 11.0.1.
 *
 * The following examples show how to implement a hook_update_N() that must be
 * skipped in a future update process.
 *
 * Future updates can be marked as equivalent by adding the following code to an
 * update.
 * @code
 * function my_module_update_10400() {
 *   \Drupal::service('update.update_hook_registry')->markFutureUpdateEquivalent(11101, '11.1.1');
 *
 *   // The rest of the update function.
 * }
 * @endcode
 *
 * At the moment we need to add defensive coding in the future update to ensure
 * it is skipped.
 * @code
 * function my_module_update_11101() {
 *   $equivalent_update = \Drupal::service('update.update_hook_registry')->getEquivalentUpdate();
 *   if ($equivalent_update instanceof \Drupal\Core\Update\EquivalentUpdate) {
 *     return $equivalent_update->toSkipMessage();
 *   }
 *
 *   // The rest of the update function.
 * }
 * @encode
 *
 * @param array $sandbox
 *   Stores information for batch updates. See above for more information.
 *
+47 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\Core\Update;

use Drupal\Core\StringTranslation\TranslatableMarkup;

/**
 * Value object to hold information about an equivalent update.
 *
 * @see module.api.php
 */
final class EquivalentUpdate {

  /**
   * Constructs a EquivalentUpdate object.
   *
   * @param string $module
   *   The module the update is for.
   * @param int $future_update
   *   The equivalent future update.
   * @param int $ran_update
   *   The update that already ran and registered the equivalent update.
   * @param string $future_version
   *   The future version that has the expected update.
   */
  public function __construct(
    public readonly string $module,
    public readonly int $future_update,
    public readonly int $ran_update,
    public readonly string $future_version,
  ) {
  }

  /**
   * Creates a message to explain why an update has been skipped.
   *
   * @return \Drupal\Core\StringTranslation\TranslatableMarkup
   *   An message explaining why an update has been skipped.
   */
  public function toSkipMessage(): TranslatableMarkup {
    return new TranslatableMarkup(
      'Update @number for the @module module has been skipped because the equivalent change was already made in update @ran_update.',
      ['@number' => $this->future_update, '@module' => $this->module, '@ran_update' => $this->ran_update]
    );
  }

}
+153 −5
Original line number Diff line number Diff line
@@ -15,6 +15,11 @@ class UpdateHookRegistry {
   */
  public const SCHEMA_UNINSTALLED = -1;

  /**
   * Regular expression to match all possible defined hook_update_N().
   */
  private const FUNC_NAME_REGEXP = '/^(?<module>.+)_update_(?<version>\d+)$/';

  /**
   * A list of enabled modules.
   *
@@ -23,12 +28,28 @@ class UpdateHookRegistry {
  protected $enabledModules;

  /**
   * The key value storage.
   * The system.schema key value storage.
   *
   * @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface
   */
  protected $keyValue;

  /**
   * The core.equivalent_updates key value storage.
   *
   * The key value keys are modules and the value is an array of equivalent
   * updates with the following shape:
   * - The array keys are the equivalent future update numbers.
   * - The value is an array containing two keys:
   *   - 'ran_update': The update that registered the future update as an
   *     equivalent.
   *   - 'future_version_string': The version that provides the future update.
   *
   * @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface
   * @see module.api.php
   */
  protected KeyValueStoreInterface $equivalentUpdates;

  /**
   * A static cache of schema currentVersions per module.
   *
@@ -69,6 +90,7 @@ public function __construct(array $module_list, KeyValueStoreInterface|KeyValueF
    }
    $this->enabledModules = array_keys($module_list);
    $this->keyValue = $key_value_factory->get('system.schema');
    $this->equivalentUpdates = $key_value_factory->get('core.equivalent_updates');
  }

  /**
@@ -89,9 +111,6 @@ public function getAvailableUpdates(string $module): array {
        $this->allAvailableSchemaVersions[$enabled_module] = [];
      }

      // Prepare regular expression to match all possible defined
      // hook_update_N().
      $regexp = '/^(?<module>.+)_update_(?<version>\d+)$/';
      $functions = get_defined_functions();
      // Narrow this down to functions ending with an integer, since all
      // hook_update_N() functions end this way, and there are other
@@ -102,7 +121,7 @@ public function getAvailableUpdates(string $module): array {
      foreach (preg_grep('/_\d+$/', $functions['user']) as $function) {
        // If this function is a module update function, add it to the list of
        // module updates.
        if (preg_match($regexp, $function, $matches)) {
        if (preg_match(self::FUNC_NAME_REGEXP, $function, $matches)) {
          $this->allAvailableSchemaVersions[$matches['module']][] = (int) $matches['version'];
        }
      }
@@ -145,6 +164,7 @@ public function getInstalledVersion(string $module): int {
   */
  public function setInstalledVersion(string $module, int $version): self {
    $this->keyValue->set($module, $version);
    $this->deleteEquivalentUpdate($module, $version);
    return $this;
  }

@@ -156,6 +176,7 @@ public function setInstalledVersion(string $module, int $version): self {
   */
  public function deleteInstalledVersion(string $module): void {
    $this->keyValue->delete($module);
    $this->equivalentUpdates->delete($module);
  }

  /**
@@ -170,4 +191,131 @@ public function getAllInstalledVersions(): array {
    return $this->keyValue->getAll();
  }

  /**
   * Marks a future update as equivalent to the current update running.
   *
   * Updates can be marked as equivalent when they are backported to a
   * previous, but still supported, major version. For example:
   * - A 2.x hook_update_N() would be added as normal, for example:
   *   MODULE_update_2005().
   * - When that same update is backported to 1.x, it is given its own update
   *   number, for example: MODULE_update_1040(). In this update, a call to
   *   @code
   *   \Drupal::service('update.update_hook_registry')->markFutureUpdateEquivalent(2005, '2.10')
   *   @endcode
   *   is added to ensure that a site that has run this update does not run
   *   MODULE_update_2005().
   *
   * @param int $future_update_number
   *   The future update number.
   * @param string $future_version_string
   *   The version that contains the future update.
   */
  public function markFutureUpdateEquivalent(int $future_update_number, string $future_version_string): void {
    [$module, $ran_update_number] = $this->determineModuleAndVersion();

    if ($ran_update_number > $future_update_number) {
      throw new \LogicException(sprintf(
        'Cannot mark the update %d as an equivalent since it is less than the current update %d for the %s module ',
        $future_update_number, $ran_update_number, $module
      ));
    }

    $data = $this->equivalentUpdates->get($module, []);
    // It does not matter if $data[$future_update_number] is already set. If two
    // updates are causing the same update to be marked as equivalent then the
    // latest information is the correct information to use.
    $data[$future_update_number] = [
      'ran_update' => $ran_update_number,
      'future_version_string' => $future_version_string,
    ];
    $this->equivalentUpdates->set($module, $data);
  }

  /**
   * Gets the EquivalentUpdate object for an update.
   *
   * @param string|null $module
   *   The module providing the update. If this is NULL the update to check will
   *   be determined from the backtrace.
   * @param int|null $version
   *   The update to check. If this is NULL the update to check will
   *   be determined from the backtrace.
   *
   * @return \Drupal\Core\Update\EquivalentUpdate|null
   *   A value object with the equivalent update information or NULL if the
   *   update does not have an equivalent update.
   */
  public function getEquivalentUpdate(?string $module = NULL, ?int $version = NULL): ?EquivalentUpdate {
    if ($module === NULL || $version === NULL) {
      [$module, $version] = $this->determineModuleAndVersion();
    }
    $data = $this->equivalentUpdates->get($module, []);
    if (isset($data[$version]['ran_update'])) {
      return new EquivalentUpdate(
        $module,
        $version,
        $data[$version]['ran_update'],
        $data[$version]['future_version_string'],
      );
    }
    return NULL;
  }

  /**
   * Determines the module and update number from the stack trace.
   *
   * @return array<string, int>
   *   An array with two values. The first value is the module name and the
   *   second value is the update number.
   */
  private function determineModuleAndVersion(): array {
    $stack = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);

    for ($i = 0; $i < count($stack); $i++) {
      if (preg_match(self::FUNC_NAME_REGEXP, $stack[$i]['function'], $matches)) {
        return [$matches['module'], $matches['version']];
      }
    }

    throw new \BadMethodCallException(__METHOD__ . ' must be called from a hook_update_N() function');
  }

  /**
   * Removes an equivalent update.
   *
   * @param string $module
   *   The module providing the update.
   * @param int $version
   *   The equivalent update to remove.
   *
   * @return bool
   *   TRUE if an equivalent update was removed, or FALSE if it was not.
   */
  protected function deleteEquivalentUpdate(string $module, int $version): bool {
    $data = $this->equivalentUpdates->get($module, []);
    if (isset($data[$version])) {
      unset($data[$version]);
      if (empty($data)) {
        $this->equivalentUpdates->delete($module);
      }
      else {
        $this->equivalentUpdates->set($module, $data);
      }
      return TRUE;
    }
    return FALSE;
  }

  /**
   * Returns the equivalent update information for all modules.
   *
   * @return array<string, array<int, array{ran_update:int, future_version_string: string}>>
   *   Array of modules as the keys and values as arrays of equivalent update
   *   information.
   */
  public function getAllEquivalentUpdates(): array {
    return $this->equivalentUpdates->getAll();
  }

}
+3 −0
Original line number Diff line number Diff line
@@ -45,6 +45,9 @@ autowired
autowiring
backlink
backlinks
backported
backporting
backports
bakeware
barbar
barchart
Loading