Skip to content

BUGFIX: Treat Redis cache entries with an evicted tag index as missing - #3637

Open
gradinarufelix wants to merge 1 commit into
neos:8.3from
gradinarufelix:bugfix-redis-evicted-tag-index
Open

gradinarufelix wants to merge 1 commit into
neos:8.3from
gradinarufelix:bugfix-redis-evicted-tag-index

Conversation

@gradinarufelix

Copy link
Copy Markdown

RedisBackend stores each cache entry in three kinds of independent keys: the entry itself, the set of its tags (tags:<entry>), and one set per tag (tag:<tag>). Under memory pressure, for example with maxmemory-policy allkeys-lru, Redis can evict a tag set and keep the frequently read entry. After that, flushByTag() can no longer find the entry, so the outdated content is served until the entry expires or someone flushes the whole cache. For the Neos content cache, the result is that a published change never shows up on the page.

get() and has() now run a Lua script that checks two things: the entry's tags set still exists, and the entry is still a member of every one of its tag sets. If either check fails, the script removes the entry and its remaining tag memberships and reports a cache miss, so the entry is rebuilt with a complete tag index. set() now adds a marker to the tags set of every entry. Without it, an evicted tags set could not be detected for untagged entries.

We saw this on a Neos 8.4 site whose content cache uses Redis with allkeys-lru. After a publish, pages kept their old content until Neos_Fusion_Content was flushed by hand. An earlier variant of this change has been running on that site since July 2026 as a fork of flownative/flow-redis-backend (flownative/flow-redis-backend#1), where @kitsunet suggested fixing it in Flow directly.

Upgrade instructions

None. Tagged entries written by older versions stay valid. Untagged entries written by older versions have no marker, so each one is rebuilt once after the update.

Review instructions

  • The script only validates and does not return the entry data. get() sends the script and a plain GET in one pipeline, which is still a single round trip. Passing the value through Lua costs time in proportion to its size: on a 64 KB entry, an earlier version that returned the data from the script took about 155–180 µs of Redis time per read, against 2–4 µs for a plain GET. A pipeline is not atomic, but the worst case is one read that returns an entry whose tag set was evicted between the two commands. The next read detects that and removes the entry.
  • Cost of the validation in Redis time per read, measured with INFO commandstats (local Docker setup, so the numbers are noisy): about 4–6 µs with no tags, 10–18 µs with 10 tags and 23–28 µs with 30 tags, regardless of entry size. On the unpatched branch, a GET takes 0.3–4 µs and an EXISTS 0.2 µs. A sample of 105 Neos content cache entries had a median of 2 tags and a 90th percentile of 4.
  • Cache tags have to match FrontendInterface::PATTERN_TAG, so a tag can never contain the null byte in the marker.
  • The new functional tests simulate eviction by deleting the tag keys directly. Without the change, entriesWithAnEvictedTagIndexAreRemoved fails because the stale value is returned. taggedEntriesWrittenWithoutTagsMarkerAreStillReturned covers entries written by older versions.
  • Tested with PHP 8.3, phpredis 6.3.0 and Redis 7 (alpine): the Neos.Cache unit tests (377) and functional tests (17) pass. The only warnings are the existing PHPUnit 9.6 deprecation notices in the FileBackend tests.
  • Not part of this change: expiring tag sets (BUG: Cache RedisBackend: TTL for :tag: and :tags: keys is always -1 (not expiring) #2992) and the transaction error handling from the Flownative pull request.

Checklist

  • Code follows the PSR-2 coding style
  • Tests have been created, run and adjusted as needed
  • The PR is created against the lowest maintained branch
  • Reviewer - PR Title is brief but complete and starts with FEATURE|TASK|BUGFIX
  • Reviewer - The first section explains the change briefly for change-logs
  • Reviewer - Breaking Changes are marked wit !!! and have upgrade-instructions

The RedisBackend stores an entry, the set of its tags and one set per tag
as independent keys. Under memory pressure, e.g. with the "allkeys-lru"
eviction policy, Redis can evict a tag set while the frequently read
entry survives. The entry can then no longer be flushed by tag and is
served until it expires.

get() and has() now verify in a Lua script that the entry's tags set
exists and that the entry is still a member of each tag set. Otherwise
the entry is removed and reported as missing, so it gets rebuilt. set()
adds a marker to every tags set, so that an evicted tags set is also
detectable for untagged entries.
@kitsunet

kitsunet commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

We already discussed that the case is real and a problem, however looking at this again, I wonder, shouldn't we just make sure the (tag) list entries never get a TTL and then document that you cannot use any "allkeys" policy for eviction if you want this to work and instead need to use "volatile" policies?

Beach would obviously switch to that ASAP, now that I am thinking about it. Might be the cleaner way? IDK, both seem sensible I just fear this increases complexity a lot.

For reference the "volatile" policies only evict entries with a set TTL, everything else cannot be evicted. So "volatile-lru" or "volatile-ttl" might be good fits (either least recently used or shortest remaining TTL)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants