Skip to content

[BUG] Aktive Benutzer-Lernpfade sind nicht gegen konkurrierende Duplikate abgesichert #501

Description

@ralferlebach

Problem

Beim Einschreiben eines Benutzers in einen Hostkurs wird zunächst geprüft, ob bereits ein aktiver Benutzer-Lernpfad existiert:

$userpath = self::buildsqlqueryuserpath(
    $learningpath->id,
    $params->relateduserid,
    $courseid
);

if (!$userpath) {
    $DB->insert_record('local_adele_path_user', ...);
}

Zwischen Leseabfrage und Insert besteht keine Datenbanksperre und keine Eindeutigkeitsbedingung.

db/install.xml definiert für local_adele_path_user lediglich den Primärschlüssel sowie Fremdschlüssel. Eine eindeutige fachliche Identität aus Benutzer, Hostkurs und Lernpfad ist nicht abgesichert.

Ursache

Es handelt sich um ein klassisches Check-then-insert-Race:

  1. Prozess A prüft, ob ein Datensatz existiert.
  2. Prozess B prüft gleichzeitig, ob ein Datensatz existiert.
  3. Beide erhalten keinen Treffer.
  4. Beide legen einen aktiven Datensatz an.

Der vorhandene Idempotenztest mit zwei nacheinander ausgeführten Events deckt diese Konkurrenzsituation nicht ab.

Lösung

Die fachliche Eindeutigkeit muss auf Datenbankebene abgesichert werden.

Für das derzeitige Datenmodell bietet sich ein Unique-Key an:

user_id, course_id, learning_path_id

Vor Einführung des Indexes müssen vorhandene Duplikate bereinigt werden.

Falls künftig mehrere historische Revisionen in derselben Tabelle gespeichert werden sollen, ist das Datenmodell vorher zu trennen, beispielsweise in:

  • eine eindeutige aktuelle Benutzer-Lernpfad-Relation,
  • eine separate Revisions- oder Historientabelle.

Zusätzlich sollte die Erzeugung transaktional erfolgen und einen möglichen Duplicate-Key-Konflikt kontrolliert behandeln:

  1. Insert versuchen.
  2. Bei konkurrierendem Unique-Verstoß vorhandenen Datensatz laden.
  3. Mit diesem Datensatz fortfahren.
  4. Keine doppelte Eventkette auslösen.

Manuelles Testverfahren

Deterministischer Datenbanktest

  1. Einen gültigen aktiven Benutzer-Lernpfad erzeugen.
  2. Seine IDs ermitteln.
  3. Einen zweiten fachlich identischen Datensatz einfügen:
INSERT INTO mdl_local_adele_path_user (
    user_id,
    course_id,
    learning_path_id,
    status,
    timecreated,
    timemodified,
    createdby,
    json,
    last_seen_by_owner
)
SELECT
    user_id,
    course_id,
    learning_path_id,
    status,
    timecreated,
    timemodified,
    createdby,
    json,
    last_seen_by_owner
FROM mdl_local_adele_path_user
WHERE id = <EXISTING_ID>;
  1. Die Anzahl kontrollieren:
SELECT
    user_id,
    course_id,
    learning_path_id,
    COUNT(*) AS records
FROM mdl_local_adele_path_user
WHERE status = 'active'
GROUP BY user_id, course_id, learning_path_id
HAVING COUNT(*) > 1;

Aktuelles Ist-Verhalten

Die Datenbank akzeptiert den zweiten fachlich identischen Datensatz.

Erwartetes Soll-Verhalten

Der zweite Insert wird durch eine Eindeutigkeitsbedingung verhindert.

Optionaler Konkurrenztest

In einer Testumgebung zwei Prozesse zeitgleich dieselbe Einschreibeverarbeitung für denselben Benutzer, Hostkurs und Lernpfad aufrufen. Nach Abschluss darf genau ein Datensatz vorhanden sein.

Upgrade-Anforderungen

  1. Vorhandene Duplikatgruppen identifizieren.
  2. Festlegen, welcher Datensatz erhalten bleibt.
  3. Fortschrittsinformationen kontrolliert zusammenführen.
  4. Abhängige Tasks und Einschreibungen bereinigen.
  5. Unique-Key anlegen.
  6. Upgrade idempotent ausführen.

Automatisierte Tests

  • Sequentielle doppelte Verarbeitung.
  • Simulierte konkurrierende Inserts.
  • Upgrade-Test mit vorhandenen Duplikaten.
  • Prüfung, dass Fortschrittsdaten bei der Bereinigung nicht verloren gehen.
  • Prüfung, dass nur eine nachgelagerte Update-Kette ausgeführt wird.

Akzeptanzkriterien

  • Pro Benutzer, Hostkurs und Lernpfad kann nur eine aktuelle Relation bestehen.
  • Die Eindeutigkeit wird von der Datenbank abgesichert.
  • Vorhandene Duplikate werden per Upgrade bereinigt.
  • Konkurrenzfehler werden kontrolliert behandelt.
  • Fortschrittsdaten werden nicht unkontrolliert verworfen.
  • Der Fix funktioniert unter PostgreSQL und MariaDB.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions