From 010cfeadcf0717b403cad9dd8f40628289d1de22 Mon Sep 17 00:00:00 2001 From: Jurriaan Roelofs Date: Tue, 14 Apr 2026 16:12:05 +0200 Subject: [PATCH] fix(rl_page_title, rl_menu_link): add hook_update_10001 to install lookup_hash Existing sites installed before the hash-indexed lookup refactor (commit e215a95) hit a 500 on every page render because VariantSelectorBase::loadExperimentByLookupHashes() now filters an entity query on `lookup_hash`, a column that exists in the new storage schema but was never added to previously-installed tables. The original refactor deliberately skipped a hook_update with the justification that the submodules were unreleased, which is not a valid reason to skip schema updates in a contrib module. Each update_10001(): 1. Adds the lookup_hash varchar(64) NOT NULL column with empty default. 2. Backfills hashes from each row's (path|langcode) or (menu_link_plugin_id|langcode) using the entity class's own computeLookupHash() helper so values match what preSave() writes. 3. Fails with a listed hash set via UpdateException if pre-existing duplicate (target, langcode) rows would violate the UNIQUE key. 4. Adds the rl_*_lookup_hash UNIQUE key and the secondary composite index declared by {PageTitle,MenuLink}ExperimentStorageSchema. 5. Registers the base field via the last-installed schema repository instead of EntityDefinitionUpdateManager::installFieldStorageDefinition(), because the latter would re-apply the full storage schema (including the UNIQUE key just added) and fail on "key already exists". Verified on a preview-checkout install: drush updb ran both updates, backfilled existing rows, applied all four indexes (SHOW INDEX confirms the unique and composite indexes on both tables), /home returns 200, and no new QueryException rows land in watchdog. --- modules/rl_menu_link/rl_menu_link.install | 109 +++++++++++++++++++ modules/rl_page_title/rl_page_title.install | 110 ++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 modules/rl_menu_link/rl_menu_link.install create mode 100644 modules/rl_page_title/rl_page_title.install diff --git a/modules/rl_menu_link/rl_menu_link.install b/modules/rl_menu_link/rl_menu_link.install new file mode 100644 index 0000000..f92102e --- /dev/null +++ b/modules/rl_menu_link/rl_menu_link.install @@ -0,0 +1,109 @@ +schema(); + $table = 'rl_menu_link_experiment'; + $entity_type_id = 'rl_menu_link_experiment'; + + // 1. Add the column if missing. NOT NULL with empty default so existing + // rows get a placeholder which we immediately backfill below. + if (!$schema->fieldExists($table, 'lookup_hash')) { + $schema->addField($table, 'lookup_hash', [ + 'type' => 'varchar', + 'length' => 64, + 'not null' => TRUE, + 'default' => '', + 'description' => 'Computed hash of (menu_link_plugin_id | langcode) for indexed runtime lookup.', + ]); + } + + // 2. Backfill hashes from each row's (menu_link_plugin_id, langcode) + // using the same algorithm preSave() uses so new code's lookups match. + $rows = $database->select($table, 't') + ->fields('t', ['id', 'menu_link_plugin_id', 'langcode']) + ->execute() + ->fetchAll(); + foreach ($rows as $row) { + $plugin_id = (string) ($row->menu_link_plugin_id ?? ''); + $langcode = (string) ($row->langcode ?: LanguageInterface::LANGCODE_NOT_SPECIFIED); + $hash = MenuLinkExperiment::computeLookupHash($plugin_id, $langcode); + $database->update($table) + ->fields(['lookup_hash' => $hash]) + ->condition('id', $row->id) + ->execute(); + } + + // 3. Refuse to add the UNIQUE index if any pre-existing duplicate + // (plugin_id, langcode) rows exist. + $duplicates = $database->query( + "SELECT lookup_hash, COUNT(*) AS n FROM {" . $table . "} GROUP BY lookup_hash HAVING n > 1" + )->fetchAll(); + if ($duplicates) { + $hashes = array_map(static fn ($row) => $row->lookup_hash, $duplicates); + throw new UpdateException('rl_menu_link_experiment contains duplicate (menu_link_plugin_id, langcode) rows. Delete duplicates before re-running this update. Offending lookup_hash values: ' . implode(', ', $hashes)); + } + + // 4. Apply the UNIQUE key and secondary composite index declared by + // MenuLinkExperimentStorageSchema. indexExists() is true for unique + // keys on MySQL, so both branches are idempotent. + if (!$schema->indexExists($table, 'rl_menu_link_lookup_hash')) { + $schema->addUniqueKey($table, 'rl_menu_link_lookup_hash', ['lookup_hash']); + } + if (!$schema->indexExists($table, 'rl_menu_link_plugin_langcode')) { + $schema->addIndex($table, 'rl_menu_link_plugin_langcode', [['menu_link_plugin_id', 191], 'langcode'], [ + 'fields' => [ + 'menu_link_plugin_id' => [ + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + ], + 'langcode' => [ + 'type' => 'varchar', + 'length' => 12, + 'not null' => TRUE, + ], + ], + ]); + } + + // 5. Register the base field with Drupal's entity system. We bypass + // EntityDefinitionUpdateManager::installFieldStorageDefinition() here + // because it would try to re-apply the full storage schema (including + // the UNIQUE key we already added) and blow up on "key already exists". + // Writing directly to the last-installed schema repository records + // the field so getChangeSummary() stops reporting drift, without + // touching the SQL layer. + $lookup_hash_definition = BaseFieldDefinition::create('string') + ->setName('lookup_hash') + ->setTargetEntityTypeId($entity_type_id) + ->setLabel(t('Lookup hash')) + ->setDescription(t('Computed sha256(menu_link_plugin_id|langcode). Uniquely identifies an experiment for indexed runtime lookup.')) + ->setRequired(TRUE) + ->setSetting('max_length', 64) + ->setReadOnly(TRUE); + \Drupal::service('entity.last_installed_schema.repository') + ->setLastInstalledFieldStorageDefinition($lookup_hash_definition); + + return (string) t('Added lookup_hash column, backfilled @count existing rows, and applied hash-based indexes on rl_menu_link_experiment.', ['@count' => count($rows)]); +} diff --git a/modules/rl_page_title/rl_page_title.install b/modules/rl_page_title/rl_page_title.install new file mode 100644 index 0000000..13666cf --- /dev/null +++ b/modules/rl_page_title/rl_page_title.install @@ -0,0 +1,110 @@ +schema(); + $table = 'rl_page_title_experiment'; + $entity_type_id = 'rl_page_title_experiment'; + + // 1. Add the column if missing. NOT NULL with empty default so existing + // rows get a placeholder which we immediately backfill below. + if (!$schema->fieldExists($table, 'lookup_hash')) { + $schema->addField($table, 'lookup_hash', [ + 'type' => 'varchar', + 'length' => 64, + 'not null' => TRUE, + 'default' => '', + 'description' => 'Computed hash of (normalized path | langcode) for indexed runtime lookup.', + ]); + } + + // 2. Backfill hashes from each row's (path, langcode) using the same + // algorithm preSave() uses so new code's lookups match the backfilled + // values exactly. + $rows = $database->select($table, 't') + ->fields('t', ['id', 'path', 'langcode']) + ->execute() + ->fetchAll(); + foreach ($rows as $row) { + $path = (string) ($row->path ?? ''); + $langcode = (string) ($row->langcode ?: LanguageInterface::LANGCODE_NOT_SPECIFIED); + $hash = PageTitleExperiment::computeLookupHash($path, $langcode); + $database->update($table) + ->fields(['lookup_hash' => $hash]) + ->condition('id', $row->id) + ->execute(); + } + + // 3. Refuse to add the UNIQUE index if any pre-existing duplicate + // (path, langcode) rows exist. Prior releases did form-level duplicate + // detection but pre-normalization rows may still collide. + $duplicates = $database->query( + "SELECT lookup_hash, COUNT(*) AS n FROM {" . $table . "} GROUP BY lookup_hash HAVING n > 1" + )->fetchAll(); + if ($duplicates) { + $hashes = array_map(static fn ($row) => $row->lookup_hash, $duplicates); + throw new UpdateException('rl_page_title_experiment contains duplicate (path, langcode) rows. Delete duplicates before re-running this update. Offending lookup_hash values: ' . implode(', ', $hashes)); + } + + // 4. Apply the UNIQUE key and secondary composite index declared by + // PageTitleExperimentStorageSchema. indexExists() is true for unique + // keys on MySQL, so both branches are idempotent. + if (!$schema->indexExists($table, 'rl_page_title_lookup_hash')) { + $schema->addUniqueKey($table, 'rl_page_title_lookup_hash', ['lookup_hash']); + } + if (!$schema->indexExists($table, 'rl_page_title_path_langcode')) { + $schema->addIndex($table, 'rl_page_title_path_langcode', [['path', 191], 'langcode'], [ + 'fields' => [ + 'path' => [ + 'type' => 'varchar', + 'length' => 2048, + 'not null' => FALSE, + ], + 'langcode' => [ + 'type' => 'varchar', + 'length' => 12, + 'not null' => TRUE, + ], + ], + ]); + } + + // 5. Register the base field with Drupal's entity system. We bypass + // EntityDefinitionUpdateManager::installFieldStorageDefinition() here + // because it would try to re-apply the full storage schema (including + // the UNIQUE key we already added) and blow up on "key already exists". + // Writing directly to the last-installed schema repository records + // the field so getChangeSummary() stops reporting drift, without + // touching the SQL layer. + $lookup_hash_definition = BaseFieldDefinition::create('string') + ->setName('lookup_hash') + ->setTargetEntityTypeId($entity_type_id) + ->setLabel(t('Lookup hash')) + ->setDescription(t('Computed sha256(path|langcode). Uniquely identifies an experiment for indexed runtime lookup.')) + ->setRequired(TRUE) + ->setSetting('max_length', 64) + ->setReadOnly(TRUE); + \Drupal::service('entity.last_installed_schema.repository') + ->setLastInstalledFieldStorageDefinition($lookup_hash_definition); + + return (string) t('Added lookup_hash column, backfilled @count existing rows, and applied hash-based indexes on rl_page_title_experiment.', ['@count' => count($rows)]); +}