¡Hola! Envíame un mensaje
Contacto

Ciudad de México, México
Disponible para proyectos remotos y presenciales.

Redes profesionales

LinkedIn · GitHub · Email
Ingeniería aplicada, resultados medibles.

Optimizar WordPress para OCR + LLM en producción: cola, cron y unicidad con 80,000 folios

· 10 min de lectura · Samuel Torres

Figura de espirógrafo (hipotrocoide)
x(t) = (R - r)cos t + d cos((R - r)t / r)

En resumen

  • El OCR y el LLM nunca corren dentro de una petición web: corren en un proceso aparte.
  • WP-Cron depende de las visitas; para trabajo crítico, apágalo y usa el cron del sistema.
  • La cola vive en una tabla propia, con estados explícitos e índices a la medida.
  • Las reglas de unicidad las garantiza MySQL con índices, no un if en PHP.
  • La concurrencia se demuestra con una prueba de carga, no se supone.

Optimizar WordPress para OCR + LLM en producción no tiene que ver con plugins de caché ni con comprimir imágenes. Tiene que ver con algo más básico: que el trabajo pesado nunca ocurra dentro de una petición web.

Lo aprendí construyendo el sistema de Panini Fan Zone FWC 2026. Eran dos plugins propios de WordPress que gestionaban un pool fijo de 80,000 folios y verificaban comprobantes de compra con Tesseract y un modelo LLaMA 3.3 70B servido por Groq.

En el artículo de optimización de Python conté el lado del procesamiento. Este es el otro lado: cómo se prepara WordPress para recibir, encolar y registrar ese trabajo sin mezclarlo con las páginas públicas.

Los ejemplos de código son ilustrativos. Muestran el patrón; los nombres de tablas, columnas y rutas son inventados para el ejemplo.

El problema: WordPress no es un worker

WordPress está diseñado para responder peticiones: alguien pide una página, PHP la arma y la entrega. Cada petición tiene un tiempo límite y comparte recursos con todas las demás.

Un pipeline de OCR + LLM no encaja en ese molde. Leer una imagen con Tesseract consume CPU. Llamar a un modelo remoto depende de la red y de los límites del proveedor. Ninguna de las dos cosas debería hacer esperar a un usuario.

El error más común es procesar el comprobante en el mismo momento en que se sube. El formulario se queda girando, la petición se acerca al límite de tiempo y, si varias personas suben a la vez, los procesos de PHP se ocupan en tareas que no son servir páginas.

La arquitectura que funciona separa tres momentos:

  • Recibir. La petición web valida lo mínimo, guarda el archivo y crea una fila en la cola. Responde de inmediato.
  • Procesar. Un proceso aparte, disparado por el cron del sistema, toma lotes de la cola y los pasa por OCR y LLM.
  • Registrar. El resultado se escribe en la base de datos con reglas que MySQL hace cumplir.

En el caso real, ese proceso corría cada 2 minutos sobre un VPS con AlmaLinux, sin que nadie revisara comprobante por comprobante.

Sacar el trabajo de la petición: cron del sistema, no WP-Cron

WP-Cron tiene un nombre engañoso. No es un cron: es una revisión que WordPress hace al cargar una página. Si hay eventos vencidos, lanza una petición a wp-cron.php para ejecutarlos.

Eso tiene dos consecuencias para un pipeline crítico. Sin visitas, nada se procesa. Con muchas visitas, el disparo depende del tráfico y no de un horario. Ninguna de las dos es aceptable cuando hay comprobantes esperando.

La solución es desactivar el disparo por visitas y dejar que el sistema operativo marque el ritmo:

# wp-config.php: WP-Cron deja de dispararse con las visitas.
#   define( 'DISABLE_WP_CRON', true );

# crontab del usuario del sitio: el sistema marca el ritmo, no el tráfico.
# Cada 2 minutos, con flock para que dos corridas nunca se encimen.
*/2 * * * * flock -n /tmp/verificador.lock \
  wp --path=/home/sitio/public_html cron event run --due-now \
  >> /home/sitio/logs/cron.log 2>&1

# Revisa qué eventos tiene programados WordPress antes de cambiar nada.
wp cron event list --fields=hook,next_run_relative

Tres decisiones en pocas líneas. DISABLE_WP_CRON quita el trabajo de las visitas. WP-CLI ejecuta los eventos fuera de Apache, sin el límite de tiempo de una petición web. Y flock -n descarta la corrida nueva si la anterior sigue viva.

Un detalle que se olvida: al desactivar WP-Cron también dejan de correr las tareas de otros plugins, como publicaciones programadas o limpiezas. El cron del sistema debe ejecutar todos los eventos vencidos, no solo los del pipeline.

Una cola en tabla propia, con estados e índices

La tentación es guardar el estado de cada comprobante en post meta o en wp_options. Funciona con decenas de registros. Con miles, se vuelve un problema.

wp_options se carga en cada petición cuando la opción es de carga automática. Y post meta es una tabla de clave y valor: preguntar «dame los pendientes más viejos» obliga a MySQL a recorrer mucho más de lo necesario.

Una tabla propia le da al worker exactamente la consulta que necesita:

