Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.16.1] - 2026-06-01

### Fixed
- Fix multi-embed cache dependency: a `#[Cacheable]` resource embedding more than one child kept only the last child's dependency, so purging an earlier child failed to invalidate the parent (stale cache). `CacheDependency::depends()` now accumulates child tags instead of overwriting, and the erroneous assertion that a parent had no prior tags has been removed.

## [1.16.0] - 2026-05-16

### Fixed
Expand Down
14 changes: 8 additions & 6 deletions src/CacheDependency.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
use BEAR\Resource\ResourceObject;
use Override;

use function assert;
use function sprintf;

final readonly class CacheDependency implements CacheDependencyInterface
Expand All @@ -20,14 +19,17 @@ public function __construct(
#[Override]
public function depends(ResourceObject $from, ResourceObject $to): void
{
assert(! isset($from->headers[Header::SURROGATE_KEY]));

$cacheDependencyTags = ($this->uriTag)($to->uri);
$childTags = ($this->uriTag)($to->uri);
if (isset($to->headers[Header::SURROGATE_KEY])) {
$cacheDependencyTags .= sprintf(' %s', $to->headers[Header::SURROGATE_KEY]);
$childTags .= sprintf(' %s', $to->headers[Header::SURROGATE_KEY]);
unset($to->headers[Header::SURROGATE_KEY]);
}

$from->headers[Header::SURROGATE_KEY] = $cacheDependencyTags;
// Accumulate across every embedded child: a resource that embeds more than one
// child must depend on all of them, not only the last. Overwriting here used to
// silently drop earlier children's dependencies (stale-cache bug).
$from->headers[Header::SURROGATE_KEY] = isset($from->headers[Header::SURROGATE_KEY])
? sprintf('%s %s', $from->headers[Header::SURROGATE_KEY], $childTags)
: $childTags;
}
}
115 changes: 115 additions & 0 deletions tests/ComplexCacheDependencyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

declare(strict_types=1);

namespace BEAR\QueryRepository;

use BEAR\Resource\ResourceInterface;
use BEAR\Resource\Uri;
use PHPUnit\Framework\TestCase;
use Ray\Di\Injector;

