From 25d0219441daa5c57b091de8be5cfca227c99743 Mon Sep 17 00:00:00 2001 From: Jacob Dreesen Date: Fri, 10 Jul 2026 21:01:34 +0200 Subject: [PATCH 1/3] Add `withoutCollecting`, `withCollecting` and `isCollecting` methods to `CacheScope` Introduce `withoutCollecting` to temporarily pause tag collection and `withCollecting` to enable it within a given context. Introduce `isCollecting` to determine whether response tag collection is currently enabled. --- config/services.php | 4 +- doc/8-cache-scope.md | 95 ++++++++++- doc/9-disable-caching-behavior.md | 27 +++- ...p => OnlyWhenCollectingResponseTagger.php} | 4 +- src/CacheScope.php | 61 +++++++ ...lyWhenInvalidatingCacheInvalidatorTest.php | 21 +++ ... OnlyWhenCollectingResponseTaggerTest.php} | 16 +- tests/Unit/CacheScopeTest.php | 152 +++++++++++++++++- 8 files changed, 356 insertions(+), 24 deletions(-) rename src/Cache/ResponseTagger/{OnlyWhenEnabledResponseTagger.php => OnlyWhenCollectingResponseTagger.php} (80%) rename tests/Unit/Cache/ResponseTagger/{OnlyWhenEnabledResponseTaggerTest.php => OnlyWhenCollectingResponseTaggerTest.php} (72%) diff --git a/config/services.php b/config/services.php index 8786b75..a383798 100644 --- a/config/services.php +++ b/config/services.php @@ -12,7 +12,7 @@ use Neusta\Pimcore\HttpCacheBundle\Cache\CacheTagChecker\ElementCacheTagChecker; use Neusta\Pimcore\HttpCacheBundle\Cache\CacheTagChecker\StaticCacheTagChecker; use Neusta\Pimcore\HttpCacheBundle\Cache\ResponseTagger; -use Neusta\Pimcore\HttpCacheBundle\Cache\ResponseTagger\OnlyWhenEnabledResponseTagger; +use Neusta\Pimcore\HttpCacheBundle\Cache\ResponseTagger\OnlyWhenCollectingResponseTagger; use Neusta\Pimcore\HttpCacheBundle\Cache\ResponseTagger\RemoveDisabledTagsResponseTagger; use Neusta\Pimcore\HttpCacheBundle\Cache\ResponseTagger\TraceableResponseTagger; use Neusta\Pimcore\HttpCacheBundle\CacheActivator; @@ -75,7 +75,7 @@ ->decorate('neusta_pimcore_http_cache.response_tagger', null, -99) ->args([service('.inner'), service('neusta_pimcore_http_cache.cache_tag_checker')]); - $services->set(null, OnlyWhenEnabledResponseTagger::class) + $services->set(null, OnlyWhenCollectingResponseTagger::class) ->decorate('neusta_pimcore_http_cache.response_tagger', null, -100) ->args([service('.inner'), service('neusta_pimcore_http_cache.cache_scope')]); diff --git a/doc/8-cache-scope.md b/doc/8-cache-scope.md index 898e7ee..767e8d1 100644 --- a/doc/8-cache-scope.md +++ b/doc/8-cache-scope.md @@ -4,11 +4,13 @@ The cache scope controls whether and from which point in the request lifecycle c Tagging and invalidation are gated independently: -- **Tagging** only happens while the scope is **enabled**. Tagging only ever makes sense for a request whose response - can actually be cached, so the scope must be explicitly activated (see [Automatic activation](#automatic-activation)). +- **Tagging** only happens while the scope is **enabled** and collection isn't paused (see + [Temporarily pausing collection](#temporarily-pausing-collection)). Tagging only ever makes sense for a request + whose response can actually be cached, so the scope must be explicitly activated (see + [Automatic activation](#automatic-activation)). - **Invalidation** is active by default — everywhere, including non-cacheable requests such as an admin-UI save or a custom `POST` endpoint — and only stops once `disable()` has been called. Invalidation does not depend on the scope - being enabled. + being enabled or on tag collection being paused. Calling `disable()` always stops both tagging and invalidation, regardless of the above. @@ -46,7 +48,7 @@ use Neusta\Pimcore\HttpCacheBundle\CacheScope; final class MyService { public function __construct( - private readonly CacheScope $cacheScope, + private CacheScope $cacheScope, ) { } @@ -60,11 +62,96 @@ final class MyService // invalidation is currently active } + if ($this->cacheScope->isCollecting()) { + // cache collection is currently enabled + } + $this->cacheScope->disable(); // both tagging and invalidation are disabled from now on } } ``` +### Temporarily pausing collection + +Use `withoutCollecting()` to temporarily pause tag collection for a specific block of code. + +> [!NOTE] +> `withoutCollecting()` only pauses response tag collection. +> It does not disable cache invalidation. +> Use `disable()` if you want to disable cache-related behavior entirely. + +```php +use Neusta\Pimcore\HttpCacheBundle\CacheScope; + +final class MyService +{ + public function __construct( + private CacheScope $cacheScope, + ) { + } + + public function doSomething(): mixed + { + return $this->cacheScope->withoutCollecting(function (CacheScope $scope): mixed { + // cache tags are not collected here + + return $this->loadSomething(); + }); + } + + private function loadSomething(): mixed + { + // ... + } +} +``` + +### Temporarily resuming collection + +Use `withCollecting()` to temporarily enable collection within a callback. +This can be useful inside `withoutCollecting()` when only a smaller inner section should collect cache tags again. + +> [!NOTE] +> `withCollecting()` still respects `disable()`. +> If the scope was disabled for the current request or command, `withCollecting()` will not make the scope enabled. + +```php +use Neusta\Pimcore\HttpCacheBundle\CacheScope; + +final class MyService +{ + public function __construct( + private readonly CacheScope $cacheScope, + ) { + } + + public function doSomething(): mixed + { + return $this->cacheScope->withoutCollecting(function (CacheScope $scope): mixed { + // cache tags are not collected here + + $result = $scope->withCollecting(function (): mixed { + // cache tags are collected here again, + // unless the scope was disabled for the request or command + + return $this->loadSomethingThatShouldBeTagged(); + }); + + // cache tags are not collected here again + + return $result; + }); + } + + private function loadSomethingThatShouldBeTagged(): mixed + { + // ... + } +} +``` + +### Disabling the scope + To disable cache-related behavior for the current request or command, see [Disabling caching behavior](9-disable-caching-behavior.md). diff --git a/doc/9-disable-caching-behavior.md b/doc/9-disable-caching-behavior.md index 21e1f79..938947d 100644 --- a/doc/9-disable-caching-behavior.md +++ b/doc/9-disable-caching-behavior.md @@ -1,24 +1,37 @@ ## Disable caching behavior -You may encounter situations where you need to disable the default caching behavior for specific requests. +You may encounter situations where you need to disable the default caching behavior for specific requests or commands. This is especially useful for testing purposes. -To achieve this, you can use the `CacheScope` to disable tagging and invalidation behavior entirely. + +To achieve this, you can use `CacheScope::disable()` to disable tagging and invalidation behavior entirely +for the current request or command. > [!IMPORTANT] -> Once disabled, the cache scope cannot be re-enabled during the same request or command. -> Calling `enable()` afterward has no effect; the disabled state is only cleared when the scope is reset. +> Once disabled, the cache scope cannot be re-enabled by calling `startCollecting()` or `withCollecting()` during the same request or command. +> The disabled state is only cleared when the scope is reset. ### Example for disabling caching behavior in a specific test case + ```php +use Neusta\Pimcore\HttpCacheBundle\CacheScope; +use Pimcore\Test\KernelTestCase; - /** @test */ - public function my_awesome_test_case(): void +final class MyAwesomeTest extends KernelTestCase +{ + /** @test */ + public function my_awesome_test_case(): void { - // Disable the caching behavior self::getContainer()->get(CacheScope::class)->disable(); // Your test code here self::assertSame('this is amazing!', $result); } +} ``` + +### Difference to temporarily pausing collection + +`disable()` disables cache-related behavior for the whole request or command. +If you only want to temporarily pause or resume tag collection for a specific block of code, use the cache scope methods +described in [Cache Scope](8-cache-scope.md#temporarily-pausing-collection). diff --git a/src/Cache/ResponseTagger/OnlyWhenEnabledResponseTagger.php b/src/Cache/ResponseTagger/OnlyWhenCollectingResponseTagger.php similarity index 80% rename from src/Cache/ResponseTagger/OnlyWhenEnabledResponseTagger.php rename to src/Cache/ResponseTagger/OnlyWhenCollectingResponseTagger.php index 8b012fa..97502d1 100644 --- a/src/Cache/ResponseTagger/OnlyWhenEnabledResponseTagger.php +++ b/src/Cache/ResponseTagger/OnlyWhenCollectingResponseTagger.php @@ -6,7 +6,7 @@ use Neusta\Pimcore\HttpCacheBundle\Cache\ResponseTagger; use Neusta\Pimcore\HttpCacheBundle\CacheScope; -final class OnlyWhenEnabledResponseTagger implements ResponseTagger +final class OnlyWhenCollectingResponseTagger implements ResponseTagger { public function __construct( private readonly ResponseTagger $inner, @@ -16,7 +16,7 @@ public function __construct( public function tag(CacheTags $tags): void { - if ($this->cacheScope->isEnabled()) { + if ($this->cacheScope->isCollecting()) { $this->inner->tag($tags); } } diff --git a/src/CacheScope.php b/src/CacheScope.php index 4a0f89d..c59b8eb 100644 --- a/src/CacheScope.php +++ b/src/CacheScope.php @@ -12,6 +12,7 @@ class CacheScope implements ResetInterface { private bool $enabled = false; private bool $disabled = false; + private bool $paused = false; /** * Enables cache-related behavior until the scope is reset. @@ -35,6 +36,9 @@ public function disable(): void /** * Whether cache-related behavior is currently enabled. + * + * This does not imply that response tags are currently collected; + * collection may still be paused via {@see withoutCollecting()}. */ public function isEnabled(): bool { @@ -53,9 +57,66 @@ public function isInvalidating(): bool return !$this->disabled; } + /** + * Whether response tag collection is currently enabled and not paused. + */ + public function isCollecting(): bool + { + return $this->isEnabled() && !$this->paused; + } + + /** + * Executes the callable with tag collection temporarily paused. + * + * @template T + * + * @param (\Closure(self): T) $fn + * + * @return T + */ + public function withoutCollecting(\Closure $fn): mixed + { + $previousPaused = $this->paused; + $this->paused = true; + + try { + return $fn($this); + } finally { + $this->paused = $previousPaused; + } + } + + /** + * Executes the callable with tag collection enabled. + * + * Temporarily resumes collection inside {@see withoutCollecting()}, but still respects {@see disable()}. + * + * @template T + * + * @param (\Closure(self): T) $fn + * + * @return T + */ + public function withCollecting(\Closure $fn): mixed + { + $previousEnabled = $this->enabled; + $previousPaused = $this->paused; + + $this->enabled = true; + $this->paused = false; + + try { + return $fn($this); + } finally { + $this->enabled = $previousEnabled; + $this->paused = $previousPaused; + } + } + public function reset(): void { $this->enabled = false; $this->disabled = false; + $this->paused = false; } } diff --git a/tests/Unit/Cache/CacheInvalidator/OnlyWhenInvalidatingCacheInvalidatorTest.php b/tests/Unit/Cache/CacheInvalidator/OnlyWhenInvalidatingCacheInvalidatorTest.php index bc66a28..501b994 100644 --- a/tests/Unit/Cache/CacheInvalidator/OnlyWhenInvalidatingCacheInvalidatorTest.php +++ b/tests/Unit/Cache/CacheInvalidator/OnlyWhenInvalidatingCacheInvalidatorTest.php @@ -60,4 +60,25 @@ public function it_should_not_invalidate_tags_when_invalidation_is_not_active(): $this->decorated->invalidate(Argument::any())->shouldNotHaveBeenCalled(); } + + /** + * @test + */ + public function it_should_invalidate_tags_when_collection_is_paused(): void + { + $tags = CacheTags::fromStrings(['tag1', 'tag2']); + $cacheScope = new CacheScope(); + $cacheScope->enable(); + + $subject = new OnlyWhenInvalidatingCacheInvalidator( + $this->decorated->reveal(), + $cacheScope, + ); + + $cacheScope->withoutCollecting(static function () use ($subject, $tags): void { + $subject->invalidate($tags); + }); + + $this->decorated->invalidate($tags)->shouldHaveBeenCalled(); + } } diff --git a/tests/Unit/Cache/ResponseTagger/OnlyWhenEnabledResponseTaggerTest.php b/tests/Unit/Cache/ResponseTagger/OnlyWhenCollectingResponseTaggerTest.php similarity index 72% rename from tests/Unit/Cache/ResponseTagger/OnlyWhenEnabledResponseTaggerTest.php rename to tests/Unit/Cache/ResponseTagger/OnlyWhenCollectingResponseTaggerTest.php index 1d1ce08..f0289c8 100644 --- a/tests/Unit/Cache/ResponseTagger/OnlyWhenEnabledResponseTaggerTest.php +++ b/tests/Unit/Cache/ResponseTagger/OnlyWhenCollectingResponseTaggerTest.php @@ -4,18 +4,18 @@ use Neusta\Pimcore\HttpCacheBundle\Cache\CacheTags; use Neusta\Pimcore\HttpCacheBundle\Cache\ResponseTagger; -use Neusta\Pimcore\HttpCacheBundle\Cache\ResponseTagger\OnlyWhenEnabledResponseTagger; +use Neusta\Pimcore\HttpCacheBundle\Cache\ResponseTagger\OnlyWhenCollectingResponseTagger; use Neusta\Pimcore\HttpCacheBundle\CacheScope; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; -final class OnlyWhenEnabledResponseTaggerTest extends TestCase +final class OnlyWhenCollectingResponseTaggerTest extends TestCase { use ProphecyTrait; - private OnlyWhenEnabledResponseTagger $subject; + private OnlyWhenCollectingResponseTagger $subject; /** @var ObjectProphecy */ private ObjectProphecy $decorated; @@ -27,7 +27,7 @@ protected function setUp(): void { $this->decorated = $this->prophesize(ResponseTagger::class); $this->cacheScope = $this->prophesize(CacheScope::class); - $this->subject = new OnlyWhenEnabledResponseTagger( + $this->subject = new OnlyWhenCollectingResponseTagger( $this->decorated->reveal(), $this->cacheScope->reveal(), ); @@ -36,11 +36,11 @@ protected function setUp(): void /** * @test */ - public function it_should_invalidate_tags_when_caching_is_active(): void + public function it_should_invalidate_tags_when_scope_is_collecting(): void { $tags = CacheTags::fromStrings(['tag1', 'tag2']); - $this->cacheScope->isEnabled()->willReturn(true); + $this->cacheScope->isCollecting()->willReturn(true); $this->subject->tag($tags); @@ -50,11 +50,11 @@ public function it_should_invalidate_tags_when_caching_is_active(): void /** * @test */ - public function it_should_not_invalidate_tags_when_caching_is_not_active(): void + public function it_should_not_invalidate_tags_when_scope_is_not_collecting(): void { $tags = CacheTags::fromStrings(['tag1', 'tag2']); - $this->cacheScope->isEnabled()->willReturn(false); + $this->cacheScope->isCollecting()->willReturn(false); $this->subject->tag($tags); diff --git a/tests/Unit/CacheScopeTest.php b/tests/Unit/CacheScopeTest.php index d4b43af..903116b 100644 --- a/tests/Unit/CacheScopeTest.php +++ b/tests/Unit/CacheScopeTest.php @@ -15,6 +15,7 @@ public function it_is_disabled_by_default(): void $cacheScope = new CacheScope(); self::assertFalse($cacheScope->isEnabled()); + self::assertFalse($cacheScope->isCollecting()); } /** @@ -27,6 +28,7 @@ public function enable_enables_the_scope(): void $cacheScope->enable(); self::assertTrue($cacheScope->isEnabled()); + self::assertTrue($cacheScope->isCollecting()); } /** @@ -40,6 +42,7 @@ public function disable_disables_scope_and_collection(): void $cacheScope->disable(); self::assertFalse($cacheScope->isEnabled()); + self::assertFalse($cacheScope->isCollecting()); } /** @@ -53,12 +56,157 @@ public function disable_survives_later_enable_calls(): void $cacheScope->enable(); self::assertFalse($cacheScope->isEnabled()); + self::assertFalse($cacheScope->isCollecting()); } /** * @test */ - public function reset_deactivates_the_scope(): void + public function withoutCollecting_keeps_scope_enabled_but_stops_collection(): void + { + $cacheScope = new CacheScope(); + $cacheScope->enable(); + + $cacheScope->withoutCollecting(static function (CacheScope $scope): void { + self::assertTrue($scope->isEnabled()); + self::assertFalse($scope->isCollecting()); + }); + + self::assertTrue($cacheScope->isEnabled()); + self::assertTrue($cacheScope->isCollecting()); + } + + /** + * @test + */ + public function withoutCollecting_returns_the_closure_result(): void + { + $cacheScope = new CacheScope(); + + $result = $cacheScope->withoutCollecting(static fn () => 'result'); + + self::assertSame('result', $result); + } + + /** + * @test + */ + public function nested_withoutCollecting_keeps_scope_disabled_until_outer_scope_finishes(): void + { + $cacheScope = new CacheScope(); + $cacheScope->enable(); + + $cacheScope->withoutCollecting(static function (CacheScope $scope): void { + self::assertTrue($scope->isEnabled()); + self::assertFalse($scope->isCollecting()); + + $scope->withoutCollecting(static function (CacheScope $scope): void { + self::assertTrue($scope->isEnabled()); + self::assertFalse($scope->isCollecting()); + }); + + self::assertTrue($scope->isEnabled()); + self::assertFalse($scope->isCollecting()); + }); + + self::assertTrue($cacheScope->isEnabled()); + self::assertTrue($cacheScope->isCollecting()); + } + + /** + * @test + */ + public function withCollecting_temporarily_enables_the_scope(): void + { + $cacheScope = new CacheScope(); + + self::assertFalse($cacheScope->isEnabled()); + self::assertFalse($cacheScope->isCollecting()); + + $cacheScope->withCollecting(static function (CacheScope $scope): void { + self::assertTrue($scope->isEnabled()); + self::assertTrue($scope->isCollecting()); + }); + + self::assertFalse($cacheScope->isEnabled()); + self::assertFalse($cacheScope->isCollecting()); + } + + /** + * @test + */ + public function withCollecting_returns_the_closure_result(): void + { + $cacheScope = new CacheScope(); + + $result = $cacheScope->withCollecting(static fn () => 'result'); + + self::assertSame('result', $result); + } + + /** + * @test + */ + public function withCollecting_respects_disable(): void + { + $cacheScope = new CacheScope(); + $cacheScope->disable(); + + $cacheScope->withCollecting(static function (CacheScope $scope): void { + self::assertFalse($scope->isEnabled()); + self::assertFalse($scope->isCollecting()); + }); + + self::assertFalse($cacheScope->isEnabled()); + self::assertFalse($cacheScope->isCollecting()); + } + + /** + * @test + */ + public function withCollecting_temporarily_resumes_collection_inside_withoutCollecting(): void + { + $cacheScope = new CacheScope(); + $cacheScope->enable(); + + $cacheScope->withoutCollecting(static function (CacheScope $scope): void { + self::assertTrue($scope->isEnabled()); + self::assertFalse($scope->isCollecting()); + + $scope->withCollecting(static function (CacheScope $scope): void { + self::assertTrue($scope->isEnabled()); + self::assertTrue($scope->isCollecting()); + }); + + self::assertTrue($scope->isEnabled()); + self::assertFalse($scope->isCollecting()); + }); + + self::assertTrue($cacheScope->isEnabled()); + self::assertTrue($cacheScope->isCollecting()); + } + + /** + * @test + */ + public function withCollecting_restores_previous_state_after_closure(): void + { + $cacheScope = new CacheScope(); + $cacheScope->enable(); + + $cacheScope->withCollecting(static function (CacheScope $scope): void { + self::assertTrue($scope->isEnabled()); + self::assertTrue($scope->isCollecting()); + }); + + self::assertTrue($cacheScope->isEnabled()); + self::assertTrue($cacheScope->isCollecting()); + } + + /** + * @test + */ + public function reset_disables_the_scope(): void { $cacheScope = new CacheScope(); $cacheScope->enable(); @@ -66,6 +214,7 @@ public function reset_deactivates_the_scope(): void $cacheScope->reset(); self::assertFalse($cacheScope->isEnabled()); + self::assertFalse($cacheScope->isCollecting()); } /** @@ -80,6 +229,7 @@ public function reset_clears_disabled_state_so_scope_can_be_enabled_again(): voi $cacheScope->enable(); self::assertTrue($cacheScope->isEnabled()); + self::assertTrue($cacheScope->isCollecting()); } /** From f710f4ba6b06b32185e144e0b6f37b1589860255 Mon Sep 17 00:00:00 2001 From: Jacob Dreesen Date: Mon, 31 Aug 2026 10:31:56 +0200 Subject: [PATCH 2/3] Add withoutInvalidating() and withInvalidating() to CacheScope Mirrors withoutCollecting()/withCollecting(): lets code temporarily pause cache invalidation for a block (e.g. a bulk import that saves many elements without wanting each save to invalidate), backed by its own pause flag independent of tag-collection pausing. Still respects disable(), same as withCollecting(). Also backfill a CHANGELOG entry for the withCollecting()/isCollecting() feature, which shipped without one. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 + doc/8-cache-scope.md | 85 ++++++++++++++++- doc/9-disable-caching-behavior.md | 6 +- src/CacheScope.php | 53 ++++++++++- ...lyWhenInvalidatingCacheInvalidatorTest.php | 20 ++++ tests/Unit/CacheScopeTest.php | 93 +++++++++++++++++++ 6 files changed, 252 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6946dd8..972f59e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - feature: Introduced `CacheScope` service for controlling when and whether cache tags are collected. Cache invalidation is active by default everywhere (including non-cacheable and admin-context requests) and only stops once `CacheScope::disable()` is called; tag collection instead requires the scope to be explicitly enabled for the current request - feature: New `scope` configuration option (`controller` / `request`) to control when tag collection begins within a request - feature: New `tag_fallback_document` configuration option to tag responses with fallback documents +- feature: New `CacheScope::withoutCollecting()`/`withCollecting()` methods to temporarily pause/resume tag collection for a block of code, and `isCollecting()` to check the current state +- feature: New `CacheScope::withoutInvalidating()`/`withInvalidating()` methods to temporarily pause/resume cache invalidation for a block of code, e.g. around a bulk import - deprecation: `CacheActivator` is deprecated; use `CacheScope` instead - deprecation: `CacheActivator::isCachingActive()` is deprecated; use `CacheScope::isInvalidating()` instead - deprecation: `CacheActivator::activateCaching()` is deprecated; use `CacheScope::enable()` instead diff --git a/doc/8-cache-scope.md b/doc/8-cache-scope.md index 767e8d1..86bffe7 100644 --- a/doc/8-cache-scope.md +++ b/doc/8-cache-scope.md @@ -9,8 +9,9 @@ Tagging and invalidation are gated independently: whose response can actually be cached, so the scope must be explicitly activated (see [Automatic activation](#automatic-activation)). - **Invalidation** is active by default — everywhere, including non-cacheable requests such as an admin-UI save or a - custom `POST` endpoint — and only stops once `disable()` has been called. Invalidation does not depend on the scope - being enabled or on tag collection being paused. + custom `POST` endpoint — and only stops once `disable()` has been called, or while temporarily paused via + `withoutInvalidating()` (see [Temporarily pausing invalidation](#temporarily-pausing-invalidation)). Invalidation + does not depend on the scope being enabled or on tag collection being paused. Calling `disable()` always stops both tagging and invalidation, regardless of the above. @@ -151,6 +152,86 @@ final class MyService } ``` +### Temporarily pausing invalidation + +Use `withoutInvalidating()` to temporarily pause cache invalidation for a specific block of code, e.g. around a bulk +import that saves many elements you don't want to trigger invalidation for. + +> [!NOTE] +> `withoutInvalidating()` only pauses cache invalidation. +> It does not disable response tagging. +> Use `disable()` if you want to disable cache-related behavior entirely. + +```php +use Neusta\Pimcore\HttpCacheBundle\CacheScope; + +final class MyService +{ + public function __construct( + private CacheScope $cacheScope, + ) { + } + + public function doSomething(): mixed + { + return $this->cacheScope->withoutInvalidating(function (CacheScope $scope): mixed { + // cache invalidation does not happen here + + return $this->importElements(); + }); + } + + private function importElements(): mixed + { + // ... + } +} +``` + +### Temporarily resuming invalidation + +Use `withInvalidating()` to temporarily enable invalidation within a callback. +This can be useful inside `withoutInvalidating()` when only a smaller inner section should invalidate again. + +> [!NOTE] +> `withInvalidating()` still respects `disable()`. +> If the scope was disabled for the current request or command, `withInvalidating()` will not make invalidation active. + +```php +use Neusta\Pimcore\HttpCacheBundle\CacheScope; + +final class MyService +{ + public function __construct( + private readonly CacheScope $cacheScope, + ) { + } + + public function doSomething(): mixed + { + return $this->cacheScope->withoutInvalidating(function (CacheScope $scope): mixed { + // cache invalidation does not happen here + + $result = $scope->withInvalidating(function (): mixed { + // cache invalidation happens here again, + // unless the scope was disabled for the request or command + + return $this->saveElementThatShouldInvalidate(); + }); + + // cache invalidation does not happen here again + + return $result; + }); + } + + private function saveElementThatShouldInvalidate(): mixed + { + // ... + } +} +``` + ### Disabling the scope To disable cache-related behavior for the current request or command, see diff --git a/doc/9-disable-caching-behavior.md b/doc/9-disable-caching-behavior.md index 938947d..d42374b 100644 --- a/doc/9-disable-caching-behavior.md +++ b/doc/9-disable-caching-behavior.md @@ -30,8 +30,8 @@ final class MyAwesomeTest extends KernelTestCase } ``` -### Difference to temporarily pausing collection +### Difference to temporarily pausing collection or invalidation `disable()` disables cache-related behavior for the whole request or command. -If you only want to temporarily pause or resume tag collection for a specific block of code, use the cache scope methods -described in [Cache Scope](8-cache-scope.md#temporarily-pausing-collection). +If you only want to temporarily pause or resume tag collection or invalidation for a specific block of code, use the +cache scope methods described in [Cache Scope](8-cache-scope.md#temporarily-pausing-collection) instead. diff --git a/src/CacheScope.php b/src/CacheScope.php index c59b8eb..213cd84 100644 --- a/src/CacheScope.php +++ b/src/CacheScope.php @@ -13,6 +13,7 @@ class CacheScope implements ResetInterface private bool $enabled = false; private bool $disabled = false; private bool $paused = false; + private bool $invalidationPaused = false; /** * Enables cache-related behavior until the scope is reset. @@ -50,11 +51,58 @@ public function isEnabled(): bool * * Unlike {@see isEnabled()}, invalidation is active by default and only * stops once {@see disable()} has been called; it does not require an - * explicit {@see enable()} call. + * explicit {@see enable()} call. It may still be paused via {@see withoutInvalidating()}. */ public function isInvalidating(): bool { - return !$this->disabled; + return !$this->disabled && !$this->invalidationPaused; + } + + /** + * Executes the callable with cache invalidation temporarily paused. + * + * Useful for suppressing invalidation around code that saves elements you don't want + * to trigger cache invalidation, e.g. bulk imports. + * + * @template T + * + * @param (\Closure(self): T) $fn + * + * @return T + */ + public function withoutInvalidating(\Closure $fn): mixed + { + $previousInvalidationPaused = $this->invalidationPaused; + $this->invalidationPaused = true; + + try { + return $fn($this); + } finally { + $this->invalidationPaused = $previousInvalidationPaused; + } + } + + /** + * Executes the callable with cache invalidation enabled. + * + * Temporarily resumes invalidation inside {@see withoutInvalidating()}, but still respects {@see disable()}. + * + * @template T + * + * @param (\Closure(self): T) $fn + * + * @return T + */ + public function withInvalidating(\Closure $fn): mixed + { + $previousInvalidationPaused = $this->invalidationPaused; + $this->invalidationPaused = false; + + try { + return $fn($this); + } finally { + $this->invalidationPaused = $previousInvalidationPaused; + } } /** @@ -118,5 +166,6 @@ public function reset(): void $this->enabled = false; $this->disabled = false; $this->paused = false; + $this->invalidationPaused = false; } } diff --git a/tests/Unit/Cache/CacheInvalidator/OnlyWhenInvalidatingCacheInvalidatorTest.php b/tests/Unit/Cache/CacheInvalidator/OnlyWhenInvalidatingCacheInvalidatorTest.php index 501b994..a1aefa1 100644 --- a/tests/Unit/Cache/CacheInvalidator/OnlyWhenInvalidatingCacheInvalidatorTest.php +++ b/tests/Unit/Cache/CacheInvalidator/OnlyWhenInvalidatingCacheInvalidatorTest.php @@ -81,4 +81,24 @@ public function it_should_invalidate_tags_when_collection_is_paused(): void $this->decorated->invalidate($tags)->shouldHaveBeenCalled(); } + + /** + * @test + */ + public function it_should_not_invalidate_tags_when_invalidation_is_paused(): void + { + $tags = CacheTags::fromStrings(['tag1', 'tag2']); + $cacheScope = new CacheScope(); + + $subject = new OnlyWhenInvalidatingCacheInvalidator( + $this->decorated->reveal(), + $cacheScope, + ); + + $cacheScope->withoutInvalidating(static function () use ($subject, $tags): void { + $subject->invalidate($tags); + }); + + $this->decorated->invalidate(Argument::any())->shouldNotHaveBeenCalled(); + } } diff --git a/tests/Unit/CacheScopeTest.php b/tests/Unit/CacheScopeTest.php index 903116b..d24c423 100644 --- a/tests/Unit/CacheScopeTest.php +++ b/tests/Unit/CacheScopeTest.php @@ -277,4 +277,97 @@ public function reset_clears_disabled_state_so_invalidation_resumes(): void self::assertTrue($cacheScope->isInvalidating()); } + + /** + * @test + */ + public function withoutInvalidating_stops_invalidation(): void + { + $cacheScope = new CacheScope(); + + $cacheScope->withoutInvalidating(static function (CacheScope $scope): void { + self::assertFalse($scope->isInvalidating()); + }); + + self::assertTrue($cacheScope->isInvalidating()); + } + + /** + * @test + */ + public function withoutInvalidating_returns_the_closure_result(): void + { + $cacheScope = new CacheScope(); + + $result = $cacheScope->withoutInvalidating(static fn () => 'result'); + + self::assertSame('result', $result); + } + + /** + * @test + */ + public function nested_withoutInvalidating_keeps_invalidation_paused_until_outer_scope_finishes(): void + { + $cacheScope = new CacheScope(); + + $cacheScope->withoutInvalidating(static function (CacheScope $scope): void { + self::assertFalse($scope->isInvalidating()); + + $scope->withoutInvalidating(static function (CacheScope $scope): void { + self::assertFalse($scope->isInvalidating()); + }); + + self::assertFalse($scope->isInvalidating()); + }); + + self::assertTrue($cacheScope->isInvalidating()); + } + + /** + * @test + */ + public function withInvalidating_returns_the_closure_result(): void + { + $cacheScope = new CacheScope(); + + $result = $cacheScope->withInvalidating(static fn () => 'result'); + + self::assertSame('result', $result); + } + + /** + * @test + */ + public function withInvalidating_respects_disable(): void + { + $cacheScope = new CacheScope(); + $cacheScope->disable(); + + $cacheScope->withInvalidating(static function (CacheScope $scope): void { + self::assertFalse($scope->isInvalidating()); + }); + + self::assertFalse($cacheScope->isInvalidating()); + } + + /** + * @test + */ + public function withInvalidating_temporarily_resumes_invalidation_inside_withoutInvalidating(): void + { + $cacheScope = new CacheScope(); + + $cacheScope->withoutInvalidating(static function (CacheScope $scope): void { + self::assertFalse($scope->isInvalidating()); + + $scope->withInvalidating(static function (CacheScope $scope): void { + self::assertTrue($scope->isInvalidating()); + }); + + self::assertFalse($scope->isInvalidating()); + }); + + self::assertTrue($cacheScope->isInvalidating()); + } } From 0e37254949407b7bc44da4ce9bdb2cb47a8e6eb4 Mon Sep 17 00:00:00 2001 From: Jacob Dreesen Date: Mon, 31 Aug 2026 10:41:51 +0200 Subject: [PATCH 3/3] Remove CacheScope::isEnabled(), rename $paused to $collectionPaused isEnabled() was never called by production code (the tagger and invalidator are wired to isCollecting()/isInvalidating()) and had become a partial predicate once collection could be paused: it says "enable() was called" without accounting for the pause, which isn't a distinction any caller actually needs. Fold it into isCollecting() directly and drop it from the public API. Rename $paused to $collectionPaused now that there's a second, independent pause flag ($invalidationPaused) for symmetry, and to make clear it only affects tag collection. Also fix a doc typo referencing a non-existent startCollecting() method. Co-Authored-By: Claude Sonnet 5 --- doc/8-cache-scope.md | 8 +-- doc/9-disable-caching-behavior.md | 2 +- src/CacheScope.php | 93 ++++++++++++++----------------- tests/Unit/CacheScopeTest.php | 27 +-------- 4 files changed, 46 insertions(+), 84 deletions(-) diff --git a/doc/8-cache-scope.md b/doc/8-cache-scope.md index 86bffe7..7b9bcc6 100644 --- a/doc/8-cache-scope.md +++ b/doc/8-cache-scope.md @@ -55,18 +55,14 @@ final class MyService public function doSomething(): void { - if ($this->cacheScope->isEnabled()) { - // tagging is currently enabled + if ($this->cacheScope->isCollecting()) { + // tagging is currently active } if ($this->cacheScope->isInvalidating()) { // invalidation is currently active } - if ($this->cacheScope->isCollecting()) { - // cache collection is currently enabled - } - $this->cacheScope->disable(); // both tagging and invalidation are disabled from now on } diff --git a/doc/9-disable-caching-behavior.md b/doc/9-disable-caching-behavior.md index d42374b..2f95150 100644 --- a/doc/9-disable-caching-behavior.md +++ b/doc/9-disable-caching-behavior.md @@ -7,7 +7,7 @@ To achieve this, you can use `CacheScope::disable()` to disable tagging and inva for the current request or command. > [!IMPORTANT] -> Once disabled, the cache scope cannot be re-enabled by calling `startCollecting()` or `withCollecting()` during the same request or command. +> Once disabled, the cache scope cannot be re-enabled by calling `enable()` or `withCollecting()`/`withInvalidating()` during the same request or command. > The disabled state is only cleared when the scope is reset. ### Example for disabling caching behavior in a specific test case diff --git a/src/CacheScope.php b/src/CacheScope.php index 213cd84..3ba57fd 100644 --- a/src/CacheScope.php +++ b/src/CacheScope.php @@ -12,7 +12,7 @@ class CacheScope implements ResetInterface { private bool $enabled = false; private bool $disabled = false; - private bool $paused = false; + private bool $collectionPaused = false; private bool $invalidationPaused = false; /** @@ -36,33 +36,15 @@ public function disable(): void } /** - * Whether cache-related behavior is currently enabled. - * - * This does not imply that response tags are currently collected; - * collection may still be paused via {@see withoutCollecting()}. - */ - public function isEnabled(): bool - { - return $this->enabled && !$this->disabled; - } - - /** - * Whether cache invalidation is currently active. - * - * Unlike {@see isEnabled()}, invalidation is active by default and only - * stops once {@see disable()} has been called; it does not require an - * explicit {@see enable()} call. It may still be paused via {@see withoutInvalidating()}. + * Whether response tag collection is currently enabled and not paused. */ - public function isInvalidating(): bool + public function isCollecting(): bool { - return !$this->disabled && !$this->invalidationPaused; + return $this->enabled && !$this->disabled && !$this->collectionPaused; } /** - * Executes the callable with cache invalidation temporarily paused. - * - * Useful for suppressing invalidation around code that saves elements you don't want - * to trigger cache invalidation, e.g. bulk imports. + * Executes the callable with tag collection temporarily paused. * * @template T * @@ -70,22 +52,22 @@ public function isInvalidating(): bool * * @return T */ - public function withoutInvalidating(\Closure $fn): mixed + public function withoutCollecting(\Closure $fn): mixed { - $previousInvalidationPaused = $this->invalidationPaused; - $this->invalidationPaused = true; + $previousCollectionPaused = $this->collectionPaused; + $this->collectionPaused = true; try { return $fn($this); } finally { - $this->invalidationPaused = $previousInvalidationPaused; + $this->collectionPaused = $previousCollectionPaused; } } /** - * Executes the callable with cache invalidation enabled. + * Executes the callable with tag collection enabled. * - * Temporarily resumes invalidation inside {@see withoutInvalidating()}, but still respects {@see disable()}. + * Temporarily resumes collection inside {@see withoutCollecting()}, but still respects {@see disable()}. * * @template T * @@ -93,28 +75,39 @@ public function withoutInvalidating(\Closure $fn): mixed * * @return T */ - public function withInvalidating(\Closure $fn): mixed + public function withCollecting(\Closure $fn): mixed { - $previousInvalidationPaused = $this->invalidationPaused; - $this->invalidationPaused = false; + $previousEnabled = $this->enabled; + $previousCollectionPaused = $this->collectionPaused; + + $this->enabled = true; + $this->collectionPaused = false; try { return $fn($this); } finally { - $this->invalidationPaused = $previousInvalidationPaused; + $this->enabled = $previousEnabled; + $this->collectionPaused = $previousCollectionPaused; } } /** - * Whether response tag collection is currently enabled and not paused. + * Whether cache invalidation is currently active. + * + * Unlike {@see isCollecting()}, invalidation is active by default and only + * stops once {@see disable()} has been called; it does not require an + * explicit {@see enable()} call. It may still be paused via {@see withoutInvalidating()}. */ - public function isCollecting(): bool + public function isInvalidating(): bool { - return $this->isEnabled() && !$this->paused; + return !$this->disabled && !$this->invalidationPaused; } /** - * Executes the callable with tag collection temporarily paused. + * Executes the callable with cache invalidation temporarily paused. + * + * Useful for suppressing invalidation around code that saves elements you don't want + * to trigger cache invalidation, e.g. bulk imports. * * @template T * @@ -122,22 +115,22 @@ public function isCollecting(): bool * * @return T */ - public function withoutCollecting(\Closure $fn): mixed + public function withoutInvalidating(\Closure $fn): mixed { - $previousPaused = $this->paused; - $this->paused = true; + $previousInvalidationPaused = $this->invalidationPaused; + $this->invalidationPaused = true; try { return $fn($this); } finally { - $this->paused = $previousPaused; + $this->invalidationPaused = $previousInvalidationPaused; } } /** - * Executes the callable with tag collection enabled. + * Executes the callable with cache invalidation enabled. * - * Temporarily resumes collection inside {@see withoutCollecting()}, but still respects {@see disable()}. + * Temporarily resumes invalidation inside {@see withoutInvalidating()}, but still respects {@see disable()}. * * @template T * @@ -145,19 +138,15 @@ public function withoutCollecting(\Closure $fn): mixed * * @return T */ - public function withCollecting(\Closure $fn): mixed + public function withInvalidating(\Closure $fn): mixed { - $previousEnabled = $this->enabled; - $previousPaused = $this->paused; - - $this->enabled = true; - $this->paused = false; + $previousInvalidationPaused = $this->invalidationPaused; + $this->invalidationPaused = false; try { return $fn($this); } finally { - $this->enabled = $previousEnabled; - $this->paused = $previousPaused; + $this->invalidationPaused = $previousInvalidationPaused; } } @@ -165,7 +154,7 @@ public function reset(): void { $this->enabled = false; $this->disabled = false; - $this->paused = false; + $this->collectionPaused = false; $this->invalidationPaused = false; } } diff --git a/tests/Unit/CacheScopeTest.php b/tests/Unit/CacheScopeTest.php index d24c423..d5c6923 100644 --- a/tests/Unit/CacheScopeTest.php +++ b/tests/Unit/CacheScopeTest.php @@ -14,7 +14,6 @@ public function it_is_disabled_by_default(): void { $cacheScope = new CacheScope(); - self::assertFalse($cacheScope->isEnabled()); self::assertFalse($cacheScope->isCollecting()); } @@ -27,7 +26,6 @@ public function enable_enables_the_scope(): void $cacheScope->enable(); - self::assertTrue($cacheScope->isEnabled()); self::assertTrue($cacheScope->isCollecting()); } @@ -41,7 +39,6 @@ public function disable_disables_scope_and_collection(): void $cacheScope->disable(); - self::assertFalse($cacheScope->isEnabled()); self::assertFalse($cacheScope->isCollecting()); } @@ -55,24 +52,21 @@ public function disable_survives_later_enable_calls(): void $cacheScope->enable(); - self::assertFalse($cacheScope->isEnabled()); self::assertFalse($cacheScope->isCollecting()); } /** * @test */ - public function withoutCollecting_keeps_scope_enabled_but_stops_collection(): void + public function withoutCollecting_stops_collection(): void { $cacheScope = new CacheScope(); $cacheScope->enable(); $cacheScope->withoutCollecting(static function (CacheScope $scope): void { - self::assertTrue($scope->isEnabled()); self::assertFalse($scope->isCollecting()); }); - self::assertTrue($cacheScope->isEnabled()); self::assertTrue($cacheScope->isCollecting()); } @@ -91,25 +85,21 @@ public function withoutCollecting_returns_the_closure_result(): void /** * @test */ - public function nested_withoutCollecting_keeps_scope_disabled_until_outer_scope_finishes(): void + public function nested_withoutCollecting_keeps_collection_paused_until_outer_scope_finishes(): void { $cacheScope = new CacheScope(); $cacheScope->enable(); $cacheScope->withoutCollecting(static function (CacheScope $scope): void { - self::assertTrue($scope->isEnabled()); self::assertFalse($scope->isCollecting()); $scope->withoutCollecting(static function (CacheScope $scope): void { - self::assertTrue($scope->isEnabled()); self::assertFalse($scope->isCollecting()); }); - self::assertTrue($scope->isEnabled()); self::assertFalse($scope->isCollecting()); }); - self::assertTrue($cacheScope->isEnabled()); self::assertTrue($cacheScope->isCollecting()); } @@ -120,15 +110,12 @@ public function withCollecting_temporarily_enables_the_scope(): void { $cacheScope = new CacheScope(); - self::assertFalse($cacheScope->isEnabled()); self::assertFalse($cacheScope->isCollecting()); $cacheScope->withCollecting(static function (CacheScope $scope): void { - self::assertTrue($scope->isEnabled()); self::assertTrue($scope->isCollecting()); }); - self::assertFalse($cacheScope->isEnabled()); self::assertFalse($cacheScope->isCollecting()); } @@ -153,11 +140,9 @@ public function withCollecting_respects_disable(): void $cacheScope->disable(); $cacheScope->withCollecting(static function (CacheScope $scope): void { - self::assertFalse($scope->isEnabled()); self::assertFalse($scope->isCollecting()); }); - self::assertFalse($cacheScope->isEnabled()); self::assertFalse($cacheScope->isCollecting()); } @@ -170,19 +155,15 @@ public function withCollecting_temporarily_resumes_collection_inside_withoutColl $cacheScope->enable(); $cacheScope->withoutCollecting(static function (CacheScope $scope): void { - self::assertTrue($scope->isEnabled()); self::assertFalse($scope->isCollecting()); $scope->withCollecting(static function (CacheScope $scope): void { - self::assertTrue($scope->isEnabled()); self::assertTrue($scope->isCollecting()); }); - self::assertTrue($scope->isEnabled()); self::assertFalse($scope->isCollecting()); }); - self::assertTrue($cacheScope->isEnabled()); self::assertTrue($cacheScope->isCollecting()); } @@ -195,11 +176,9 @@ public function withCollecting_restores_previous_state_after_closure(): void $cacheScope->enable(); $cacheScope->withCollecting(static function (CacheScope $scope): void { - self::assertTrue($scope->isEnabled()); self::assertTrue($scope->isCollecting()); }); - self::assertTrue($cacheScope->isEnabled()); self::assertTrue($cacheScope->isCollecting()); } @@ -213,7 +192,6 @@ public function reset_disables_the_scope(): void $cacheScope->reset(); - self::assertFalse($cacheScope->isEnabled()); self::assertFalse($cacheScope->isCollecting()); } @@ -228,7 +206,6 @@ public function reset_clears_disabled_state_so_scope_can_be_enabled_again(): voi $cacheScope->reset(); $cacheScope->enable(); - self::assertTrue($cacheScope->isEnabled()); self::assertTrue($cacheScope->isCollecting()); }