7,5 millones de preguntas duplicadas: cómo rescaté un curso de Moodle sin cargarme los exámenes de otro

7,5 millones de preguntas duplicadas: cómo rescaté un curso de Moodle sin cargarme los exámenes de otro
ContenidoContents

Hay upgrades de Moodle que en el laboratorio salen en media hora y en producción te enseñan humildad. Esta es la historia completa de uno de los segundos: empezó con una tarea de actualización que no había forma de terminar, y acabó siendo una investigación forense en la base de datos que sacó a la luz un bug de restauración con meses de antigüedad, afectando a exámenes reales de un curso completamente distinto.

El primer aviso: la actualización a mod_qbank se atasca

Desde el cambio del banco de preguntas tradicional a la actividad mod_qbank (bancos compartibles), el banco deja de vivir “solo” en el contexto del curso/categoría/sitio. Tras el upgrade corre la tarea ad-hoc mod_qbank\task\transfer_question_categories (y, por cada categoría movida, transfer_questions), que selecciona todas las preguntas de la categoría de golpe para mover ficheros y tags. En un curso normal ni te enteras. En un contexto con millones de preguntas, esa consulta monta un IN (?, ?, …) tan grande que PostgreSQL se niega a ejecutarla.

Esto no es un post de “cómo hacer clic en actualizar”. Es el diario de un sitio de producción universitaria (campus grande, bancos hinchados, copias de curso de años) donde ya convivíamos con otra herida del banco —los stamps duplicados, que no rompen nada por sí solos pero se convierten en un problema de rendimiento en cuanto el banco crece a millones de preguntas— y donde el cambio al nuevo modelo de banco de preguntas ha sacado a la luz ese mismo problema de volumen, ahora a una escala muchísimo mayor.

El fallo de PostgreSQL: demasiados parámetros

PostgreSQL y el driver tienen un techo al número de parámetros enlazados en una sentencia (el orden de magnitud suele rondar 65.535 binds; el número exacto depende de versión y camino). Un IN (?, ?, …) con cientos de miles de placeholders no es una query razonable: el servidor la rechaza.

Los síntomas en los logs de cron: la tarea transfer_question_categories o transfer_questions queda failed, el admin ve el aviso de que hay bancos preexistentes aún sin transferir a instancias mod_qbank, y por debajo, un error de “demasiados parámetros” al preparar la sentencia.

PostgreSQL no es el villano. El villano es asumir que “todos los IDs de esta categoría caben en un solo IN con placeholders”. En un campus con un contexto patológico, ese supuesto es falso.

Cómo se llega a “millones de preguntas” en un solo sitio

No hace falta que un docente haya escrito un millón de ítems: el problema son los duplicados. Cada restore de un curso, cada duplicación de un cuestionario, cada copia de una categoría de preguntas de un año académico al siguiente genera una copia nueva de las preguntas en vez de reutilizar las que ya existen. Repite eso durante años, con el mismo curso plantilla restaurado una y otra vez — y, visto caso a caso, cada restore o cada duplicado individual parece ir perfectamente bien. El mismo puñado de preguntas originales se multiplica solo. El courseid en la UI parece un curso más; en mdl_question*, después de años de este bucle, es un agujero negro.

El curso que no había forma de borrar

Con la actualización bloqueada, tocaba lidiar directamente con el contexto que la estaba haciendo fallar: un curso de pruebas llamado “Curso basura preguntas” que, además, tampoco había forma de borrar por las bravas.

Teníamos un script CLI de limpieza de preguntas duplicadas (uno de esos scripts de comunidad que circulan para Moodle) que debía detectar y fusionar preguntas duplicadas en un contexto de curso. Al lanzarlo en modo --dryrun:

php cleanup_duplicates_context_cli_global_dp_parent_fix.php --courseid=55914 --dryrun
[...]
SQL query completed, starting to process results...
Fatal error: Allowed memory size of 8589934592 bytes exhausted (tried to allocate 20480 bytes)
in /var/www/aula/moodle/lib/dml/pgsql_native_moodle_database.php on line 1057

8 GB de memoria PHP agotados, fallando al intentar reservar 20 KB más. Eso no es “hay muchos duplicados” — es que algo se ha ido de madre. Y era el mismo patrón de fondo que el de la actualización: el script cargaba en un array de PHP todas las preguntas del contexto de golpe, en vez de procesarlas de una en una con un cursor de base de datos.

Primer diagnóstico: ¿cuánto es “mucho”?

Antes de tocar nada, quise saber la magnitud real con SQL directo:

1SELECT COUNT(*)
2FROM mdl_question q
3JOIN mdl_question_versions qv ON qv.questionid = q.id
4JOIN mdl_question_bank_entries qbe ON qbe.id = qv.questionbankentryid
5JOIN mdl_question_categories qc ON qc.id = qbe.questioncategoryid
6WHERE qc.contextid = <contexto_del_curso>
7   OR qc.contextid IN (SELECT id FROM mdl_context WHERE path LIKE '<path>/%');

