Aller au contenu principal

Éviter les commandes en double dans un connecteur : idempotence et file d’attente

Le défaut le plus coûteux d’un connecteur n’est pas la commande qui ne part pas : celle-là se voit. C’est la commande envoyée deux fois, parce qu’un paiement a été validé deux fois, qu’une tâche s’est relancée pendant que la précédente tournait, ou qu’un délai d’attente a été dépassé alors que l’ERP avait bien reçu. La réponse tient en un mot : idempotence. Envoyer deux fois doit produire le même résultat qu’envoyer une fois.

SQL
CREATE TABLE sync_file (
    id          INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    type        VARCHAR(32)  NOT NULL,
    reference   VARCHAR(64)  NOT NULL,
    statut      ENUM('attente', 'en_cours', 'fait', 'erreur') NOT NULL DEFAULT 'attente',
    tentatives  TINYINT UNSIGNED NOT NULL DEFAULT 0,
    erreur      TEXT NULL,
    cree_le     DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    maj_le      DATETIME NULL,
    UNIQUE KEY uniq_type_reference (type, reference)
) ENGINE=InnoDB;

-- Mise en file : une deuxième insertion de la même commande est ignorée
INSERT IGNORE INTO sync_file (type, reference) VALUES ('commande', 'XKBKNABJK');

Trois protections se superposent. La clé d’unicité empêche qu’une commande entre deux fois dans la file. FOR UPDATE SKIP LOCKED garantit que deux processus lancés en même temps ne prennent jamais la même ligne. Et la vérification côté ERP avant création couvre le cas le plus sournois : l’envoi réussi dont la réponse s’est perdue. Si l’API d’en face accepte une clé d’idempotence dans ses en-têtes, on l’utilise en plus. Les erreurs restent en file avec leur message : on les relit, on corrige, elles repartent seules.

PHP
// Réserver une ligne sans gêner un autre processus (MariaDB 10.6+, MySQL 8+)
$pdo->beginTransaction();
$ligne = $pdo->query("
    SELECT id, reference FROM sync_file
    WHERE statut = 'attente' OR (statut = 'erreur' AND tentatives < 5)
    ORDER BY id LIMIT 1
    FOR UPDATE SKIP LOCKED")->fetch(PDO::FETCH_ASSOC);
if ($ligne) {
    $pdo->prepare("UPDATE sync_file SET statut = 'en_cours', maj_le = NOW() WHERE id = ?")
        ->execute([$ligne['id']]);
}
$pdo->commit();
if (!$ligne) {
    exit;
}

try {
    // Vérifier avant d'écrire : la commande existe-t-elle déjà côté ERP ?
    if (!$erp->commandeExiste($ligne['reference'])) {
        $erp->creerCommande($ligne['reference']);
    }
    $pdo->prepare("UPDATE sync_file SET statut = 'fait', maj_le = NOW() WHERE id = ?")
        ->execute([$ligne['id']]);
} catch (Throwable $e) {
    $pdo->prepare("UPDATE sync_file SET statut = 'erreur', tentatives = tentatives + 1,
                  erreur = ?, maj_le = NOW() WHERE id = ?")
        ->execute([mb_substr($e->getMessage(), 0, 2000), $ligne['id']]);
}

Sources

  1. MariaDB Knowledge Base, SELECT … SKIP LOCKED mariadb.com