Skip to main content
Jonathan Andrei
Retour aux billets
Août 202612 min de lecture

Python ne peut pas prendre une weakref sur un ValueError. La déduplication de Sentry l'a appris à ses dépens : 1024 Ko par tâche asyncio vivante.

La DedupeIntegration de sentry-python empêche qu'une même erreur soit rapportée deux fois en se souvenant de la dernière exception. Se souvenir d'une exception est exactement ce qu'il ne faut pas faire : une exception détient sa traceback, une traceback détient ses frames, un frame détient chaque local. Le SDK le savait et stockait un weakref.ref(exc). Les trois lignes sous le commentaire `# we can only weakref non builtin types` font la seule chose que la weakref existait pour empêcher : un `except TypeError:` attrape le refus de Python de prendre une weakref sur un builtin et retient silencieusement l'exception. Sous asyncio, chaque tâche garde sa propre copie dans un ContextVar, donc 200 sessions de longue durée épinglent 205 Mo, toutes atteignables après gc.collect(). Deux tentatives avant le correctif — une empreinte par valeur qui échoue aux propres tests du SDK, une empreinte basée sur id qui écrase 2000 erreurs distinctes en 2 clés — puis l'approche du jeton d'identité qui garde le comportement inchangé et fait tomber la rétention à 0,5 Mo.

BugSmashClearTheLineupSentryPythonasyncioweakrefContextVarMemory LeakOpenSourceShowDev
J'ai créé ce billet et le correctif pour DEV's Summer Bug Smash: Clear the Lineup, propulsé par Sentry. #bugsmash
La revendication en une phrase : la DedupeIntegration de sentry-python retient 1024 Ko par tâche asyncio vivante parce que Python ne peut pas prendre une weakref sur une exception builtin, et le fallback du SDK retient l'exception elle-même. Mesuré sur master sur 200 sessions de longue durée détenant 1 Mo chacune : 205,5 Mo retenus, 200 / 200 sessions encore atteignables après gc.collect(). Après le correctif (attacher un jeton d'identité weakréférençable à l'exception, prendre une weakref sur le jeton) : 0,5 Mo, 0 / 200. Le comportement de déduplication est inchangé — le jeton est un-par-exception et vit précisément aussi longtemps que l'exception, donc `last_seen is token` équivaut au précédent `last_seen is exc`.
Capture d'écran de sentry_sdk/integrations/dedupe.py montrant un `except TypeError:` nu qui stocke l'exception elle-même quand weakref.ref(exc) lève. Le commentaire sur la ligne juste au-dessus dit `# we can only weakref non builtin types`.
Le propre commentaire du SDK raconte ce qui est sur le point de mal tourner. `weakref.ref(exc)` lève TypeError quand exc est un builtin (ValueError, KeyError, TypeError — celles que presque chaque vraie erreur est en pratique), et le fallback retient l'exception elle-même. C'est la seule ligne que la weakref existait pour empêcher.

Pourquoi asyncio fait mal

`_last_seen` est un `ContextVar`. Sous asyncio, chaque tâche obtient sa propre copie de contexte. Donc ce n'est pas une exception retenue par processus. C'est une exception retenue par tâche vivante, chacune traînant sa traceback et chaque local de frame atteignable depuis elle. Le rapporteur de l'issue #6094 voyait ça dans un crawler web asyncio où les frames détenaient des corps de réponse entre 500 Ko et 1 Mo.

Mon premier instinct a été d'appeler ça une fuite non bornée. J'ai construit un pool de workers, je l'ai lancé, et il est resté plat. Ça valait la peine de le découvrir avant de l'écrire quelque part. Un pool fixe ne grandit pas, parce que la prochaine erreur de chaque worker écrase la précédente. La rétention est bornée à (tâches vivantes × payload). L'histoire de croissance n'est pas les erreurs dans le temps, c'est les tâches dans le temps : une tâche par session, par abonnement, par connexion. Donc j'ai mesuré contre le nombre de tâches vivantes à la place.

Sortie terminal intitulée `master`, 200 sessions de longue durée, 1 Mo chacune, sentry-sdk 2.68.0, payload 1000 Ko par session. Tableau : 25 tâches vivantes → 26,2 Mo retenus, 1047 Ko par tâche, 25/25 sessions épinglées. 50 → 52,1 Mo, 1042 Ko, 50/50. 100 → 103,1 Mo, 1031 Ko, 100/100. 200 → 205,5 Mo, 1028 Ko, 200/200. En dessous : `growth per additional live task: 1025 KB` et en rouge `LEAKING: sessions remain reachable after gc`.
1024 Ko retenus par tâche vivante, tout droit, et `sessions pinned` compte les weakrefs vers des objets session encore atteignables après gc.collect(). Pas un artefact d'échantillonnage. Le garbage collector ne peut pas les toucher parce qu'un ContextVar vivant pointe encore sur chacune. À 200 sessions concurrentes c'est 205 Mo qui ne reviennent jamais.

