From 248449138428c05cf2d9174b29605897a1f8403a Mon Sep 17 00:00:00 2001 From: Nick Santamaria Date: Wed, 19 Aug 2026 13:19:01 +1000 Subject: [PATCH 1/6] feat: added purger_http module --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index a1b095f..def6a16 100644 --- a/composer.json +++ b/composer.json @@ -6,6 +6,7 @@ "require": { "php": "^8.3", "drupal/purge": "^3.4", + "drupal/purge_purger_http": "^1.3", "drupal/section_purge": "4.x", "drupal/redis": "1.11.0", "drupal/smtp": "^1.2", From 7faf4c2b946ddf00009b8ac9f2828f32d2a8c3c7 Mon Sep 17 00:00:00 2001 From: Nick Santamaria Date: Tue, 25 Aug 2026 19:03:39 +1000 Subject: [PATCH 2/6] checkpoint commit --- README.md | 23 +++++++ ...rsettings.settings.marina_cf_cachetags.yml | 20 ++++++ .../marina_cf_cachetags.info.yml | 11 ++++ .../marina_cf_cachetags.install | 65 +++++++++++++++++++ .../TagsHeader/MarinaCfCachetagsHeader.php | 16 +++++ 5 files changed, 135 insertions(+) create mode 100644 modules/marina_cf_cachetags/config/install/httppurgersettings.settings.marina_cf_cachetags.yml create mode 100644 modules/marina_cf_cachetags/marina_cf_cachetags.info.yml create mode 100644 modules/marina_cf_cachetags/marina_cf_cachetags.install create mode 100644 modules/marina_cf_cachetags/src/Plugin/Purge/TagsHeader/MarinaCfCachetagsHeader.php diff --git a/README.md b/README.md index 4534e57..1513309 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,29 @@ tools/vendor/bin/phpcs --standard=phpcs.xml.dist environment variable. - Adds validation to webform email handler form, restricting configuration emails configured in SMTP_WHITELIST envvar. +- Provides the optional `marina_cf_cachetags` module. It sends cacheable + responses' Drupal cache tags in the `x-amz-meta-cache-tag` header and + invalidates those tags through the local CloudFront SigV4 sidecar. + +### CloudFront cache tags + +Enable the submodule with: + +```sh +drush en marina_cf_cachetags +``` + +The module configures Purge's HTTP bundled purger to send a `POST` request to +`http://localhost:8083/prod/cache-invalidation/{project}/{environment}` with a JSON +body containing the comma-separated tags: + +```json +{"tagsCsv":"tag:node:123"} +``` + +Purge processes invalidations at the end of the request with its late-runtime +processor. Override the `httppurgersettings.settings.marina_cf_cachetags` +configuration with the deployment's project and environment names. ## Patches diff --git a/modules/marina_cf_cachetags/config/install/httppurgersettings.settings.marina_cf_cachetags.yml b/modules/marina_cf_cachetags/config/install/httppurgersettings.settings.marina_cf_cachetags.yml new file mode 100644 index 0000000..57de160 --- /dev/null +++ b/modules/marina_cf_cachetags/config/install/httppurgersettings.settings.marina_cf_cachetags.yml @@ -0,0 +1,20 @@ +id: marina_cf_cachetags +label: 'Marina CloudFront' +name: 'Marina CloudFront cache invalidation' +invalidationtype: tag +hostname: localhost +port: 8083 +# Override this in settings.php with the deployment's project and environment. +path: /prod/cache-invalidation/{project}/{environment} +request_method: POST +scheme: http +verify: true +headers: { } +body: '{"tagsCsv":"[invalidations:separated_comma]"}' +body_content_type: application/json +runtime_measurement: true +timeout: 1.0 +connect_timeout: 1.0 +cooldown_time: 0.0 +max_requests: 100 +http_errors: true diff --git a/modules/marina_cf_cachetags/marina_cf_cachetags.info.yml b/modules/marina_cf_cachetags/marina_cf_cachetags.info.yml new file mode 100644 index 0000000..9abcd61 --- /dev/null +++ b/modules/marina_cf_cachetags/marina_cf_cachetags.info.yml @@ -0,0 +1,11 @@ +name: 'Marina CloudFront Cache Tags' +type: module +description: 'Exports Drupal cache tags for CloudFront and dispatches tag invalidations through the platform sidecar.' +package: 'SDP Bay' +core_version_requirement: ^10.2 || ^11 +dependencies: + - bay_platform_dependencies:bay_platform_dependencies + - purge:purge + - purge_processor_lateruntime:purge_processor_lateruntime + - purge_purger_http:purge_purger_http + - purge_tokens:purge_tokens diff --git a/modules/marina_cf_cachetags/marina_cf_cachetags.install b/modules/marina_cf_cachetags/marina_cf_cachetags.install new file mode 100644 index 0000000..795e945 --- /dev/null +++ b/modules/marina_cf_cachetags/marina_cf_cachetags.install @@ -0,0 +1,65 @@ +getEditable('purge.plugins'); + + // Register the bundled HTTP purger so Purge sends tags to the sidecar. + $purgers = $config->get('purgers') ?? []; + $purger_exists = FALSE; + foreach ($purgers as $purger) { + if ($purger['instance_id'] === 'marina_cf_cachetags') { + $purger_exists = TRUE; + break; + } + } + if (empty($purger_exists)) { + $purgers[] = [ + 'order_index' => 3, + 'instance_id' => 'marina_cf_cachetags', + 'plugin_id' => 'httpbundled', + ]; + $config->set('purgers', $purgers); + } + + // Process pending invalidations after the response has been sent. + $processors = $config->get('processors') ?? []; + $lateruntime_exists = FALSE; + foreach ($processors as &$processor) { + if ($processor['plugin_id'] === 'lateruntime') { + $processor['status'] = TRUE; + $lateruntime_exists = TRUE; + break; + } + } + unset($processor); + if (empty($lateruntime_exists)) { + $processors[] = [ + 'plugin_id' => 'lateruntime', + 'status' => TRUE, + ]; + } + $config->set('processors', $processors)->save(); +} + +/** + * Implements hook_uninstall(). + */ +function marina_cf_cachetags_uninstall() { + $config = \Drupal::configFactory()->getEditable('purge.plugins'); + $purgers = $config->get('purgers') ?? []; + + // Avoid leaving Purge with a reference to this module's deleted settings. + $purgers = array_values(array_filter($purgers, static function (array $purger) { + return $purger['instance_id'] !== 'marina_cf_cachetags'; + })); + + $config->set('purgers', $purgers)->save(); +} diff --git a/modules/marina_cf_cachetags/src/Plugin/Purge/TagsHeader/MarinaCfCachetagsHeader.php b/modules/marina_cf_cachetags/src/Plugin/Purge/TagsHeader/MarinaCfCachetagsHeader.php new file mode 100644 index 0000000..0b4feb1 --- /dev/null +++ b/modules/marina_cf_cachetags/src/Plugin/Purge/TagsHeader/MarinaCfCachetagsHeader.php @@ -0,0 +1,16 @@ + Date: Tue, 1 Sep 2026 07:46:01 +1000 Subject: [PATCH 3/6] chore: Uninstalled section_purger. Installed marina_cf_cachetags. --- bay_platform_dependencies.install | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/bay_platform_dependencies.install b/bay_platform_dependencies.install index b14338a..850674f 100644 --- a/bay_platform_dependencies.install +++ b/bay_platform_dependencies.install @@ -294,3 +294,16 @@ function bay_platform_dependencies_update_10005(&$sandbox) { return implode("\n", $message); } + +/** + * Install marina_cf_cachetags module. + */ +function bay_platform_dependencies_update_10006() { + if (\Drupal::moduleHandler()->moduleExists('section_purger')) { + // If exists, uninstall module. + \Drupal::service('module_installer')->uninstall(['section_purger']); + } + if (!\Drupal::moduleHandler()->moduleExists('marina_cf_cachetags')) { + \Drupal::service('module_installer')->install(['marina_cf_cachetags']); + } +} From 47fb6a01ef3027fbb0d9477e283cb161dd44fd6f Mon Sep 17 00:00:00 2001 From: vincent-gao Date: Wed, 16 Sep 2026 10:54:17 +1000 Subject: [PATCH 4/6] feat: migrate to `purge_purger_http` module, fully replacing section.io modules. (#40) * feat: added purger_http module and retire the section.io related module and settings. * Fix CloudFront cache tag invalidation tokens Prefix bare tag invalidations with tag: so the invalidation API treats them as CloudFront cache tags. Support comma, pipe and tab separators, preserve existing markers and deduplicate expressions. * Empty the purge queue when switching to the Marina CloudFront purger Items queued for the Section.io purger (URL and wildcard invalidations) can never be handled by the tag-only Marina purger. Purge marks them NOT_SUPPORTED and returns them to the queue, so they recirculate on every run and, because the queue is ordered by creation time, always occupy the first slot of every claimed batch. purge_tokens_tokens() only builds token replacements when the batch offered to a purger starts at index 0, so one stale item is enough to send every real invalidation with the literal [invalidations:separated_comma] token as its request body. bay_platform_dependencies_update_10007() empties the queue once so environments migrated from Section.io start clean. * Hash cache tags for CloudFront and fix the tags header format CloudFront's tag invalidation indexes the x-amz-meta-cache-tag response header as a comma-separated list, rejects tags containing spaces, keeps at most 50 tags per object and caps the header at 1,783 characters. The header this module emitted was space-separated and unbounded, so a "#tag" invalidation could never match anything. The header now filters tags against the purge_queuer_coretags blacklist, orders entity tags first, hashes each tag with xxHash3 truncated to six hex characters, de-duplicates and caps the list at 50, and joins it with commas. The invalidation side uses the same hash service and prefixes each value with "tag:", which the cache invalidation API rewrites to "#". Both ends therefore always agree. The token replacement is also rebuilt unconditionally in hook_tokens_alter(): purge_tokens_tokens() checks $data['invalidations'][0] and silently produces nothing when the batch offered by PurgersService does not start at index 0, which happens whenever an unsupported item sits ahead in the queue. The hash, filter and prioritizer are services that sites can override. Adapted from the cloudfront_purger_tags submodule of drupal/cloudfront_purger (GPL-2.0-or-later). * Patch Purge invalidation token replacement Apply the patch for Drupal issue #3484260 to prevent token replacement from being skipped when invalidation sets lack index 0. --- README.md | 53 ++++++++-- bay_platform_dependencies.info.yml | 1 - bay_platform_dependencies.install | 60 ++++++++++-- composer.json | 5 + .../optional/key.key.section_io_password.yml | 13 --- config/optional/purge.logger_channels.yml | 5 - config/optional/purge.plugins.yml | 5 +- .../section_purge.settings.8714ff77fc.yml | 28 ------ ...ger_http.settings.marina_cf_cachetags.yml} | 0 .../marina_cf_cachetags.info.yml | 5 +- .../marina_cf_cachetags.install | 83 ++++++++++++++-- .../marina_cf_cachetags.module | 60 ++++++++++++ .../marina_cf_cachetags.services.yml | 15 +++ .../src/CacheTagFilterInterface.php | 23 +++++ .../src/CacheTagPrioritizerInterface.php | 23 +++++ .../marina_cf_cachetags/src/CacheTagsHash.php | 29 ++++++ .../src/CacheTagsHashInterface.php | 52 ++++++++++ .../src/DefaultCacheTagFilter.php | 51 ++++++++++ .../src/DefaultCacheTagPrioritizer.php | 43 ++++++++ .../src/InvalidationTokens.php | 56 +++++++++++ .../TagsHeader/MarinaCfCachetagsHeader.php | 65 ++++++++++++- .../tests/src/Unit/CacheTagsHashTest.php | 56 +++++++++++ .../src/Unit/DefaultCacheTagFilterTest.php | 51 ++++++++++ .../Unit/DefaultCacheTagPrioritizerTest.php | 50 ++++++++++ .../tests/src/Unit/InvalidationTokensTest.php | 55 +++++++++++ .../MarinaCfCachetagsHeaderTest.php | 88 +++++++++++++++++ .../tests/src/Unit/TokensAlterTest.php | 97 +++++++++++++++++++ 27 files changed, 998 insertions(+), 74 deletions(-) delete mode 100644 config/optional/key.key.section_io_password.yml delete mode 100644 config/optional/section_purge.settings.8714ff77fc.yml rename modules/marina_cf_cachetags/config/install/{httppurgersettings.settings.marina_cf_cachetags.yml => purge_purger_http.settings.marina_cf_cachetags.yml} (100%) create mode 100644 modules/marina_cf_cachetags/marina_cf_cachetags.module create mode 100644 modules/marina_cf_cachetags/marina_cf_cachetags.services.yml create mode 100644 modules/marina_cf_cachetags/src/CacheTagFilterInterface.php create mode 100644 modules/marina_cf_cachetags/src/CacheTagPrioritizerInterface.php create mode 100644 modules/marina_cf_cachetags/src/CacheTagsHash.php create mode 100644 modules/marina_cf_cachetags/src/CacheTagsHashInterface.php create mode 100644 modules/marina_cf_cachetags/src/DefaultCacheTagFilter.php create mode 100644 modules/marina_cf_cachetags/src/DefaultCacheTagPrioritizer.php create mode 100644 modules/marina_cf_cachetags/src/InvalidationTokens.php create mode 100644 modules/marina_cf_cachetags/tests/src/Unit/CacheTagsHashTest.php create mode 100644 modules/marina_cf_cachetags/tests/src/Unit/DefaultCacheTagFilterTest.php create mode 100644 modules/marina_cf_cachetags/tests/src/Unit/DefaultCacheTagPrioritizerTest.php create mode 100644 modules/marina_cf_cachetags/tests/src/Unit/InvalidationTokensTest.php create mode 100644 modules/marina_cf_cachetags/tests/src/Unit/Plugin/Purge/TagsHeader/MarinaCfCachetagsHeaderTest.php create mode 100644 modules/marina_cf_cachetags/tests/src/Unit/TokensAlterTest.php diff --git a/README.md b/README.md index 1513309..597b5b4 100644 --- a/README.md +++ b/README.md @@ -51,17 +51,56 @@ Enable the submodule with: drush en marina_cf_cachetags ``` -The module configures Purge's HTTP bundled purger to send a `POST` request to -`http://localhost:8083/prod/cache-invalidation/{project}/{environment}` with a JSON -body containing the comma-separated tags: +On install it registers Purge's HTTP bundled purger (instance +`marina_cf_cachetags`), enables the `coretags` queuer and the `lateruntime` +processor, and empties the purge queue so nothing queued for a previous purger +lingers (`bay_platform_dependencies_update_10007()`). + +**Response header.** Cacheable responses carry `x-amz-meta-cache-tag`, a +comma-separated list of the response's Drupal cache tags hashed with xxHash3 +and truncated to 6 hex characters (`node:203` → `#` + first 6 chars of +`hash('xxh3', 'node:203')`). Tags matching the `purge_queuer_coretags` +blacklist are dropped, entity tags are listed first, and the list is capped at +the 50 tags CloudFront stores per object. Hashing keeps the header far below +CloudFront's 1,783-character limit. + +**Invalidation.** The purger sends a `POST` to +`http://localhost:8083/prod/cache-invalidation/{project}/{environment}` (the +platform's SigV4 sidecar) with a JSON body of the same hashes, each prefixed +with `tag:`: ```json -{"tagsCsv":"tag:node:123"} +{"tagsCsv":"tag:b9917e,tag:4c1d2a"} ``` -Purge processes invalidations at the end of the request with its late-runtime -processor. Override the `httppurgersettings.settings.marina_cf_cachetags` -configuration with the deployment's project and environment names. +The cache invalidation API rewrites `tag:` to `#`, which is +CloudFront's tag-invalidation syntax. Both sides must use the same hash, so +they share the `marina_cf_cachetags.cache_tags_hash` service. Override +`marina_cf_cachetags.cache_tag_filter` or +`marina_cf_cachetags.cache_tag_prioritizer` in a site's `services.yml` to +change which tags are kept and in what order. + +**Site configuration.** Override the purger's `path` with the deployment's +project and environment names (the placeholders are not substituted +anywhere else), e.g. in `settings.php`: + +```php +$config['purge_purger_http.settings.marina_cf_cachetags']['path'] = + sprintf('/prod/cache-invalidation/%s/%s', 'my-project', getenv('MARINA_ENVIRONMENT')); +``` + +**Platform requirements.** The CloudFront distribution must declare +`CacheTagConfig` with `HeaderName: x-amz-meta-cache-tag`; distributions +without it ignore the header and every tag invalidation is a no-op. Objects +cached before `CacheTagConfig` is enabled carry no tags — issue one `/*` +invalidation after enabling it. + +To invalidate a tag by hand, hash it first: + +```sh +aws cloudfront create-invalidation --distribution-id \ + --paths "#$(php -r 'echo substr(hash("xxh3", "node:203"), 0, 6);')" +``` ## Patches diff --git a/bay_platform_dependencies.info.yml b/bay_platform_dependencies.info.yml index 6d0a28c..831c76d 100644 --- a/bay_platform_dependencies.info.yml +++ b/bay_platform_dependencies.info.yml @@ -7,6 +7,5 @@ dependencies: - bay_monitoring:bay_monitoring - purge:purge - redis:redis - - section_purge:section_purge - smtp:smtp - tide_logs:tide_logs diff --git a/bay_platform_dependencies.install b/bay_platform_dependencies.install index 850674f..a6ad064 100644 --- a/bay_platform_dependencies.install +++ b/bay_platform_dependencies.install @@ -296,14 +296,62 @@ function bay_platform_dependencies_update_10005(&$sandbox) { } /** - * Install marina_cf_cachetags module. + * Replace the Section.io purger with the Marina CloudFront integration. */ function bay_platform_dependencies_update_10006() { - if (\Drupal::moduleHandler()->moduleExists('section_purger')) { - // If exists, uninstall module. - \Drupal::service('module_installer')->uninstall(['section_purger']); - } + $module_installer = \Drupal::service('module_installer'); + if (!\Drupal::moduleHandler()->moduleExists('marina_cf_cachetags')) { - \Drupal::service('module_installer')->install(['marina_cf_cachetags']); + $module_installer->install(['marina_cf_cachetags']); + } + + // Remove the legacy Section.io purger from Purge's plugin configuration. + $config_factory = \Drupal::configFactory(); + $purge_plugins = $config_factory->getEditable('purge.plugins'); + $purgers = $purge_plugins->get('purgers') ?? []; + $purgers = array_values(array_filter($purgers, static function (array $purger) { + return !in_array($purger['plugin_id'] ?? NULL, ['section', 'sectionbundled'], TRUE); + })); + $purge_plugins->set('purgers', $purgers)->save(); + + // Remove the logger channel associated with the legacy Section.io purger. + $logger_config = $config_factory->getEditable('purge.logger_channels'); + $channels = $logger_config->get('channels') ?? []; + $channels = array_values(array_filter($channels, static function (array $channel) { + return !str_starts_with($channel['id'] ?? '', 'purger_section'); + })); + $logger_config->set('channels', $channels)->save(); + + // Remove configuration and credentials that are no longer used. + $config_factory + ->getEditable('section_purge.settings.8714ff77fc') + ->delete(); + $config_factory + ->getEditable('key.key.section_io_password') + ->delete(); + + // Keep the Composer package for this release so Drupal can uninstall it. + if (\Drupal::moduleHandler()->moduleExists('section_purge')) { + $module_installer->uninstall(['section_purge']); + } +} + +/** + * Empty the purge queue after switching to the Marina CloudFront purger. + * + * Items queued for the Section.io purger (URL and wildcard invalidations) can + * never be handled by the tag-only Marina purger. Purge marks them + * NOT_SUPPORTED and returns them to the queue, so they recirculate on every + * run and, because the queue is ordered by creation time, always occupy the + * first slot of every claimed batch. purge_tokens_tokens() only builds token + * replacements when the batch offered to a purger starts at index 0, so one + * stale item is enough to send every real invalidation with the literal + * `[invalidations:separated_comma]` token as its request body. + */ +function bay_platform_dependencies_update_10007() { + if (!\Drupal::hasService('purge.queue')) { + return t('Purge queue service unavailable; nothing to empty.'); } + \Drupal::service('purge.queue')->emptyQueue(); + return t('Emptied the purge queue to discard invalidations queued for the retired Section.io purger.'); } diff --git a/composer.json b/composer.json index def6a16..9675df2 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,11 @@ "composer-exit-on-patch-failure": true, "enable-patching": true, "patches": { + "drupal/purge": { + "Fix duplicate Drush command registration - https://www.drupal.org/project/purge/issues/3460094#comment-15821421": "https://www.drupal.org/files/issues/2024-10-18/3460094-remove_drush_services_yml.patch", + "Handle missing $data['invalidations'][0] in invalidation check (token replacement skipped when the offered set is not indexed from 0) - https://www.drupal.org/project/purge/issues/3484260": "https://www.drupal.org/files/issues/2024-10-29/3484260.patch" + + }, "drupal/redis": { "Add RedisCluster client support": "https://www.drupal.org/files/issues/2026-05-12/2900947-98.patch", "Forward the configured TLS context to RedisCluster": "https://gist.githubusercontent.com/GROwen/c29a081160f81c98414cf7c74f00fbbd/raw/e3cdbed53874c012dc86713fdeda95702daa45cd/redis-cluster-tls-context.patch" diff --git a/config/optional/key.key.section_io_password.yml b/config/optional/key.key.section_io_password.yml deleted file mode 100644 index a1feeb4..0000000 --- a/config/optional/key.key.section_io_password.yml +++ /dev/null @@ -1,13 +0,0 @@ -langcode: en -status: true -dependencies: { } -id: section_io_password -label: 'Section.io Password' -key_type: authentication -key_type_settings: { } -key_provider: env -key_provider_settings: - env_variable: SECTION_IO_PASSWORD - strip_line_breaks: true -key_input: none -key_input_settings: { } diff --git a/config/optional/purge.logger_channels.yml b/config/optional/purge.logger_channels.yml index 574a321..139a03e 100644 --- a/config/optional/purge.logger_channels.yml +++ b/config/optional/purge.logger_channels.yml @@ -9,11 +9,6 @@ channels: - 0 - 2 - 3 - - id: purger_sectionbundled_8714ff77fc - grants: - - 0 - - 2 - - 3 - id: diagnostics grants: - 3 diff --git a/config/optional/purge.plugins.yml b/config/optional/purge.plugins.yml index 5bbd8fe..2c51939 100644 --- a/config/optional/purge.plugins.yml +++ b/config/optional/purge.plugins.yml @@ -1,7 +1,4 @@ -purgers: - - order_index: 2 - instance_id: 8714ff77fc - plugin_id: sectionbundled +purgers: [] processors: - plugin_id: drush_purge_queue_work status: true diff --git a/config/optional/section_purge.settings.8714ff77fc.yml b/config/optional/section_purge.settings.8714ff77fc.yml deleted file mode 100644 index 02c626f..0000000 --- a/config/optional/section_purge.settings.8714ff77fc.yml +++ /dev/null @@ -1,28 +0,0 @@ -langcode: en -status: true -dependencies: { } -id: 8714ff77fc -name: 'Section Develop' -invalidationtype: tag -hostname: aperture.section.io -sitename: '' -port: 443 -path: / -account: 1918 -application: 1234 -environmentname: Develop -username: de.ops@dpc.vic.gov.au -password: section_io_password -request_method: POST -scheme: https -verify: '1' -varnishname: varnish -headers: { } -body: '' -body_content_type: application/json -runtime_measurement: true -timeout: !!float 0 -connect_timeout: !!float 0 -cooldown_time: !!float 0 -max_requests: 100 -http_errors: true diff --git a/modules/marina_cf_cachetags/config/install/httppurgersettings.settings.marina_cf_cachetags.yml b/modules/marina_cf_cachetags/config/install/purge_purger_http.settings.marina_cf_cachetags.yml similarity index 100% rename from modules/marina_cf_cachetags/config/install/httppurgersettings.settings.marina_cf_cachetags.yml rename to modules/marina_cf_cachetags/config/install/purge_purger_http.settings.marina_cf_cachetags.yml diff --git a/modules/marina_cf_cachetags/marina_cf_cachetags.info.yml b/modules/marina_cf_cachetags/marina_cf_cachetags.info.yml index 9abcd61..11d9029 100644 --- a/modules/marina_cf_cachetags/marina_cf_cachetags.info.yml +++ b/modules/marina_cf_cachetags/marina_cf_cachetags.info.yml @@ -6,6 +6,7 @@ core_version_requirement: ^10.2 || ^11 dependencies: - bay_platform_dependencies:bay_platform_dependencies - purge:purge - - purge_processor_lateruntime:purge_processor_lateruntime + - purge:purge_processor_lateruntime + - purge:purge_queuer_coretags + - purge:purge_tokens - purge_purger_http:purge_purger_http - - purge_tokens:purge_tokens diff --git a/modules/marina_cf_cachetags/marina_cf_cachetags.install b/modules/marina_cf_cachetags/marina_cf_cachetags.install index 795e945..603a250 100644 --- a/modules/marina_cf_cachetags/marina_cf_cachetags.install +++ b/modules/marina_cf_cachetags/marina_cf_cachetags.install @@ -9,31 +9,59 @@ * Implements hook_install(). */ function marina_cf_cachetags_install() { + marina_cf_cachetags_configure_purge(); +} + +/** + * Configure the purger, queuer, and processor used by the integration. + */ +function marina_cf_cachetags_configure_purge() { $config = \Drupal::configFactory()->getEditable('purge.plugins'); // Register the bundled HTTP purger so Purge sends tags to the sidecar. $purgers = $config->get('purgers') ?? []; $purger_exists = FALSE; - foreach ($purgers as $purger) { - if ($purger['instance_id'] === 'marina_cf_cachetags') { + foreach ($purgers as &$purger) { + if (($purger['instance_id'] ?? NULL) === 'marina_cf_cachetags') { + $purger['plugin_id'] = 'httpbundled'; $purger_exists = TRUE; break; } } + unset($purger); if (empty($purger_exists)) { $purgers[] = [ 'order_index' => 3, 'instance_id' => 'marina_cf_cachetags', 'plugin_id' => 'httpbundled', ]; - $config->set('purgers', $purgers); } + $config->set('purgers', $purgers); + + // Queue cache tags invalidated by Drupal core and contributed modules. + $queuers = $config->get('queuers') ?? []; + $coretags_exists = FALSE; + foreach ($queuers as &$queuer) { + if (($queuer['plugin_id'] ?? NULL) === 'coretags') { + $queuer['status'] = TRUE; + $coretags_exists = TRUE; + break; + } + } + unset($queuer); + if (empty($coretags_exists)) { + $queuers[] = [ + 'plugin_id' => 'coretags', + 'status' => TRUE, + ]; + } + $config->set('queuers', $queuers); // Process pending invalidations after the response has been sent. $processors = $config->get('processors') ?? []; $lateruntime_exists = FALSE; foreach ($processors as &$processor) { - if ($processor['plugin_id'] === 'lateruntime') { + if (($processor['plugin_id'] ?? NULL) === 'lateruntime') { $processor['status'] = TRUE; $lateruntime_exists = TRUE; break; @@ -47,19 +75,62 @@ function marina_cf_cachetags_install() { ]; } $config->set('processors', $processors)->save(); + + // Log failures from the HTTP bundled purger. + $logger_config = \Drupal::configFactory() + ->getEditable('purge.logger_channels'); + $channels = $logger_config->get('channels') ?? []; + $channel_id = 'purger_httpbundled_marina_cf_cachetags'; + $channel_exists = FALSE; + foreach ($channels as $channel) { + if (($channel['id'] ?? NULL) === $channel_id) { + $channel_exists = TRUE; + break; + } + } + if (empty($channel_exists)) { + $channels[] = [ + 'id' => $channel_id, + 'grants' => [0, 2, 3], + ]; + $logger_config->set('channels', $channels)->save(); + } +} + +/** + * Install the correctly named HTTP purger config on existing sites. + */ +function marina_cf_cachetags_update_10001() { + $config_factory = \Drupal::configFactory(); + $config_name = 'purge_purger_http.settings.marina_cf_cachetags'; + + if ($config_factory->get($config_name)->isNew()) { + \Drupal::service('config.installer') + ->installDefaultConfig('module', 'marina_cf_cachetags'); + } + + marina_cf_cachetags_configure_purge(); } /** * Implements hook_uninstall(). */ function marina_cf_cachetags_uninstall() { - $config = \Drupal::configFactory()->getEditable('purge.plugins'); + $config_factory = \Drupal::configFactory(); + $config = $config_factory->getEditable('purge.plugins'); $purgers = $config->get('purgers') ?? []; // Avoid leaving Purge with a reference to this module's deleted settings. $purgers = array_values(array_filter($purgers, static function (array $purger) { - return $purger['instance_id'] !== 'marina_cf_cachetags'; + return ($purger['instance_id'] ?? NULL) !== 'marina_cf_cachetags'; })); $config->set('purgers', $purgers)->save(); + + $logger_config = $config_factory->getEditable('purge.logger_channels'); + $channels = $logger_config->get('channels') ?? []; + $channels = array_values(array_filter($channels, static function (array $channel) { + return ($channel['id'] ?? NULL) !== 'purger_httpbundled_marina_cf_cachetags'; + })); + $logger_config->set('channels', $channels)->save(); } diff --git a/modules/marina_cf_cachetags/marina_cf_cachetags.module b/modules/marina_cf_cachetags/marina_cf_cachetags.module new file mode 100644 index 0000000..3e1c9e9 --- /dev/null +++ b/modules/marina_cf_cachetags/marina_cf_cachetags.module @@ -0,0 +1,60 @@ +` becomes `#` (a CloudFront cache tag) + * - starts with `/`, `#`, or contains `:`: passes through unchanged + * - anything else gets a leading `/` (a CloudFront path) + * + * A bare `node:203` contains a colon, so it would pass through untouched and + * reach CloudFront's CreateInvalidation as a literal path. Prefixing the + * hashed expression with `tag:` routes it into the first branch, so it + * arrives as `#` and matches the values CloudFront indexed from the + * `x-amz-meta-cache-tag` response header, which uses the same hash service. + * + * The replacement is always rebuilt here rather than only adjusted, because + * purge_tokens_tokens() checks `$data['invalidations'][0]` and silently + * produces nothing when the offered batch does not start at index 0. That is + * the normal case whenever the queue holds an item of a type this purger does + * not support: PurgersService groups by original key, so the supported items + * arrive with sparse keys and the request body would go out with the literal + * token in it. + */ +function marina_cf_cachetags_tokens_alter(array &$replacements, array $context, BubbleableMetadata $bubbleable_metadata) { + if (($context['type'] ?? NULL) !== 'invalidations') { + return; + } + $invalidations = $context['data']['invalidations'] ?? []; + if (!$invalidations) { + return; + } + + $expressions = InvalidationTokens::hashedTagExpressions( + $invalidations, + \Drupal::service('marina_cf_cachetags.cache_tags_hash') + ); + if (!$expressions) { + return; + } + + foreach ($context['tokens'] as $name => $original) { + if (isset(InvalidationTokens::SEPARATORS[$name])) { + $replacements[$original] = implode(InvalidationTokens::SEPARATORS[$name], $expressions); + } + } +} diff --git a/modules/marina_cf_cachetags/marina_cf_cachetags.services.yml b/modules/marina_cf_cachetags/marina_cf_cachetags.services.yml new file mode 100644 index 0000000..fe4d892 --- /dev/null +++ b/modules/marina_cf_cachetags/marina_cf_cachetags.services.yml @@ -0,0 +1,15 @@ +services: + _defaults: + autowire: true + + marina_cf_cachetags.cache_tags_hash: + class: Drupal\marina_cf_cachetags\CacheTagsHash + Drupal\marina_cf_cachetags\CacheTagsHashInterface: '@marina_cf_cachetags.cache_tags_hash' + + marina_cf_cachetags.cache_tag_filter: + class: Drupal\marina_cf_cachetags\DefaultCacheTagFilter + Drupal\marina_cf_cachetags\CacheTagFilterInterface: '@marina_cf_cachetags.cache_tag_filter' + + marina_cf_cachetags.cache_tag_prioritizer: + class: Drupal\marina_cf_cachetags\DefaultCacheTagPrioritizer + Drupal\marina_cf_cachetags\CacheTagPrioritizerInterface: '@marina_cf_cachetags.cache_tag_prioritizer' diff --git a/modules/marina_cf_cachetags/src/CacheTagFilterInterface.php b/modules/marina_cf_cachetags/src/CacheTagFilterInterface.php new file mode 100644 index 0000000..fd49b08 --- /dev/null +++ b/modules/marina_cf_cachetags/src/CacheTagFilterInterface.php @@ -0,0 +1,23 @@ +` invalidation + * items sent to CloudFront. + * + * Adapted from the cloudfront_purger_tags submodule of drupal/cloudfront_purger + * (GPL-2.0-or-later). + */ +interface CacheTagsHashInterface { + + /** + * Length of the hash output. + * + * 6 hex characters = 16^6 = 16,777,216 values; collisions become likely only + * around ~5,000 distinct tags (birthday bound). + */ + public const HASH_LENGTH = 6; + + /** + * Hashes a single cache tag. + * + * @param string $tag + * The cache tag. + * + * @return string + * The hashed tag. + */ + public function hashTag(string $tag): string; + + /** + * Hashes a list of cache tags, preserving order. + * + * @param string[] $tags + * The cache tags. + * + * @return string[] + * The hashed tags. + */ + public function hashTags(array $tags): array; + +} diff --git a/modules/marina_cf_cachetags/src/DefaultCacheTagFilter.php b/modules/marina_cf_cachetags/src/DefaultCacheTagFilter.php new file mode 100644 index 0000000..1bdb2dd --- /dev/null +++ b/modules/marina_cf_cachetags/src/DefaultCacheTagFilter.php @@ -0,0 +1,51 @@ +configFactory + ->get('purge_queuer_coretags.settings') + ->get('blacklist'); + + if (!\is_array($blocklist) || $blocklist === []) { + return \array_values($tags); + } + + return \array_values(\array_filter( + $tags, + static function (string $tag) use ($blocklist): bool { + foreach ($blocklist as $prefix) { + if (\is_string($prefix) && $prefix !== '' && \str_starts_with($tag, $prefix)) { + return FALSE; + } + } + return TRUE; + } + )); + } + +} diff --git a/modules/marina_cf_cachetags/src/DefaultCacheTagPrioritizer.php b/modules/marina_cf_cachetags/src/DefaultCacheTagPrioritizer.php new file mode 100644 index 0000000..e18d73a --- /dev/null +++ b/modules/marina_cf_cachetags/src/DefaultCacheTagPrioritizer.php @@ -0,0 +1,43 @@ + ',', + 'separated_pipe' => '|', + 'separated_tab' => "\t", + ]; + + /** + * Returns the `tag:` expressions for the tag invalidations in a batch. + * + * Only tag invalidations are transformed; URL and path invalidations are + * left to purge_tokens so other purgers keep working. Expressions that + * already carry a `tag:` or `#` marker are assumed to be pre-hashed and are + * passed through unchanged. Duplicates are removed and order is preserved. + * + * @param iterable $invalidations + * The invalidations offered to the purger, possibly with sparse keys. + * @param \Drupal\marina_cf_cachetags\CacheTagsHashInterface $hash + * The hash shared with the response header. + * + * @return string[] + * The expressions, re-indexed. + */ + public static function hashedTagExpressions(iterable $invalidations, CacheTagsHashInterface $hash): array { + $expressions = []; + foreach ($invalidations as $invalidation) { + if (!$invalidation instanceof InvalidationInterface || $invalidation->getType() !== 'tag') { + continue; + } + $expression = $invalidation->getExpression(); + if (!\is_string($expression) || $expression === '') { + continue; + } + $expressions[] = \str_starts_with($expression, 'tag:') || \str_starts_with($expression, '#') + ? $expression + : 'tag:' . $hash->hashTag($expression); + } + return \array_values(\array_unique($expressions)); + } + +} diff --git a/modules/marina_cf_cachetags/src/Plugin/Purge/TagsHeader/MarinaCfCachetagsHeader.php b/modules/marina_cf_cachetags/src/Plugin/Purge/TagsHeader/MarinaCfCachetagsHeader.php index 0b4feb1..a5be43d 100644 --- a/modules/marina_cf_cachetags/src/Plugin/Purge/TagsHeader/MarinaCfCachetagsHeader.php +++ b/modules/marina_cf_cachetags/src/Plugin/Purge/TagsHeader/MarinaCfCachetagsHeader.php @@ -1,16 +1,77 @@ get('marina_cf_cachetags.cache_tags_hash'), + $container->get('marina_cf_cachetags.cache_tag_filter'), + $container->get('marina_cf_cachetags.cache_tag_prioritizer'), + ); + } + + /** + * {@inheritdoc} + * + * CloudFront requires a comma-separated value and rejects tags containing + * spaces, so the base class' space-separated output can never be matched by + * a `#tag` invalidation. Tags are filtered, ordered so entity tags come + * first, hashed, de-duplicated and capped at the 50 CloudFront keeps. + */ + public function getValue(array $tags): string { + $tags = \array_values(\array_unique(\array_filter($tags, static fn ($tag): bool => \is_string($tag) && $tag !== ''))); + $tags = $this->cacheTagFilter->filter($tags); + $tags = $this->cacheTagPrioritizer->prioritize($tags); + $hashes = \array_values(\array_unique($this->cacheTagsHash->hashTags($tags))); + + return \implode(',', \array_slice($hashes, 0, self::MAX_TAGS)); + } + +} diff --git a/modules/marina_cf_cachetags/tests/src/Unit/CacheTagsHashTest.php b/modules/marina_cf_cachetags/tests/src/Unit/CacheTagsHashTest.php new file mode 100644 index 0000000..3c6b517 --- /dev/null +++ b/modules/marina_cf_cachetags/tests/src/Unit/CacheTagsHashTest.php @@ -0,0 +1,56 @@ +hash = new CacheTagsHash(); + } + + /** + * @covers ::hashTag + */ + public function testHashTag(): void { + $hash = $this->hash->hashTag('node:1'); + $this->assertSame(CacheTagsHashInterface::HASH_LENGTH, \strlen($hash)); + $this->assertMatchesRegularExpression('/^[0-9a-f]{6}$/', $hash); + $this->assertSame($hash, $this->hash->hashTag('node:1')); + $this->assertNotSame($hash, $this->hash->hashTag('node:2')); + // Hashes only ever contain characters CloudFront accepts in a tag. + $this->assertSame($hash, \trim($hash, ", \t")); + } + + /** + * @covers ::hashTags + */ + public function testHashTags(): void { + $this->assertSame([], $this->hash->hashTags([])); + $hashes = $this->hash->hashTags(['node:1', 'user:1', 'config:system.site']); + $this->assertSame([ + $this->hash->hashTag('node:1'), + $this->hash->hashTag('user:1'), + $this->hash->hashTag('config:system.site'), + ], $hashes); + } + +} diff --git a/modules/marina_cf_cachetags/tests/src/Unit/DefaultCacheTagFilterTest.php b/modules/marina_cf_cachetags/tests/src/Unit/DefaultCacheTagFilterTest.php new file mode 100644 index 0000000..50534dd --- /dev/null +++ b/modules/marina_cf_cachetags/tests/src/Unit/DefaultCacheTagFilterTest.php @@ -0,0 +1,51 @@ +getConfigFactoryStub([ + 'purge_queuer_coretags.settings' => [ + 'blacklist' => ['config:filter.format', 'http_response', 'extensions', ''], + ], + ])); + $this->assertSame( + ['node:1', 'config:system.site', 'rendered'], + $filter->filter([ + 'node:1', + 'config:filter.format.rich_text', + 'http_response', + 'config:system.site', + 'extensions', + 'rendered', + ]) + ); + } + + /** + * @covers ::filter + */ + public function testFilterWithoutBlocklistPassesThrough(): void { + $tags = ['node:1', 'http_response']; + foreach ([[], NULL, 'not-an-array'] as $blacklist) { + $filter = new DefaultCacheTagFilter($this->getConfigFactoryStub([ + 'purge_queuer_coretags.settings' => ['blacklist' => $blacklist], + ])); + $this->assertSame($tags, $filter->filter($tags)); + } + } + +} diff --git a/modules/marina_cf_cachetags/tests/src/Unit/DefaultCacheTagPrioritizerTest.php b/modules/marina_cf_cachetags/tests/src/Unit/DefaultCacheTagPrioritizerTest.php new file mode 100644 index 0000000..fea9310 --- /dev/null +++ b/modules/marina_cf_cachetags/tests/src/Unit/DefaultCacheTagPrioritizerTest.php @@ -0,0 +1,50 @@ +assertSame([], $prioritizer->prioritize([])); + + $tags = [ + 'config:block_list', + 'config:block.block.claro_page_title', + 'node_view', + 'node:203', + 'node_list:landing_page', + 'taxonomy_term:9193', + 'rendered', + 'scheduled_transitions_for:node:203', + 'user:34', + ]; + $this->assertSame([ + // Entity tags first, original order kept. + 'node:203', + 'taxonomy_term:9193', + 'user:34', + // Then list tags. + 'config:block_list', + 'node_list:landing_page', + // Then everything else. + 'config:block.block.claro_page_title', + 'node_view', + 'rendered', + 'scheduled_transitions_for:node:203', + ], $prioritizer->prioritize($tags)); + } + +} diff --git a/modules/marina_cf_cachetags/tests/src/Unit/InvalidationTokensTest.php b/modules/marina_cf_cachetags/tests/src/Unit/InvalidationTokensTest.php new file mode 100644 index 0000000..9766c80 --- /dev/null +++ b/modules/marina_cf_cachetags/tests/src/Unit/InvalidationTokensTest.php @@ -0,0 +1,55 @@ +createMock(InvalidationInterface::class); + $invalidation->method('getType')->willReturn($type); + $invalidation->method('getExpression')->willReturn($expression); + return $invalidation; + } + + /** + * @covers ::hashedTagExpressions + */ + public function testHashedTagExpressions(): void { + $hash = new CacheTagsHash(); + $this->assertSame([], InvalidationTokens::hashedTagExpressions([], $hash)); + + $expressions = InvalidationTokens::hashedTagExpressions([ + // Sparse keys, as handed over by PurgersService. + 3 => $this->invalidation('tag', 'node:203'), + 4 => $this->invalidation('url', 'http://example.com/'), + 5 => $this->invalidation('tag', 'node:203'), + 6 => $this->invalidation('tag', '#already'), + 7 => $this->invalidation('tag', 'tag:pre'), + 8 => $this->invalidation('tag', ''), + 9 => 'not an invalidation', + 10 => $this->invalidation('tag', 'config:system.site'), + ], $hash); + + $this->assertSame([ + 'tag:' . $hash->hashTag('node:203'), + '#already', + 'tag:pre', + 'tag:' . $hash->hashTag('config:system.site'), + ], $expressions); + } + +} diff --git a/modules/marina_cf_cachetags/tests/src/Unit/Plugin/Purge/TagsHeader/MarinaCfCachetagsHeaderTest.php b/modules/marina_cf_cachetags/tests/src/Unit/Plugin/Purge/TagsHeader/MarinaCfCachetagsHeaderTest.php new file mode 100644 index 0000000..9b94994 --- /dev/null +++ b/modules/marina_cf_cachetags/tests/src/Unit/Plugin/Purge/TagsHeader/MarinaCfCachetagsHeaderTest.php @@ -0,0 +1,88 @@ +hash = new CacheTagsHash(); + $filter = $this->createMock(CacheTagFilterInterface::class); + $filter->method('filter')->willReturnCallback(static fn (array $tags): array => \array_values(\array_filter($tags, static fn (string $t): bool => $t !== 'http_response'))); + $this->header = new MarinaCfCachetagsHeader( + [], + 'marina_cf_cachetags', + ['id' => 'marina_cf_cachetags', 'header_name' => 'x-amz-meta-cache-tag'], + $this->hash, + $filter, + new DefaultCacheTagPrioritizer(), + ); + } + + /** + * @covers ::getHeaderName + */ + public function testHeaderName(): void { + $this->assertSame('x-amz-meta-cache-tag', $this->header->getHeaderName()); + } + + /** + * @covers ::getValue + */ + public function testValueIsCommaSeparatedHashesWithEntityTagsFirst(): void { + $value = $this->header->getValue([ + 'config:block_list', + 'config:block.block.claro_page_title', + 'http_response', + 'node:203', + 'node:203', + 'taxonomy_term:9193', + ]); + $this->assertSame(\implode(',', [ + $this->hash->hashTag('node:203'), + $this->hash->hashTag('taxonomy_term:9193'), + $this->hash->hashTag('config:block_list'), + $this->hash->hashTag('config:block.block.claro_page_title'), + ]), $value); + $this->assertStringNotContainsString(' ', $value); + $this->assertSame('', $this->header->getValue([])); + $this->assertSame('', $this->header->getValue(['', 'http_response'])); + } + + /** + * @covers ::getValue + */ + public function testValueIsCappedAtCloudFrontLimit(): void { + $tags = \array_map(static fn (int $i): string => "config:thing$i", \range(1, 70)); + $tags[] = 'node:999'; + $hashes = \explode(',', $this->header->getValue($tags)); + $this->assertCount(MarinaCfCachetagsHeader::MAX_TAGS, $hashes); + $this->assertSame($this->hash->hashTag('node:999'), $hashes[0]); + } + +} diff --git a/modules/marina_cf_cachetags/tests/src/Unit/TokensAlterTest.php b/modules/marina_cf_cachetags/tests/src/Unit/TokensAlterTest.php new file mode 100644 index 0000000..8089fe2 --- /dev/null +++ b/modules/marina_cf_cachetags/tests/src/Unit/TokensAlterTest.php @@ -0,0 +1,97 @@ +hash = new CacheTagsHash(); + $container = new ContainerBuilder(); + $container->set('marina_cf_cachetags.cache_tags_hash', $this->hash); + \Drupal::setContainer($container); + } + + /** + * Builds an invalidation double. + */ + protected function invalidation(string $type, string $expression): InvalidationInterface { + $invalidation = $this->createMock(InvalidationInterface::class); + $invalidation->method('getType')->willReturn($type); + $invalidation->method('getExpression')->willReturn($expression); + return $invalidation; + } + + /** + * Tag expressions are hashed and prefixed; other types are left alone. + */ + public function testTagsAreHashedAndPrefixed(): void { + $context = [ + 'type' => 'invalidations', + 'tokens' => ['separated_comma' => '[invalidations:separated_comma]'], + // Keys deliberately do not start at 0: this is how PurgersService hands + // a batch to the purger when an earlier item was unsupported. + 'data' => [ + 'invalidations' => [ + 3 => $this->invalidation('tag', 'node:203'), + 4 => $this->invalidation('url', 'http://example.com/'), + 5 => $this->invalidation('tag', 'node:203'), + 6 => $this->invalidation('tag', '#already'), + 7 => $this->invalidation('tag', 'config:system.site'), + ], + ], + ]; + // purge_tokens produced nothing for this batch (sparse keys). + $replacements = []; + marina_cf_cachetags_tokens_alter($replacements, $context, new BubbleableMetadata()); + $this->assertSame( + 'tag:' . $this->hash->hashTag('node:203') . ',#already,tag:' . $this->hash->hashTag('config:system.site'), + $replacements['[invalidations:separated_comma]'] + ); + } + + /** + * Other token types and unknown token names are untouched. + */ + public function testIgnoresUnrelatedTokens(): void { + $bubbleable = new BubbleableMetadata(); + $replacements = ['[invalidation:expression]' => 'node:1']; + marina_cf_cachetags_tokens_alter($replacements, [ + 'type' => 'invalidation', + 'tokens' => ['expression' => '[invalidation:expression]'], + 'data' => ['invalidation' => $this->invalidation('tag', 'node:1')], + ], $bubbleable); + $this->assertSame(['[invalidation:expression]' => 'node:1'], $replacements); + + $replacements = ['[invalidations:other]' => 'x']; + marina_cf_cachetags_tokens_alter($replacements, [ + 'type' => 'invalidations', + 'tokens' => ['other' => '[invalidations:other]'], + 'data' => ['invalidations' => [$this->invalidation('tag', 'node:1')]], + ], $bubbleable); + $this->assertSame(['[invalidations:other]' => 'x'], $replacements); + } + +} From e75400ee2eb9355a974943cdf2eb73cdf78c8062 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Wed, 16 Sep 2026 14:24:20 +1000 Subject: [PATCH 5/6] Send bare hashed cache tags to the invalidation API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cache invalidation API's VTL normalises the tag itself, so the module no longer prefixes invalidations with `tag:`. Each tag invalidation is sent as the bare xxHash3 hash — the same value emitted in the x-amz-meta-cache-tag response header — and the API converts it to a CloudFront cache tag. --- .../marina_cf_cachetags.module | 30 +++++-------------- .../src/InvalidationTokens.php | 7 +++-- .../tests/src/Unit/InvalidationTokensTest.php | 18 +++++------ .../tests/src/Unit/TokensAlterTest.php | 7 ++--- 4 files changed, 23 insertions(+), 39 deletions(-) diff --git a/modules/marina_cf_cachetags/marina_cf_cachetags.module b/modules/marina_cf_cachetags/marina_cf_cachetags.module index 3e1c9e9..3ab769e 100644 --- a/modules/marina_cf_cachetags/marina_cf_cachetags.module +++ b/modules/marina_cf_cachetags/marina_cf_cachetags.module @@ -11,29 +11,15 @@ use Drupal\marina_cf_cachetags\InvalidationTokens; /** * Implements hook_tokens_alter(). * - * The purge_tokens module builds `[invalidations:separated_comma]` from each - * invalidation's raw expression, so a tag invalidation contributes the bare - * Drupal cache tag, e.g. `node:203`. The cache invalidation API normalises - * what it receives before publishing it to EventBridge (marina, - * src/infra/constructs/cache-invalidation/publisher.ts): + * purge_tokens builds `[invalidations:separated_comma]` from each invalidation's + * raw expression (e.g. `node:203`). We replace it with the xxHash3 hash of each + * tag — the same value emitted in the `x-amz-meta-cache-tag` response header — + * which the cache invalidation API normalises into a CloudFront cache tag. * - * - `tag:` becomes `#` (a CloudFront cache tag) - * - starts with `/`, `#`, or contains `:`: passes through unchanged - * - anything else gets a leading `/` (a CloudFront path) - * - * A bare `node:203` contains a colon, so it would pass through untouched and - * reach CloudFront's CreateInvalidation as a literal path. Prefixing the - * hashed expression with `tag:` routes it into the first branch, so it - * arrives as `#` and matches the values CloudFront indexed from the - * `x-amz-meta-cache-tag` response header, which uses the same hash service. - * - * The replacement is always rebuilt here rather than only adjusted, because - * purge_tokens_tokens() checks `$data['invalidations'][0]` and silently - * produces nothing when the offered batch does not start at index 0. That is - * the normal case whenever the queue holds an item of a type this purger does - * not support: PurgersService groups by original key, so the supported items - * arrive with sparse keys and the request body would go out with the literal - * token in it. + * The replacement is rebuilt unconditionally because purge_tokens_tokens() + * checks `$data['invalidations'][0]` and produces nothing when the batch offered + * by PurgersService is not indexed from 0 (which happens whenever the queue + * holds an item of a type this purger does not support). */ function marina_cf_cachetags_tokens_alter(array &$replacements, array $context, BubbleableMetadata $bubbleable_metadata) { if (($context['type'] ?? NULL) !== 'invalidations') { diff --git a/modules/marina_cf_cachetags/src/InvalidationTokens.php b/modules/marina_cf_cachetags/src/InvalidationTokens.php index e24f5ff..fb0bfaa 100644 --- a/modules/marina_cf_cachetags/src/InvalidationTokens.php +++ b/modules/marina_cf_cachetags/src/InvalidationTokens.php @@ -46,9 +46,10 @@ public static function hashedTagExpressions(iterable $invalidations, CacheTagsHa if (!\is_string($expression) || $expression === '') { continue; } - $expressions[] = \str_starts_with($expression, 'tag:') || \str_starts_with($expression, '#') - ? $expression - : 'tag:' . $hash->hashTag($expression); + // The cache invalidation API hashes/normalises the raw tag itself, so + // send the bare xxHash3 hash — the same value emitted in the + // x-amz-meta-cache-tag response header. + $expressions[] = $hash->hashTag($expression); } return \array_values(\array_unique($expressions)); } diff --git a/modules/marina_cf_cachetags/tests/src/Unit/InvalidationTokensTest.php b/modules/marina_cf_cachetags/tests/src/Unit/InvalidationTokensTest.php index 9766c80..aa2ddf3 100644 --- a/modules/marina_cf_cachetags/tests/src/Unit/InvalidationTokensTest.php +++ b/modules/marina_cf_cachetags/tests/src/Unit/InvalidationTokensTest.php @@ -33,22 +33,20 @@ public function testHashedTagExpressions(): void { $this->assertSame([], InvalidationTokens::hashedTagExpressions([], $hash)); $expressions = InvalidationTokens::hashedTagExpressions([ - // Sparse keys, as handed over by PurgersService. + // Sparse keys, as handed over by PurgersService. Only tag invalidations + // are hashed; url items, empty expressions and non-invalidations are + // skipped, and duplicates are removed with order preserved. 3 => $this->invalidation('tag', 'node:203'), 4 => $this->invalidation('url', 'http://example.com/'), 5 => $this->invalidation('tag', 'node:203'), - 6 => $this->invalidation('tag', '#already'), - 7 => $this->invalidation('tag', 'tag:pre'), - 8 => $this->invalidation('tag', ''), - 9 => 'not an invalidation', - 10 => $this->invalidation('tag', 'config:system.site'), + 6 => $this->invalidation('tag', ''), + 7 => 'not an invalidation', + 8 => $this->invalidation('tag', 'config:system.site'), ], $hash); $this->assertSame([ - 'tag:' . $hash->hashTag('node:203'), - '#already', - 'tag:pre', - 'tag:' . $hash->hashTag('config:system.site'), + $hash->hashTag('node:203'), + $hash->hashTag('config:system.site'), ], $expressions); } diff --git a/modules/marina_cf_cachetags/tests/src/Unit/TokensAlterTest.php b/modules/marina_cf_cachetags/tests/src/Unit/TokensAlterTest.php index 8089fe2..f741e78 100644 --- a/modules/marina_cf_cachetags/tests/src/Unit/TokensAlterTest.php +++ b/modules/marina_cf_cachetags/tests/src/Unit/TokensAlterTest.php @@ -47,7 +47,7 @@ protected function invalidation(string $type, string $expression): InvalidationI /** * Tag expressions are hashed and prefixed; other types are left alone. */ - public function testTagsAreHashedAndPrefixed(): void { + public function testTagsAreHashed(): void { $context = [ 'type' => 'invalidations', 'tokens' => ['separated_comma' => '[invalidations:separated_comma]'], @@ -58,8 +58,7 @@ public function testTagsAreHashedAndPrefixed(): void { 3 => $this->invalidation('tag', 'node:203'), 4 => $this->invalidation('url', 'http://example.com/'), 5 => $this->invalidation('tag', 'node:203'), - 6 => $this->invalidation('tag', '#already'), - 7 => $this->invalidation('tag', 'config:system.site'), + 6 => $this->invalidation('tag', 'config:system.site'), ], ], ]; @@ -67,7 +66,7 @@ public function testTagsAreHashedAndPrefixed(): void { $replacements = []; marina_cf_cachetags_tokens_alter($replacements, $context, new BubbleableMetadata()); $this->assertSame( - 'tag:' . $this->hash->hashTag('node:203') . ',#already,tag:' . $this->hash->hashTag('config:system.site'), + $this->hash->hashTag('node:203') . ',' . $this->hash->hashTag('config:system.site'), $replacements['[invalidations:separated_comma]'] ); } From 0a83e321d13e90e82d06cfaa30fc6e7d75fe57e2 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Wed, 16 Sep 2026 14:29:16 +1000 Subject: [PATCH 6/6] Fix coding standards in tokens_alter docblock --- .../marina_cf_cachetags.module | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/modules/marina_cf_cachetags/marina_cf_cachetags.module b/modules/marina_cf_cachetags/marina_cf_cachetags.module index 3ab769e..5f8e197 100644 --- a/modules/marina_cf_cachetags/marina_cf_cachetags.module +++ b/modules/marina_cf_cachetags/marina_cf_cachetags.module @@ -11,15 +11,16 @@ use Drupal\marina_cf_cachetags\InvalidationTokens; /** * Implements hook_tokens_alter(). * - * purge_tokens builds `[invalidations:separated_comma]` from each invalidation's - * raw expression (e.g. `node:203`). We replace it with the xxHash3 hash of each - * tag — the same value emitted in the `x-amz-meta-cache-tag` response header — - * which the cache invalidation API normalises into a CloudFront cache tag. + * The purge_tokens module builds `[invalidations:separated_comma]` from each + * invalidation's raw expression (e.g. `node:203`). We replace it with the + * xxHash3 hash of each tag — the same value emitted in the + * `x-amz-meta-cache-tag` response header — which the cache invalidation API + * normalises into a CloudFront cache tag. * * The replacement is rebuilt unconditionally because purge_tokens_tokens() - * checks `$data['invalidations'][0]` and produces nothing when the batch offered - * by PurgersService is not indexed from 0 (which happens whenever the queue - * holds an item of a type this purger does not support). + * checks `$data['invalidations'][0]` and produces nothing when the batch + * offered by PurgersService is not indexed from 0 (which happens whenever the + * queue holds an item of a type this purger does not support). */ function marina_cf_cachetags_tokens_alter(array &$replacements, array $context, BubbleableMetadata $bubbleable_metadata) { if (($context['type'] ?? NULL) !== 'invalidations') {