7.594.560 preguntas. En un solo curso. Ningún curso real tiene eso — era la firma inconfundible de un bucle de restauración/duplicación descontrolado, y el mismo monstruo que había hecho fallar la tarea de mod_qbank.

Con esa cifra, cargarlo todo en PHP para compararlo pregunta a pregunta (que es justo lo que hacía el script, y lo que hace Moodle internamente al borrar un curso) nunca iba a funcionar, ni dándole toda la RAM del servidor. Había que purgar directamente en la base de datos, sin pasar por la capa de aplicación.

El giro: comprobar antes de borrar

Antes de arrasar con 7,5M de filas, hice la pregunta obligatoria: ¿hay algo enganchado a esto que no debería perderse? Concretamente, ¿algún intento de examen real referencia alguna de estas preguntas?

1SELECT COUNT(*)
2FROM mdl_question_attempts qa
3WHERE qa.questionid IN (
4    -- todas las preguntas del contexto sospechoso
5);

Resultado: 588. No cero. Rastreando a qué curso pertenecían esos intentos:

1SELECT DISTINCT c.id, c.fullname, COUNT(*) AS n_intentos
2FROM mdl_question_attempts qa
3JOIN mdl_question_usages qu ON qu.id = qa.questionusageid
4JOIN mdl_context ctx ON ctx.id = qu.contextid AND ctx.contextlevel = 70
5JOIN mdl_course_modules cm ON cm.id = ctx.instanceid
6JOIN mdl_course c ON c.id = cm.course
7WHERE qa.questionid IN (...)
8GROUP BY c.id, c.fullname;

Los 588 intentos pertenecían a otro curso completamente distinto, uno real y activo. Sus preguntas estaban físicamente alojadas en el contexto del curso basura que yo iba a destruir. Si hubiera purgado sin comprobar esto, habría roto la revisión de exámenes de alumnos reales.

Rastreando los ids exactos, resultó que eran solo 50 preguntas distintas (reutilizadas muchas veces en los 588 intentos), repartidas en dos categorías.

La pista del árbol roto

Investigando esas dos categorías descubrí algo revelador: el campo parent de una de ellas apuntaba a una categoría que, comprobando su fila directamente, vivía en el contexto del curso correcto (el de los exámenes reales), mientras que la propia categoría tenía el contextid apuntando al curso basura. Es decir, el puntero jerárquico (parent) estaba bien; el campo que dice “a qué curso perteneces” (contextid) estaba corrupto.

Esa fue la prueba de que el bug no solo duplicaba preguntas — en algún punto de una restauración/duplicación mal hecha, también desplazó categorías enteras de contexto sin actualizar correctamente toda la cadena. El curso “basura” no era basura al azar: era el vertedero acumulado de restauraciones repetidas de contenido real, y precisamente ese vertedero era lo que estaba bloqueando la actualización de todo el sitio a mod_qbank.

El rescate

Con eso claro, moví las dos categorías de vuelta a su contexto correcto:

1UPDATE mdl_question_categories
2SET contextid = <contexto_correcto>,
3    parent = <categoria_top_correcta>
4WHERE id IN (<cat_a>, <cat_b>);

Y verifiqué de la única forma que importa: abriendo la revisión de varios intentos reales (/mod/quiz/review.php?attempt=<id>) y confirmando que cargaban bien.

Sorpresa adicional: esas dos categorías, aunque solo tenían 50 preguntas “necesarias”, arrastraban 2.331.648 preguntas en total — el mismo bug de duplicación también las había contaminado a ellas. Tocó limpiarlas aparte, conservando solo las 50 en uso real.

La purga: por qué no usar la API de Moodle

Moodle tiene una función interna, question_delete_question(), que comprueba si una pregunta está en uso antes de borrarla — exactamente la garantía de seguridad que yo necesitaba. El problema: la comprueba una pregunta a la vez, cargando el objeto completo cada vez. Es el mismo patrón de “fila a fila” que hacía reventar tanto la tarea de actualización como el script de limpieza.

Como ya había hecho esa comprobación de seguridad en bloque, con SQL, contra todo el sistema (no solo intentos históricos, también referencias “vivas” de ranuras de cuestionario vía mdl_question_references/mdl_question_set_references), no necesitaba repetirla pregunta a pregunta. Podía purgar directo por SQL con la misma garantía de seguridad, sin el coste de rendimiento.