Le correctif que j'ai raté d'abord

Les mainteneurs avaient déjà rejeté le correctif évident. Le rapporteur proposait de sauter la dédup pour les builtins, et Sentry a refusé, disant vouloir « une approche de fingerprinting plus robuste qui peut aussi être utilisée pour les exceptions builtin » à la place. Donc j'ai proposé une empreinte : type d'exception, message, et le frame d'origine de la traceback. Toutes primitives immuables, rien de retenu. Je l'ai posté sur l'issue. Puis la suite de tests existante m'a dit que j'avais tort, de deux façons différentes.

Sortie terminal des deux cas de test que l'approche par empreinte de valeur échoue. `test_breadcrumbs` appelle `capture_exception(ValueError())` deux fois avec des exceptions distinctes jamais levées — empreintes identiques, deuxième événement mal supprimé. `test_option_before_breadcrumb` lève trois `ValueError("aha!")` distincts depuis la même ligne — trois empreintes identiques, deux événements mal dédupliqués.
C'est le défaut de l'idée entière. Une empreinte par valeur ne peut pas distinguer « le même objet exception deux fois » de « la même erreur levée à répétition depuis la même ligne ». La première doit dédupliquer. La seconde non. Aucune empreinte ne les distingue, parce que par valeur elles sont identiques.

La tentative qui a précédé la mienne

Tardivement, je suis allé fouiller dans la liste des branches du fork et j'ai trouvé deux branches mainteneur abandonnées de septembre 2025 : `antonpirker/dedupe-integration-memory-usage` et `antonpirker/make-dedupe-integration-more-memory-efficient`. Aucune n'a été mergée. La première fait une empreinte sur `(type_module, type_name, id(exc_value))`. Ce `id()` est la partie intéressante. Une fois que vous cessez de retenir l'exception, ce qui est tout l'intérêt du changement, son adresse devient immédiatement réutilisable, et CPython réutilise les adresses agressivement.

Sortie terminal modélisant l'empreinte basée sur id sur 2000 ValueError distincts alloués séquentiellement : exceptions distinctes créées 2000, empreintes distinctes 2, collisions de réutilisation d'adresse 1998. Chaque collision est une erreur distincte qui serait traitée comme un doublon et supprimée.
2000 erreurs distinctes s'effondrent en deux empreintes. Chaque collision est une vraie erreur supprimée comme doublon. L'approche du jeton d'identité évite ça par construction : le jeton est un vrai objet dont la durée de vie est liée à l'exception, donc il ne peut pas être confondu avec un objet ultérieur à la même adresse.

Le correctif qui a été livré

