Skip to content

Commit 264543f

Browse files
koriymclaude
andcommitted
Relocate cache logging into decorators (storage and dependency)
Move all semantic-log emission for cache writes out of the business classes and into transparent decorators, so ResourceStorage and CacheDependency carry no logging concern. - LoggableResourceStorage wraps ResourceStorage: every save event, the invalidate outcome (manual_invalidate scope when top-level), and the fail-closed CDN re-throw live here. ResourceStorage no longer takes a logger. - LoggableCacheDependency wraps CacheDependency: emits the depends-on edge. The child tags are read before delegating, since depends() unsets the child's surrogate-key header while wiring the dependency. - CacheTags centralizes the tag derivation shared by the storage (which saves with the tags) and the decorators (which log the same tags), with no duplication. - invalidateTags() returns an InvalidateResult value object carrying the per-target (resource pool / ETag pool / CDN) outcome, which the side-effecting pool operations compute and a bare bool could not express. The decorator turns it into the InvalidateContext and re-throws the CDN error after logging. - Wire both via the Ray.Di decorator idiom: the undecorated implementation is bound under 'origin', the interface to the decorator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJnuk1B1yXK6bxXuU9DGCo
1 parent aa99a81 commit 264543f

10 files changed

Lines changed: 377 additions & 126 deletions

src/CacheDependency.php

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,43 +4,29 @@
44

55
namespace BEAR\QueryRepository;
66

7-
use BEAR\QueryRepository\Log\Context\DependsOnContext;
8-
use BEAR\QueryRepository\Log\NullSemanticLogger;
97
use BEAR\Resource\ResourceObject;
10-
use Koriym\SemanticLogger\SemanticLoggerInterface;
118
use Override;
129

13-
use function explode;
1410
use function sprintf;
1511

1612
final readonly class CacheDependency implements CacheDependencyInterface
1713
{
1814
public function __construct(
19-
private UriTagInterface $uriTag,
20-
private SemanticLoggerInterface $logger = new NullSemanticLogger(),
15+
private CacheTags $cacheTags,
2116
) {
2217
}
2318

2419
#[Override]
2520
public function depends(ResourceObject $from, ResourceObject $to): void
2621
{
27-
$childTags = ($this->uriTag)($to->uri);
28-
if (isset($to->headers[Header::SURROGATE_KEY])) {
29-
$childTags .= sprintf(' %s', $to->headers[Header::SURROGATE_KEY]);
30-
unset($to->headers[Header::SURROGATE_KEY]);
31-
}
22+
$childTags = $this->cacheTags->childTags($to);
23+
unset($to->headers[Header::SURROGATE_KEY]);
3224

3325
// Accumulate across every embedded child: a resource that embeds more than one
3426
// child must depend on all of them, not only the last. Overwriting here used to
3527
// silently drop earlier children's dependencies (stale-cache bug).
3628
$from->headers[Header::SURROGATE_KEY] = isset($from->headers[Header::SURROGATE_KEY])
3729
? sprintf('%s %s', $from->headers[Header::SURROGATE_KEY], $childTags)
3830
: $childTags;
39-
40-
$this->logger->event(new DependsOnContext(
41-
(string) $from->uri,
42-
(string) $to->uri,
43-
explode(' ', $childTags),
44-
));
4531
}
4632
}