CREATE TABLE wp_verif_cola (
  id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  folio       CHAR(12) NOT NULL,
  estado      ENUM('pendiente','procesando','aprobado','rechazado','error')
              NOT NULL DEFAULT 'pendiente',
  intentos    TINYINT UNSIGNED NOT NULL DEFAULT 0,
  tomado_por  CHAR(36) NULL,            -- qué corrida reclamó la fila
  tomado_en   DATETIME NULL,            -- detecta trabajos huérfanos
  -- El worker siempre pide los pendientes más viejos: índice a la medida.
  KEY idx_estado_id (estado, id),
  UNIQUE KEY uq_folio (folio)           -- un comprobante por folio, lo garantiza MySQL
) ENGINE=InnoDB;

Los estados explícitos son la parte importante. Un comprobante está pendiente, en proceso, aprobado, rechazado o con error. No hay combinaciones ambiguas, y cualquier persona del equipo puede contar cuántos hay en cada fase con una consulta.

La columna tomado_en resuelve un caso que siempre llega: una corrida que muere a la mitad. Si una fila lleva demasiado tiempo en procesando, se regresa a pendiente y se suma un intento. Si supera el máximo de intentos, pasa a error para revisión.

En un plugin, esta tabla se crea con dbDelta() al activarlo, y su versión se guarda en una opción para saber cuándo hace falta migrar.

Reclamar trabajo sin que dos procesos tomen lo mismo

flock evita que dos corridas del cron se encimen. Pero si algún día ejecutas dos workers a propósito, necesitas otra garantía: que nunca tomen la misma fila.

El patrón es reclamar con un UPDATE atómico y después leer solo lo que ese worker marcó:

// Reclamar un lote de forma atómica: dos workers nunca toman la misma fila.
global $wpdb;
$token = wp_generate_uuid4();
$wpdb->query( $wpdb->prepare(
    "UPDATE {$wpdb->prefix}verif_cola
        SET estado = 'procesando', tomado_por = %s, tomado_en = NOW()
      WHERE estado = 'pendiente' ORDER BY id LIMIT %d",
    $token, 25
) );
// Solo procesa lo que este worker marcó con su token.
$lote = $wpdb->get_results( $wpdb->prepare( "SELECT id, folio FROM {$wpdb->prefix}verif_cola WHERE tomado_por = %s", $token ) );

MySQL ejecuta el UPDATE como una sola operación. Si dos workers lo lanzan al mismo tiempo, cada fila termina con un solo token. No hay ventana entre «leer» y «marcar» en la que otro proceso se cuele.

El tamaño del lote es deliberadamente chico. Un lote corto termina dentro de la ventana del cron, y si algo falla, se pierde poco trabajo.

Unicidad: la regla vive en MySQL, no en un if

Aquí está la lección que más me costó del proyecto. El módulo de registro para el intento de Récord Guinness necesitaba su propia regla: una persona, un registro, acotado a esa actividad.

Le di su propio esquema de base de datos en lugar de reusar la validación del plugin de folios. Aun así, la primera versión del índice de unicidad no estaba acotada a la actividad como debía, y un mismo correo podía contarse de una forma que la regla no permitía.

La corrección fue un índice único compuesto, acotado a la actividad, y una migración que revisa el esquema existente antes de alterarlo. El plugin pasó de la versión 1.1.0 a la 1.1.1.

// v1.1.1: migración que revisa el esquema antes de alterarlo.
// ⚠️ Producción: respalda la tabla antes (mysqldump --single-transaction).
function registro_migrar_indice() {
    global $wpdb;
    $tabla = $wpdb->prefix . 'registro_record';
    // Idempotente: si el índice compuesto ya existe, no hay nada que hacer.
    if ( $wpdb->get_var( "SHOW INDEX FROM {$tabla} WHERE Key_name = 'uq_actividad_correo'" ) ) { return; }
    // Un duplicado previo haría fallar el ALTER: se detiene antes y lo registra.
    $dup = $wpdb->get_var( "SELECT COUNT(*) FROM (SELECT 1 FROM {$tabla} GROUP BY actividad_id, correo HAVING COUNT(*) > 1) d" );
    if ( $dup ) { error_log( "registro: {$dup} duplicados, migración detenida" ); return; }
    $wpdb->query( "ALTER TABLE {$tabla} ADD UNIQUE KEY uq_actividad_correo (actividad_id, correo)" );
}

Hay tres ideas en esa función que aplican a cualquier plugin con datos reales:

  • Idempotencia. La migración puede correr dos veces sin daño. Si el índice ya existe, sale.
  • Verificación previa. Si ya hay datos que violan la regla nueva, el ALTER fallaría. Es mejor detenerse, registrarlo y decidir con calma qué hacer con esos datos.
  • La regla en la base. Un if en PHP que consulta «¿ya existe este correo?» antes de insertar tiene una carrera: dos peticiones simultáneas pueden pasar la consulta antes de que cualquiera inserte. El índice único no tiene esa carrera.

La validación en PHP sigue siendo útil para dar un mensaje amable. Pero la garantía la da MySQL.

Demostrar la concurrencia: 50 peticiones al mismo cupo