Garder l'identité. Cesser de tenir l'objet pour l'exprimer. Attacher un petit `_DedupeToken` weakréférençable à l'exception (un par exception, vivant précisément aussi longtemps que l'exception), et prendre une weakref sur le jeton plutôt que sur l'exception. `last_seen is token` équivaut au précédent `last_seen is exc`, donc le comportement de déduplication est inchangé — ce qui compte étant donné la demande explicite du fil d'issue de ne pas changer le comportement avant la refonte du fingerprinting. Attacher un attribut privé `_sentry_*` sur un objet utilisateur pour porter une weakref suit un précédent existant dans ce SDK : `sentry_sdk/integrations/django/__init__.py` définit `_sentry_drf_request_backref = weakref.ref(...)` sur la requête.

Diff de code en deux blocs intitulé « The fix. Keep identity. Stop holding the object to express it. » Haut : `class _DedupeToken: __slots__ = ("__weakref__",)`. Bas : `token = _identity_token(exc); if token is not None: new_last_seen = weakref.ref(token); is_duplicate = last_seen is token`.
Tout le correctif. Un jeton de cinq octets de slots existe juste pour être weakréférençable pour que le ContextVar cesse de tenir la traceback. Le comportement est inchangé ; le ContextVar ne garde plus un local de frame vivant.
Sortie terminal intitulée `patched`, mêmes 200 sessions de longue durée de 1 Mo chacune, sentry-sdk avec le correctif du jeton d'identité appliqué. Tableau : 25 tâches vivantes → 0,6 Mo retenus, 0/25 épinglés. 50 → 0,9 Mo, 0/50. 100 → 0,7 Mo, 0/100. 200 → 0,5 Mo, 0/200.
Mêmes deux cents sessions. Deux mégaoctets au total, rien d'épinglé. La rétention ne monte plus avec le nombre de tâches vivantes.

Le propre Seer de Sentry sur le sujet

Puis j'ai lancé le propre Seer de Sentry dessus. Il a trouvé la cause racine exactement, y compris que les ContextVars sont par tâche sous asyncio, ce qui m'avait pris un tour de débogage à démêler moi-même. Il a lu la source du SDK pour le faire. Puis il a proposé de stocker le type et l'id de l'exception. Le même piège. Son correctif aurait supprimé chaque erreur distincte comme un doublon.

Capture de la sortie de cause racine de Sentry Seer sur l'issue #6094, identifiant correctement que _last_seen est un ContextVar qui garde une référence forte par tâche vers l'exception quand weakref.ref échoue sur les builtins, retenant la traceback et ses locals de frame pour la durée de vie de la tâche.
La cause racine de Seer est exacte. Son patch proposé est faux pour la même raison que la tentative 2 : id() est réutilisé, donc 2000 erreurs distinctes s'effondrent en 2 empreintes. À noter les deux ensemble : le diagnostic et le patch ne sont pas le même problème, et réussir l'un ne vous donne pas l'autre.

Un dernier détail qui mérite d'être nommé, parce que c'est la partie qui m'a convaincu que les PR de Seer ont besoin de la même revue qu'une PR humaine. Le plan en prose de Seer proposait l'empreinte type + id (qui écrase les erreurs distinctes comme montré plus haut). Son patch réellement généré faisait autre chose, et pire : il enveloppait le `weakref.ref(exc)` défaillant dans `except TypeError: pass`, écartant silencieusement chaque exception builtin de la déduplication entièrement. Dans le test de tuple d'identité que j'ai fait tourner sur les deux variantes, ce même motif de collision d'adresse aurait fait que 1999 sur 2000 (100,0 %) des erreurs distinctes dans une session soient mal dédupliquées. La prose qu'il vous montre pour revue n'est pas nécessairement le diff qu'il écrit.

Les tests que j'ai ajoutés

Cinq nouveaux tests dans `tests/integrations/dedupe/test_dedupe.py`, chacun écrit pour attraper une régression spécifique que les deux tentatives abandonnées auraient introduite. `test_dedupe_does_not_retain_builtin_exception` prend une weakref sur le jeton et vérifie que l'exception elle-même est collectable par gc après retour du frame. `test_dedupe_still_dedupes_builtin_exception` vérifie que le correctif ne casse pas le cas qui motive l'intégration au départ — le même objet exception attrapé et re-rapporté doit toujours dédupliquer. `test_dedupe_distinguishes_equal_builtin_exceptions` est la régression de l'empreinte par valeur : deux instances `ValueError('same')` levées depuis la même ligne ne doivent pas se dédupliquer l'une l'autre. `test_dedupe_leaves_unraised_exception_untouched` vérifie que l'attachement du jeton ne mute pas les exceptions que l'intégration ne voit jamais. `test_dedupe_survives_exotic_exception_dict` couvre le seul caprice de Python qui pourrait casser l'approche par jeton : une sous-classe d'exception avec `__dict__ = None`. Dans ce cas `_identity_token` retourne None et l'intégration décline silencieusement la déduplication au lieu de crasher — un chemin fail-safe que la revue de Gemini a signalé et pour lequel j'ai écrit un test.

Le deuxième regard de Gemini sur mon patch

Avant d'ouvrir la PR, j'ai passé le diff dans Gemini en mode revue adversariale : « trouve toutes les façons dont ça casse ». Il a suggéré six modes de défaillance. Deux étaient de vrais crashs auxquels je n'avais pas pensé (le cas `__dict__ = None` ci-dessus, et un où l'attachement du jeton pouvait provoquer une race dans un contexte threadé — j'ai résolu ça en rendant l'attachement idempotent). Un a été réfuté en lisant le propre précédent du SDK pour les attributs privés `_sentry_*` sur les objets utilisateur. Les trois restants (scrubbers PII enlevant le jeton, exceptions qui redéfinissent `__setattr__`, callbacks de weakref se déclenchant pendant l'arrêt) étaient tous des cas où l'intégration retombe sur ne-pas-dédupliquer, ce qui est fail-safe : le pire cas est un événement en double, pas un crash, pas une fuite mémoire, pas une suppression erronée. J'ai ajouté des tests pour les deux vrais et documenté le chemin fail-safe pour les trois autres. C'est le motif que je veux avec la revue AI : elle fait remonter des cas, je décide lesquels sont réels, j'écris le test.

Ce que je retiens de ce bug : la prose qu'une IA vous montre pour revue n'est pas nécessairement le diff qu'elle écrit, et le diagnostic et le patch sont deux problèmes différents. Seer a trouvé la cause racine exactement et a ensuite écrit un patch qui aurait silencieusement écarté chaque exception builtin. La revue adversariale de Gemini était l'inverse : elle a fait remonter six cas, dont trois étaient réels, et m'a donné des tests à écrire. Le vérificateur reste humain. Ce qui change, c'est le débit.
Projet associé

sentry-python DedupeIntegration Retained 1024 KB per Live Asyncio Task Because Python Cannot Weakref a ValueError. Root-Caused, Fixed with a Weak-Referenceable Identity Token, Repro'd, Measured, and Prepared as an Upstream PR to Fix Issue #6094.

Voir le projet