src/CacheTags.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace BEAR\QueryRepository;
6+
7+
use BEAR\Resource\AbstractUri;
8+
use BEAR\Resource\ResourceObject;
9+
10+
use function array_merge;
11+
use function array_unique;
12+
use function array_values;
13+
use function explode;
14+
use function sprintf;
15+
16+
/**
17+
* Pure cache-tag computation shared by ResourceStorage (which saves with the tags)
18+
* and LoggableResourceStorage (which logs the same tags)
19+
*
20+
* Extracted so the decorator can report exactly the tags that were stored without
21+
* duplicating the derivation logic. Every method is a pure function of its inputs.
22+
*/
23+
final class CacheTags
24+
{
25+
public function __construct(private readonly UriTagInterface $uriTag)
26+
{
27+
}
28+
29+
/**
30+
* Tags for storing a resource: its ETag, its URI tag, and any surrogate keys
31+
*
32+
* @return list<string>
33+
*/
34+
public function ofResource(ResourceObject $ro): array
35+
{
36+
$tags = [$ro->headers[Header::ETAG], ($this->uriTag)($ro->uri)];
37+
if (isset($ro->headers[Header::SURROGATE_KEY])) {
38+
$tags = array_merge($tags, explode(' ', $ro->headers[Header::SURROGATE_KEY]));
39+
}
40+
41+
/** @var list<string> $uniqueTags */
42+
$uniqueTags = array_values(array_unique($tags));
43+
44+
return $uniqueTags;
45+
}
46+
47+
/**
48+
* The surrogate-key string a parent inherits when it depends on a child
49+
*
50+
* The child's URI tag, plus the child's own surrogate keys when present. Pure read:
51+
* it does not mutate the child (the dependency wiring is CacheDependency's concern).
52+
*/
53+
public function childTags(ResourceObject $to): string
54+
{
55+
$childTags = ($this->uriTag)($to->uri);
56+
if (isset($to->headers[Header::SURROGATE_KEY])) {
57+
$childTags .= sprintf(' %s', $to->headers[Header::SURROGATE_KEY]);
58+
}
59+
60+
return $childTags;
61+
}
62+
63+
/**
64+
* Tags for storing an ETag entry: the surrogate keys plus the URI tag
65+
*
66+
* @return list<string>
67+
*/
68+
public function ofEtag(AbstractUri $uri, string $surrogateKeys): array
69+
{
70+
$tags = $surrogateKeys !== '' ? explode(' ', $surrogateKeys) : [];
71+
$tags[] = ($this->uriTag)($uri);
72+
73+
/** @var list<string> $uniqueTags */
74+
$uniqueTags = array_values(array_unique($tags));
75+
76+
return $uniqueTags;
77+
}
78+
}

src/InvalidateResult.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace BEAR\QueryRepository;
6+
7+
use Throwable;
8+
9+
/**
10+
* Outcome of a tag invalidation across the resource pool, the ETag pool, and the CDN
11+
*
12+
* ResourceStorage returns this instead of a bare bool so the logging decorator can
13+
* report the per-target outcome (roPool / etagPool / cdn) that is computed by the
14+
* side-effecting pool operations and is not derivable from the inputs alone. The
15+
* CDN purge error is carried (not thrown) so the decorator can log the failure
16+
* before re-throwing it (fail-closed).
17+
*/
18+
final class InvalidateResult
19+
{
20+
/** @param list<string> $tags */
21+
public function __construct(
22+
public readonly array $tags,
23+
public readonly bool $roInvalidated,
24+
public readonly bool $etagInvalidated,
25+
public readonly Throwable|null $cdnError = null,
26+
) {
27+
}
28+
29+
/**
30+
* Whether both local pools were invalidated (the legacy bool return value)
31+
*/
32+
public function isInvalidated(): bool
33+
{
34+
return $this->roInvalidated && $this->etagInvalidated;
35+
}
36+
}

src/LoggableCacheDependency.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace BEAR\QueryRepository;
6+
7+
use BEAR\QueryRepository\Log\Context\DependsOnContext;
8+
use BEAR\Resource\ResourceObject;
9+
use Koriym\SemanticLogger\SemanticLoggerInterface;
10+
use Override;
11+
use Ray\Di\Di\Named;
12+
13+
use function explode;
14+
15+
/**
16+
* Logging decorator for CacheDependency
17+
*
18+
* Emits the depends-on edge as a semantic-log event, keeping CacheDependency itself
19+
* free of logging. The child tags are read with {@see CacheTags} before delegating,
20+
* because depends() unsets the child's surrogate-key header as part of wiring the
21+
* dependency, which would otherwise destroy the inputs needed to report the edge.
22+
*/
23+
final readonly class LoggableCacheDependency implements CacheDependencyInterface
24+
{
25+
public function __construct(
26+
#[Named('origin')]
27+
private CacheDependencyInterface $cacheDependency,
28+
private SemanticLoggerInterface $logger,
29+
private CacheTags $cacheTags,
30+
) {
31+
}
32+
33+
#[Override]
34+
public function depends(ResourceObject $from, ResourceObject $to): void
35+
{
36+
$childTags = $this->cacheTags->childTags($to);
37+
$this->cacheDependency->depends($from, $to);
38+
$this->logger->event(new DependsOnContext(
39+
(string) $from->uri,
40+
(string) $to->uri,
41+
explode(' ', $childTags),
42+
));
43+
}
44+
}

src/LoggableResourceStorage.php

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace BEAR\QueryRepository;
6+
7+
use BEAR\QueryRepository\Log\Context\InvalidateContext;
8+
use BEAR\QueryRepository\Log\Context\ManualInvalidateContext;
9+
use BEAR\QueryRepository\Log\Context\SaveDonutContext;
10+
use BEAR\QueryRepository\Log\Context\SaveDonutViewContext;
11+
use BEAR\QueryRepository\Log\Context\SaveEtagContext;
12+
use BEAR\QueryRepository\Log\Context\SaveValueContext;
13+
use BEAR\QueryRepository\Log\Context\SaveViewContext;
14+
use BEAR\QueryRepository\Log\SafeSemanticLogger;
15+
use BEAR\Resource\AbstractUri;
16+
use BEAR\Resource\ResourceObject;
17+
use Koriym\SemanticLogger\SemanticLoggerInterface;
18+
use Override;
19+
use Ray\Di\Di\Named;
20+
21+
use function hrtime;
22+
use function round;
23+
24+
/**
25+
* Logging decorator for ResourceStorage
26+
*
27+
* Keeps the storage itself free of any logging concern: every cache write emits its
28+
* semantic-log event here, around a transparent delegation to the wrapped storage.
29+
* The tags reported for a save are recomputed with the same {@see CacheTags} the
30+
* storage uses, so the log records exactly what was stored.
31+
*
32+
* Reads (get / getDonut / hasEtag) are pass-through: a request's hit/miss is logged
33+
* at the interceptor / donut-repository layer where the request-level outcome lives,
34+
* not at the low-level pool read.
35+
*/
36+
final class LoggableResourceStorage implements ResourceStorageInterface
37+
{
38+
public function __construct(
39+
#[Named('origin')]
40+
private readonly ResourceStorageInterface $storage,
41+
private readonly SemanticLoggerInterface $logger,
42+
private readonly CacheTags $cacheTags,
43+
private readonly UriTagInterface $uriTag,
44+
) {
45+
}
46+
47+
#[Override]
48+
public function hasEtag(string $etag): bool
49+
{
50+
return $this->storage->hasEtag($etag);
51+
}
52+
53+
#[Override]
54+
public function saveEtag(AbstractUri $uri, string $etag, string $surrogateKeys, int|null $ttl): void
55+
{
56+
$this->storage->saveEtag($uri, $etag, $surrogateKeys, $ttl);
57+
$this->logger->event(new SaveEtagContext((string) $uri, $etag, $this->cacheTags->ofEtag($uri, $surrogateKeys)));
58+
}
59+
60+
#[Override]
61+
public function deleteEtag(AbstractUri $uri)
62+
{
63+
return $this->invalidateTags([($this->uriTag)($uri)])->isInvalidated();
64+
}
65+
66+
#[Override]
67+
public function get(AbstractUri $uri): ResourceState|null
68+
{
69+
return $this->storage->get($uri);
70+
}
71+
72+
#[Override]
73+
public function getDonut(AbstractUri $uri): ResourceDonut|null
74+
{
75+
return $this->storage->getDonut($uri);
76+
}
77+
78+
/**
79+
* {@inheritDoc}
80+
*
81+
* A top-level invalidation is a direct (non-AOP) call with no enclosing scope, so the
82+
* event would be dropped at flush. Root it in a manual_invalidate scope whose close
83+
* carries the outcome. Nested invalidations (inside a GET or a command) stay events.
84+
*
85+
* The CDN purge is fail-closed: a purge failure is logged (cdn=failed) and then
86+
* re-thrown so a write does not silently leave stale CDN content.
87+
*/
88+
#[Override]
89+
public function invalidateTags(array $tags): InvalidateResult
90+
{
91+
$start = hrtime(true);
92+
$result = $this->storage->invalidateTags($tags);
93+
$context = new InvalidateContext(
94+
$result->tags,
95+
roPoolInvalidated: $result->roInvalidated,
96+
etagPoolInvalidated: $result->etagInvalidated,
97+
cdnPurged: $result->cdnError === null,
98+
durationMs: round((hrtime(true) - $start) / 1_000_000, 3),
99+
);
100+
101+
if ($this->logger instanceof SafeSemanticLogger && $this->logger->isTopLevel()) {
102+
$openId = $this->logger->open(new ManualInvalidateContext($tags));
103+
$this->logger->close($context, $openId);
104+
} else {
105+
$this->logger->event($context);
106+
}
107+
108+
if ($result->cdnError !== null) {
109+
throw $result->cdnError;
110+
}
111+
112+
return $result;
113+
}
114+
115+
/**
116+
* {@inheritDoc}
117+
*
118+
* @return bool
119+
*/
120+
#[Override]
121+
public function saveValue(ResourceObject $ro, int $ttl)
122+
{
123+
$saved = $this->storage->saveValue($ro, $ttl);
124+
$this->logger->event(new SaveValueContext((string) $ro->uri, $this->cacheTags->ofResource($ro), $ttl));
125+
126+
return $saved;
127+
}
128+
129+
/**
130+
* {@inheritDoc}
131+
*
132+
* @return bool
133+
*/
134+
#[Override]
135+
public function saveView(ResourceObject $ro, int $ttl)
136+
{
137+
$saved = $this->storage->saveView($ro, $ttl);
138+
$this->logger->event(new SaveViewContext((string) $ro->uri, $ttl));
139+
140+
return $saved;
141+
}
142+
143+
#[Override]
144+
public function saveDonut(AbstractUri $uri, ResourceDonut $donut, int|null $sMaxAge, array $headerKeys): void
145+
{
146+
$this->storage->saveDonut($uri, $donut, $sMaxAge, $headerKeys);
147+
$this->logger->event(new SaveDonutContext((string) $uri, $sMaxAge));
148+
}
149+
150+
#[Override]
151+
public function saveDonutView(ResourceObject $ro, int|null $ttl): bool
152+
{
153+
$saved = $this->storage->saveDonutView($ro, $ttl);
154+
$this->logger->event(new SaveDonutViewContext((string) $ro->uri, $this->cacheTags->ofResource($ro), $ttl));
155+
156+
return $saved;
157+
}
158+
}