Un índice bien puesto es una hipótesis hasta que lo pruebas con carga. Para ese módulo escribí un script verificador independiente y una suite de 15 pruebas.

Una de ellas lanzaba 50 workers simultáneos contra la misma ruta de registro. La condición de éxito era doble: exactamente un registro gana cada cupo, y cero errores de servidor.

La forma más simple de reproducir esa prueba cabe en unas líneas de Bash:

# 50 peticiones simultáneas contra el mismo cupo: debe ganar exactamente una.
# Solo contra staging: nunca dispares esto contra producción.
URL="https://staging.ejemplo.test/wp-json/registro/v1/inscribir"
seq 50 | xargs -P 50 -I{} curl -s -o /dev/null -w "%{http_code}\n" \
  -X POST "$URL" -H "Content-Type: application/json" \
  -d "{\"actividad\":7,\"correo\":\"qa@${DOMINIO_QA}\"}" \
  | sort | uniq -c
# Esperado: 1 respuesta 201, 49 respuestas 409 y ningún 5xx.
# Un 500 indica que MySQL frenó la carrera, pero el plugin no tradujo el error a un 409.

La segunda condición es tan importante como la primera. Si el índice detiene los duplicados pero el plugin deja escapar la excepción como un error 500, la regla funciona y la experiencia no.

Con la migración ya en su lugar, el módulo pasó su verificador completo, 24 de 24 revisiones, y su suite, 15 de 15 pruebas. Esa evidencia es la que permite decir «terminado».

Que el pipeline no toque las páginas públicas

Todo lo anterior tiene un beneficio que no es obvio: las páginas públicas no se enteran de que existe el pipeline. Y eso es justo lo que protege el rendimiento y el SEO del sitio.

  • Las visitas no ejecutan trabajo pesado. Con WP-Cron desactivado, cargar una página no dispara OCR, llamadas al modelo ni limpiezas.
  • Cada plugin carga solo donde se usa. Los scripts y estilos del formulario se encolan únicamente en la página de registro, no en todo el sitio.
  • Las rutas internas no se indexan. Los endpoints de la API y las páginas de staff no aparecen en el sitemap y piden autenticación.
  • La cola no comparte tablas con el contenido. Las consultas del worker no compiten con las que arman las páginas.

El resultado es un sitio que sigue siendo un sitio, con un sistema de procesamiento al lado, no adentro.

Lo que NO debes hacer

  • Procesar el archivo en la misma petición que lo recibe. El usuario espera, el proceso de PHP queda ocupado y la petición puede cortarse por tiempo. Recibe, encola y responde.
  • Confiar la unicidad a una consulta previa en PHP. «Revisar si existe y luego insertar» tiene una carrera que solo aparece con tráfico real. Un índice único la elimina.
  • Migrar el esquema sin respaldo ni verificación. Un ALTER TABLE sobre datos que ya violan la regla nueva falla a la mitad, en producción y con el evento en curso. Respalda, revisa duplicados y haz la migración idempotente.

El siguiente paso

Optimizar WordPress para un pipeline de OCR + LLM es una cuestión de separación: recibir en la web, procesar fuera de ella y dejar que MySQL haga cumplir las reglas. Con eso, la parte de IA se puede cambiar o escalar sin tocar el sitio.

Si tu equipo enfrenta cuellos de botella en WordPress, necesita integrar procesamiento con IA sin arriesgar el sitio o quiere auditar plugins que ya están en producción, en Conqr Digital Agency diseñamos arquitecturas que escalan. Revisemos tu caso.

El sistema completo está en el caso de Panini Fan Zone. Si buscas este tipo de trabajo, revisa los servicios de desarrollo web a medida y automatización con IA.

Preguntas frecuentes

¿Por qué no usar WP-Cron para un pipeline de OCR + LLM?
Porque WP-Cron solo se dispara cuando alguien visita el sitio y corre dentro de esa visita. Sin tráfico no procesa nada, y con mucho tráfico puede lanzar corridas encimadas. Para trabajo crítico conviene desactivarlo y llamar a los eventos desde el cron del sistema.
¿Puedo guardar la cola en wp_options o en post meta?
Para unas cuantas tareas, sí. Para miles, no: wp_options se carga en cada petición y post meta no tiene índices pensados para consultar por estado. Una tabla propia con su índice resuelve la consulta del worker sin afectar al resto del sitio.
¿Cómo pruebo que mi registro aguanta peticiones simultáneas?
En un entorno de pruebas, dispara muchas peticiones al mismo tiempo contra el mismo cupo y cuenta las respuestas. Debe ganar exactamente una, las demás deben recibir un rechazo controlado y no debe aparecer ningún error de servidor.
Samuel Torres Pichardo

Samuel Torres Pichardo
Ingeniero de software y cloud en Ciudad de México, con unos 3 años de experiencia profesional.
LinkedIn · GitHub · Contacto

Contacto profesional:

Ciudad de México, México

Disponible para proyectos remotos y presenciales.
Ingeniero en Tecnologías de Computación y Telecomunicaciones

© 2026 Ing. Samuel Torres. Ingeniería aplicada.