Laravel

La key fantasma de Cache::flexible() en Laravel

Autorangel cruz
Publicado
Lectura6 min de lectura
La key fantasma de Cache::flexible() en Laravel

Hay bugs que rompen la build y se arreglan en cinco minutos. Y hay descubrimientos que no rompen nada (todo sigue "verde") pero que te obligan a abrir el código del framework para entender qué está pasando de verdad.

Mantengo un paquete pequeño, cache-ui-laravel, cuya promesa es simple: listar, buscar y borrar claves de caché individuales sin tener que purgar toda la caché. Útil cuando estás depurando en local o limpiando una sola entrada en producción sin tirar abajo todo lo demás.

Mientras lo actualizaba a Laravel 13 me topé con una pregunta incómoda: ¿qué pasa con las features nuevas de caché? En concreto, con una que se ha vuelto muy popular: Cache::flexible().

Primero, ¿qué es Cache::flexible()?

Es la implementación de Laravel del patrón stale-while-revalidate (SWR), el mismo que usan los CDNs y los navegadores. La idea: en lugar de hacer esperar al usuario cuando la caché expira, le sirves el dato "viejo" (stale) inmediatamente y refrescas el valor en segundo plano, después de enviar la respuesta.

$value = Cache::flexible('users', [5, 10], function () {
    return DB::table('users')->get();
});

Ese [5, 10] se lee así:

  • Primeros 5 segundos → el dato está fresco. Se devuelve tal cual.
  • Entre el segundo 5 y el 10 → el dato está stale. Se devuelve igual, pero se registra una deferred function que recalcula el valor tras la respuesta.
  • A partir del segundo 10 → el dato está expirado. Se recalcula en el momento (y sí, ese request paga el coste).

Es una herramienta excelente para endpoints muy consultados cuyo cómputo es caro. Pero para poder decidir si un valor está fresco, stale o expirado, Laravel necesita saber cuándo se creó. Y ahí está el detalle.

El descubrimiento

Mi paquete tiene un comando cache:list que muestra todas las claves. Al probar la compatibilidad con flexible(), escribí un test mínimo:

it('DEMO: flexible() leaks an internal created key into the listing', function () {
    Config::set('cache.default', 'file');
    Config::set('cache.stores.file.driver', 'key-aware-file');
 
    Cache::store('file')->flexible('users', [5, 10], fn () => 'payload');
 
    $keys = app(CacheUiLaravel::class)->getAllKeys('file');
 
    dump($keys);
});

Esperaba ver una sola clave: users. Esto es lo que salió:

array:2 [
  0 => "users"
  1 => "illuminate:cache:flexible:created:users"
]

Una segunda clave que yo nunca creé. Aparecía en el listado como si fuera un dato de la aplicación, cuando en realidad es contabilidad interna del framework.

A las fuentes: qué hace Laravel por debajo

La regla de oro al investigar algo así no es buscar en un blog (irónico, lo sé), sino ir al código original. Abrí Illuminate\Cache\Repository y ahí estaba, sin disimulo:

// vendor/laravel/framework/src/Illuminate/Cache/Repository.php
 
const FLEXIBLE_CREATED_KEY_PREFIX = 'illuminate:cache:flexible:created:';

Y el método flexible() confirma para qué se usa:

public function flexible($key, $ttl, $callback, $lock = null, $alwaysDefer = false)
{
    $key = enum_value($key);
 
    [
        $key => $value,
        self::FLEXIBLE_CREATED_KEY_PREFIX.$key => $created,
    ] = $this->many([$key, self::FLEXIBLE_CREATED_KEY_PREFIX.$key]);
 
    if (in_array(null, [$value, $created], true)) {
        return tap(value($callback), fn ($value) => $this->putMany([
            $key => $value,
            self::FLEXIBLE_CREATED_KEY_PREFIX.$key => Carbon::now()->getTimestamp(),
        ], $ttl[1]));
    }
 
    // ...
}

Traducido: cada vez que guardas un valor con flexible(), Laravel guarda DOS entradas: tu valor, y un timestamp en illuminate:cache:flexible:created:<tu-key>. Ese timestamp es lo que le permite calcular después si el dato está fresco o stale.

Es una decisión de diseño perfectamente razonable. El problema es que esa segunda clave vive en el mismo keyspace que tus datos. Para Redis, para la base de datos o para el sistema de ficheros, no hay diferencia entre users y illuminate:cache:flexible:created:users: ambas son claves de caché. Y por tanto ambas aparecen cuando listas.

Apunte interesante: flexible() lee y escribe con many() y putMany(), no con get()/put() directos. Eso fue clave para mi paquete, porque tengo un store custom (key-aware-file) que envuelve los valores. Como many()/putMany() delegan internamente en get()/put(), todo el envoltorio funciona sin tocar nada. La compatibilidad estaba; el único síntoma era el ruido visual.

La trampa de "no rompe nada"

Esto es lo que hace el caso interesante: nada fallaba. Los tests pasaban, la caché funcionaba, flexible() se comportaba de manual. El único síntoma era cosmético: un listado contaminado con claves internas que ningún humano escribió y que a nadie le importan.

Pero los síntomas cosméticos en herramientas de inspección no son inofensivos. Una UI de caché existe precisamente para darte una visión fiel de tu keyspace. Si la mitad de lo que muestra es ruido del framework, la herramienta miente un poco. Y una herramienta de depuración que miente es peor que no tener herramienta.

La solución

La corrección fue deliberadamente pequeña. Filtré las claves internas conocidas en el punto donde agrego el listado, detrás de una opción de configuración por si alguien quiere verlas:

final class CacheUiLaravel
{
    /**
     * Prefijo que Laravel usa para la entrada companion de Cache::flexible().
     *
     * @see \Illuminate\Cache\Repository::FLEXIBLE_CREATED_KEY_PREFIX
     */
    private const string FLEXIBLE_CREATED_KEY_PREFIX = 'illuminate:cache:flexible:created:';
 
    public function getAllKeys(?string $store = null, ?int $limit = null, int $offset = 0): array
    {
        // ... obtención de claves por driver (redis / file / database) ...
 
        if (config('cache-ui-laravel.hide_internal_keys', true)) {
            return $this->filterInternalKeys($keys);
        }
 
        return $keys;
    }
 
    /**
     * Elimina las claves internas de contabilidad de Laravel del listado.
     *
     * @param  array<string>  $keys
     * @return array<string>
     */
    private function filterInternalKeys(array $keys): array
    {
        return array_values(array_filter(
            $keys,
            static fn (string $key): bool => ! str_starts_with($key, self::FLEXIBLE_CREATED_KEY_PREFIX)
        ));
    }
}

Y la opción de configuración, con default sensato:

// config/cache-ui-laravel.php
 
'hide_internal_keys' => env('CACHE_UI_HIDE_INTERNAL_KEYS', true),

Tres decisiones de diseño merecen explicación:

  1. El prefijo es una constante, no un literal suelto, y apunta con @see al constante real del framework. Si Laravel lo renombra algún día, el rastro de migas está ahí.
  2. Es configurable. Por defecto oculto el ruido, pero quien esté depurando el propio flexible() puede querer ver las claves internas. Decisión del usuario, no mía.
  3. El limit pasa a ser un tope "suave". Como filtro después de pedir las claves al driver, una página de 100 podría devolver 99 visibles. Es un trade-off consciente y documentado: en una herramienta interactiva de búsqueda, vale más un listado honesto que un conteo exacto.

Lo que decidí NO hacer

La primera tentación fue "limpiar bien": cuando borras la clave users, borrar también su companion illuminate:cache:flexible:created:users. Lo hice... y lo revertí. Por tres razones:

  • Añadía un forget() extra en cada borrado, también para claves que nunca pasaron por flexible().
  • Acoplaba mi forgetKey() a un detalle interno del framework.
  • Es innecesario. El "huérfano" se auto-sana: si borras el valor pero queda el timestamp, la siguiente llamada a flexible() ve el valor en null, entra por la rama de in_array(null, ...) y recalcula ambos. Además expira solo por su propio TTL. Y, sobre todo, ya queda oculto del listado por el filtro.

La lección: cuando algo se auto-corrige y es invisible, "arreglarlo" suele ser añadir complejidad para resolver un problema que no existe.

Conclusiones para llevarte

  • Cache::flexible() escribe dos entradas por clave. Si listas, inspeccionas o cuentas claves de caché, ten en cuenta el prefijo illuminate:cache:flexible:created:.
  • El keyspace de caché es compartido y plano. Redis, base de datos o ficheros no distinguen entre tus datos y la contabilidad del framework. Cualquier herramienta de inspección tiene que filtrar lo que no es del usuario.
  • Cuando algo te sorprenda, lee el framework, no un blog. El código de Illuminate es legible y está a un grep de distancia en tu carpeta vendor/. La fuente primaria responde preguntas que ningún resumen de tercera mano puede.
  • No todo lo que descubres hay que "arreglarlo". A veces la mejor corrección es la más pequeña, y a veces es ninguna.

Un descubrimiento que no rompía nada terminó mejorando la honestidad de la herramienta y, de paso, me hizo entender mucho mejor cómo funciona una de las features más bonitas de la caché de Laravel. No está mal para una "key fantasma".

¿Usas Cache::flexible() en producción? Échale un ojo a tu keyspace con un SCAN y busca el prefijo illuminate:cache:flexible:created:. Te sorprenderá cuántas hay.

Sobre el autor
Angel Cruz

Angel Cruz

Soy desarrollador PHP senior. Casi todo lo que construyo pasa por Laravel, me obsesiona el código que se mantiene y escribo sobre lo que aprendo, con sus trade-offs incluidos.