Diseñé un procedimiento PL/pgSQL que purga una categoría completa por llamada, en tandas de 5.000 filas con COMMIT tras cada tanda (para no acumular una transacción gigante):

 1CREATE OR REPLACE PROCEDURE purgar_categoria(p_cat_id integer)
 2LANGUAGE plpgsql
 3AS $$
 4DECLARE
 5  filas_borradas integer;
 6  total_lote integer := 0;
 7BEGIN
 8  LOOP
 9    DELETE FROM mdl_question
10    WHERE id IN (
11      SELECT q.id FROM mdl_question q
12      JOIN mdl_question_versions qv ON qv.questionid = q.id
13      JOIN mdl_question_bank_entries qbe ON qbe.id = qv.questionbankentryid
14      WHERE qbe.questioncategoryid = p_cat_id
15      LIMIT 5000
16    );
17    GET DIAGNOSTICS filas_borradas = ROW_COUNT;
18    total_lote := total_lote + filas_borradas;
19    COMMIT;
20    EXIT WHEN filas_borradas = 0;
21  END LOOP;
22  -- limpieza de question_versions, question_bank_entries y la categoría...
23END;
24$$;

Categoría a categoría, de la más pequeña a la más grande, verificando cada una antes de seguir con la siguiente. Tedioso pero controlable: si algo se corta a media ejecución, no se pierde nada (cada tanda ya está confirmada), y basta con relanzar la misma llamada.

La lección de PostgreSQL: NOT IN contra NOT EXISTS

A mitad de la purga, una limpieza que debería tardar segundos se quedó colgada minutos. La culpable era esta consulta, aparentemente inocente:

1-- LENTO con tablas grandes
2DELETE FROM mdl_question_versions
3WHERE questionbankentryid IN (...)
4  AND questionid NOT IN (SELECT id FROM mdl_question);

NOT IN con una subconsulta contra una tabla de millones de filas es una trampa clásica de rendimiento en PostgreSQL — el planificador no siempre encuentra un buen plan. El arreglo es cambiarlo por NOT EXISTS, que permite un anti-join mucho más eficiente:

1-- RÁPIDO
2DELETE FROM mdl_question_versions qv
3WHERE qv.questionbankentryid IN (...)
4  AND NOT EXISTS (SELECT 1 FROM mdl_question q WHERE q.id = qv.questionid);

Ese cambio convirtió una consulta de “minutos, quizá cuelgue” en “segundos”. Si escribes borrados masivos contra tablas grandes en Postgres, evita NOT IN con subconsultas — usa NOT EXISTS.

El resultado

Con el contexto limpio, volví a lanzar el script original de limpieza:

php cleanup_duplicates_context_cli_global_dp_parent_fix.php --courseid=55914 --dryrun
[...]
Found 0 questions, grouping by stamp...
No questions found with the current filter criteria.

Instantáneo, sin error de memoria. Y finalmente, el borrado del curso:

sudo -u www-data php admin/cli/delete_course.php --courseid=55914 --disablerecyclebin
[...]
++ Borrado - Preguntas ++
[...]
Done!

La línea “Borrado - Preguntas” —la que antes hacía reventar todo el proceso— pasó sin incidentes. Y con el contexto fuera de escena, la tarea mod_qbank\task\transfer_question_categories del resto del sitio dejó de tropezar con él.

Lo que me llevo de esto

  • La actualización a mod_qbank es un cambio de modelo, no un “export a formato nuevo”: reubica categorías a contextos de actividad y mueve ficheros y tags. Por eso un contexto patológico la hace fallar entera.
  • Un error de memoria (o de “demasiados parámetros”) casi nunca es “dale más recursos”. Casi siempre es la misma señal repetida en dos sitios distintos: algo procesa fila a fila —o categoría entera de golpe— lo que debería procesarse en bloques acotados.
  • Antes de purgar nada a gran escala, comprueba sistemáticamente qué depende de ello — y hazlo contra todo el sistema, no solo el caso obvio. Los 588 intentos de otro curso no eran evidentes a simple vista.
  • Un dato “corrupto” a menudo cuenta una historia si lo miras con atención — la desincronización entre contextid y parent fue la pista que explicó tanto por qué el curso basura tenía exámenes reales dentro como por qué llevaba meses bloqueando la actualización.
  • NOT IN contra subconsultas grandes es una trampa de rendimiento recurrente en PostgreSQL. NOT EXISTS casi siempre es la alternativa correcta.
  • Los procedimientos con COMMIT por lotes son tu amigo cuando tienes que borrar millones de filas de forma seudo-interrumpible: si algo falla a mitad, no pierdes el progreso.
  • Los sitios pequeños nunca van a ver ninguno de estos dos bugs. Por eso merece la pena documentarlo: en un campus grande con historial salvaje, tarde o temprano un contexto se convierte en agujero negro, y el síntoma puede aparecer primero como fallo de actualización y luego como fallo de memoria en un script sin relación aparente.
CompartirShare