1<?php
2
3/*
4 * This file is part of the Symfony package.
5 *
6 * (c) Fabien Potencier <fabien@symfony.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Symfony\Component\Cache\Adapter;
13
14use Psr\Log\LoggerAwareInterface;
15use Symfony\Component\Cache\CacheItem;
16use Symfony\Component\Cache\Exception\InvalidArgumentException;
17use Symfony\Component\Cache\ResettableInterface;
18use Symfony\Component\Cache\Traits\AbstractAdapterTrait;
19use Symfony\Component\Cache\Traits\ContractsTrait;
20use Symfony\Contracts\Cache\TagAwareCacheInterface;
21
22/**
23 * Abstract for native TagAware adapters.
24 *
25 * To keep info on tags, the tags are both serialized as part of cache value and provided as tag ids
26 * to Adapters on operations when needed for storage to doSave(), doDelete() & doInvalidate().
27 *
28 * @author Nicolas Grekas <p@tchwork.com>
29 * @author André Rømcke <andre.romcke+symfony@gmail.com>
30 *
31 * @internal
32 */
33abstract class AbstractTagAwareAdapter implements TagAwareAdapterInterface, TagAwareCacheInterface, LoggerAwareInterface, ResettableInterface
34{
35    use AbstractAdapterTrait;
36    use ContractsTrait;
37
38    private const TAGS_PREFIX = "\0tags\0";
39
40    protected function __construct(string $namespace = '', int $defaultLifetime = 0)
41    {
42        $this->namespace = '' === $namespace ? '' : CacheItem::validateKey($namespace).':';
43        if (null !== $this->maxIdLength && \strlen($namespace) > $this->maxIdLength - 24) {
44            throw new InvalidArgumentException(sprintf('Namespace must be %d chars max, %d given ("%s").', $this->maxIdLength - 24, \strlen($namespace), $namespace));
45        }
46        $this->createCacheItem = \Closure::bind(
47            static function ($key, $value, $isHit) {
48                $item = new CacheItem();
49                $item->key = $key;
50                $item->isTaggable = true;
51                // If structure does not match what we expect return item as is (no value and not a hit)
52                if (!\is_array($value) || !\array_key_exists('value', $value)) {
53                    return $item;
54                }
55                $item->isHit = $isHit;
56                // Extract value, tags and meta data from the cache value
57                $item->value = $value['value'];
58                $item->metadata[CacheItem::METADATA_TAGS] = $value['tags'] ?? [];
59                if (isset($value['meta'])) {
60                    // For compactness these values are packed, & expiry is offset to reduce size
61                    $v = unpack('Ve/Nc', $value['meta']);
62                    $item->metadata[CacheItem::METADATA_EXPIRY] = $v['e'] + CacheItem::METADATA_EXPIRY_OFFSET;
63                    $item->metadata[CacheItem::METADATA_CTIME] = $v['c'];
64                }
65
66                return $item;
67            },
68            null,
69            CacheItem::class
70        );
71        $getId = \Closure::fromCallable([$this, 'getId']);
72        $tagPrefix = self::TAGS_PREFIX;
73        $this->mergeByLifetime = \Closure::bind(
74            static function ($deferred, &$expiredIds) use ($getId, $tagPrefix, $defaultLifetime) {
75                $byLifetime = [];
76                $now = microtime(true);
77                $expiredIds = [];
78
79                foreach ($deferred as $key => $item) {
80                    $key = (string) $key;
81                    if (null === $item->expiry) {
82                        $ttl = 0 < $defaultLifetime ? $defaultLifetime : 0;
83                    } elseif (!$item->expiry) {
84                        $ttl = 0;
85                    } elseif (0 >= $ttl = (int) (0.1 + $item->expiry - $now)) {
86                        $expiredIds[] = $getId($key);
87                        continue;
88                    }
89                    // Store Value and Tags on the cache value
90                    if (isset(($metadata = $item->newMetadata)[CacheItem::METADATA_TAGS])) {
91                        $value = ['value' => $item->value, 'tags' => $metadata[CacheItem::METADATA_TAGS]];
92                        unset($metadata[CacheItem::METADATA_TAGS]);
93                    } else {
94                        $value = ['value' => $item->value, 'tags' => []];
95                    }
96
97                    if ($metadata) {
98                        // For compactness, expiry and creation duration are packed, using magic numbers as separators
99                        $value['meta'] = pack('VN', (int) (0.1 + $metadata[self::METADATA_EXPIRY] - self::METADATA_EXPIRY_OFFSET), $metadata[self::METADATA_CTIME]);
100                    }
101
102                    // Extract tag changes, these should be removed from values in doSave()
103                    $value['tag-operations'] = ['add' => [], 'remove' => []];
104                    $oldTags = $item->metadata[CacheItem::METADATA_TAGS] ?? [];
105                    foreach (array_diff($value['tags'], $oldTags) as $addedTag) {
106                        $value['tag-operations']['add'][] = $getId($tagPrefix.$addedTag);
107                    }
108                    foreach (array_diff($oldTags, $value['tags']) as $removedTag) {
109                        $value['tag-operations']['remove'][] = $getId($tagPrefix.$removedTag);
110                    }
111
112                    $byLifetime[$ttl][$getId($key)] = $value;
113                    $item->metadata = $item->newMetadata;
114                }
115
116                return $byLifetime;
117            },
118            null,
119            CacheItem::class
120        );
121    }
122
123    /**
124     * Persists several cache items immediately.
125     *
126     * @param array   $values        The values to cache, indexed by their cache identifier
127     * @param int     $lifetime      The lifetime of the cached values, 0 for persisting until manual cleaning
128     * @param array[] $addTagData    Hash where key is tag id, and array value is list of cache id's to add to tag
129     * @param array[] $removeTagData Hash where key is tag id, and array value is list of cache id's to remove to tag
130     *
131     * @return array The identifiers that failed to be cached or a boolean stating if caching succeeded or not
132     */
133    abstract protected function doSave(array $values, int $lifetime, array $addTagData = [], array $removeTagData = []): array;
134
135    /**
136     * Removes multiple items from the pool and their corresponding tags.
137     *
138     * @param array $ids An array of identifiers that should be removed from the pool
139     *
140     * @return bool True if the items were successfully removed, false otherwise
141     */
142    abstract protected function doDelete(array $ids);
143
144    /**
145     * Removes relations between tags and deleted items.
146     *
147     * @param array $tagData Array of tag => key identifiers that should be removed from the pool
148     */
149    abstract protected function doDeleteTagRelations(array $tagData): bool;
150
151    /**
152     * Invalidates cached items using tags.
153     *
154     * @param string[] $tagIds An array of tags to invalidate, key is tag and value is tag id
155     *
156     * @return bool True on success
157     */
158    abstract protected function doInvalidate(array $tagIds): bool;
159
160    /**
161     * Delete items and yields the tags they were bound to.
162     */
163    protected function doDeleteYieldTags(array $ids): iterable
164    {
165        foreach ($this->doFetch($ids) as $id => $value) {
166            yield $id => \is_array($value) && \is_array($value['tags'] ?? null) ? $value['tags'] : [];
167        }
168
169        $this->doDelete($ids);
170    }
171
172    /**
173     * {@inheritdoc}
174     */
175    public function commit(): bool
176    {
177        $ok = true;
178        $byLifetime = $this->mergeByLifetime;
179        $byLifetime = $byLifetime($this->deferred, $expiredIds);
180        $retry = $this->deferred = [];
181
182        if ($expiredIds) {
183            // Tags are not cleaned up in this case, however that is done on invalidateTags().
184            $this->doDelete($expiredIds);
185        }
186        foreach ($byLifetime as $lifetime => $values) {
187            try {
188                $values = $this->extractTagData($values, $addTagData, $removeTagData);
189                $e = $this->doSave($values, $lifetime, $addTagData, $removeTagData);
190            } catch (\Exception $e) {
191            }
192            if (true === $e || [] === $e) {
193                continue;
194            }
195            if (\is_array($e) || 1 === \count($values)) {
196                foreach (\is_array($e) ? $e : array_keys($values) as $id) {
197                    $ok = false;
198                    $v = $values[$id];
199                    $type = \is_object($v) ? \get_class($v) : \gettype($v);
200                    $message = sprintf('Failed to save key "{key}" of type %s%s', $type, $e instanceof \Exception ? ': '.$e->getMessage() : '.');
201                    CacheItem::log($this->logger, $message, ['key' => substr($id, \strlen($this->namespace)), 'exception' => $e instanceof \Exception ? $e : null]);
202                }
203            } else {
204                foreach ($values as $id => $v) {
205                    $retry[$lifetime][] = $id;
206                }
207            }
208        }
209
210        // When bulk-save failed, retry each item individually
211        foreach ($retry as $lifetime => $ids) {
212            foreach ($ids as $id) {
213                try {
214                    $v = $byLifetime[$lifetime][$id];
215                    $values = $this->extractTagData([$id => $v], $addTagData, $removeTagData);
216                    $e = $this->doSave($values, $lifetime, $addTagData, $removeTagData);
217                } catch (\Exception $e) {
218                }
219                if (true === $e || [] === $e) {
220                    continue;
221                }
222                $ok = false;
223                $type = \is_object($v) ? \get_class($v) : \gettype($v);
224                $message = sprintf('Failed to save key "{key}" of type %s%s', $type, $e instanceof \Exception ? ': '.$e->getMessage() : '.');
225                CacheItem::log($this->logger, $message, ['key' => substr($id, \strlen($this->namespace)), 'exception' => $e instanceof \Exception ? $e : null]);
226            }
227        }
228
229        return $ok;
230    }
231
232    /**
233     * {@inheritdoc}
234     */
235    public function deleteItems(array $keys): bool
236    {
237        if (!$keys) {
238            return true;
239        }
240
241        $ok = true;
242        $ids = [];
243        $tagData = [];
244
245        foreach ($keys as $key) {
246            $ids[$key] = $this->getId($key);
247            unset($this->deferred[$key]);
248        }
249
250        try {
251            foreach ($this->doDeleteYieldTags(array_values($ids)) as $id => $tags) {
252                foreach ($tags as $tag) {
253                    $tagData[$this->getId(self::TAGS_PREFIX.$tag)][] = $id;
254                }
255            }
256        } catch (\Exception $e) {
257            $ok = false;
258        }
259
260        try {
261            if ((!$tagData || $this->doDeleteTagRelations($tagData)) && $ok) {
262                return true;
263            }
264        } catch (\Exception $e) {
265        }
266
267        // When bulk-delete failed, retry each item individually
268        foreach ($ids as $key => $id) {
269            try {
270                $e = null;
271                if ($this->doDelete([$id])) {
272                    continue;
273                }
274            } catch (\Exception $e) {
275            }
276            $message = 'Failed to delete key "{key}"'.($e instanceof \Exception ? ': '.$e->getMessage() : '.');
277            CacheItem::log($this->logger, $message, ['key' => $key, 'exception' => $e]);
278            $ok = false;
279        }
280
281        return $ok;
282    }
283
284    /**
285     * {@inheritdoc}
286     */
287    public function invalidateTags(array $tags)
288    {
289        if (empty($tags)) {
290            return false;
291        }
292
293        $tagIds = [];
294        foreach (array_unique($tags) as $tag) {
295            $tagIds[] = $this->getId(self::TAGS_PREFIX.$tag);
296        }
297
298        if ($this->doInvalidate($tagIds)) {
299            return true;
300        }
301
302        return false;
303    }
304
305    /**
306     * Extracts tags operation data from $values set in mergeByLifetime, and returns values without it.
307     */
308    private function extractTagData(array $values, ?array &$addTagData, ?array &$removeTagData): array
309    {
310        $addTagData = $removeTagData = [];
311        foreach ($values as $id => $value) {
312            foreach ($value['tag-operations']['add'] as $tag => $tagId) {
313                $addTagData[$tagId][] = $id;
314            }
315
316            foreach ($value['tag-operations']['remove'] as $tag => $tagId) {
317                $removeTagData[$tagId][] = $id;
318            }
319
320            unset($values[$id]['tag-operations']);
321        }
322
323        return $values;
324    }
325}
326