Problem/Motivation

Spin-off from #3586104: Move route loading into chained fast and remove RoutePreloader which is itself a spin-off from #3503843: Remove automatic preloading of all "public" routes, cache routes in fast chained bin.

There are two kinds of data we put in chained fast:

1. Large discovery cache items (libraries, plugins) that either have one entry or vary by language/theme etc.

2. Smaller, finite, but numerous items - config and assuming one of the above issues happens, route definitions.

There is also the file cache which uses APCu without chained fast, #3486503: Add a persistent cache for file-based discovery based on FileCache will allow us to move some of those things out of APCu.

For both config and routes, some items are accessed more or less on every request, but some are only accessed very rarely (e.g. on certain admin routes) or immediately after cache clears (where the result ends up in a higher level cache like the render cache).

To be able to use chained fast for more things, and also save space in APCu, it would be useful if we could naturally cycle out items that are infrequently used. APCu supports setting a TTL, but the APCu backend never sets it (because we handle expires manually), and also all of the items we put in chained fast are permanent due to cache tags.

APCu docs say:

 During eviction, if apc.ttl was specified, APCu will first attempt to remove expired entries, i.e. entries whose TTL has either expired, or entries that have no TTL set and haven't been accessed in the last apc.ttl seconds. If apc.ttl was not set, or removing expired entries did not free up enough space, APCu will clear the entire cache.

https://www.php.net/manual/en/apcu.configuration.php

Also

Consider cache entries without an explicit TTL to be expired if they have not been accessed in this many seconds. Effectively, this allows such entries to be removed opportunistically during a cache insert, or prior to a full expunge. Note that because removal is opportunistic, entries can still be readable even if they are older than apc.ttl seconds. This setting has no effect on cache entries that have an explicit TTL specified.

Steps to reproduce

Proposed resolution

The APCu backend should set a TTL in apcu_store() based on the TTL of the item, this will allow APCu to remove items that have expired when it's starting to fill up.

We could then add a setting to the chained fast backend, to be used for the config and routing bins, to override the TTL to e.g. 24 hours.

A TTL of 24 hours would mean that if the item is evicted, chained fast will fall back to the persistent backend, then write a fresh item to APCu (possibly once per web head). This equates to only a handful of persistent cache gets per item per day, so a very small impact on the persistent backend but potentially allowing APCu to be used much more efficiently - we'll be able to put more in there without it filling up.

Remaining tasks

User interface changes

Introduced terminology

API changes

Data model changes

Release notes snippet

Comments

catch created an issue. See original summary.

andypost’s picture

Would be great to test it replacing default serializer for APCu with igbinary as it slightly affects size of stored data
ref https://www.php.net/manual/apcu.configuration.php#ini.apcu.serializer

catch’s picture

Issue summary: View changes

Updated the issue summary with why this ought to work.

Also APCu will keep entries around beyond their TTL and still return them if it hasn't had to evict them, which I think means we can be quite aggressive setting a short APCu TTL and rely on the existing logic for actual expiry which already handles short expires and permanent APCu TTL.

berdir’s picture

Not sure if I understand how those two settings work.

For testing, I added this to index.php:

+
+for ($i = 0; $i < 500; $i++) {
+  apcu_store(\Drupal::time()->getRequestTime() . '_' . $i, str_repeat('a', 5000), 3600);
+}

That's enough data to fill up 32MB in a few refreshs.

I also made this change to the system requirements:

diff --git a/core/modules/system/src/Hook/SystemRequirementsHooks.php b/core/modules/system/src/Hook/SystemRequirementsHooks.php
index bafd3ee8492..12b042bd624 100644
--- a/core/modules/system/src/Hook/SystemRequirementsHooks.php
+++ b/core/modules/system/src/Hook/SystemRequirementsHooks.php
@@ -7,6 +7,7 @@
 use Drupal\Component\Utility\OpCodeCache;
 use Drupal\Component\Utility\Unicode;
 use Drupal\Core\Database\Database;
+use Drupal\Core\Datetime\DateFormatterInterface;
 use Drupal\Core\DrupalKernel;
 use Drupal\Core\Extension\ExtensionLifecycle;
 use Drupal\Core\Extension\Requirement\RequirementSeverity;
@@ -343,6 +344,7 @@ public function checkRequirements(string $phase): array {
         // @see https://pecl.php.net/package/APCu/5.1.25
         $apcu_shm_segments = ini_get('apc.shm_segments') ?: 1;
         $memory_info = apcu_sma_info(TRUE);
+        $cache_info = apcu_cache_info(TRUE);
         $apcu_actual_size = ByteSizeMarkup::create($memory_info['seg_size'] * $apcu_shm_segments);
         $apcu_recommended_size = '32 MB';
         $requirements['php_apcu_enabled']['value'] = $this->t('Enabled (@size)', ['@size' => $apcu_actual_size]);
@@ -370,8 +372,11 @@ public function checkRequirements(string $phase): array {
           else {
             $requirements['php_apcu_available']['severity'] = RequirementSeverity::OK;
           }
-          $requirements['php_apcu_available']['value'] = $this->t('Memory available: @available.', [
+          $runtime = \Drupal::time()->getRequestTime() - $cache_info['start_time'];
+          $requirements['php_apcu_available']['value'] = $this->t('Memory available: @available, cache started @interval ago, @expunges total cache restarts.', [
             '@available' => ByteSizeMarkup::create($memory_info['avail_mem']),
+            '@expunges' => $cache_info['expunges'],
+            '@interval' => \Drupal::service(DateFormatterInterface::class)->formatInterval($runtime),
           ]);
         }

That gives me "Memory available: 26.49 MB, cache started 1 min 16 sec ago, 8 total cache restarts. " for example.

with 3600s, it behaves about the same as if no ttl is set, it fills up, then resets.

When I set the ttl to a very low value, like 5s, then when the memory gets too low, it seems to do a garbage collection pass, removes some/all expired items and allows me to set the new ones and does NOT set the expunges counter. Based on a quick test, I think apcu_fetch() respects the apcu_store() ttl explicitly, and doesn't return expired items anymore. whereas the global setting will still return the item.

so if I understand this correctly, we could only suggest to set apc.ttl globally, which would then affect everything (but it's ttl on *read*, not on set)

catch’s picture

@berdir thanks for testing, that makes this a bit harder but I still think we can do something.

Possible approach:

When we set in the fast backend, we look for a special key that is something like 'first_write_timestamp', it it's not set, create it. This gives the age of the oldest entry in the backend.

We set items with the same TTL as time() - $first_write_timestamp with some kind of floor, probably between 10s and 60s.

When the items expire, if apcu hasn't refreshed, the first_write_timestamp will be longer, so then we set with a longer TTL, until we hit a maximum TTL (or no maximum potentially) - or to try to be even cleverer try to add some extra logic when APCu reaches 80% full or similar.

With chained fast, the same item will be in the persistent cache, so the cost of falling through to the persistent cache and writing back to apcu is fairly minimal - and will only happen for cache items that are being frequently accessed.

There might also be a way to handle setting items with the longer TTL when they're read - for example if we've already got $first_write_timestamp due to a write earlier in the request, we could set any items read afterwards with an updated TTL to save them falling through to the persistent backend later.

The TTL would increase close to exponentially if we do this - e.g. each time we set a new TTL on an item it will be the previous TTL * 2.

If I work on this, I'll make an extra APCu backend rather than modifying the main one so it's possible to switch between them easily.