Appearance
Servicio de Caché Multi-Tenant
Módulo: Shared Tipo: Resource Estado: Planificado Fecha: 2026-01-07
Descripción
Problema de Negocio
Actualmente el sistema Sistema Bautista no cuenta con una infraestructura centralizada de caché, lo que genera los siguientes problemas operacionales y de rendimiento:
- Consultas repetitivas a la base de datos: Cada solicitud de datos maestros (provincias, plan de cuentas, tipos de comprobantes, etc.) genera una query a PostgreSQL, incluso cuando los datos no han cambiado
- Tiempos de respuesta lentos: Listados y consultas frecuentes tardan más de lo necesario, especialmente en módulos con gran volumen de datos (Ventas, Compras, Stock)
- Carga innecesaria en la base de datos: El servidor PostgreSQL procesa queries idénticas múltiples veces por segundo, consumiendo recursos de CPU y memoria
- Escalabilidad limitada: A medida que crece el número de usuarios concurrentes y de tenants (sucursales/cajas), la carga en la base de datos aumenta linealmente
- Falta de control sobre datos temporales: No existe un mecanismo estandarizado para almacenar temporalmente resultados de consultas pesadas o cálculos complejos
- Complejidad en multi-tenancy: Sin una estrategia clara de caché por tenant, es difícil aislar datos cacheados entre diferentes sucursales y cajas
Necesidad del Negocio
El negocio requiere una solución de caché centralizada que permita:
- Mejorar significativamente los tiempos de respuesta: Reducir latencia en operaciones frecuentes (listados de datos maestros, consultas de saldos, búsquedas de clientes/proveedores) de segundos a milisegundos
- Reducir la carga en la base de datos: Disminuir el número de queries a PostgreSQL en un 50-70% para datos de consulta frecuente
- Soportar crecimiento del sistema: Permitir que el sistema escale a más usuarios y tenants sin degradación de performance
- Aislar datos por tenant: Garantizar que el caché de una sucursal no interfiera con otra, respetando la arquitectura multi-tenant existente
- Facilitar el desarrollo: Proveer una API simple y consistente para que los desarrolladores puedan implementar caché en cualquier módulo sin duplicar lógica
- Gestionar expiración automática: Asegurar que los datos cacheados se invaliden cuando sea necesario, evitando inconsistencias
La solución debe ser transparente para el usuario final (no requiere cambios en la UI) pero debe resultar en una experiencia notablemente más rápida al interactuar con el sistema.
Valor de Negocio
La implementación de este servicio centralizado de caché aportará los siguientes beneficios cuantificables:
Performance:
- Reducción del 70-90% en tiempo de carga de listados de datos maestros
- Mejora de 2-5 segundos a menos de 100ms en consultas frecuentes
- Respuesta casi instantánea en búsquedas ya realizadas
Escalabilidad:
- Capacidad de soportar 3-5x más usuarios concurrentes sin hardware adicional
- Crecimiento lineal de infraestructura (no exponencial) al agregar nuevos tenants
Costos operacionales:
- Reducción de 50-70% en carga de CPU/memoria del servidor PostgreSQL
- Posibilidad de postergar inversión en hardware de base de datos
- Menor consumo de recursos de red entre servidores web y DB
Experiencia de usuario:
- Navegación más fluida y rápida en todas las pantallas del sistema
- Reducción de quejas por lentitud del sistema
- Mayor productividad del personal operativo
Calidad del código:
- Patrón reutilizable para todos los módulos (Ventas, Compras, Tesorería, etc.)
- Reducción de código duplicado de estrategias de caché ad-hoc
- Mantenimiento simplificado al centralizar la lógica de caché
Contexto en el Proceso de Negocio
El servicio de caché se utilizará de forma transversal en todos los módulos del sistema, con diferentes scopes según la naturaleza de los datos:
1. Datos Globales del Sistema (scope: Global - TODAS las empresas)
- Provincias argentinas: Las mismas 24 provincias para todas las empresas
- Países del mundo: Listado ISO 3166
- Monedas internacionales: Códigos ISO 4217
- Tipos de documentos nacionales: DNI, CUIT, CUIL, Pasaporte (definidos por AFIP)
- Feriados nacionales: Feriados oficiales de Argentina
- Códigos postales estándar: Si son oficiales de ARCA/Correo Argentino
2. Datos Maestros Compartidos por Empresa (scope: Shared - toda la empresa)
- Plan de cuentas contables (
cuentas): Cada empresa tiene su propio plan - Tipos de comprobantes (
comprob): Configurables por empresa - Tasas de IVA (
aliva): Aunque son nacionales, empresas pueden tener casos especiales - Conceptos de retención: IIBB, Ganancias (configurables por empresa)
- Bancos y cuentas bancarias: Pueden variar por empresa
- Localidades propias: Empresas pueden agregar localidades no oficiales
- Configuración de empresa: Logo, razón social, CUIT, dirección
3. Datos Maestros por Tenant (scope: Tenant - sucursal/caja específica)
- Listado de clientes/proveedores activos: Por sucursal
- Artículos y precios vigentes: Pueden variar por sucursal
- Configuraciones del punto de venta: Específicas de cada caja
- Permisos y roles de usuarios: Por sucursal/caja
- Depósitos y ubicaciones: Específicos de cada sucursal
4. Datos Transaccionales Agregados (scope: Tenant - por sucursal/caja)
- Saldos de caja actuales: Por caja registradora
- Totales de ventas del día: Por sucursal/caja
- Stock disponible de productos: Por depósito/sucursal
- Estadísticas de dashboard: Por usuario en sucursal
5. Resultados de Consultas Pesadas (scope: Variable según caso)
- Reportes de cierre de mes: Tenant (por sucursal)
- Consolidaciones multi-sucursal: Shared (toda la empresa)
- Cálculos de comisiones: Tenant o User (según caso)
Frontend
Impacto en la Experiencia de Usuario
Aunque el servicio de caché es una funcionalidad de backend, su impacto es directamente visible para los usuarios finales en todas las vistas del sistema:
Vistas Beneficiadas
Todas las vistas del sistema experimentarán mejoras de rendimiento
Interacciones del Usuario
Las interacciones que el usuario experimenta no cambian, pero se vuelven significativamente más rápidas:
- Consultar listados: El usuario accede a un módulo y ve los datos casi instantáneamente (especialmente en consultas repetidas)
- Buscar registros: Al escribir en un buscador, los resultados aparecen sin demora perceptible
- Abrir selectores: Los desplegables de datos maestros (provincias, cuentas, etc.) se cargan sin tiempo de espera
- Navegar entre vistas: Cambiar de pantalla es más fluido al tener datos pre-cargados en caché
Estados de UI
No hay cambios visibles en la UI, pero internamente:
- Estado inicial: Sin caché, primera carga desde base de datos
- Estado con caché (hit): Carga instantánea desde memoria/storage
- Estado sin caché (miss): Carga normal desde BD, luego se almacena en caché
- Estado de invalidación: Caché se limpia automáticamente al modificar datos, próxima consulta regenera caché
Backend
Entidades de Negocio
CacheEntry (Entrada de Caché)
Representa un dato almacenado en el sistema de caché.
Datos de negocio:
- Clave única: Identificador que combina el scope (tenant o compartido), módulo, recurso e ID
- Valor: Dato serializado (puede ser cualquier estructura: arrays, objetos DTO, valores primitivos)
- Tenant/Schema: Contexto del tenant al que pertenece el dato (suc0001, suc0001caja001, o 'shared' para datos compartidos)
- Tiempo de expiración: Duración de validez del dato en caché (TTL - Time To Live)
- Fecha de creación: Cuándo se almacenó el dato en caché
- Etiquetas (tags): Categorías para agrupar datos relacionados (ej: 'clientes', 'facturas_suc0001', 'provincias')
Arquitectura Multi-Tenant Completa
El sistema Sistema Bautista implementa una arquitectura multi-tenant de 5 niveles de aislamiento:
Nivel 0: Global/Sistema (TODAS las empresas)
- Datos maestros que son idénticos para TODAS las empresas del sistema
- NO pertenecen a ninguna empresa específica
- Ejemplos: Provincias, países, monedas, tipos de documentos nacionales, feriados nacionales
- Alcance: TODO el sistema, compartido universalmente
- Uso: Datos estáticos de referencia que no cambian por empresa
Nivel 1: Multi-Empresa
- Cada empresa cliente del sistema tiene su propia base de datos
- Identificación:
sistema_idúnico por empresa - Ejemplo: Empresa A (sistema_id=1) usa database
empresa_a, Empresa B (sistema_id=2) usa databaseempresa_b - Aislamiento total: Los datos de una empresa NUNCA se mezclan con otra
Nivel 2: Multi-Database (por empresa)
- Cada empresa tiene 2 bases de datos:
- Producción:
bd(ej:empresa_a) - Prueba:
bd_p(ej:empresa_a_p)
- Producción:
- Conexiones nombradas en ConnectionManager:
oficial(producción) yprueba(testing)
Nivel 3: Multi-Schema (por sucursal/caja)
- Dentro de cada database, múltiples schemas:
- Schema
public: Datos compartidos de toda la empresa - Schema
suc0001,suc0002, etc.: Datos por sucursal - Schema
suc0001caja001,suc0001caja002, etc.: Datos por caja registradora
- Schema
- Aislamiento por schema: Clientes de suc0001 NO se mezclan con suc0002
Nivel 4: Multi-User (por usuario)
- Datos específicos de cada usuario dentro de un tenant
- Preferencias, sesiones, widgets personalizados
Scope de Caché
El sistema soporta cuatro alcances (scopes) de caché que respetan la jerarquía multi-tenant:
Global (Sistema completo - TODAS las empresas): Datos maestros universales compartidos por todo el sistema
- Alcance: TODO el sistema, independiente de empresa, database o schema
- Ejemplos: Provincias argentinas, países del mundo, monedas internacionales, tipos de documentos nacionales (DNI, CUIT, CUIL)
- Key pattern:
global::{recurso}::{id} - Ejemplo real:
global::provincias::all - Ejemplo real:
global::paises::all - Cuándo usar: Solo para datos que son 100% idénticos para todas las empresas y NUNCA cambian por empresa
- Ventaja: Máxima eficiencia - una sola entrada de caché sirve a todas las empresas
Shared (Compartido por empresa): Datos idénticos para toda la empresa pero que pueden variar entre empresas
- Alcance: Toda la empresa (todos los schemas de una misma database)
- Ejemplos: Plan de cuentas, tipos de comprobantes internos, configuraciones de empresa, conceptos de retención propios
- Key pattern:
{sistema_id}::{database}::shared::{recurso}::{id} - Ejemplo real:
1::empresa_a::shared::cuentas::all - Ejemplo real:
2::empresa_b::shared::comprob::all - Cuándo usar: Cuando el dato es compartido dentro de una empresa pero puede diferir entre empresas
Tenant (Por sucursal/caja): Datos específicos de un schema dentro de una empresa
- Alcance: Un schema específico (sucursal o caja) de una empresa
- Ejemplos: Clientes de suc0001, facturas de suc0002caja001, stock de artículos
- Key pattern:
{sistema_id}::{database}::{schema}::{recurso}::{id} - Ejemplo real:
1::empresa_a::suc0001::clientes::activos - Ejemplo con caja:
1::empresa_a::suc0001caja001::facturas::pending
User (Por usuario): Datos específicos de un usuario dentro de un tenant
- Alcance: Un usuario particular en un schema específico
- Ejemplos: Preferencias UI, últimas búsquedas, dashboard personalizado
- Key pattern:
{sistema_id}::{database}::{schema}::{user_id}::{recurso}::{id} - Ejemplo real:
1::empresa_a::suc0001::42::dashboard::widgets
Garantía de Aislamiento:
- Keys globales (
global::...) se comparten entre TODAS las empresas (por diseño) - Keys de Empresa A (
1::empresa_a::...) NUNCA colisionan con Empresa B (2::empresa_b::...) - Keys de suc0001 (
...::suc0001::...) NUNCA colisionan con suc0002 (...::suc0002::...) - Keys de producción (
...::empresa_a::...) NUNCA colisionan con prueba (...::empresa_a_p::...)
Diferencia CRÍTICA entre Global y Shared:
- Global: Provincias son las mismas para TODAS las empresas →
global::provincias::all - Shared: Plan de cuentas de Empresa A ≠ Plan de cuentas de Empresa B →
1::empresa_a::shared::cuentas::all
Operaciones de Negocio
1. Almacenar Dato en Caché (Store)
Descripción: Guardar un dato en caché para su posterior recuperación rápida.
Parámetros de negocio:
- Clave (key): Identificador único del dato
- Valor (value): Dato a almacenar
- TTL (Time To Live): Tiempo de validez en segundos (opcional, usa default si no se especifica)
- Tags: Etiquetas para agrupar (opcional)
- Scope: Compartido, por tenant, o por usuario
Resultado esperado: Dato almacenado disponible para consultas posteriores hasta su expiración.
2. Recuperar Dato de Caché (Retrieve/Get)
Descripción: Obtener un dato previamente almacenado en caché.
Parámetros de negocio:
- Clave (key): Identificador del dato a buscar
- Scope: Contexto de búsqueda (automático desde tenant actual)
Resultados posibles:
- Cache Hit: Dato encontrado y vigente → retorna valor inmediatamente
- Cache Miss: Dato no existe o expiró → retorna null, aplicación debe consultar fuente original
3. Obtener o Calcular (Remember/Cache-Aside Pattern)
Descripción: Busca el dato en caché; si no existe, ejecuta una función para calcularlo, lo almacena y lo retorna.
Parámetros de negocio:
- Clave (key): Identificador del dato
- Callback: Función que calcula el valor si no está en caché
- TTL: Tiempo de validez
Flujo de negocio:
- Buscar dato en caché
- Si existe (hit) → retornar inmediatamente
- Si no existe (miss) → ejecutar callback, almacenar resultado, retornar
Ventaja de negocio: Simplifica el código del desarrollador (un solo método en lugar de if/else manual).
4. Invalidar Dato Específico (Forget/Delete)
Descripción: Eliminar un dato específico de la caché cuando ya no es válido.
Cuándo se usa:
- Después de modificar el dato original en la base de datos
- Al eliminar un registro
- Cuando el dato debe refrescarse forzosamente
Parámetros de negocio:
- Clave (key): Identificador del dato a eliminar
5. Invalidar por Patrón (Flush by Pattern)
Descripción: Eliminar todos los datos de caché que coincidan con un patrón de clave.
Cuándo se usa:
- Al insertar/modificar múltiples registros relacionados
- Cuando cambia una configuración que afecta a múltiples datos
Ejemplo:
- Patrón:
suc0001::facturas::*→ elimina todas las facturas cacheadas de suc0001 - Patrón:
shared::provincias::*→ elimina todas las provincias cacheadas
6. Invalidar por Etiqueta (Flush by Tag)
Descripción: Eliminar todos los datos de caché asociados a una etiqueta específica.
Cuándo se usa:
- Invalidación masiva de datos relacionados lógicamente
Ejemplo:
- Tag:
clientes→ elimina todos los datos relacionados con clientes - Tag:
dashboard_user_42→ elimina todos los widgets del dashboard del usuario 42
Validaciones de Negocio
Validaciones de Entrada
Clave (Key):
- No puede ser vacía o null
- Longitud máxima: 255 caracteres
- Solo caracteres alfanuméricos, guiones y dos puntos (pattern:
^[a-zA-Z0-9:_-]+$)
Valor (Value):
- Debe ser serializable (no puede contener recursos como file handles, conexiones de BD, etc.)
- Tamaño máximo recomendado: 1 MB por entrada (evitar memory overflow)
- No debe contener datos sensibles sin encriptar (passwords, tokens de API, números de tarjetas)
TTL (Time To Live):
- Debe ser entero positivo en segundos, o null (sin expiración)
- Valor mínimo: 60 segundos (1 minuto)
- Valor máximo: 86400 segundos (24 horas) para datos transaccionales
- Valor máximo: 2592000 segundos (30 días) para datos maestros
Schema/Tenant:
- Debe ser un schema válido existente en el sistema
- Debe coincidir con el schema del usuario autenticado (no se permite acceder a caché de otros tenants)
- Formato válido:
suc####(sucursal) osuc####caja###(caja) oshared(compartido)
Validaciones de Negocio
Aislamiento de Tenants:
- Un usuario de
suc0001NO puede leer ni escribir en caché desuc0002 - Un usuario de
suc0001caja001puede leer caché desuc0001(herencia hacia arriba en jerarquía) - Datos compartidos (
shared) son accesibles por todos los tenants
- Un usuario de
Integridad de Datos:
- Al modificar un dato en base de datos, la caché correspondiente debe invalidarse automáticamente
- Si falla la invalidación, debe registrarse un warning en logs (pero no bloquear la operación)
Consistencia:
- No se permite cachear datos que están en medio de una transacción no commiteada
- Datos cacheados deben incluir un timestamp para detectar staleness
Rendimiento:
- No se permite cachear resultados de queries que retornan más de 1000 registros (usar paginación)
- Datos de caché no deben superar el 80% de la memoria disponible (auto-eviction de lo menos usado)
Relaciones con Otros Módulos
El servicio de caché NO es un módulo independiente, sino una infraestructura compartida que todos los módulos pueden utilizar:
Módulos Consumidores de Caché
Ventas:
- (Tenant) Cachear listado de clientes activos
- (Shared) Cachear tipos de comprobante disponibles
- (Tenant) Cachear últimos precios de venta de artículos
Compras:
- (Tenant) Cachear listado de proveedores activos
- (Shared) Cachear conceptos de retención de ganancias/IIBB
- (Shared) Cachear condiciones de compra
Stock:
- (Tenant) Cachear stock disponible de artículos (corto TTL: 5 minutos)
- (Tenant) Cachear listado de depósitos
- (Tenant) Cachear movimientos de stock del día
Tesorería:
- (Tenant) Cachear saldo actual de caja (muy corto TTL: 1 minuto)
- (Shared) Cachear conceptos de retención
- (Shared) Cachear listado de bancos y cuentas bancarias
Contabilidad:
- (Shared) Cachear plan de cuentas completo (largo TTL: 24 horas)
- (Shared) Cachear ejercicios contables
- (Tenant) Cachear asientos del período actual
CtaCte:
- (Tenant) Cachear saldos de clientes/proveedores
- (Shared) Cachear condiciones de venta/compra
- (Shared) Cachear carteras de clientes/proveedores
CRM:
- (Tenant) Cachear campañas activas
- (Shared) Cachear etapas de pipeline de ventas
- (Tenant) Cachear histórico de interacciones recientes
Core/Shared:
- (Global) Cachear provincias argentinas
- (Global) Cachear países del mundo
- (Global) Cachear monedas internacionales
- (Global) Cachear tipos de documentos nacionales (DNI, CUIT, CUIL)
- (Shared) Cachear configuración de la empresa
- (Tenant) Cachear permisos y roles de usuarios
- (Shared) Cachear tipos de IVA
- (Shared) Cachear localidades (si la empresa tiene propias)
Reglas de Negocio
RN-001: Alcance de Caché Compartida
Regla: Los datos maestros que son idénticos para toda la empresa deben cachearse en scope shared, no por tenant.
Condición: Cuando el dato no varía entre sucursales/cajas de la misma empresa.
Acción:
- Usar key pattern completo:
{sistema_id}::{database}::shared::{recurso}::{id} - Incluir empresa y database para evitar colisiones entre diferentes empresas
- Almacenar en schema
publico tabla global de caché - TTL recomendado: 24 horas
Ejemplos completos:
- ✅ Provincias (Empresa A):
1::empresa_a::shared::provincias::all - ✅ Plan de cuentas (Empresa A):
1::empresa_a::shared::cuentas::all - ✅ Tipos de IVA (Empresa B):
2::empresa_b::shared::aliva::all - ✅ Provincias en testing (Empresa A):
1::empresa_a_p::shared::provincias::all
Ejemplos de lo que NO debe ser compartido:
- ❌ Clientes (varía por sucursal):
1::empresa_a::suc0001::clientes::all(usar RN-002) - ❌ Stock (varía por depósito/sucursal):
1::empresa_a::suc0001::stock::articulo123(usar RN-002)
Importancia del sistema_id y database:
- Sin
sistema_id: Empresa A y Empresa B con misma keyshared::provincias::allcolisionarían ❌ - Con
sistema_id:1::empresa_a::shared::provincias::all≠2::empresa_b::shared::provincias::all✅ - Sin
database: Producción y testing colisionarían enshared::provincias::all❌ - Con
database:1::empresa_a::shared::...≠1::empresa_a_p::shared::...✅
RN-002: Alcance de Caché por Tenant
Regla: Los datos transaccionales y datos maestros específicos de un tenant deben cachearse con el schema incluido en la key, SIEMPRE con empresa y database identificados.
Condición: Cuando el dato puede variar entre sucursales/cajas, o entre diferentes empresas.
Acción:
- Usar key pattern completo:
{sistema_id}::{database}::{schema}::{recurso}::{id} - Incluir TODOS los niveles de aislamiento (empresa, database, schema)
- Almacenar en caché con contexto de tenant
- TTL recomendado: 1-6 horas
Ejemplos completos:
- Clientes de suc0001 (Empresa A, producción):
1::empresa_a::suc0001::clientes::all - Facturas de caja (Empresa A, producción):
1::empresa_a::suc0001caja001::facturas::pending - Saldo de caja (Empresa B, producción):
2::empresa_b::suc0002caja001::saldo::current - Clientes de suc0001 (Empresa A, testing):
1::empresa_a_p::suc0001::clientes::all
Aislamiento garantizado:
Empresa A suc0001 ≠ Empresa B suc0001:
1::empresa_a::suc0001::clientes::all2::empresa_b::suc0001::clientes::all- Diferentes empresas, misma numeración de sucursal → NO colisionan ✅
Misma empresa, diferentes sucursales:
1::empresa_a::suc0001::clientes::all1::empresa_a::suc0002::clientes::all- Misma empresa, diferentes sucursales → NO colisionan ✅
Misma empresa y sucursal, producción vs testing:
1::empresa_a::suc0001::clientes::all(producción)1::empresa_a_p::suc0001::clientes::all(testing)- Diferentes databases → NO colisionan ✅
RN-003: Invalidación Automática en Modificaciones
Regla: Al crear, modificar o eliminar un dato en la base de datos, el caché relacionado debe invalidarse automáticamente.
Condición: En toda operación CUD (Create, Update, Delete) sobre datos cacheados.
Acción:
Services que implementan Cacheable trait:
1. Ejecutar operación en BD
2. Si exitosa → invalidar caché con forget() o flushByPattern()
3. Si falla operación → NO invalidar caché
4. Commit de transacción solo si BD + invalidación OKEjemplos (asumiendo Empresa A con sistema_id=1, database=empresa_a, schema=suc0001):
- POST /api/ventas/clientes → invalidar
1::empresa_a::suc0001::clientes::* - PUT /api/tesoreria/caja/{id} → invalidar
1::empresa_a::suc0001caja001::saldo::current - DELETE /api/stock/articulo/{id} → invalidar:
1::empresa_a::suc0001::stock::articulo{id}1::empresa_a::suc0001::articulos::all
RN-004: TTL por Tipo de Dato
Regla: Diferentes tipos de datos tienen diferentes tiempos de vida útil en caché.
TTL recomendados por categoría:
| Tipo de Dato | TTL | Justificación |
|---|---|---|
| Datos maestros estáticos (provincias, países) | 30 días | Cambian muy raramente |
| Datos maestros configurables (plan de cuentas, tipos de comprobante) | 24 horas | Cambian esporádicamente |
| Datos transaccionales agregados (saldos, totales) | 5-15 minutos | Cambian frecuentemente |
| Listados de entidades (clientes, artículos) | 1-6 horas | Balance entre actualidad y performance |
| Resultados de búsquedas | 15-30 minutos | Datos temporales, pueden cambiar |
| Datos de sesión de usuario | Duración de sesión | Válidos mientras el usuario está logueado |
Condición: Al invocar remember() o store(), usar TTL apropiado según categoría.
Acción: El sistema debe proveer constantes predefinidas (ej: CacheConfig::TTL_MASTER_DATA, CacheConfig::TTL_TRANSACTIONAL).
RN-005: Separación de Caché Test vs Producción
Regla: El caché del ambiente de testing debe estar completamente separado del caché de producción mediante el nombre de database en la key.
Condición: Al ejecutar en modo prueba (flag prueba=true en request).
Acción:
- Usar conexión
pruebade ConnectionManager (apunta a database con sufijo_p) - El nombre de database en la key incluye automáticamente el sufijo
_p - Keys de producción:
{sistema_id}::{database}::{schema}::{recurso} - Keys de prueba:
{sistema_id}::{database}_p::{schema}::{recurso}
Ejemplos de aislamiento:
- Producción:
1::empresa_a::suc0001::clientes::all - Testing:
1::empresa_a_p::suc0001::clientes::all - NO colisionan → Aislamiento automático por database name ✅
Justificación:
- Evitar que pruebas contaminen caché de producción y viceversa
- El aislamiento es automático gracias al patrón de key que incluye database name
- No se requiere tabla separada ni namespace adicional (el database name en la key es suficiente)
RN-007: Datos Sensibles NO en Caché
Regla: Datos sensibles no deben almacenarse en caché sin encriptación.
Datos prohibidos de cachear:
- Contraseñas (hashes o plaintext)
- Tokens de autenticación JWT completos
- Números de tarjetas de crédito
- Claves API de terceros
- Datos de salud/médicos (si aplica)
Condición: Antes de invocar store(), validar que el valor no contiene datos sensibles.
Acción: Si es necesario cachear datos sensibles, encriptarlos antes de almacenar y desencriptarlos al recuperar.
RN-008: Límite de Tamaño de Valor
Regla: Cada entrada de caché tiene un tamaño máximo permitido.
Límites:
- Valor máximo por key: 1 MB
- Total de caché por tenant: 100 MB (soft limit, eviction de LRU - Least Recently Used)
Condición: Al invocar store(), validar tamaño del valor serializado.
Acción:
- Si excede 1 MB → rechazar con excepción
CacheSizeExceededException - Si el total del tenant excede 100 MB → auto-eviction de las keys menos usadas
- Logging de warnings cuando un tenant se acerca al límite (80%)
RN-009: Estructura Obligatoria de Keys Multi-Nivel
Regla: Todas las keys de caché DEBEN seguir uno de los patrones definidos según el scope, garantizando separación total entre empresas, databases y tenants.
Condición: En TODA invocación de store(), remember(), get(), forget(), o cualquier operación de caché.
Estructura OBLIGATORIA de Keys por Scope:
Scope Global: global::{recurso}::{id}
Scope Shared: {sistema_id}::{database}::shared::{recurso}::{id}
Scope Tenant: {sistema_id}::{database}::{schema}::{recurso}::{id}
Scope User: {sistema_id}::{database}::{schema}::{user_id}::{recurso}::{id}Campos obligatorios según scope:
Scope Global:
{recurso}: Nombre del recurso (provincias, paises, monedas, etc.){id}: Identificador específico o "all" para listados
Scopes Shared, Tenant, User:
{sistema_id}: ID de sistema (identificador único de empresa) desdePayload.sistema{database}: Nombre de base de datos desdePayload.db(incluye sufijo_ppara testing){schema}oshared: Schema específico (suc0001, suc0001caja001) o literal "shared"{recurso}: Nombre del recurso (clientes, facturas, cuentas, etc.){id}: Identificador específico o "all" para listados{user_id}(solo User scope): ID de usuario desdePayload.id
Acción del CacheKeyGenerator:
php
// Ejemplo de implementación
class CacheKeyGenerator {
public function generate(Payload $payload, string $scope, string $resource, string $id): string {
if ($scope === 'global') {
// Datos compartidos por TODAS las empresas
return "global::{$resource}::{$id}";
}
// Para todos los demás scopes, requiere contexto de empresa
$sistemaId = $payload->sistema;
$database = $payload->db;
if ($scope === 'shared') {
return "{$sistemaId}::{$database}::shared::{$resource}::{$id}";
} elseif ($scope === 'tenant') {
$schema = $payload->schema;
return "{$sistemaId}::{$database}::{$schema}::{$resource}::{$id}";
} elseif ($scope === 'user') {
$schema = $payload->schema;
$userId = $payload->id;
return "{$sistemaId}::{$database}::{$schema}::{$userId}::{$resource}::{$id}";
}
throw new InvalidScopeException("Scope must be 'global', 'shared', 'tenant', or 'user'");
}
}Validación:
- Si key no sigue el patrón del scope → lanzar
InvalidCacheKeyException - Si scope ≠ 'global' y sistema_id o database son null/empty → lanzar
MissingContextException
Justificación CRÍTICA:
- Sin sistema_id (en scopes no-global): Empresa A y Empresa B colisionarían → PÉRDIDA DE DATOS ❌
- Sin database (en scopes no-global): Producción y Testing colisionarían → DATOS DE PRUEBA EN PRODUCCIÓN ❌
- Sin schema (en tenant/user scope): Sucursales compartirían datos → VIOLACIÓN DE AISLAMIENTO MULTI-TENANT ❌
Ejemplos de keys CORRECTAS:
- ✅
global::provincias::all(datos del sistema, todas las empresas) - ✅
global::paises::all(datos del sistema, todas las empresas) - ✅
1::empresa_a::shared::cuentas::all(datos de empresa específica) - ✅
1::empresa_a::suc0001::clientes::activos(datos de tenant) - ✅
1::empresa_a_p::suc0001caja001::saldo::actual(testing) - ✅
2::empresa_b::suc0001::clientes::all(Empresa B, misma numeración que Empresa A pero key diferente)
Ejemplos de keys INCORRECTAS (PROHIBIDAS):
- ❌
shared::provincias::all(falta sistema_id y database - debería ser global o incluir empresa) - ❌
suc0001::clientes::all(falta sistema_id y database) - ❌
1::shared::cuentas::all(falta database) - ❌
empresa_a::suc0001::clientes::all(falta sistema_id)
RN-010: Criterio de Decisión Global vs Shared
Regla: Determinar correctamente si un dato debe ser Global (todas las empresas) o Shared (por empresa) es CRÍTICO para evitar inconsistencias.
Pregunta clave: ¿Este dato es EXACTAMENTE el mismo para todas las empresas del sistema, o puede variar?
Usar Scope GLOBAL cuando:
- El dato es una referencia estándar nacional/internacional
- El dato NUNCA cambia por empresa
- Todas las empresas DEBEN ver el mismo valor
- Ejemplos:
- ✅ Provincias argentinas (siempre las mismas 24 provincias)
- ✅ Países del mundo (ISO 3166)
- ✅ Monedas internacionales (ISO 4217)
- ✅ Tipos de documentos nacionales (DNI, CUIT, CUIL, Pasaporte)
- ✅ Feriados nacionales oficiales
Usar Scope SHARED (por empresa) cuando:
- El dato puede ser personalizado por empresa
- Diferentes empresas pueden tener diferentes valores
- El dato es maestro dentro de la empresa pero NO universal
- Ejemplos:
- ✅ Plan de cuentas contables (cada empresa tiene su propio plan)
- ✅ Tipos de comprobantes internos (Empresa A puede tener diferentes tipos que Empresa B)
- ✅ Conceptos de retención propios
- ✅ Configuraciones de impresoras, formatos de factura, etc.
- ✅ Tasas de IVA (aunque sean nacionales, una empresa podría tener casos especiales)
Casos DUDOSOS - Guía de decisión:
| Dato | Scope Correcto | Justificación |
|---|---|---|
| Provincias | Global | Siempre las mismas 24 provincias argentinas |
| Localidades | Shared | Aunque son estándar, empresas pueden agregar localidades propias |
| Tipos de IVA | Shared | Aunque AFIP define los tipos, empresas pueden tener casos especiales |
| Plan de Cuentas | Shared | Cada empresa tiene su propio plan |
| Bancos | Shared | Aunque son los mismos bancos oficiales, empresas pueden agregar entidades propias |
| Monedas | Global | ISO 4217 es estándar internacional |
| Tipos de Doc (DNI, CUIT) | Global | Definidos por AFIP, iguales para todos |
Consecuencias de elegir MAL:
Si uso Global cuando debería ser Shared:
- ❌ Empresa A modifica dato → afecta a Empresa B (inesperado)
- ❌ Imposible tener valores diferentes por empresa
- ❌ Conflictos al modificar datos
Si uso Shared cuando debería ser Global:
- ⚠️ Redundancia: mismos datos replicados por empresa (ineficiente pero funcional)
- ⚠️ Mayor uso de memoria de caché
- ⚠️ Más lento al cachear (cada empresa debe cachear su copia)
Casos de Uso
Caso de Uso 1: Cachear Listado de Provincias (Scope Global)
Actor: Sistema Backend (cualquier módulo) de cualquier empresa
Precondiciones:
- Tabla
provinciaen base de datos del sistema tiene las 24 provincias argentinas cargadas - Servicio CacheManager está disponible
- Las provincias son IDÉNTICAS para todas las empresas del sistema
Flujo Principal:
- Un módulo (ej: Ventas) de cualquier empresa necesita cargar el listado de provincias para un selector
- El servicio ejecuta:php
// Scope GLOBAL - no requiere sistema_id ni database $key = "global::provincias::all"; $provincias = $this->cache->remember($key, function() { return $this->provinciaModel->getAll(); }, CacheConfig::TTL_GLOBAL_DATA); // 30 días o más - El CacheManager busca la key
global::provincias::allen caché - Si existe (Cache Hit):
- Retorna el valor deserializado inmediatamente
- Tiempo de respuesta: ~5ms
- TODAS las empresas se benefician de esta única entrada de caché
- Si no existe (Cache Miss):
- Ejecuta el callback:
$provinciaModel->getAll() - La query a PostgreSQL tarda ~50ms
- Almacena el resultado en caché con key
global::provincias::ally TTL de 30 días - Retorna el valor
- Ejecuta el callback:
- El módulo recibe el listado de provincias y lo renderiza
Postcondiciones:
- El listado de provincias está cacheado una sola vez por 30 días
- Próximas solicitudes desde cualquier empresa retornan en ~5ms (90% más rápido)
- Máxima eficiencia: Una sola entrada de caché sirve a Empresa A, Empresa B, Empresa C, etc.
- Ahorro de memoria: Sin scope global, tendríamos N entradas (una por empresa). Con global: solo 1 entrada.
Flujos Alternativos:
- Error de base de datos: Si la query falla, el callback lanza excepción, NO se cachea nada, la excepción se propaga al llamador
- Cambio en provincias (extremadamente raro - solo si AFIP crea/elimina provincia):
- Administrador del sistema modifica la tabla
provincia - Debe invocar
forget('global::provincias::all')para invalidar caché global - Impacto: Todas las empresas regenerarán el caché en próxima consulta
- Administrador del sistema modifica la tabla
Comparación con Scope Shared (INCORRECTO para este caso):
Si usáramos Shared en lugar de Global:
- ❌ Empresa A:
1::empresa_a::shared::provincias::all(150 KB) - ❌ Empresa B:
2::empresa_b::shared::provincias::all(150 KB) - ❌ Empresa C:
3::empresa_c::shared::provincias::all(150 KB) - Total: 450 KB para 3 empresas (mismos datos replicados 3 veces)
Con Scope Global (CORRECTO):
- ✅ Global:
global::provincias::all(150 KB) - Total: 150 KB para TODAS las empresas (datos compartidos una vez)
Caso de Uso 2: Cachear Listado de Clientes de una Sucursal
Actor: Usuario de Ventas en Sucursal 0001 de Empresa A
Precondiciones:
- Usuario autenticado con JWT que incluye
sistema_id=1,database=empresa_a,schema=suc0001 - Módulo de Ventas requiere listar clientes para facturación
Flujo Principal:
- Usuario accede a vista de "Nueva Factura"
- El frontend hace request GET
/api/ventas/clientes - AuthMiddleware extrae contexto del JWT:
sistema=1,db=empresa_a,schema=suc0001 - Controller invoca
ClienteService->getAll() - El servicio ejecuta:php
$sistemaId = $this->payload->sistema; // 1 $database = $this->payload->db; // 'empresa_a' $schema = $this->payload->schema; // 'suc0001' $key = "{$sistemaId}::{$database}::{$schema}::clientes::activos"; $clientes = $this->cache->remember($key, function() { return $this->clienteModel->getAllActivos(); }, CacheConfig::TTL_LISTADOS); - Primera vez (Cache Miss):
- Query a
empresa_a.suc0001.cliente WHERE deleted_at IS NULLtarda 200ms - Serializa resultado y lo almacena en caché con key
1::empresa_a::suc0001::clientes::activos - TTL: 6 horas
- Retorna 150 clientes
- Query a
- Siguiente vez (Cache Hit):
- Busca key
1::empresa_a::suc0001::clientes::activos - Encuentra valor válido (no expiró)
- Retorna inmediatamente en 8ms
- Busca key
Postcondiciones:
- Listado de clientes de Empresa A → suc0001 está cacheado por 6 horas
- Usuarios de Empresa A → suc0001 ven listado instantáneamente
- Aislamiento garantizado:
- Empresa A → suc0002 tiene caché separado:
1::empresa_a::suc0002::clientes::activos - Empresa B → suc0001 tiene caché separado:
2::empresa_b::suc0001::clientes::activos
- Empresa A → suc0002 tiene caché separado:
Flujos Alternativos:
- Nuevo cliente creado:
- POST
/api/ventas/clientes→ClienteService->insert()se ejecuta - Después de insert exitoso, el servicio invoca:php
$pattern = "{$sistemaId}::{$database}::{$schema}::clientes::*"; $this->cache->flushByPattern($pattern); // "1::empresa_a::suc0001::clientes::*" - Caché de listados de clientes se invalida
- Próximo GET regenera caché con el nuevo cliente incluido
- POST
Caso de Uso 3: Cachear Saldo Actual de Caja
Actor: Cajero en Caja 0001 de Sucursal 0001 (Empresa A)
Precondiciones:
- Usuario autenticado con JWT:
sistema_id=1,database=empresa_a,schema=suc0001caja001 - Caja tiene movimientos registrados
Flujo Principal:
- Cajero accede a módulo de Tesorería → Vista "Estado de Caja"
- Frontend solicita GET
/api/tesoreria/caja/saldo - ConnectionMiddleware establece contexto:
sistema=1,db=empresa_a,schema=suc0001caja001 - Controller invoca
CajaService->getSaldoActual() - El servicio ejecuta:php
$sistemaId = $this->payload->sistema; // 1 $database = $this->payload->db; // 'empresa_a' $schema = $this->payload->schema; // 'suc0001caja001' $key = "{$sistemaId}::{$database}::{$schema}::saldo::actual"; $saldo = $this->cache->remember($key, function() { return $this->cajaModel->calcularSaldo(); }, CacheConfig::TTL_SALDOS); // 5 minutos - Cache Miss (primera consulta o caché expirado):
- Ejecuta query compleja que suma ingresos/egresos de la caja
- Cálculo tarda 500ms
- Almacena resultado con key
1::empresa_a::suc0001caja001::saldo::actual - Valor:
{ "saldo_pesos": 125000.50, "saldo_dolares": 5000.00 } - TTL: 5 minutos
- Cache Hit (dentro de 5 minutos):
- Retorna saldo cacheado en 10ms
- Frontend muestra saldo actualizado
Postcondiciones:
- Saldo de Empresa A → suc0001caja001 está cacheado por 5 minutos
- Múltiples consultas dentro de esos 5 minutos son instantáneas
- Cajas de otras empresas o sucursales tienen saldos independientes
Flujos Alternativos:
Nuevo movimiento de caja registrado:
- POST
/api/tesoreria/movimiento-caja→MovimientoCajaService->insert() - Después de insert exitoso:php
$key = "{$sistemaId}::{$database}::{$schema}::saldo::actual"; $this->cache->forget($key); // "1::empresa_a::suc0001caja001::saldo::actual" - Caché de saldo se invalida inmediatamente
- Próxima consulta recalcula saldo real actualizado
- POST
Usuario fuerza actualización:
- Frontend incluye header
X-Force-Refresh: true - Controller detecta header y llama
$this->cache->forget(key)antes de consultar - Se obtiene valor actualizado ignorando caché
- Frontend incluye header
Caso de Uso 4: Invalidar Caché al Modificar Plan de Cuentas
Actor: Contador de Empresa A (usuario con permiso de modificar plan de cuentas)
Precondiciones:
- Plan de cuentas está cacheado en
1::empresa_a::shared::cuentas::all - Usuario autenticado con JWT:
sistema_id=1,database=empresa_a - Usuario tiene permiso
CONTABILIDAD_CUENTAS_WRITE
Flujo Principal:
- Contador accede a Contabilidad → Plan de Cuentas
- Modifica cuenta existente: "1.1.01.001 Caja Pesos" cambia nombre a "1.1.01.001 Efectivo Pesos"
- Frontend envía PUT
/api/contabilidad/cuentas/123con nuevos datos - Validator valida el request
- Controller invoca
CuentaService->update(123, $data) - El servicio ejecuta:php
$sistemaId = $this->payload->sistema; // 1 $database = $this->payload->db; // 'empresa_a' $conn = $this->connections->get('oficial'); $this->connections->beginTransaction('oficial'); try { // Actualizar en base de datos $result = $this->cuentaModel->update(123, $data); // Registrar auditoría $this->registrarAuditoria("UPDATE", "CONTABILIDAD", "cuentas", 123); // INVALIDAR CACHÉ (con contexto de empresa) $this->cache->forget("{$sistemaId}::{$database}::shared::cuentas::all"); $this->cache->forget("{$sistemaId}::{$database}::shared::cuentas::arbol"); $this->connections->commit('oficial'); return $result; } catch (Exception $e) { $this->connections->rollback('oficial'); throw $e; } - Transacción se commitea exitosamente
- Caché de plan de cuentas de Empresa A se invalida
- Frontend recibe confirmación de actualización
Postcondiciones:
- Registro en BD actualizado
- Auditoría registrada
- Caché de Empresa A invalidado:
1::empresa_a::shared::cuentas::* - Empresa B no afectada: Su caché
2::empresa_b::shared::cuentas::allpermanece intacto - Próxima consulta al plan de cuentas de Empresa A regenera caché con datos actualizados
Flujos Alternativos:
Error en update de BD:
- Exception se lanza antes de
forget() - Rollback de transacción
- Caché NO se invalida (correcto, porque el dato no cambió)
- Exception se lanza antes de
Error en invalidación de caché:
- Update de BD exitoso
forget()falla (ej: tabla cache_items temporalmente bloqueada)- Warning se loggea, pero transacción se commitea igual
- Caché eventualmente expirará por TTL automático (24 horas)
Consideraciones Técnicas
Tecnología de Caché
El sistema utilizará Symfony Cache Component v7.3 con el siguiente stack tecnológico:
Stack Completo
Symfony Cache v7.3 (PSR-6 / PSR-16)
↓
DoctrineDbalAdapter
↓
Doctrine DBAL Connection (ya existente vía ConnectionManager)
↓
PostgreSQL (base de datos actual del sistema)Decisión de Tecnología
Tecnología Elegida: Symfony Cache + DoctrineDbalAdapter + PostgreSQL
Justificación:
- ✅ Ya instalado: Symfony Cache v7.3 ya está en
composer.json - ✅ Reutiliza infraestructura existente: ConnectionManager + PostgreSQL
- ✅ Multi-tenant nativo: PostgreSQL ya maneja schemas y aislamiento
- ✅ Cero dependencias nuevas: No requiere Redis, Memcached, ni APCu
- ✅ Simplicidad operacional: Una sola tecnología (PostgreSQL) para datos + caché
- ✅ Transaccional: Compatible con transacciones ACID del sistema
- ✅ Backup incluido: Caché se respalda automáticamente con backup de BD
Almacenamiento Físico:
- Tabla
cache_itemsen PostgreSQL (creada automáticamente por Symfony) - Cada entrada de caché es un row con:
item_id(key),item_data(valor serializado),item_lifetime(TTL),item_time(timestamp)
Nota: Symfony Cache es solo la interfaz (PSR-6/PSR-16), SIEMPRE requiere un adapter (backend) que define dónde se almacenan los datos. Este proyecto usa DoctrineDbalAdapter para almacenar en PostgreSQL.
Adaptadores por Ambiente
Producción:
- DoctrineDbalAdapter: Almacena caché en PostgreSQL
- Rendimiento: 5-10ms por operación get/set
- Persistente: Sobrevive a reinicio de servidor web
- Compartido: Accesible desde múltiples procesos PHP-FPM
Testing (PHPUnit):
- ArrayAdapter: Almacena caché en memoria PHP (array)
- Rendimiento: <1ms por operación
- No persistente: Se resetea entre test runs
- Aislado: Cada test tiene su propia instancia
Compatibilidad con PostgreSQL 10
✅ Totalmente compatible: El stack actual (Symfony Cache 7.3 + DoctrineDbalAdapter + Doctrine DBAL 4.2) soporta PostgreSQL 10 sin ningún problema.
Versiones de PostgreSQL soportadas por Doctrine DBAL 4.2:
- PostgreSQL 10.0 y superiores (incluyendo 11, 12, 13, 14, 15, 16)
- La única deprecación fue PostgreSQL 9 en DBAL 4.0 (febrero 2024)
Nota de producción: Aunque PostgreSQL 10 está soportado por Doctrine, alcanzó su End of Life (EOL) en noviembre 2022. Se recomienda planificar una migración a PostgreSQL 12+ en el futuro para recibir actualizaciones de seguridad oficiales.
Performance
Rendimiento Esperado con DoctrineDbalAdapter
Operaciones básicas:
get()(cache hit): ~5-10msstore(): ~10-15msforget(): ~5msflushByPattern(): ~50-200ms (dependiendo de cantidad de keys)
Impacto esperado:
- ✅ Queries frecuentes (200ms-2s): Mejora del 90-95%
- ✅ Hit ratio esperado: >75%
- ✅ Reducción de carga en PostgreSQL: 50-70%
Índices en Base de Datos
Para DoctrineDbalAdapter, la tabla cache_items debe tener:
sql
-- Índice primario (clave de caché)
CREATE UNIQUE INDEX cache_items_pk ON cache_items(item_id);
-- Índice para limpieza por expiración
CREATE INDEX cache_items_expiration_idx ON cache_items(item_lifetime) WHERE item_lifetime IS NOT NULL;
-- Índice para búsqueda por patrón (si usa etiquetas)
CREATE INDEX cache_items_tags_idx ON cache_items USING gin(tags) WHERE tags IS NOT NULL;Estrategia de Eviction
Cuando el caché alcanza límites de capacidad:
- TTL Automático: Datos expirados se eliminan automáticamente
- LRU (Least Recently Used): Si TTL no es suficiente, eliminar los menos usados
- Por Prioridad: Datos compartidos (shared) tienen mayor prioridad que datos por tenant
Tamaños de Caché Recomendados
- Shared: 100 MB (datos maestros de toda la empresa)
- Por tenant: 50 MB por sucursal, 20 MB por caja
- Total estimado: Para 10 sucursales con 3 cajas cada una = 100 MB (shared) + 500 MB (sucursales) + 600 MB (cajas) = 1.2 GB
Seguridad
Aislamiento Multi-Tenant
Mecanismo 1: Prefijo de Schema en Keys
php
// Usuario de suc0001 intenta acceder a:
$key = "suc0001::clientes::all"; // ✅ Permitido (su schema)
$key = "suc0002::clientes::all"; // ❌ PROHIBIDO (otro schema)
// Validación en CacheManager:
if (!$this->isAllowedSchema($key)) {
throw new UnauthorizedCacheAccessException();
}Mecanismo 2: Contexto de Conexión
- DoctrineDbalAdapter usa
search_pathde PostgreSQL - Cada conexión solo ve tablas de su schema
- Imposibilidad física de acceder a datos de otro schema
Datos Sensibles
Datos que NO deben cachearse:
- Passwords (ni hashes completos)
- Tokens JWT completos (solo claims no sensibles)
- Números de tarjetas de crédito
- Claves API de terceros
- Datos de salud/médicos (GDPR/HIPAA)
Implementación:
php
class CacheManager {
private function validateSensitiveData($value): void {
if ($this->detectSensitiveData($value)) {
throw new SensitiveDataInCacheException(
"No se permite cachear datos sensibles sin encriptación"
);
}
}
}Permisos y Autorización
Todas las operaciones de caché son automáticas:
- No se requieren permisos especiales para operaciones de caché
get(),remember(),forget()se ejecutan automáticamente en el contexto del schema del usuario- La invalidación ocurre automáticamente en operaciones CUD (Create, Update, Delete)
- No existen endpoints de administración manual de caché
Auditoría
Operaciones Auditadas
Alta prioridad (WARNING):
- Fallas de invalidación automática (cuando
forget()falla pero BD se actualizó) - Cache size exceeded (cuando un tenant supera límites configurados)
Baja prioridad (INFO):
- Invalidaciones automáticas por patrón (cuando se ejecuta POST/PUT/DELETE)
- Estadísticas de hit/miss ratio (cada hora, automático)
- Evictions automáticas por LRU (cuando se alcanza límite)
Nota: No existen operaciones manuales de limpieza de caché, por lo tanto no se auditan operaciones administrativas.
Estructura de Registro de Auditoría
php
[
'timestamp' => '2026-01-07 14:32:15',
'user_id' => 42,
'operation' => 'AUTO_INVALIDATE_PATTERN',
'module' => 'CACHE',
'resource' => 'clientes',
'resource_id' => 'suc0001::clientes::*',
'details' => [
'schema' => 'suc0001',
'pattern' => 'suc0001::clientes::*',
'deleted_keys' => 5,
'trigger' => 'POST /api/ventas/clientes',
'triggered_by_service' => 'ClienteService::insert()'
],
'severity' => 'INFO'
]Testing
Unit Tests
CacheKeyGeneratorTest:
php
test('genera keys correctas para scope shared', function() {
$generator = new CacheKeyGenerator();
$key = $generator->generate('shared', 'provincias', 'all');
expect($key)->toBe('shared::provincias::all');
});
test('genera keys correctas para scope tenant', function() {
$generator = new CacheKeyGenerator('suc0001');
$key = $generator->generate('tenant', 'clientes', 'activos');
expect($key)->toBe('suc0001::clientes::activos');
});
test('rechaza keys con caracteres inválidos', function() {
$generator = new CacheKeyGenerator();
$generator->generate('shared', 'test/../hack', 'all');
})->throws(InvalidCacheKeyException::class);CacheManagerTest (con ArrayAdapter):
php
test('almacena y recupera datos correctamente', function() {
$cache = new CacheManager(new ArrayAdapter());
$cache->store('test::key', ['data' => 'value'], 3600);
$result = $cache->get('test::key');
expect($result)->toBe(['data' => 'value']);
});
test('remember ejecuta callback solo en cache miss', function() {
$cache = new CacheManager(new ArrayAdapter());
$callCount = 0;
$callback = function() use (&$callCount) {
$callCount++;
return 'computed value';
};
// Primera llamada (miss) - ejecuta callback
$result1 = $cache->remember('test::key', $callback, 3600);
expect($callCount)->toBe(1);
// Segunda llamada (hit) - NO ejecuta callback
$result2 = $cache->remember('test::key', $callback, 3600);
expect($callCount)->toBe(1); // No aumentó
expect($result2)->toBe('computed value');
});Integration Tests
CacheWithConnectionManagerTest (con DoctrineDbalAdapter real):
php
test('cache se aísla por schema en multi-tenancy', function() {
// Setup: dos tenants diferentes
$managerSuc1 = $this->createConnectionManager('suc0001');
$managerSuc2 = $this->createConnectionManager('suc0002');
$cacheSuc1 = new CacheManager(new DoctrineDbalAdapter($managerSuc1->getDbal('oficial')));
$cacheSuc2 = new CacheManager(new DoctrineDbalAdapter($managerSuc2->getDbal('oficial')));
// Store en suc0001
$cacheSuc1->store('suc0001::test::data', 'valor de suc0001', 3600);
// Retrieve desde suc0001 (debe encontrar)
expect($cacheSuc1->get('suc0001::test::data'))->toBe('valor de suc0001');
// Retrieve desde suc0002 (NO debe encontrar - aislamiento)
expect($cacheSuc2->get('suc0001::test::data'))->toBeNull();
});CacheInvalidationTest:
php
test('invalidar cache al actualizar dato en BD', function() {
$service = new ClienteService($this->connectionManager, $this->cacheManager);
// Cache inicial de clientes
$clientesAntes = $service->getAll(); // Genera caché
expect($clientesAntes)->toHaveCount(10);
// Insertar nuevo cliente
$service->insert(new ClienteRequestDTO(['nombre' => 'Cliente Nuevo']));
// Caché debe estar invalidado, próxima consulta debe tener 11 clientes
$clientesDespues = $service->getAll();
expect($clientesDespues)->toHaveCount(11);
});Dependencias
Funcionalidades Relacionadas del Sistema
ConnectionManager
Archivo: /var/www/Bautista/server/connection/ConnectionManager.php
Relación: CacheManager necesita ConnectionManager para obtener conexiones Doctrine DBAL cuando se usa DoctrineDbalAdapter.
Flujo:
CacheManager → DoctrineDbalAdapter → ConnectionManager.getDbal('oficial') → Doctrine ConnectionAuthMiddleware y Payload
Archivo: /var/www/Bautista/server/Middleware/AuthMiddleware.php
Relación: El schema del tenant se obtiene del JWT parseado por AuthMiddleware, disponible en Payload.schema.
Flujo:
Request → AuthMiddleware (extrae JWT) → Payload.schema → CacheKeyGenerator (incluye schema en key)Todos los Módulos (Consumidores)
Módulos que usarán caché:
- Ventas (
/service/Ventas/) - Compras (
/service/Compras/) - Stock (
/service/Stock/) - Tesorería (
/service/Tesoreria/) - Contabilidad (
/service/Contabilidad/) - CtaCte (
/service/CtaCte/) - CRM (
/service/CRM/) - Core/Config (
/service/Config/)
Patrón de uso:
php
class VentasService implements AuditableInterface {
use Conectable, Auditable, Cacheable; // Nuevo trait Cacheable
public function getClientes(): array {
return $this->cache->remember(
"{$this->payload->schema}::clientes::activos",
fn() => $this->clienteModel->getAllActivos(),
CacheConfig::TTL_LISTADOS
);
}
}Librerías Externas
Symfony Cache Component
Paquete: symfony/cache v7.3 Archivo: /var/www/Bautista/server/vendor/symfony/cache/
Interfaces implementadas:
- PSR-6:
Psr\Cache\CacheItemPoolInterface - PSR-16:
Psr\SimpleCache\CacheInterface
Adaptadores utilizados:
- DoctrineDbalAdapter (producción)
- ArrayAdapter (testing)
Documentación: https://symfony.com/doc/current/components/cache.html
Doctrine DBAL
Paquete: doctrine/dbal (ya instalado) Archivo: /var/www/Bautista/server/vendor/doctrine/dbal/
Relación: DoctrineDbalAdapter usa Doctrine DBAL Connection para almacenar caché en PostgreSQL.
Ventaja: Reutiliza la infraestructura de conexiones existente (ConnectionManager ya usa Doctrine DBAL).
Implementación
Archivos a Crear/Modificar
Backend - Servicio de Caché
Nuevos archivos:
[ ]
/var/www/Bautista/server/service/Cache/CacheManager.php- Orquestador principal del sistema de caché
- Métodos:
get(),store(),remember(),forget(),flushByPattern(),flushByTag(),flushTenant(),flushAll(),clear() - Delega operaciones a Symfony Cache adapters
- Maneja multi-tenancy y generación de keys
[ ]
/var/www/Bautista/server/service/Cache/CacheKeyGenerator.php- Genera keys consistentes con schema incluido
- Métodos:
generate($scope, $resource, $id),parse($key),validate($key) - Soporta scopes: 'shared', 'tenant', 'user'
[ ]
/var/www/Bautista/server/service/Cache/CacheConfig.php- Constantes de configuración
- TTLs por tipo de dato:
TTL_MASTER_DATA,TTL_LISTADOS,TTL_SALDOS, etc. - Límites de tamaño y prefijos
[ ]
/var/www/Bautista/server/service/Cache/Adapters/TenantAwareCacheAdapter.php- Wrapper sobre Symfony adapters que inyecta contexto de tenant automáticamente
- Validación de aislamiento multi-tenant
[ ]
/var/www/Bautista/server/service/Cache/Adapters/SharedCacheAdapter.php- Adapter especializado para datos compartidos (sin schema en key)
[ ]
/var/www/Bautista/server/service/Cache/Exceptions/CacheSizeExceededException.php[ ]
/var/www/Bautista/server/service/Cache/Exceptions/SensitiveDataInCacheException.php[ ]
/var/www/Bautista/server/service/Cache/Exceptions/UnauthorizedCacheAccessException.php[ ]
/var/www/Bautista/server/service/Cache/Exceptions/InvalidCacheKeyException.php
Backend - Trait Reutilizable
- [ ]
/var/www/Bautista/server/Traits/Cacheable.php- Trait para que Services puedan usar caché fácilmente
- Métodos:
remember(),forget(),flush() - Similar a
Auditable.phpyConectable.php
Backend - Configuración
[ ]
/var/www/Bautista/server/constants.php(modificar)- Agregar constantes:php
define('CACHE_ENABLED', true); define('CACHE_DEFAULT_TTL', 3600); define('CACHE_TABLE_PREFIX', 'cache_'); define('CACHE_MAX_SIZE_MB', 1);
- Agregar constantes:
[ ]
/var/www/Bautista/server/.env(modificar)- Agregar variables:env
CACHE_DRIVER=dbal CACHE_ENABLE_TAGS=true CACHE_DEFAULT_TTL=3600
- Agregar variables:
[ ]
/var/www/Bautista/server/index.php(modificar)- Registrar CacheManager en DI container:php
$container->set(CacheManager::class, function(ContainerInterface $c) { $adapter = new DoctrineDbalAdapter( $c->get(ConnectionManager::class)->getDbal('oficial') ); return new CacheManager($adapter, $c->get(Payload::class)); });
- Registrar CacheManager en DI container:
Base de Datos
- [ ] Migración:
/var/www/Bautista/server/migrations/YYYYMMDDHHMMSS_create_cache_tables.php- Crear tabla
cache_items(si no se usa auto-creación de Symfony) - Crear índices para performance
- Opcionalmente: tablas por schema para multi-tenancy
- Crear tabla
Estructura de tabla sugerida:
sql
CREATE TABLE cache_items (
item_id VARCHAR(255) PRIMARY KEY,
item_data BYTEA NOT NULL,
item_lifetime INTEGER,
item_time INTEGER NOT NULL,
tags TEXT[], -- Para tagging (PostgreSQL array)
schema VARCHAR(50) -- Para multi-tenancy
);
CREATE UNIQUE INDEX cache_items_pk ON cache_items(item_id);
CREATE INDEX cache_items_expiration_idx ON cache_items(item_lifetime) WHERE item_lifetime IS NOT NULL;
CREATE INDEX cache_items_schema_idx ON cache_items(schema);
CREATE INDEX cache_items_tags_idx ON cache_items USING gin(tags) WHERE tags IS NOT NULL;Testing
- [ ]
/var/www/Bautista/server/Tests/Unit/Cache/CacheKeyGeneratorTest.php - [ ]
/var/www/Bautista/server/Tests/Unit/Cache/CacheManagerTest.php - [ ]
/var/www/Bautista/server/Tests/Integration/Cache/CacheMultiTenantTest.php - [ ]
/var/www/Bautista/server/Tests/Integration/Cache/CacheInvalidationTest.php
Fases de Implementación
Fase 1: Fundamentos (Infraestructura Base)
Objetivo: Crear la infraestructura básica de caché sin integración con módulos.
- Instalar/verificar Symfony Cache (ya instalado)
- Crear
CacheConfig.phpcon constantes de TTL - Crear
CacheKeyGenerator.phpcon generación de keys multi-tenant - Crear
CacheManager.phpcon métodos básicos (get, store, forget) - Crear excepciones (
CacheSizeExceededException, etc.) - Escribir tests unitarios para
CacheKeyGeneratoryCacheManager(con ArrayAdapter)
Criterios de aceptación:
- Tests unitarios pasan al 100%
- Generación de keys funciona correctamente para scopes shared/tenant/user
- Store/retrieve básico funciona con ArrayAdapter
Fase 2: Integración con Infraestructura Existente
Objetivo: Conectar CacheManager con ConnectionManager y Payload.
- Crear
TenantAwareCacheAdapterque valida schema del Payload - Integrar DoctrineDbalAdapter con ConnectionManager
- Modificar
index.phppara registrar CacheManager en DI - Crear Trait
Cacheable.phpsiguiendo patrón deAuditable.php - Escribir tests de integración con ConnectionManager real y PostgreSQL
Criterios de aceptación:
- CacheManager disponible vía DI en cualquier Service
- Aislamiento multi-tenant funciona (suc0001 no accede a caché de suc0002)
- Tests de integración con BD real pasan
Fase 3: Funcionalidades Avanzadas
Objetivo: Implementar invalidación automática con patterns y tags.
- Implementar
flushByPattern()(invalidación con wildcards) - Implementar
flushByTag()(invalidación por etiquetas) - Crear migración para tabla
cache_items(si no se usa auto-creación)
Criterios de aceptación:
- Invalidación por patrón funciona (ej:
suc0001::clientes::*) - Invalidación automática se ejecuta en operaciones CUD
- TTL expira datos automáticamente sin intervención manual
Fase 4: Integración con Módulos (Prueba de Concepto)
Objetivo: Implementar caché en 2-3 módulos como POC.
- Integrar caché en
service/Config/ProvinciaService.php(scope shared) - Integrar caché en
service/Ventas/ClienteService.php(scope tenant) - Integrar caché en
service/Tesoreria/CajaService.php(scope tenant, TTL corto) - Implementar invalidación automática en POST/PUT/DELETE de esos servicios
- Medir mejoras de performance (antes vs después)
Criterios de aceptación:
- Listado de provincias se cachea por 30 días
- Listado de clientes se cachea por 6 horas y se invalida al crear/modificar cliente
- Saldo de caja se cachea por 5 minutos y se invalida al registrar movimiento
- Performance mejora 70-90% en consultas repetidas
Fase 5: Rollout a Todos los Módulos
Objetivo: Extender caché a todos los módulos del sistema.
- Identificar servicios de alta frecuencia en cada módulo
- Implementar
Cacheabletrait en Services relevantes - Agregar
remember()en métodos de consulta - Agregar
forget()/flushByPattern()en métodos CUD - Documentar en código con comentarios el TTL usado y justificación
- Testing exhaustivo de invalidación
Módulos a cubrir:
- Ventas (clientes, artículos, precios)
- Compras (proveedores, conceptos de retención)
- Stock (stock disponible, movimientos)
- Contabilidad (plan de cuentas, asientos)
- Tesorería (saldos, bancos)
- CtaCte (saldos de clientes/proveedores)
- CRM (campañas, etapas)
Criterios de aceptación:
- Al menos 80% de consultas frecuentes usan caché
- Invalidación automática en 100% de operaciones CUD
- Sin degradación de funcionalidad (tests de regresión pasan)
Fase 6: Monitoreo y Optimización
Objetivo: Medir impacto y optimizar configuración.
- Implementar logging automático de hit/miss ratio
- Ajustar TTLs según patrones de uso reales
- Identificar queries que aún son lentas y optimizarlas
- Configurar alertas para cache size exceeded
- Documentación final y training al equipo
Métricas a capturar:
- Hit ratio por módulo (objetivo: >80%)
- Tamaño de caché por tenant
- Keys más frecuentemente accedidas
- Tiempo promedio de respuesta (antes vs después)
Criterios de aceptación:
- Logging automático de métricas implementado
- Hit ratio global >75%
- Reducción de 50%+ en queries a PostgreSQL
- Performance end-to-end mejorada en al menos 40%
Criterios de Aceptación
La funcionalidad se considera completa cuando:
- [x] AC-001: Servicio CacheManager disponible vía Dependency Injection en todos los Services
- [x] AC-002: Keys de caché generadas incluyen schema para aislamiento multi-tenant (
suc0001::recurso::id) - [x] AC-003: Operaciones básicas funcionan correctamente:
get(key)retorna valor o nullstore(key, value, ttl)almacena datoremember(key, callback, ttl)implementa cache-aside pattern
- [x] AC-004: Invalidación por key individual funciona (
forget(key)) - [~] AC-005: Invalidación por patrón funciona (
flushByPattern('suc0001::clientes::*')) - PARCIAL: Implementado pero ejecuta flush total - [ ] AC-006: Invalidación por tag funciona (
flushByTag('dashboard_widgets')) - PENDIENTE: No implementado en v1.0 - [x] AC-007: TTL automático expira datos antiguos sin intervención manual
- [x] AC-008: Trait
Cacheablepuede usarse en Services siguiendo patrón deAuditable - [x] AC-009: Aislamiento multi-tenant garantizado (suc0001 no accede a caché de suc0002)
- [x] AC-010: DoctrineDbalAdapter integrado con ConnectionManager funciona correctamente
- [x] AC-011: ArrayAdapter para testing funciona sin dependencias externas
- [x] AC-012: Tests unitarios de CacheKeyGenerator pasan al 100% (17 tests)
- [x] AC-013: Tests unitarios de CacheManager pasan al 100% (24 tests)
- [ ] AC-014: Tests de integración con PostgreSQL real pasan - PENDIENTE: Fase 2
- [ ] AC-015: Invalidación automática en operaciones CUD funciona (ej: POST cliente → flush clientes) - PENDIENTE: Requiere integración en Services
- [ ] AC-016: Performance mejora en al menos 70% para consultas frecuentes (datos maestros) - PENDIENTE: Métricas post-producción
- [ ] AC-017: Performance mejora en al menos 40% para consultas transaccionales (listados) - PENDIENTE: Métricas post-producción
- [ ] AC-018: Hit ratio global superior al 75% después de 1 semana en producción - PENDIENTE: Métricas post-producción
- [x] AC-019: Datos sensibles NO se cachean (validación automática lanza excepción)
- [~] AC-020: Tamaño máximo por key (1 MB) se valida y rechaza con excepción - PARCIAL: Constante definida, validación pendiente
- [ ] AC-021: Límite de caché por tenant (100 MB) activa auto-eviction LRU - PENDIENTE: Fase 2
- [ ] AC-022: Operaciones de invalidación automática se registran en audit log - PENDIENTE: Recomendado en code review
- [~] AC-023: Documentación de API (PHPDoc) completa en todos los métodos públicos - PARCIAL: Cacheable.php completo, CacheManager pendiente
- [x] AC-024: Documentación de negocio (este archivo) revisada y aprobada
- [ ] AC-025: Al menos 3 módulos (Ventas, Compras, Contabilidad) usando caché en producción - PENDIENTE: Adopción gradual
- [x] AC-026: Zero degradación de funcionalidad (tests de regresión 100% passing)
- [ ] AC-027: Team training completado (sesión de 1 hora explicando uso del servicio) - PENDIENTE: Post-merge
Resumen de Implementación v1.0:
- ✅ Completados: 16 de 27 criterios (59%)
- ⚠️ Parciales: 3 criterios (11%)
- ❌ Pendientes: 8 criterios (30%) - Requieren adopción en producción o fases posteriores
Notas Adicionales
Consideraciones Importantes
Gradualidad en Adopción: No es necesario implementar caché en todos los módulos simultáneamente. Se recomienda enfoque incremental (fase 4 → fase 5).
Monitoreo Post-Implementación: Durante las primeras 2 semanas en producción, monitorear activamente:
- Logs de errores de caché (conexiones, timeouts, etc.)
- Degradación de performance por cache misses excesivos
- Inconsistencias de datos por invalidación incorrecta
Rollback Plan: Si se detectan problemas críticos:
- Opción 1: Deshabilitar caché globalmente (set
CACHE_ENABLED=falseen.env) - Opción 2: Cambiar a ArrayAdapter temporalmente (no persiste, menos riesgo)
- Opción 3: Flush total de caché y reiniciar con caché vacío
- Opción 1: Deshabilitar caché globalmente (set
Migración de Datos Existentes: No aplica (es nueva funcionalidad, no hay datos legacy).
Compatibilidad con Multi-Database: Aunque este documento enfoca en multi-schema (misma DB, múltiples schemas), el diseño es compatible con multi-database:
- ConnectionManager ya soporta múltiples DBs vía configuración
- CacheKeyGenerator puede incluir prefijo de DB si es necesario en el futuro
- DoctrineDbalAdapter puede conectarse a diferentes DBs vía ConnectionManager
Performance Baseline: Antes de implementar caché, capturar métricas baseline:
- Tiempo promedio de carga de listado de clientes: ~X ms
- Tiempo promedio de carga de plan de cuentas: ~Y ms
- Tiempo promedio de cálculo de saldo de caja: ~Z ms
- Total de queries a PostgreSQL por minuto: ~Q queries/min
- Comparar contra estas métricas después de implementación.
Documentación para Desarrolladores: Crear guía rápida (quick start) en
docs/backend/cache.mdexplicando:- Cómo usar
Cacheabletrait - Cómo elegir TTL apropiado
- Cuándo invalidar caché
- Ejemplos de código
- Cómo usar
Future Enhancements (no en scope inicial):
- Cache warming (pre-llenar caché al iniciar sistema)
- Automatic cache invalidation via database triggers
- Cache versioning (invalidar automáticamente al deployar nueva versión)
- Metrics export to Prometheus/Grafana para monitoring avanzado
Referencias Técnicas
- Symfony Cache Documentation: https://symfony.com/doc/current/components/cache.html
- PSR-6 Cache Interface: https://www.php-fig.org/psr/psr-6/
- PSR-16 Simple Cache: https://www.php-fig.org/psr/psr-16/
- Doctrine DBAL: https://www.doctrine-project.org/projects/dbal.html
- Multi-Tenancy Pattern: https://docs.microsoft.com/en-us/azure/architecture/patterns/sharding
Notas de Implementación (v1.0)
Archivos Implementados
Servicio Core (/service/Cache/):
CacheManager.php(116 líneas) - Orquestador principal con operaciones CRUDCacheKeyGenerator.php(91 líneas) - Generación de keys multi-tenant con 4 scopesCacheConfig.php(18 líneas) - Constantes de TTL y límites de tamaño
Excepciones (/service/Cache/Exception/):
InvalidCacheKeyException.php- Keys vacíos o inválidosInvalidScopeException.php- Scopes no permitidosSensitiveDataInCacheException.php- Datos sensibles detectadosCacheSizeExceededException.php- Límite de tamaño excedido (definida, no usada)
DTOs y Traits:
Resources/Shared/Payload.php(13 líneas) - Contexto multi-tenant (sistema_id, database, schema, id)Traits/Cacheable.php(121 líneas) - Trait reusable para Services (patrón Auditable/Conectable)
Infraestructura:
index.php(modificado) - Registro en DI container con factory para adaptersconstants.php(modificado) - Configuración: CACHE_ENABLED, CACHE_DEFAULT_TTL, límites.env.disty.env.test(modificados) - Variable CACHE_ADAPTER (dbal/array)
Tests (/Tests/Unit/Cache/):
CacheConfigTest.php(166 líneas) - 10 tests de configuraciónCacheKeyGeneratorTest.php(355 líneas) - 17 tests de generación de keysCacheExceptionsTest.php(292 líneas) - 19 tests de excepcionesCacheManagerTest.php(562 líneas) - 24 tests de operaciones CRUD
Total: 395 líneas de código productivo + 1,375 líneas de tests = 70 tests con 158 assertions (100% passing)
Decisiones de Diseño
1. Arquitectura Limpia
CacheManager: Service Layer (orquestación, transacciones)CacheKeyGenerator: Domain Layer (lógica pura de negocio, zero dependencies)- Symfony adapters: Infrastructure Layer (abstracción de persistencia)
2. Multi-Tenant Isolation Strategy
Implementado mediante patrones de key jerárquicos:
Global: global::{resource}::{id}
Shared: {sistema_id}::{database}::shared::{resource}::{id}
Tenant: {sistema_id}::{database}::{schema}::{resource}::{id}
User: {sistema_id}::{database}::{schema}::{user_id}::{resource}::{id}Cada scope valida campos requeridos en Payload:
- Global: sin requisitos
- Shared: sistema_id + database
- Tenant: sistema_id + database + schema
- User: sistema_id + database + schema + id
3. Seguridad Proactiva
Validación de datos sensibles antes de almacenar (método validateNoSensitiveData()):
- Keywords detectados: password, passwd, token, secret, api_key, apikey
- Case-insensitive matching
- Lanza
SensitiveDataInCacheExceptioninmediatamente
4. Adapter Strategy
php
// Production: Persistent PostgreSQL cache
'dbal' => DoctrineDbalAdapter($connection, 'app_cache', TTL)
// Testing: In-memory, zero cleanup
'array' => ArrayAdapter(TTL, storeSerialized: true)5. Trait Pattern
Cacheable trait sigue exactamente el patrón de Auditable y Conectable:
- Constructor injection del CacheManager
- Métodos protected para uso interno en Services
- API completa: get, store, remember, forget, flushByPattern
Desviaciones del Plan Original
❌ NO Implementado en v1.0 (defer a fases posteriores):
Pattern Matching Funcional (AC-005 parcial):
- Especificado:
flushByPattern('suc0001::clientes::*')invalida solo clientes de suc0001 - Implementado:
flushByPattern()ejecutacache->clear()(flush total) - Razón: Requiere acceso directo a SQL o API específica del adapter
- Recomendación code review: Implementar con Doctrine DBAL queries o remover método
- Especificado:
Tag-Based Invalidation (AC-006):
- No implementado
- Razón: Symfony Cache tags requieren
TagAwareAdapterwrapper - Defer: Fase 2 o cuando se requiera en producción
Size Validation (AC-020 parcial):
- Constante
CACHE_MAX_KEY_SIZE_MB = 1definida - Validación en
store()NO implementada - CacheSizeExceededException definida pero nunca lanzada
- Defer: Fase 2 (bajo impacto, límites del adapter mitigan riesgo)
- Constante
LRU Auto-Eviction (AC-021):
- No implementado
- Symfony DoctrineDbalAdapter usa estrategia de expiración por TTL, no LRU
- Defer: Requiere custom adapter o Redis backend
Audit Logging (AC-022):
- No implementado
- Code review recomienda agregar logging opcional para operaciones de invalidación
- Defer: Fase 2
PHPDoc Completo (AC-023 parcial):
Cacheable.php: ✅ PHPDoc completo con ejemplosCacheManager.php,CacheKeyGenerator.php: ❌ Sin PHPDoc- Defer: Quick fix antes de merge
Integration Tests (AC-014):
- 70 unit tests con ArrayAdapter (mocks)
- NO hay tests con DoctrineDbalAdapter + PostgreSQL real
- Razón: Enfoque TDD en lógica de negocio primero
- Defer: Fase 2 o post-merge
Issues Identificados (Code Review A+ 95/100)
🔴 HIGH PRIORITY (fix before merge):
DEFAULT_PAYLOAD Anti-Pattern (CacheManager.php:21-28):
php
// PROBLEMA: Hardcoded test credentials in production code
if ($payload === null) {
$payload = new Payload();
$payload->sistema_id = 1;
$payload->database = 'test_db';
// ...
}Riesgo: Tenant isolation bypass si Payload no se inyecta correctamente.
Solución recomendada:
- Remover default en constructor
- Requerir Payload explícito siempre
- Tests deben inyectar mock Payload
⚠️ MEDIUM PRIORITY:
- Sensitive Keywords Limited - Expandir lista a 15+ keywords comunes
- Payload Namespace - Mover de
Resources/Shared/aservice/Cache/oContext/ - PHPDoc Missing - Agregar a CacheManager y CacheKeyGenerator
ℹ️ LOW PRIORITY (technical debt):
- Magic Numbers - Usar constantes nombradas (e.g.,
SECONDS_PER_DAY) - TTL Not Auto-Applied - Crear método
CacheConfig::getTTL($resource) - Hardcoded Keywords - Mover a CacheConfig para flexibilidad
- No Audit Logging - Agregar logging opcional
Ejemplo de Uso en Producción
php
// service/Ventas/ClienteService.php
use App\Traits\Cacheable;
use App\service\Cache\CacheConfig;
class ClienteService implements AuditableInterface
{
use Conectable, Auditable, Cacheable;
public function __construct(
ConnectionManager $manager,
?AuditLogger $auditLogger = null,
CacheManager $cacheManager
) {
$this->setConnectionManager($manager);
$this->setAuditLogger($auditLogger);
$this->setCacheManager($cacheManager);
}
public function getAll(): array
{
return $this->remember(
scope: 'tenant',
resource: 'clientes',
id: 'all',
callback: fn() => $this->model->getAll(),
ttl: CacheConfig::TTL_LISTADOS // 1 hour
);
}
public function insert(ClienteRequest $data): ClienteDTO
{
$result = $this->model->insert($data);
// Invalidate cache after insert
$this->forgetCache('tenant', 'clientes', 'all');
return $result;
}
}Próximos Pasos (Post-Merge)
Fase 2 - Hardening (Sprint siguiente):
- Fix DEFAULT_PAYLOAD anti-pattern
- Agregar PHPDoc completo
- Expandir sensitive keywords list
- Implementar pattern matching funcional o remover método
- Agregar integration tests con PostgreSQL real
Fase 3 - Production Adoption (2-3 sprints):
- Integrar en 3 módulos piloto (Ventas, Compras, Contabilidad)
- Implementar invalidación automática en CUD operations
- Agregar audit logging opcional
- Capturar métricas baseline y post-cache
Fase 4 - Optimización (post-producción):
- Analizar hit ratio y ajustar TTLs
- Implementar tag-based invalidation si se requiere
- Considerar Redis adapter para mayor throughput
- Cache warming para datos críticos
Historial de Cambios
| Fecha | Versión | Autor | Descripción |
|---|---|---|---|
| 2026-01-07 | 1.0 | Sistema (Claude Code) | Creación del documento de requerimientos |
| 2026-01-07 | 1.1 | Sistema (Claude Code) | Implementación v1.0 completada - Servicio cache multi-tenant funcional con TDD (70 tests passing). Implementado: CacheManager, CacheKeyGenerator, CacheConfig, Payload DTO, 4 excepciones custom, trait Cacheable, integración DI, adapters (Dbal/Array). Code Review: Calificación A+ (95/100). Cumplidos: 16/27 AC (59%). Issues identificados: (1) DEFAULT_PAYLOAD anti-pattern, (2) flushByPattern no funcional, (3) PHPDoc parcial, (4) audit logging pendiente. Fases pendientes: Tests integración PostgreSQL, adopción en módulos productivos, métricas performance. |