/**
* Stress test for the cache dependency resolution with a DIAMOND graph.
*
* diamond-top
* / \
* diamond-left diamond-right
* \ /
* diamond-bottom
*
* `diamond-top` embeds BOTH `diamond-left` and `diamond-right` (multi-embed);
* each arm embeds the shared leaf `diamond-bottom`. This exercises cases the
* existing chain / single-embed fixtures never reach:
* - a parent that embeds more than one child (multi-embed accumulation),
* - a leaf reachable through two independent paths,
* - precise (non-over-reaching) invalidation of a single arm.
*/
class ComplexCacheDependencyTest extends TestCase
{
private ResourceInterface $resource;
private QueryRepositoryInterface $repository;

protected function setUp(): void
{
$injector = new Injector(new FakeEtagPoolModule(ModuleFactory::getInstance('FakeVendor\HelloWorld')), __DIR__ . '/tmp');
$this->resource = $injector->getInstance(ResourceInterface::class);
$this->repository = $injector->getInstance(QueryRepositoryInterface::class);

parent::setUp();
}

private function isCached(string $name): bool
{
return $this->repository->get(new Uri('page://self/dep/' . $name)) instanceof ResourceState;
}

public function testDiamondBuildsEveryNode(): void
{
$this->resource->get('page://self/dep/diamond-top');

foreach (['diamond-top', 'diamond-left', 'diamond-right', 'diamond-bottom'] as $name) {
$this->assertTrue($this->isCached($name), $name . ' should be cached after the build');
}
}

public function testMultiEmbedParentDependsOnEveryChild(): void
{
// Regression: diamond-top embeds BOTH arms. Purging EITHER arm must invalidate top.
// Before the fix, the second embed overwrote the first, so one arm's dependency was lost.
$this->resource->get('page://self/dep/diamond-top');
$this->repository->purge(new Uri('page://self/dep/diamond-left'));
$this->assertFalse($this->isCached('diamond-top'), 'top must depend on its LEFT child');

$this->resource->get('page://self/dep/diamond-top'); // rebuild
$this->repository->purge(new Uri('page://self/dep/diamond-right'));
$this->assertFalse($this->isCached('diamond-top'), 'top must also depend on its RIGHT child');
}

public function testPurgingSharedLeafCascadesThroughBothPaths(): void
{
$this->resource->get('page://self/dep/diamond-top');

// diamond-bottom is embedded via both arms; purging it invalidates every ancestor.
$this->repository->purge(new Uri('page://self/dep/diamond-bottom'));

$this->assertFalse($this->isCached('diamond-top'), 'top reaches bottom via both arms');
$this->assertFalse($this->isCached('diamond-left'), 'left embeds bottom');
$this->assertFalse($this->isCached('diamond-right'), 'right embeds bottom');
}

public function testPurgingOneArmIsPreciseAndDoesNotOverReach(): void
{
$this->resource->get('page://self/dep/diamond-top');

// Purge only the left arm.
$this->repository->purge(new Uri('page://self/dep/diamond-left'));

$this->assertFalse($this->isCached('diamond-top'), 'top embeds left -> invalidated');
$this->assertTrue($this->isCached('diamond-right'), 'the right arm is independent of left');
$this->assertTrue($this->isCached('diamond-bottom'), 'the shared leaf survives an arm purge');
}

public function testRebuildAfterPurgeRePropagatesDependencies(): void
{
$this->resource->get('page://self/dep/diamond-top');
$this->repository->purge(new Uri('page://self/dep/diamond-bottom'));
$this->assertFalse($this->isCached('diamond-top'), 'cascade invalidated top');

// Rebuild and confirm the dependency graph is restored (not a one-shot).
$this->resource->get('page://self/dep/diamond-top');
$this->assertTrue($this->isCached('diamond-top'));
$this->repository->purge(new Uri('page://self/dep/diamond-bottom'));
$this->assertFalse($this->isCached('diamond-top'), 'cascade still works after a rebuild');
}

public function testUnrelatedResourceIsUnaffectedByDiamondPurges(): void
{
$this->resource->get('page://self/dep/diamond-top');
$this->resource->get('page://self/dep/child-c'); // unrelated chain

$this->repository->purge(new Uri('page://self/dep/diamond-bottom'));

$this->assertTrue($this->isCached('child-c'), 'an unrelated resource must not be touched');
}
}
17 changes: 17 additions & 0 deletions tests/Fake/fake-app/src/Resource/Page/Dep/DiamondBottom.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace FakeVendor\HelloWorld\Resource\Page\Dep;

use BEAR\RepositoryModule\Annotation\Cacheable;
use BEAR\Resource\ResourceObject;

#[Cacheable]
class DiamondBottom extends ResourceObject
{
public $body = ['diamond-bottom' => 1];

public function onGet()
{
return $this;
}
}
19 changes: 19 additions & 0 deletions tests/Fake/fake-app/src/Resource/Page/Dep/DiamondLeft.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace FakeVendor\HelloWorld\Resource\Page\Dep;

use BEAR\RepositoryModule\Annotation\Cacheable;
use BEAR\Resource\Annotation\Embed;
use BEAR\Resource\ResourceObject;

#[Cacheable]
class DiamondLeft extends ResourceObject
{
public $body = ['diamond-left' => 1];

#[Embed(rel: 'bottom', src: '/dep/diamond-bottom')]
public function onGet()
{
return $this;
}
}
19 changes: 19 additions & 0 deletions tests/Fake/fake-app/src/Resource/Page/Dep/DiamondRight.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace FakeVendor\HelloWorld\Resource\Page\Dep;

use BEAR\RepositoryModule\Annotation\Cacheable;
use BEAR\Resource\Annotation\Embed;
use BEAR\Resource\ResourceObject;

#[Cacheable]
class DiamondRight extends ResourceObject
{
public $body = ['diamond-right' => 1];

#[Embed(rel: 'bottom', src: '/dep/diamond-bottom')]
public function onGet()
{
return $this;
}
}
20 changes: 20 additions & 0 deletions tests/Fake/fake-app/src/Resource/Page/Dep/DiamondTop.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace FakeVendor\HelloWorld\Resource\Page\Dep;

use BEAR\RepositoryModule\Annotation\Cacheable;
use BEAR\Resource\Annotation\Embed;
use BEAR\Resource\ResourceObject;

#[Cacheable]
class DiamondTop extends ResourceObject
{
public $body = ['diamond-top' => 1];

#[Embed(rel: 'left', src: '/dep/diamond-left')]
#[Embed(rel: 'right', src: '/dep/diamond-right')]
public function onGet()
{
return $this;
}
}
Loading