diff --git a/inc/attachment_cache.php b/inc/attachment_cache.php index 64fa4a62..392caa9c 100644 --- a/inc/attachment_cache.php +++ b/inc/attachment_cache.php @@ -5,6 +5,13 @@ */ class Optml_Attachment_Cache { const CACHE_GROUP = 'om_att'; + /** + * Most URLs kept in the in-memory map during one request. + * + * A page with many distinct image URLs would otherwise grow the map for the whole request. + * When it is full, the older half is dropped; those URLs are still in the persistent cache. + */ + const MAX_MEMORY_ENTRIES = 2000; /** * Local cache map. * @@ -32,10 +39,10 @@ public static function get_cached_attachment_id( $url ) { return self::$cache_map[ $cache_key ]; } - $value = wp_using_ext_object_cache() + $value = wp_using_ext_object_cache() ? wp_cache_get( $cache_key, self::CACHE_GROUP ) : get_transient( self::CACHE_GROUP . $cache_key ); - self::$cache_map[ $cache_key ] = $value; + self::remember( $cache_key, $value ); return $value; } @@ -51,7 +58,7 @@ public static function get_cached_attachment_id( $url ) { public static function set_cached_attachment_id( $url, $id ) { $cache_key = self::get_cache_key( $url ); // We cache also in memory to avoid calling DB every time when not using Object Cache. - self::$cache_map[ $cache_key ] = $id; + self::remember( $cache_key, $id ); // If the ID is not found we cache for 10 minutes, otherwise for a week. // We try to reduce the cache time when is not found to // avoid caching for situation when this might be temporary. @@ -61,6 +68,21 @@ public static function set_cached_attachment_id( $url, $id ) { : set_transient( self::CACHE_GROUP . $cache_key, $id, $expiration ); } + /** + * Keep a value in the in-memory map, dropping the older half when it is full. + * + * @param string $cache_key The cache key. + * @param int|numeric-string|false $value The attachment ID (a numeric string when read back from a transient), or false when it is not cached. + * + * @return void + */ + private static function remember( $cache_key, $value ) { + if ( ! isset( self::$cache_map[ $cache_key ] ) && count( self::$cache_map ) >= self::MAX_MEMORY_ENTRIES ) { + self::$cache_map = array_slice( self::$cache_map, (int) ( self::MAX_MEMORY_ENTRIES / 2 ), null, true ); + } + self::$cache_map[ $cache_key ] = $value; + } + /** * Generate cache key for URL. * diff --git a/tests/test-attachment-cache.php b/tests/test-attachment-cache.php new file mode 100644 index 00000000..67ce1f29 --- /dev/null +++ b/tests/test-attachment-cache.php @@ -0,0 +1,82 @@ + + */ + private function memory_map() { + $property = new ReflectionProperty( Optml_Attachment_Cache::class, 'cache_map' ); + $property->setAccessible( true ); + + return $property->getValue(); + } + + /** + * A page with many distinct uncached image URLs must not grow the in-memory map for the whole request. + */ + public function test_memory_map_is_bounded_on_cache_misses() { + $limit = Optml_Attachment_Cache::MAX_MEMORY_ENTRIES; + + for ( $i = 0; $i <= $limit; $i++ ) { + Optml_Attachment_Cache::get_cached_attachment_id( 'https://example.org/wp-content/uploads/miss-' . $i . '.jpg' ); + } + + $this->assertCount( (int) ( $limit / 2 ) + 1, $this->memory_map() ); + } + + /** + * The newest URLs stay in memory when the older half is dropped, and the dropped ones are + * still found in the persistent cache. + */ + public function test_dropped_entries_are_still_found_in_the_persistent_cache() { + $limit = Optml_Attachment_Cache::MAX_MEMORY_ENTRIES; + + for ( $i = 0; $i <= $limit; $i++ ) { + Optml_Attachment_Cache::set_cached_attachment_id( 'https://example.org/wp-content/uploads/image-' . $i . '.jpg', $i + 1 ); + } + + $map = $this->memory_map(); + $this->assertCount( (int) ( $limit / 2 ) + 1, $map ); + $this->assertContains( $limit + 1, $map ); + $this->assertNotContains( 1, $map ); + + $this->assertSame( 1, (int) Optml_Attachment_Cache::get_cached_attachment_id( 'https://example.org/wp-content/uploads/image-0.jpg' ) ); + } + + /** + * Updating a URL that is already in memory never drops other entries. + */ + public function test_updating_a_known_url_keeps_the_map() { + $limit = Optml_Attachment_Cache::MAX_MEMORY_ENTRIES; + + for ( $i = 0; $i < $limit; $i++ ) { + Optml_Attachment_Cache::set_cached_attachment_id( 'https://example.org/wp-content/uploads/image-' . $i . '.jpg', $i + 1 ); + } + Optml_Attachment_Cache::set_cached_attachment_id( 'https://example.org/wp-content/uploads/image-0.jpg', 99 ); + + $this->assertCount( $limit, $this->memory_map() ); + $this->assertSame( 99, Optml_Attachment_Cache::get_cached_attachment_id( 'https://example.org/wp-content/uploads/image-0.jpg' ) ); + } +}