Chambéry, le 29 Avril 2025

Vous connaissez peut-être explain.dalibo.com, l’outil pour visualiser et comprendre les plans d’exécution générés par la commande EXPLAIN. Il accepte des plans au format text et JSON. Dans des versions précédentes de PostgreSQL, un bug entraînait la génération de plans au format JSON invalides. Ce bug a été corrigé depuis longtemps, mais des plans invalides sont toujours envoyés sur explain.dalibo.com (possiblement générés avec une vieille version de PostgreSQL). Dans cet article, nous allons découvrir combien sont concernés, et comment nous avons obtenu cette information.

Le bug

Pour commencer, parlons du problème initial.

Il y a environ 6 ans (en août 2019), alors que j’étais en train de travailler sur PEV2 (le visualiseur de plans au cœur de explain.dalibo.com), j’ai trouvé et rapporté un bug dans PostgreSQL. Pour faire court, il y avait dans certains cas des clés dupliquées dans la structure JSON, ce qui considéré comme invalide.

Par exemple, dans le plan ci-dessous, la clé Workers est dupliquée :

[
  {
    ...
      "Plans": [
        {
          "Node Type": "Sort",
          ...
          "Workers": [
            {
              "Worker Number": 0,
              "Sort Method": "external merge",
              ...
            }
          ],
          "Workers": [
            {
              "Worker Number": 0,
              "Actual Startup Time": 1487.846,
              ...
            }
          ],
    ...
  }
]

Analyser un plan comme celui-ci en utilisant JSON.parse(…) en Javascript ne déclenche aucune erreur, mais ne garde qu’une seule des deux clés dupliquées. Il en découle une perte d’information. Dans l’exemple ci-dessus, la Sort Method serait oubliée.

Contournement

Comme JSON.parse() ne peut pas être utilisé directement, pour PEV2, j’ai décidé de me reposer sur une bibliothèque tierce (clarinet) pour analyser le plan par flux (c’est à dire ligne par ligne) et prendre soin des clés dupliquées en fusionnant les valeurs.

Mais cette bibliothèque n’est plus maintenue et (parmi d’autres) a une dépendance sur stream pour gérer les flux Node.js dans le navigateur. Avoir trop de dépendances est un risque et celle-ci peut sembler superflue.

Bug corrigé

Par chance, le problème dans PostgreSQL a été corrigé en janvier 2020 (version 13).

Pour faciliter la maintenance de PEV2, j’ai donc envie de supprimer la dépendance. Cependant, j’ai aussi besoin d’être certain que ça ne sera pas au détriment des utilisateurs. La bonne question à laquelle il faut d’abord répondre est : « Combien d’utilisateurs utilisent encore une version de PostgreSQL affectée par le bug ? ». C’est quasiment impossible à savoir, mais je peux au moins répondre à la question : « Combien de plans contiennent des clés dupliquées ? ».

À la recherche de clés dupliquées

En avril 2025 (à peu près 6 ans après le lancement du site), un peu plus de 951 000 plans sont stockés dans la base de données utilisée pour explain.dalibo.com (~ 10 Gio). On peut considérer que c’est représentatif.

Nettoyage des sources

Les plans d’exécution peuvent être générés par un grand nombre d’outils différents avec différents paramètres d’affichage. Il y a donc une importante variété de rendus.

Voici quelques exemples de variantes quelque peu exotiques d’exports par psql :

+----------------------------------------------------+
|                     QUERY PLAN                     |
+----------------------------------------------------+
| [                                                 +|
|   {                                               +|
|     "Plan": {                                     +|
|       "Node Type": "Nested Loop",                 +|
╔════════════════════════════════════════════════════╗
║                     QUERY PLAN                     ║
╠════════════════════════════════════════════════════╣
║ [                                                 ↵║
║   {                                               ↵║
║     "Plan": {                                     ↵║
║       "Node Type": "Nested Loop",                 ↵║

Pour savoir combien de plans sont au format JSON et ont des clés dupliquées, la première étape consiste à supprimer les caractères d’encadrement, les titres et autres informations extra. C’est fait en créant une fonction en PL/pgSQL avec un lot d’expressions régulières pour remplacer les caractères inutiles.

CREATE OR REPLACE FUNCTION cleanup_source(source TEXT)
RETURNS TEXT AS $$
BEGIN
  -- Remove frames around, handles |, ║,
  source := REGEXP_REPLACE(source, E'^(\\||║|│)(.*)\\1\\r?\\n', E'\\2\n', 'gm');
  -- Remove frames at the end of line, handles |, ║,
  source := REGEXP_REPLACE(source, E'(.*)(\\||║|│)$\\r?\\n', E'\\1\n', 'gm');

  -- Remove separator lines from various types of borders
  source := REGEXP_REPLACE(source, E'^\\+-+\\+\\r?\\n', '', 'gm');
  source := REGEXP_REPLACE(source, E'^(-|─|═)\\1+\\r?\\n', '', 'gm');
  source := REGEXP_REPLACE(source, E'^(├|╟|╠|╞)(─|═)\\2*(┤|╢|╣|╡)\\r?\\n', '', 'gm');

  -- Remove more horizontal lines
  source := REGEXP_REPLACE(source, E'^\\+-+\\+\\r?\\n', '', 'gm');
  source := REGEXP_REPLACE(source, E'^└(─)+┘\\r?\\n', '', 'gm');
  source := REGEXP_REPLACE(source, E'^╚(═)+╝\\r?\\n', '', 'gm');
  source := REGEXP_REPLACE(source, E'^┌(─)+┐\\r?\\n', '', 'gm');
  source := REGEXP_REPLACE(source, E'^╔(═)+╗\\r?\\n', '', 'gm');

  -- Remove quotes around lines, both ' and "
  source := REGEXP_REPLACE(source, E'^([\\"\'])(.*)\\1\\r?', E'\\2', 'gm');

  -- Remove "+" line continuations
  source := REGEXP_REPLACE(source, E'\\s*\\+\\r?\\n', E'\n', 'gm');

  -- Remove "↵" line continuations
  source := REGEXP_REPLACE(source, E'↵\\r?', E'\n', 'gm');

  -- Remove "query plan" header
  source := REGEXP_REPLACE(source, E'^\\s*QUERY PLAN\\s*\\r?\\n', '', 'm');

  -- Remove rowcount
  -- example: (8 rows)
  -- Note: can be translated
  -- example: (8 lignes)
  source := REGEXP_REPLACE(source, E'^\\(\\d+\\s+[a-z]*s?\\)(\\r?\\n|$)', E'\n', 'gm');

  -- Replace two double quotes (added by pgAdmin)
  source := REGEXP_REPLACE(source, E'""', '"', 'gm');

  -- Remove double quotes at begining and end of plan
  source := REGEXP_REPLACE(source, E'^\\"(.*)\\r?', '', 'gm');
  source := REGEXP_REPLACE(source, E'^[^\\"]*\\"\\r?$', '', 'gm');

  RETURN source;
END;
$$ LANGUAGE plpgsql;

Détection des clés dupliquées

La seconde étape consiste évidemment à détecter les clés dupliquées. Pour cela, la meilleure option que j’ai trouvée a été de reposer sur PL/v8 pour faire l’analyse du texte brut.

-- Create the v8 extension
CREATE EXTENSION plv8;

-- Create function to check if plan is JSON parsable
CREATE OR REPLACE FUNCTION is_json(json_text TEXT)
RETURNS BOOLEAN AS $$
  try {
    JSON.parse(json_text);
    return true;
  } catch (e) {
    return false;
  }
$$ LANGUAGE plv8 IMMUTABLE STRICT;

-- Create function to detect duplicate keys
CREATE OR REPLACE FUNCTION detect_json_duplicate_keys(json_text TEXT)
RETURNS TEXT[] AS $$
  const duplicates = new Set();
  const stack = [];
  const keysStack = [];

  const tokenizer = /[{}\[\]]|"(?:[^"\\]|\\.)*"(?=\s*:)/g;
  let match;
  let currentKeys = [];

  while ((match = tokenizer.exec(json_text)) !== null) {
    const token = match[0];

    if (token === '{') {
      // Start a new object scope
      currentKeys.push(new Set());
    } else if (token === '}') {
      // End of an object scope
      currentKeys.pop();
    } else if (token === '[') {
      // Just track arrays — doesn't affect key scope
      stack.push('[');
    } else if (token === ']') {
      stack.pop();
    } else if (token.startsWith('"')) {
      const key = token.slice(1, -1).replace(/\\"/g, '"');

      if (currentKeys.length > 0) {
        const keySet = currentKeys[currentKeys.length - 1];
        if (keySet.has(key)) {
          duplicates.add(key);
        } else {
          keySet.add(key);
        }
      }
    }
  }

  return Array.from(duplicates);
$$ LANGUAGE plv8 IMMUTABLE;

Mise à jour des données

Ensuite, ces fonctions sont utilisées dans une requête sur tous les enregistrements de la table plans pour renseigner une nouvelle colonne dédiée.

WITH updated_data AS (
    SELECT
    *,
    cleanup_source(plan) AS cleaned
    FROM plans
),
enriched_data AS (
    SELECT
    *,
    is_json(cleaned) AS is_json,
    detect_json_duplicate_keys(cleaned) AS dup_keys
    FROM updated_data
)
UPDATE plans
SET duplicated_keys = u.dup_keys
FROM enriched_data u
WHERE t.id = u.id AND u.is_json IS TRUE;

Performance

Malheureusement, une telle requête aurait été extrêmement longue. Notamment, elle ne peut pas être parallélisée puisque PostgreSQL ne permet pas la parallélisation pour des requêtes d’écriture.

Par contre, grâce au fait que la table plans est divisée en partitions, il est possible de faire une sorte de « parallélisation côté client » en lançant la requête sur chaque partition dans un script bash.

Cela m’a même permis au passage de calculer et afficher la durée.

#!/bin/bash
START_TIME=$(date +%s)

for i in $(seq 0 49); do
  echo "Start detection on partition $i"
  psql -f detect.sql --set partition_table=plans.part_$i > /dev/null 2>&1 &
done

wait

END_TIME=$(date +%s)
echo "✅ Finished in $((END_TIME - START_TIME)) seconds."

Le script (qui dépend des processeurs et de leur nombre) a tourné pendant un peu plus de 6 heures.

Résultats

Après ça, j’ai simplement exécuté la requête SQL suivante :

SELECT
  month,
  filtered_count,
  unfiltered_count,
  SUM(filtered_count) OVER (ORDER BY month) AS total_filtered,
  SUM(unfiltered_count) OVER (ORDER BY month) AS total_unfiltered,
  SUM(json_count) OVER (ORDER BY month) AS total_json,
  (100.0 * filtered_count / NULLIF(unfiltered_count, 0)) AS ratio_filtered
FROM (
  SELECT
    date_trunc('month', created)::date AS month,
    COUNT(*) FILTER (WHERE duplicated_keys != '{}' AND duplicated_keys IS NOT NULL) AS filtered_count,
    COUNT(*) AS unfiltered_count,
    COUNT(*) FILTER (WHERE duplicated_keys IS NOT NULL) AS json_count
  FROM
    plans
  GROUP BY
    date_trunc('month', created)
) sub
ORDER BY
  month;

Un peu plus de 380 000 plans envoyés sont au format JSON (sur un total d’environ 950 000). 7 600 sont au format JSON et ont des clés dupliquées, ce qui représente moins de 0,8% du total. Sur les derniers 6 mois, c’est même 0,3% (670 à comparer à ~200 000). Et c’est en décroissance.

Le graphique suivant montre l’évolution de la proportion des plans avec des clés dupliquées en fonction du temps.

Ratio of invalid JSON plans per month

Politique de rétention des données

Nous conservons les plans que les utilisateurs envoient sur explain.dalibo.com, sauf s’ils les suppriment. Ceci uniquement dans le seul but d’améliorer PEV2 et notre connaissance de PostgreSQL, comme nous avons pu le voir dans cet article.

Si la confidentialité de vos requêtes est importante pour vous, vous pouvez toujours installer l’application Flask application sur votre réseau ou utiliser la version autonome pev2.html qui peut être utilisée localement sur votre navigateur.

Conclusion

Un petit nombre de plans envoyés sur explain.dalibo.com semble généré sur des clusters qui utilisent encore une version « buguée » de PostgreSQL, seulement moins de 3 pour 1000 ces derniers temps et il y en a de moins en moins. Cela donne une idée de la proportion de clusters pre-PG14 dans l’écosystème PostgreSQL, bien que beaucoup d’autres puissent être utilisés dans des cas où des optimisations ne sont plus tentées. Partant de ce constat, du fait que seulement une petite partie des informations sont perdues avec une analyse “standard” du JSON, et que PostgreSQL v13 sera en fin de support communautaire d’ici fin 2025, je pense qu’il est prudent de supprimer le support des clés dupliquées dans PEV2, et donc de supprimer la dépendance sur la bibliothèque tierce.


DALIBO

DALIBO est le spécialiste français de PostgreSQL®. Nous proposons du support, de la formation et du conseil depuis 2005.