Found during the semantic-log review of #178: the log showed save_donut{tags: [], ttl: null, saved: true} for donut resources that declare no Surrogate-Key header — an entry no invalidation can reach.
Root cause
DonutRepository::putDonut() reads getHeaderKeys($ro) at line 78 BEFORE $keys->setSurrogateHeader($ro) at line 81 (same in putStatic(): line 57 before line 60). The donut template entry is therefore saved with only the resource's self-declared Surrogate-Key header tags; the auto-assembled SurrogateKeys (own URI tag + embedded child tags) never reach the template entry.
For a donut resource with no self-declared Surrogate-Key this means tags: []. Symfony's TagAwareAdapter freshness check iterates the item's tags, so an empty-tag item is never staled by any invalidateTags() call. On the interceptor path the TTL is also null (putStatic($ro, null, null) / putDonut($ro, null)), so the template entry is effectively immortal: the only eviction path is a same-key overwrite.
Reproduction
GET a #[DonutCache] page with no declared Surrogate-Key → purge() its own URI → GET again: manual_purge_result reports purged, but the next GET closes cache_hit{donut} and emits refresh_donut — the shell is reused. The purge succeeds against content/ETag entries but is a no-op against the donut template.
Note the #[DonutCache] docblock promises tag-based invalidation ("Unlike #[Cacheable] which uses TTL-based expiration, this enables tag-based invalidation"), which currently does not work for the template entry.
Fix direction
Move the getHeaderKeys($ro) call after setSurrogateHeader($ro) so the template entry carries its own URI tag (and child tags). This widens the template's tag set and therefore changes cascade-invalidation behavior, so it needs its own PR with a design review — deliberately kept out of #178 (logging-only).
Found during the semantic-log review of #178: the log showed
save_donut{tags: [], ttl: null, saved: true}for donut resources that declare no Surrogate-Key header — an entry no invalidation can reach.Root cause
DonutRepository::putDonut()readsgetHeaderKeys($ro)at line 78 BEFORE$keys->setSurrogateHeader($ro)at line 81 (same inputStatic(): line 57 before line 60). The donut template entry is therefore saved with only the resource's self-declared Surrogate-Key header tags; the auto-assembledSurrogateKeys(own URI tag + embedded child tags) never reach the template entry.For a donut resource with no self-declared Surrogate-Key this means
tags: []. Symfony'sTagAwareAdapterfreshness check iterates the item's tags, so an empty-tag item is never staled by anyinvalidateTags()call. On the interceptor path the TTL is also null (putStatic($ro, null, null)/putDonut($ro, null)), so the template entry is effectively immortal: the only eviction path is a same-key overwrite.Reproduction
GET a #[DonutCache] page with no declared Surrogate-Key →
purge()its own URI → GET again:manual_purge_resultreportspurged, but the next GET closescache_hit{donut}and emitsrefresh_donut— the shell is reused. The purge succeeds against content/ETag entries but is a no-op against the donut template.Note the #[DonutCache] docblock promises tag-based invalidation ("Unlike #[Cacheable] which uses TTL-based expiration, this enables tag-based invalidation"), which currently does not work for the template entry.
Fix direction
Move the
getHeaderKeys($ro)call aftersetSurrogateHeader($ro)so the template entry carries its own URI tag (and child tags). This widens the template's tag set and therefore changes cascade-invalidation behavior, so it needs its own PR with a design review — deliberately kept out of #178 (logging-only).