src/QueryRepositoryModule.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,22 @@ protected function configure(): void
6161
$this->bind(TagAwareAdapterInterface::class)->annotatedWith(EtagPool::class)->toInstance(null);
6262
// core
6363
$this->bind(QueryRepositoryInterface::class)->to(QueryRepository::class)->in(Scope::SINGLETON);
64-
$this->bind(CacheDependencyInterface::class)->to(CacheDependency::class);
64+
// CacheDependency is likewise wrapped by a logging decorator; the undecorated
65+
// dependency resolver is bound under 'origin'.
66+
$this->bind(CacheDependencyInterface::class)->annotatedWith('origin')->to(CacheDependency::class);
67+
$this->bind(CacheDependencyInterface::class)->to(LoggableCacheDependency::class);
6568
$this->bind(EtagSetterInterface::class)->to(EtagSetter::class);
6669
$this->bind(NamedParameterInterface::class)->to(NamedParameter::class);
67-
$this->bind(ResourceStorageInterface::class)->to(ResourceStorage::class)->in(Scope::SINGLETON);
70+
// The storage is wrapped by a logging decorator: cache writes are emitted as
71+
// semantic-log events in LoggableResourceStorage, keeping ResourceStorage itself
72+
// free of any logging concern. The undecorated storage is bound under 'origin'.
73+
$this->bind(ResourceStorageInterface::class)->annotatedWith('origin')->to(ResourceStorage::class)->in(Scope::SINGLETON);
74+
$this->bind(ResourceStorageInterface::class)->to(LoggableResourceStorage::class)->in(Scope::SINGLETON);
6875
$this->bind(MatchQueryInterface::class)->to(MatchQuery::class);
6976
$this->bind(RefreshAnnotatedCommand::class);
7077
$this->bind(RefreshSameCommand::class);
7178
$this->bind(ResourceStorageSaver::class);
79+
$this->bind(CacheTags::class);
7280
// Server context for thread safety (Swoole, RoadRunner, etc.)
7381
$this->bind(ServerContextInterface::class)->to(GlobalServerContext::class)->in(Scope::SINGLETON);
7482
// #[Cacheable]

0 commit comments

Comments
 (0)