Saltar al contenido principal

Caso Real: Integración del Plugin Moodle LMS

El Problema

Moodle (y las plataformas LMS en general) funcionan sobre PHP + MySQL/Postgres. Cuando un estudiante entrega una tarea, el flujo síncrono típico bloquea la respuesta HTTP hasta que cada operación posterior se completa:

  • Guardar la entrega en la base de datos
  • Ejecutar el análisis de plagio con IA
  • Generar el informe PDF
  • Enviar notificaciones por email
  • Actualizar el libro de calificaciones
  • Disparar el tracking SCORM

Esto hace que los tiempos de respuesta sean impredecibles. Con carga alta (picos de entregas a fin de trimestre), las conexiones a la base de datos se acumulan, se producen timeouts y los estudiantes ven páginas de error.

El problema de raíz: el modelo de ejecución síncrona de PHP está haciendo demasiado en una sola petición HTTP.

La Solución TinyMQ

TinyMQ actúa como una capa de desacoplamiento entre el tier web síncrono de Moodle y cualquier worker de procesamiento asíncrono.

El plugin PHP publica un payload de evento ligero a TinyMQ en < 5ms. La respuesta HTTP retorna de inmediato. Workers en segundo plano (Go o cualquier lenguaje) consumen el evento y realizan el trabajo pesado de forma asíncrona.

Arquitectura

La observación clave: los pasos 1–3 en el handler de Moodle ahora toman < 10ms en total. El trabajo pesado (análisis IA, generación de PDF, email) ocurre fuera del ciclo de vida de la petición HTTP.

Plugin Moodle: Publicar Eventos

En el handler de eventos PHP de tu plugin Moodle:

<?php
// File: local/tinymq_integration/classes/event/submission_created.php

namespace local_tinymq_integration\event;

class submission_created extends \core\event\base {

public static function send_to_queue(array $submissionData): bool {
$tinymqUrl = get_config('local_tinymq_integration', 'broker_url')
?? 'http://tinymq:7800';

$payload = json_encode([
'submission_id' => $submissionData['id'],
'user_id' => $submissionData['userid'],
'course_id' => $submissionData['courseid'],
'assignment_id' => $submissionData['assignmentid'],
'file_url' => $submissionData['file_url'],
'timestamp' => time(),
]);

// Non-blocking: this call completes in < 5ms
$ch = curl_init("$tinymqUrl/publish/lms.submissions");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 3, // Hard timeout: never block for more than 3s
]);

$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

return $httpCode === 202;
}
}

Llámalo desde el observer de entrega de tareas:

<?php
// File: local/tinymq_integration/db/events.php

$observers = [
[
'eventname' => '\assignsubmission_file\event\assessable_uploaded',
'callback' => '\local_tinymq_integration\event\submission_created::send_to_queue',
],
];

Worker Go: Pipeline de Análisis IA

package main

import (
"encoding/json"
"fmt"
"log"

"github.com/x-name15/tinymq/client"
"github.com/x-name15/tinymq/internal/message"
)

type SubmissionEvent struct {
SubmissionID int `json:"submission_id"`
UserID int `json:"user_id"`
CourseID int `json:"course_id"`
AssignmentID int `json:"assignment_id"`
FileURL string `json:"file_url"`
Timestamp int64 `json:"timestamp"`
}

func analyzeSubmission(msg message.Message) error {
var event SubmissionEvent
if err := json.Unmarshal(msg.Payload, &event); err != nil {
// Malformed event — will go to lms.submissions.dlq after 3 retries
return fmt.Errorf("invalid event payload: %w", err)
}

log.Printf("Analyzing submission %d for user %d...\n", event.SubmissionID, event.UserID)

// Run AI plagiarism check (hypothetical)
score, err := runPlagiarismCheck(event.FileURL)
if err != nil {
return fmt.Errorf("analysis failed: %w", err)
}

// Write result back to Moodle's DB
if err := savePlagiarismScore(event.SubmissionID, score); err != nil {
return fmt.Errorf("DB write failed: %w", err)
}

log.Printf("Submission %d: plagiarism score = %.2f%%\n", event.SubmissionID, score)
return nil
}

func main() {
mq := client.NewClient("http://tinymq:7800")

log.Println("LMS Analysis Worker started. Waiting for submissions...")

// This blocks forever, processing one submission at a time
// Exponential backoff + DLQ on 3 failures — no extra config needed
mq.Subscribe("lms.submissions", client.SubscriptionOptions{
Timeout: "15s",
}, analyzeSubmission)
}

Escalar el Pool de Workers

Ejecuta múltiples instancias de worker para procesar entregas en paralelo. Cada instancia compite por la misma queue (FIFO, sin entrega duplicada):

# docker-compose.yml alongside Moodle
services:
tinymq:
image: ghcr.io/x-name15/tinymq:latest
ports:
- "7800:7800"
volumes:
- ./data:/root/data
restart: unless-stopped

lms-worker:
build: ./workers/lms-analysis
environment:
- TINYMQ_URL=http://tinymq:7800
- DB_HOST=moodle-db
deploy:
replicas: 3 # 3 parallel workers
restart: unless-stopped
depends_on:
- tinymq

Tres réplicas significan tres análisis concurrentes. TinyMQ garantiza que cada mensaje se entrega exactamente a un worker.

Monitorizar Entregas Fallidas (DLQ)

Si una entrega falla el procesamiento 3 veces (error de red, servicio de IA caído, etc.), aterriza en lms.submissions.dlq. Monitorízalo desde el dashboard o CLI:

# Check DLQ size
tmq status | grep dlq

# Inspect failed submissions without removing them
tmq peek lms.submissions.dlq --limit=5

# Re-process after fixing the root cause
tmq sub lms.submissions.dlq --limit=10 | xargs re-publish

Beneficios Clave para Administradores de Moodle

Antes de TinyMQDespués de TinyMQ
El endpoint de entrega bloquea 2–8sEl endpoint de entrega responde en < 100ms
El análisis IA se ejecuta en la petición HTTP de PHPEl análisis IA se ejecuta en un worker Go dedicado
La conexión a la base de datos se retiene durante el análisisLa conexión a la BD se libera de inmediato
Los picos de fin de trimestre causan timeoutsLa queue absorbe los picos; los workers drenan a su propio ritmo
Los análisis fallidos se pierden silenciosamenteLos eventos fallidos aterrizan en el DLQ para su inspección