[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6CSW00BB928WS9QM2C55CH5":3,"skill-same:01M6CSW00BB928WS9QM2C55CH5":160},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":34,"publishedAt":56,"linkOnly":57,"markdownContent":58,"markdownBody":59,"frontMatter":60,"refName":125,"commitSha":126,"skillDirectory":127,"forksCount":14,"repositoryHtmlUrl":128,"repositoryHomepageUrl":6,"ownerHtmlUrl":129,"ownerBlogUrl":6,"ownerDescription":130,"updatedAt":131,"install":132,"resources":145},"01M6CSW00BB928WS9QM2C55CH5","volontariapp-trace-async-flow",null,"Diagnostiquer un flux asynchrone qui ne produit pas son effet (job, événement, saga, notification WebSocket) : cartographie avec analyze_impact puis requêtes SQL sur jobs_outbox, job_audit, event_queue et inspection des Redis Streams et de leur DLQ. À utiliser pour un bug runtime, pas pour implémenter un flux.","Volontariapp\u002Fmeta","Volontariapp","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F263673138?v=4","01M6CSQNQ3J4SKRWA512JQF5K7","VolontariApp","01M6CSQNV8T2M4PR5A22DKHY5T",0,"MIT","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-trace-async-flow\u002FSKILL.md",[18,23,26,31],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000010","后端与接口","SCENARIO","使用场景",{"categoryNo":24,"categoryName":25,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000023","Skill与智能体开发",{"categoryNo":27,"categoryName":28,"taxonomyType":29,"taxonomyTypeDesc":30},"01JBSEEDCAT000000000000030","开发工程师","PROFESSION","适用职业",{"categoryNo":32,"categoryName":33,"taxonomyType":29,"taxonomyTypeDesc":30},"01JBSEEDCAT000000000000033","数据分析师",[35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55],"volontariapp trace async flow","flow","trace","async","inspection","dispatcher","impact","streams","retry","types","processing","ne","shared","packages","processor","analyze","common","code","database","ms","npm-packages","2026-11-10 10:12:30",false,"---\nname: volontariapp-trace-async-flow\ndescription: \"Diagnostiquer un flux asynchrone qui ne produit pas son effet (job, événement, saga, notification WebSocket) : cartographie avec analyze_impact puis requêtes SQL sur jobs_outbox, job_audit, event_queue et inspection des Redis Streams et de leur DLQ. À utiliser pour un bug runtime, pas pour implémenter un flux.\"\ntype: Agent Skill\ntitle: Trace Async Flow\ntags: [async, debug, sql, outbox]\nstatus: stable\npaths:\n  - \"ms-*\u002Fsrc\u002Fmigrations\u002Fcommon\u002F**\"\n  - \"npm-packages\u002Fpackages\u002Fdatabase\u002Fsrc\u002Foutbox\u002F**\"\n  - \"npm-packages\u002Fpackages\u002Fpost-processors\u002Fsrc\u002Fcommon\u002F**\"\n  - npm-packages\u002Fpackages\u002Fpost-processors\u002Fsrc\u002Fcore\u002Fhelpers\u002Fretry.helper.ts\nmesh_keys:\n  - jobs_outbox\n  - job_audit\n  - event_queue\ngenerated:\n  by: claude-code\u002Fclaude-opus-5-5\n  at: \"2026-10-06T08:22:12Z\"\nsources:\n  - id: migrations\n    resource: ms-user\u002Fsrc\u002Fmigrations\u002Fcommon\n    title: \"Migrations communes (jobs_outbox, job_audit, event_queue, triggers)\"\n  - id: status\n    resource: npm-packages\u002Fpackages\u002Fdatabase\u002Fsrc\u002Foutbox\u002Ftypes\u002Foutbox.status.ts\n    title: OutboxStatus\n  - id: consumer\n    resource: npm-packages\u002Fpackages\u002Fdatabase\u002Fsrc\u002Foutbox\u002Fconsumers\u002Foutbox.consumer.ts\n    title: \"OutboxConsumer (FOR UPDATE SKIP LOCKED, PROCESSING)\"\n  - id: dispatcher\n    resource: npm-packages\u002Fpackages\u002Fdatabase\u002Fsrc\u002Foutbox\u002Fdispatchers\u002Foutbox.dispatcher.ts\n    title: OutboxDispatcher (COMPLETED après push)\n  - id: streams\n    resource: npm-packages\u002Fpackages\u002Fshared\u002Fsrc\u002Fenums\u002Fstreams.enum.ts\n    title: Enum Streams\n  - id: retry\n    resource: npm-packages\u002Fpackages\u002Fpost-processors\u002Fsrc\u002Fcore\u002Fhelpers\u002Fretry.helper.ts\n    title: \"RetryHelper (DLQ \u003Cstream>-dlq)\"\n  - id: pp-failed\n    resource: npm-packages\u002Fpackages\u002Fpost-processors\u002Fsrc\u002Fcommon\u002Fjob-outbox-failed.post-processor.ts\n    title: JobOutboxFailedPostProcessor (bug P4)\n  - id: panne\n    resource: docs\u002Fstockage-fichiers\u002F11-scenarios-de-panne.md\n    title: \"Scénarios de panne, problèmes P2 et P4\"\nverified:\n  - by: claude-code\u002Fagent\n    at: \"2026-11-04T18:28:32Z\"\n    digest: 1384b4fa258ad16f\n  - by: claude-code\u002Fagent\n    at: \"2026-11-04T20:07:52Z\"\n    digest: de92aaea9328081b\n  - by: claude-code\u002Fagent\n    at: \"2026-11-04T21:24:17Z\"\n    digest: c5dec9dcd6763cd3\n  - by: claude-code\u002Fagent\n    at: \"2026-11-08T12:02:24Z\"\n    digest: e6d2dea0a9ade67e\n  - by: claude-code\u002Fagent\n    at: \"2026-11-08T20:59:18Z\"\n    digest: e6d2dea0a9ade67e\n---\n\n# Trace Async Flow & Diagnostic Runtime\n\nAucun microservice n'écrit dans Redis : tout effet asynchrone passe par une table outbox PostgreSQL de la base du service, puis par un runner. Un effet qui ne se produit pas s'est arrêté à l'un des maillons ci-dessous ; le diagnostic consiste à trouver lequel, dans l'ordre.\n\n## 1. Les deux chaînes\n\n```\nJOB (1:1)\nms-\u003Cd> ── jobs_outbox (PENDING) ── outbox-\u003Cd> : PROCESSING puis push BullMQ, COMPLETED\n       ── worker-\u003Cd> : job_audit PROCESSING puis COMPLETED | FAILED\n       ── trigger notify_job_audit_status_change : event_queue type \u003Cd>:job:outbox:success | failure\n       ── outbox-\u003Cd> : push Redis Stream \u003Cd>:job:outbox:success | failure\n       ── JobOutboxSuccessPostProcessor : DELETE jobs_outbox  (failure : bug P4, voir section 4)\n\nÉVÉNEMENT (1:N)\ndomain-\u003Cd> repository (EventQueueEntity.createEvent, même transaction)\n  ou trigger SQL de domaine (create_\u003Ctopic>_event_queue_record, CDC de domain-event)\n       ── event_queue (PENDING) ── outbox-\u003Cd> : push Redis Stream nommé dans target_services\n       ── post-processors (post-processors-runner, ws-service) : XREADGROUP, retry, DLQ \u003Cstream>-dlq\n```\n\n`event_queue` ne reçoit donc pas que l'audit des jobs : c'est aussi la table des événements de domaine[^migrations]. La table s'appelle `event_queue` (jamais `event_outbox`, nom resté dans plusieurs README).\n\n## 2. Étape statique : cartographier avec mesh-mcp\n\n```json\nanalyze_impact({ \"target\": \"USER_CREATED\" })            \u002F\u002F producteurs, topic, consommateurs, sagas\nanalyze_impact({ \"target\": \"FALLBACK_UPDATE_USER\" })    \u002F\u002F job : controller producteur et handler\nanalyze_impact({ \"target\": \"EVENT_CREATED\", \"depth\": 2 })  \u002F\u002F suit les réémissions (lignes heuristic)\n```\n\n« No producer resolved » n'est pas une preuve d'absence pour un événement émis via une table de correspondance (gather de `ws-service`) ou un stream construit en SQL (`\u003Cd>:job:outbox:*`) : vérifier par un `rg` ciblé sur un repo.\n\n## 3. Étape runtime : requêtes SQL, base du microservice concerné\n\nStatuts : `PENDING`, `PROCESSING`, `COMPLETED`, `FAILED` pour les trois tables[^status]. Colonnes camelCase entre guillemets (`\"lastError\"`, `\"emitterId\"`, `\"traceId\"`).\n\n```sql\n-- 1. Le job est-il sorti de l'outbox ?  PROCESSING bloqué = runner mort entre pull et push\nSELECT id, type, target, status, attempts, \"lastError\", created_at, updated_at\nFROM jobs_outbox WHERE type = '\u003Ctype>' ORDER BY created_at DESC LIMIT 20;\n\n-- 2. Le worker l'a-t-il pris ?  aucune ligne = job jamais consommé (queue, worker arrêté)\nSELECT job_id, job_type, status, worker_id, current_attempt, error_message, started_at, finished_at\nFROM job_audit WHERE job_id = '\u003Cjobs_outbox.id>';\n\n-- 3. L'événement est-il parti ?  PENDING qui vieillit = outbox-\u003Cd> arrêté ou Redis injoignable\nSELECT id, type, status, target_services, attempts, \"lastError\", correlation_id, processed_at\nFROM event_queue WHERE type = '\u003Ctype>' ORDER BY created_at DESC LIMIT 20;\n```\n\n| Observation | Maillon en cause |\n| :--- | :--- |\n| `jobs_outbox` en `PENDING` qui vieillit | `outbox-\u003Cd>` arrêté ou ne voit pas la base |\n| `jobs_outbox` en `COMPLETED`, aucune ligne `job_audit` | Job dans BullMQ mais non consommé : `worker-\u003Cd>` arrêté, mauvaise queue (`target`) |\n| `job_audit` en `FAILED` | Erreur du handler : `error_message`, logs de `worker-\u003Cd>` |\n| `job_audit` en `COMPLETED`, `jobs_outbox` toujours présent | Le `DELETE` de `JobOutboxSuccessPostProcessor` n'a pas eu lieu : stream `\u003Cd>:job:outbox:success` ou post-processor commun |\n| `event_queue` en `COMPLETED`, aucun effet | Consommateur : groupe du stream, filtre `shouldProcess`, DLQ |\n\n`jobs_outbox` passe `COMPLETED` dès le push vers BullMQ[^dispatcher], pas après l'exécution : seul `job_audit` dit si le job a tourné.\n\n**Fenêtre d'historique : un jour.** Depuis `@volontariapp\u002Fdatabase` 3.13.0 (déployé le 2026-11-08), `OutboxRunner` supprime les lignes `COMPLETED` de `event_queue`, `jobs_outbox` et `job_audit` plus vieilles qu'un jour (réglable par `OutboxRunnerConfig.purge`). Une requête sur un événement ou un job terminé plus ancien ne trouvera rien : « aucune ligne » veut alors dire « purgée », pas « jamais émis ». Les lignes `PENDING`, `PROCESSING` et `FAILED` sont conservées, donc une ligne coincée reste visible (en novembre 2026, 2 `PROCESSING` et 4 `FAILED` du 7 novembre 15:36 étaient toujours dans `event_queue` de `ms-ranking`, non investiguées). Avant cette purge, `ms-ranking` accumulait environ 248 000 lignes `COMPLETED` par table, y compris dans `jobs_outbox` alors que `JobOutboxSuccessPostProcessor` devrait les supprimer : la raison n'a pas été cherchée.\n\n## 4. Redis Streams et pièges connus\n\n```bash\nredis-cli XINFO GROUPS \u003Cstream>           # groupes, lag, pending\nredis-cli XPENDING \u003Cstream> \u003Cgroupe>      # messages réclamés non acquittés\nredis-cli XRANGE \u003Cstream>-dlq - + COUNT 20  # messages abandonnés après maxRetries\n```\n\n- **Noms des streams** : valeurs de l'enum `Streams` (`@volontariapp\u002Fshared`)[^streams], conventions mélangées (`user:created`, `post-created`, `event:successfully_created`, `ws:event-created-feedback`). Le README de `post-processors-runner` (`stream:ms-user`...) est faux.\n- **DLQ** : après `maxRetries` tentatives, le message part dans `\u003Cstream>-dlq` et il est acquitté[^retry]. Aucun rejeu automatique : un événement en DLQ est perdu pour son consommateur (problème P2)[^panne].\n- **Bug P4** : le trigger émet `\u003Cd>:job:outbox:failure` mais `JobOutboxFailedPostProcessor` n'accepte que `:job:outbox:failed`[^pp-failed]. Aucun feedback d'échec de job n'existe aujourd'hui : un job `FAILED` reste silencieux côté client.\n- **Saga** : un échec de création d'événement passe `saga_status` à `CANCEL` (post-processor-event) ; un post en échec reste `PENDING`, aucun consommateur ne le compense.\n\n### Contexte de trace des lignes outbox (`traceparent`)\n\n- `jobs_outbox.traceparent` et `event_queue.traceparent` (`varchar(55)` NULL, migration commune `1791900000000-AddTraceparentToJobsOutboxAndEventQueue`) contiennent le contexte W3C (`00-\u003Ctrace-id>-\u003Cspan-id>-\u003Cflags>`) capturé par `captureTraceparent()` (`@volontariapp\u002Fdatabase`) dans `EventQueueEntity.createEvent` et `JobsOutboxEntity.createJob`. `traceId` (uuid) est l'ancien champ, conservé.\n- NULL est normal : ligne écrite par un trigger SQL (dont `notify_job_audit_status_change`), tâche planifiée, ou tracing désactivé. Ne pas y voir un bug d'émission.\n- Transport : champ `traceparent` du stream Redis (chaîne vide si absent) et dans le JSON `event` ; champ `traceparent` de `JobEnvelope` dans les données du job BullMQ. Les consommateurs doivent traiter `''` et l'absence (messages antérieurs) comme « pas de contexte ».\n- Un événement déclenché par un autre hérite du `traceparent` de la source via `EventQueueEntity.createFollowUp` (comme `traceId` et `correlationId`) ; `correlationId` sert au Scatter-Gather de `ws-service`, `traceparent` à la trace Datadog, les deux n'ont pas le même rôle.\n- Restauration du contexte (tâche 2.11) : `BaseWorker.process` exécute le job dans un span `job \u003Ctype>` enfant du `traceparent` de `JobEnvelope` ; `SinglePostProcessor` ouvre un span `event \u003Ctype>` enfant du `traceparent` de l'événement ; `BatchPostProcessor` ouvre un span par message (chacun dans la trace de son message) plus un span `batch \u003Cgroupe>` qui les référence par liens et qui est actif pendant `processEvents`. Helpers `runInTraceContext` \u002F `runBatchInTraceContext` de `@volontariapp\u002Fdatabase`, tracer `@volontariapp\u002Fasync`, spans `CONSUMER`, attributs `type`, `emitter`, `correlation_id` (absent pour un job : `JobEnvelope` n'a pas de `correlationId`). Dans un lot, les spans de message durent autant que le lot (le handler reçoit tous les messages en un appel). Sans `traceparent` (message antérieur, trigger SQL) : nouvelle trace. Jusqu'à `@volontariapp\u002Fdatabase` 3.10.13, un `createFollowUp` appelé dans le handler recopie le `traceparent` de la source, pas celui du span courant, et les pushers recopient celui de la ligne : tous les sauts asynchrones d'une requête sont alors des enfants directs du même span (observé en prod le 2026-10-29 sur `POST \u002Fapi\u002Fv1\u002Fevents` : 14 sauts `Producer`\u002F`Consumer` enfants du span gRPC `ms-event`), donc la Flow Map Datadog dessine une étoile `ms-event -> chaque runner` et non la chaîne `outbox -> post-processor -> ws-service`. Correctif en cours (branche `fix\u002Ftrace-parent-child-async-hops` de `npm-packages`, non publié au 2026-10-29) : `continueTraceparent(origin)` renvoie le span actif s'il est dans la même trace que `origin`, sinon `origin` ; `EventQueuePusher`, `JobsOutboxPusher` et `createFollowUp` l'utilisent. Un `BatchPostProcessor` ouvre son span `batch` comme enfant du span du premier message (correctif `@volontariapp\u002Fdatabase` 3.11.1, publié le 2026-10-29) : le travail du lot (appels, écritures, événements de suite, jobs) reste dans la trace de ce premier message, et `continueTraceparent` chaîne dessus ; les autres messages du lot gardent leur span et un lien, et leurs événements de suite retombent sur le `traceparent` d'origine (frères). Avant 3.11.1, le span `batch` était une racine d'une autre trace.\n\n### Configuration du tracing (`initTracing`, `@volontariapp\u002Fmonitoring` 2.2.0)\n\n- Échantillonnage `ParentBased(TraceIdRatioBased)` : option `sampleRatio` ou `OTEL_TRACES_SAMPLER_ARG` (0 à 1, défaut 1 ; valeur invalide : retour à 1 avec avertissement). Seules les traces racines sont tirées ; une saga asynchrone suit la décision de son parent, donc une trace écartée à la gateway reste écartée dans les workers et post-processors. Le défaut reste à fixer avec les quotas Datadog (ticket 0.4 : quotas non documentés).\n- Attribut `deployment.environment` : option `environment` ou `DD_ENV`, absent sinon (jamais déduit de `NODE_ENV`). `DD_ENV` n'est injecté ni dans `deploy` (YAML) ni dans `ci-tools\u002Fdocker-compose.yml` : à ajouter dans les manifests pour que l'attribut apparaisse.\n- SIGTERM : le SDK est vidé sans `process.exit`. Si un autre listener SIGTERM existe au moment du signal (NestJS `enableShutdownHooks`) il termine le processus, sinon le signal est renvoyé après le vidage. Piège : un seul listener SIGTERM sans `exit` fige le processus (Node n'applique plus l'arrêt par défaut) ; au 2026-10-13 seuls `ws-service` appelle `enableShutdownHooks`, pas les autres services.\n\n## 5. Règles\n\n- [ ] Ne jamais supposer qu'un service pousse directement dans Redis : partir de la table outbox de sa base.\n- [ ] Toujours `analyze_impact` avant d'ouvrir un runner, puis SQL, puis Redis, dans cet ordre.\n- [ ] Les runners `outbox-*` (Node pur, sans NestJS) n'ont pas de logique propre : tout est dans `@volontariapp\u002Foutbox` et `@volontariapp\u002Fdatabase`. Une modification du polling doit garder `FOR UPDATE SKIP LOCKED` : livraison au moins une fois, sans double publication quand le runner est répliqué. Le lot réclamé est trié par `created_at` puis `id` (le `ORDER BY` du sous-select choisit le lot mais n'ordonne pas `UPDATE ... RETURNING`, d'où un tri côté code) : deux lignes écrites dans la même milliseconde sont ordonnées par `id`, pas par ordre d'écriture[^consumer].\n- [ ] Un diagnostic qui révèle un nouveau piège devient une leçon : `evolve.py learn --skill volontariapp-trace-async-flow --kind pitfall`.\n\n[^migrations]: Migrations communes (jobs_outbox, job_audit, event_queue, triggers)\n[^status]: OutboxStatus\n[^dispatcher]: OutboxDispatcher (COMPLETED après push)\n[^streams]: Enum Streams\n[^retry]: RetryHelper (DLQ \u003Cstream>-dlq)\n[^panne]: Scénarios de panne, problèmes P2 et P4\n[^pp-failed]: JobOutboxFailedPostProcessor (bug P4)\n[^consumer]: OutboxConsumer (FOR UPDATE SKIP LOCKED, PROCESSING)\n","# Trace Async Flow & Diagnostic Runtime\n\nAucun microservice n'écrit dans Redis : tout effet asynchrone passe par une table outbox PostgreSQL de la base du service, puis par un runner. Un effet qui ne se produit pas s'est arrêté à l'un des maillons ci-dessous ; le diagnostic consiste à trouver lequel, dans l'ordre.\n\n## 1. Les deux chaînes\n\n```\nJOB (1:1)\nms-\u003Cd> ── jobs_outbox (PENDING) ── outbox-\u003Cd> : PROCESSING puis push BullMQ, COMPLETED\n       ── worker-\u003Cd> : job_audit PROCESSING puis COMPLETED | FAILED\n       ── trigger notify_job_audit_status_change : event_queue type \u003Cd>:job:outbox:success | failure\n       ── outbox-\u003Cd> : push Redis Stream \u003Cd>:job:outbox:success | failure\n       ── JobOutboxSuccessPostProcessor : DELETE jobs_outbox  (failure : bug P4, voir section 4)\n\nÉVÉNEMENT (1:N)\ndomain-\u003Cd> repository (EventQueueEntity.createEvent, même transaction)\n  ou trigger SQL de domaine (create_\u003Ctopic>_event_queue_record, CDC de domain-event)\n       ── event_queue (PENDING) ── outbox-\u003Cd> : push Redis Stream nommé dans target_services\n       ── post-processors (post-processors-runner, ws-service) : XREADGROUP, retry, DLQ \u003Cstream>-dlq\n```\n\n`event_queue` ne reçoit donc pas que l'audit des jobs : c'est aussi la table des événements de domaine[^migrations]. La table s'appelle `event_queue` (jamais `event_outbox`, nom resté dans plusieurs README).\n\n## 2. Étape statique : cartographier avec mesh-mcp\n\n```json\nanalyze_impact({ \"target\": \"USER_CREATED\" })            \u002F\u002F producteurs, topic, consommateurs, sagas\nanalyze_impact({ \"target\": \"FALLBACK_UPDATE_USER\" })    \u002F\u002F job : controller producteur et handler\nanalyze_impact({ \"target\": \"EVENT_CREATED\", \"depth\": 2 })  \u002F\u002F suit les réémissions (lignes heuristic)\n```\n\n« No producer resolved » n'est pas une preuve d'absence pour un événement émis via une table de correspondance (gather de `ws-service`) ou un stream construit en SQL (`\u003Cd>:job:outbox:*`) : vérifier par un `rg` ciblé sur un repo.\n\n## 3. Étape runtime : requêtes SQL, base du microservice concerné\n\nStatuts : `PENDING`, `PROCESSING`, `COMPLETED`, `FAILED` pour les trois tables[^status]. Colonnes camelCase entre guillemets (`\"lastError\"`, `\"emitterId\"`, `\"traceId\"`).\n\n```sql\n-- 1. Le job est-il sorti de l'outbox ?  PROCESSING bloqué = runner mort entre pull et push\nSELECT id, type, target, status, attempts, \"lastError\", created_at, updated_at\nFROM jobs_outbox WHERE type = '\u003Ctype>' ORDER BY created_at DESC LIMIT 20;\n\n-- 2. Le worker l'a-t-il pris ?  aucune ligne = job jamais consommé (queue, worker arrêté)\nSELECT job_id, job_type, status, worker_id, current_attempt, error_message, started_at, finished_at\nFROM job_audit WHERE job_id = '\u003Cjobs_outbox.id>';\n\n-- 3. L'événement est-il parti ?  PENDING qui vieillit = outbox-\u003Cd> arrêté ou Redis injoignable\nSELECT id, type, status, target_services, attempts, \"lastError\", correlation_id, processed_at\nFROM event_queue WHERE type = '\u003Ctype>' ORDER BY created_at DESC LIMIT 20;\n```\n\n| Observation | Maillon en cause |\n| :--- | :--- |\n| `jobs_outbox` en `PENDING` qui vieillit | `outbox-\u003Cd>` arrêté ou ne voit pas la base |\n| `jobs_outbox` en `COMPLETED`, aucune ligne `job_audit` | Job dans BullMQ mais non consommé : `worker-\u003Cd>` arrêté, mauvaise queue (`target`) |\n| `job_audit` en `FAILED` | Erreur du handler : `error_message`, logs de `worker-\u003Cd>` |\n| `job_audit` en `COMPLETED`, `jobs_outbox` toujours présent | Le `DELETE` de `JobOutboxSuccessPostProcessor` n'a pas eu lieu : stream `\u003Cd>:job:outbox:success` ou post-processor commun |\n| `event_queue` en `COMPLETED`, aucun effet | Consommateur : groupe du stream, filtre `shouldProcess`, DLQ |\n\n`jobs_outbox` passe `COMPLETED` dès le push vers BullMQ[^dispatcher], pas après l'exécution : seul `job_audit` dit si le job a tourné.\n\n**Fenêtre d'historique : un jour.** Depuis `@volontariapp\u002Fdatabase` 3.13.0 (déployé le 2026-11-08), `OutboxRunner` supprime les lignes `COMPLETED` de `event_queue`, `jobs_outbox` et `job_audit` plus vieilles qu'un jour (réglable par `OutboxRunnerConfig.purge`). Une requête sur un événement ou un job terminé plus ancien ne trouvera rien : « aucune ligne » veut alors dire « purgée », pas « jamais émis ». Les lignes `PENDING`, `PROCESSING` et `FAILED` sont conservées, donc une ligne coincée reste visible (en novembre 2026, 2 `PROCESSING` et 4 `FAILED` du 7 novembre 15:36 étaient toujours dans `event_queue` de `ms-ranking`, non investiguées). Avant cette purge, `ms-ranking` accumulait environ 248 000 lignes `COMPLETED` par table, y compris dans `jobs_outbox` alors que `JobOutboxSuccessPostProcessor` devrait les supprimer : la raison n'a pas été cherchée.\n\n## 4. Redis Streams et pièges connus\n\n```bash\nredis-cli XINFO GROUPS \u003Cstream>           # groupes, lag, pending\nredis-cli XPENDING \u003Cstream> \u003Cgroupe>      # messages réclamés non acquittés\nredis-cli XRANGE \u003Cstream>-dlq - + COUNT 20  # messages abandonnés après maxRetries\n```\n\n- **Noms des streams** : valeurs de l'enum `Streams` (`@volontariapp\u002Fshared`)[^streams], conventions mélangées (`user:created`, `post-created`, `event:successfully_created`, `ws:event-created-feedback`). Le README de `post-processors-runner` (`stream:ms-user`...) est faux.\n- **DLQ** : après `maxRetries` tentatives, le message part dans `\u003Cstream>-dlq` et il est acquitté[^retry]. Aucun rejeu automatique : un événement en DLQ est perdu pour son consommateur (problème P2)[^panne].\n- **Bug P4** : le trigger émet `\u003Cd>:job:outbox:failure` mais `JobOutboxFailedPostProcessor` n'accepte que `:job:outbox:failed`[^pp-failed]. Aucun feedback d'échec de job n'existe aujourd'hui : un job `FAILED` reste silencieux côté client.\n- **Saga** : un échec de création d'événement passe `saga_status` à `CANCEL` (post-processor-event) ; un post en échec reste `PENDING`, aucun consommateur ne le compense.\n\n### Contexte de trace des lignes outbox (`traceparent`)\n\n- `jobs_outbox.traceparent` et `event_queue.traceparent` (`varchar(55)` NULL, migration commune `1791900000000-AddTraceparentToJobsOutboxAndEventQueue`) contiennent le contexte W3C (`00-\u003Ctrace-id>-\u003Cspan-id>-\u003Cflags>`) capturé par `captureTraceparent()` (`@volontariapp\u002Fdatabase`) dans `EventQueueEntity.createEvent` et `JobsOutboxEntity.createJob`. `traceId` (uuid) est l'ancien champ, conservé.\n- NULL est normal : ligne écrite par un trigger SQL (dont `notify_job_audit_status_change`), tâche planifiée, ou tracing désactivé. Ne pas y voir un bug d'émission.\n- Transport : champ `traceparent` du stream Redis (chaîne vide si absent) et dans le JSON `event` ; champ `traceparent` de `JobEnvelope` dans les données du job BullMQ. Les consommateurs doivent traiter `''` et l'absence (messages antérieurs) comme « pas de contexte ».\n- Un événement déclenché par un autre hérite du `traceparent` de la source via `EventQueueEntity.createFollowUp` (comme `traceId` et `correlationId`) ; `correlationId` sert au Scatter-Gather de `ws-service`, `traceparent` à la trace Datadog, les deux n'ont pas le même rôle.\n- Restauration du contexte (tâche 2.11) : `BaseWorker.process` exécute le job dans un span `job \u003Ctype>` enfant du `traceparent` de `JobEnvelope` ; `SinglePostProcessor` ouvre un span `event \u003Ctype>` enfant du `traceparent` de l'événement ; `BatchPostProcessor` ouvre un span par message (chacun dans la trace de son message) plus un span `batch \u003Cgroupe>` qui les référence par liens et qui est actif pendant `processEvents`. Helpers `runInTraceContext` \u002F `runBatchInTraceContext` de `@volontariapp\u002Fdatabase`, tracer `@volontariapp\u002Fasync`, spans `CONSUMER`, attributs `type`, `emitter`, `correlation_id` (absent pour un job : `JobEnvelope` n'a pas de `correlationId`). Dans un lot, les spans de message durent autant que le lot (le handler reçoit tous les messages en un appel). Sans `traceparent` (message antérieur, trigger SQL) : nouvelle trace. Jusqu'à `@volontariapp\u002Fdatabase` 3.10.13, un `createFollowUp` appelé dans le handler recopie le `traceparent` de la source, pas celui du span courant, et les pushers recopient celui de la ligne : tous les sauts asynchrones d'une requête sont alors des enfants directs du même span (observé en prod le 2026-10-29 sur `POST \u002Fapi\u002Fv1\u002Fevents` : 14 sauts `Producer`\u002F`Consumer` enfants du span gRPC `ms-event`), donc la Flow Map Datadog dessine une étoile `ms-event -> chaque runner` et non la chaîne `outbox -> post-processor -> ws-service`. Correctif en cours (branche `fix\u002Ftrace-parent-child-async-hops` de `npm-packages`, non publié au 2026-10-29) : `continueTraceparent(origin)` renvoie le span actif s'il est dans la même trace que `origin`, sinon `origin` ; `EventQueuePusher`, `JobsOutboxPusher` et `createFollowUp` l'utilisent. Un `BatchPostProcessor` ouvre son span `batch` comme enfant du span du premier message (correctif `@volontariapp\u002Fdatabase` 3.11.1, publié le 2026-10-29) : le travail du lot (appels, écritures, événements de suite, jobs) reste dans la trace de ce premier message, et `continueTraceparent` chaîne dessus ; les autres messages du lot gardent leur span et un lien, et leurs événements de suite retombent sur le `traceparent` d'origine (frères). Avant 3.11.1, le span `batch` était une racine d'une autre trace.\n\n### Configuration du tracing (`initTracing`, `@volontariapp\u002Fmonitoring` 2.2.0)\n\n- Échantillonnage `ParentBased(TraceIdRatioBased)` : option `sampleRatio` ou `OTEL_TRACES_SAMPLER_ARG` (0 à 1, défaut 1 ; valeur invalide : retour à 1 avec avertissement). Seules les traces racines sont tirées ; une saga asynchrone suit la décision de son parent, donc une trace écartée à la gateway reste écartée dans les workers et post-processors. Le défaut reste à fixer avec les quotas Datadog (ticket 0.4 : quotas non documentés).\n- Attribut `deployment.environment` : option `environment` ou `DD_ENV`, absent sinon (jamais déduit de `NODE_ENV`). `DD_ENV` n'est injecté ni dans `deploy` (YAML) ni dans `ci-tools\u002Fdocker-compose.yml` : à ajouter dans les manifests pour que l'attribut apparaisse.\n- SIGTERM : le SDK est vidé sans `process.exit`. Si un autre listener SIGTERM existe au moment du signal (NestJS `enableShutdownHooks`) il termine le processus, sinon le signal est renvoyé après le vidage. Piège : un seul listener SIGTERM sans `exit` fige le processus (Node n'applique plus l'arrêt par défaut) ; au 2026-10-13 seuls `ws-service` appelle `enableShutdownHooks`, pas les autres services.\n\n## 5. Règles\n\n- [ ] Ne jamais supposer qu'un service pousse directement dans Redis : partir de la table outbox de sa base.\n- [ ] Toujours `analyze_impact` avant d'ouvrir un runner, puis SQL, puis Redis, dans cet ordre.\n- [ ] Les runners `outbox-*` (Node pur, sans NestJS) n'ont pas de logique propre : tout est dans `@volontariapp\u002Foutbox` et `@volontariapp\u002Fdatabase`. Une modification du polling doit garder `FOR UPDATE SKIP LOCKED` : livraison au moins une fois, sans double publication quand le runner est répliqué. Le lot réclamé est trié par `created_at` puis `id` (le `ORDER BY` du sous-select choisit le lot mais n'ordonne pas `UPDATE ... RETURNING`, d'où un tri côté code) : deux lignes écrites dans la même milliseconde sont ordonnées par `id`, pas par ordre d'écriture[^consumer].\n- [ ] Un diagnostic qui révèle un nouveau piège devient une leçon : `evolve.py learn --skill volontariapp-trace-async-flow --kind pitfall`.\n\n[^migrations]: Migrations communes (jobs_outbox, job_audit, event_queue, triggers)\n[^status]: OutboxStatus\n[^dispatcher]: OutboxDispatcher (COMPLETED après push)\n[^streams]: Enum Streams\n[^retry]: RetryHelper (DLQ \u003Cstream>-dlq)\n[^panne]: Scénarios de panne, problèmes P2 et P4\n[^pp-failed]: JobOutboxFailedPostProcessor (bug P4)\n[^consumer]: OutboxConsumer (FOR UPDATE SKIP LOCKED, PROCESSING)\n",{"name":5,"tags":61,"type":65,"paths":66,"title":71,"status":72,"sources":73,"verified":102,"generated":118,"mesh_keys":121,"description":7},[38,62,63,64],"debug","sql","outbox","Agent Skill",[67,68,69,70],"ms-*\u002Fsrc\u002Fmigrations\u002Fcommon\u002F**","npm-packages\u002Fpackages\u002Fdatabase\u002Fsrc\u002Foutbox\u002F**","npm-packages\u002Fpackages\u002Fpost-processors\u002Fsrc\u002Fcommon\u002F**","npm-packages\u002Fpackages\u002Fpost-processors\u002Fsrc\u002Fcore\u002Fhelpers\u002Fretry.helper.ts","Trace Async Flow","stable",[74,78,82,86,89,92,94,98],{"id":75,"title":76,"resource":77},"migrations","Migrations communes (jobs_outbox, job_audit, event_queue, triggers)","ms-user\u002Fsrc\u002Fmigrations\u002Fcommon",{"id":79,"title":80,"resource":81},"status","OutboxStatus","npm-packages\u002Fpackages\u002Fdatabase\u002Fsrc\u002Foutbox\u002Ftypes\u002Foutbox.status.ts",{"id":83,"title":84,"resource":85},"consumer","OutboxConsumer (FOR UPDATE SKIP LOCKED, PROCESSING)","npm-packages\u002Fpackages\u002Fdatabase\u002Fsrc\u002Foutbox\u002Fconsumers\u002Foutbox.consumer.ts",{"id":40,"title":87,"resource":88},"OutboxDispatcher (COMPLETED après push)","npm-packages\u002Fpackages\u002Fdatabase\u002Fsrc\u002Foutbox\u002Fdispatchers\u002Foutbox.dispatcher.ts",{"id":42,"title":90,"resource":91},"Enum Streams","npm-packages\u002Fpackages\u002Fshared\u002Fsrc\u002Fenums\u002Fstreams.enum.ts",{"id":43,"title":93,"resource":70},"RetryHelper (DLQ \u003Cstream>-dlq)",{"id":95,"title":96,"resource":97},"pp-failed","JobOutboxFailedPostProcessor (bug P4)","npm-packages\u002Fpackages\u002Fpost-processors\u002Fsrc\u002Fcommon\u002Fjob-outbox-failed.post-processor.ts",{"id":99,"title":100,"resource":101},"panne","Scénarios de panne, problèmes P2 et P4","docs\u002Fstockage-fichiers\u002F11-scenarios-de-panne.md",[103,107,110,113,116],{"at":104,"by":105,"digest":106},"2026-11-04T18:28:32Z","claude-code\u002Fagent","1384b4fa258ad16f",{"at":108,"by":105,"digest":109},"2026-11-04T20:07:52Z","de92aaea9328081b",{"at":111,"by":105,"digest":112},"2026-11-04T21:24:17Z","c5dec9dcd6763cd3",{"at":114,"by":105,"digest":115},"2026-11-08T12:02:24Z","e6d2dea0a9ade67e",{"at":117,"by":105,"digest":115},"2026-11-08T20:59:18Z",{"at":119,"by":120},"2026-10-06T08:22:12Z","claude-code\u002Fclaude-opus-5-5",[122,123,124],"jobs_outbox","job_audit","event_queue","main","6b8f03b57018fa66da4666071dc17c1577ff45e8",".agents\u002Fskills\u002Fvolontariapp-trace-async-flow","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta","https:\u002F\u002Fgithub.com\u002FVolontariapp","Volontariapp — Application Web\u002FMobile scalabelle construite sur une architecture microservices événementielle (Event-Driven Architecture), implémentant le Trans","2026-11-10 23:40:22",{"source":8,"skillName":5,"commands":133,"prompt":143,"zipAvailable":144},[134,137,140],{"tool":135,"command":136},"npx","npx skills add Volontariapp\u002Fmeta --skill volontariapp-trace-async-flow",{"tool":138,"command":139},"bunx","bunx skills add Volontariapp\u002Fmeta --skill volontariapp-trace-async-flow",{"tool":141,"command":142},"pnpm","pnpm dlx skills add Volontariapp\u002Fmeta --skill volontariapp-trace-async-flow","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-trace-async-flow\u002FSKILL.md",true,[146,152,156],{"path":147,"resourceType":148,"resourceTypeDesc":149,"sizeBytes":150,"sourceHtmlUrl":151},"index.md","REFERENCE","参考文档",687,"https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-trace-async-flow\u002Findex.md",{"path":153,"resourceType":148,"resourceTypeDesc":149,"sizeBytes":154,"sourceHtmlUrl":155},"log.md",4122,"https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-trace-async-flow\u002Flog.md",{"path":157,"resourceType":148,"resourceTypeDesc":149,"sizeBytes":158,"sourceHtmlUrl":159},"references\u002Flessons.md",1987,"https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-trace-async-flow\u002Freferences\u002Flessons.md",[161,184,210,233,252,272],{"skillNo":162,"name":163,"nameZh":6,"description":164,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":165,"categories":166,"keywords":167,"publishedAt":56},"01M6CSW3Y5ZHVBF5BX7S5BAVS0","volontariapp-user-account-deletion","Suppression de compte en deux temps (PENDING_DELETION): requestDeletionWithUserDeleted, finalizeDeletion, fan-out de user.deleted vers les domaines et gather ws. A utiliser pour tout changement de domain-user touchant la suppression, le statut ou le cycle de vie d'un user.","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-user-account-deletion\u002FSKILL.md",[],[168,169,170,171,172,173,174,175,176,52,177,178,179,180,181,182,183,55,48],"volontariapp user account deletion","user","account deletion","deletion","cycle","gather","sonnet","pending","de","claude-code","reference","npm","user-account-deletion","src","en","domain",{"skillNo":185,"name":186,"nameZh":6,"description":187,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":188,"categories":189,"keywords":193,"publishedAt":56},"01M6CSVC1ZW30DDFZ8VMEYAAK6","volontariapp-proto-contract-evolution","Modifier un contrat Protobuf de proto-registry (champ, message, enum, RPC) sans casser la compatibilité wire, puis suivre la cascade proto-registry, PR automatique dans npm-packages, publication, consommateurs. À utiliser avant toute édition d'un .proto.","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-proto-contract-evolution\u002FSKILL.md",[190],{"categoryNo":191,"categoryName":192,"taxonomyType":29,"taxonomyTypeDesc":30},"01JBSEEDCAT000000000000037","律师与法务",[194,195,196,197,52,198,50,48,199,200,201,202,203,179,204,205,206,207,208,209],"evolution","volontariapp proto contract evolution","grpc","lint","workflows","rpc","ci","pr","protobuf","message","registry","sans","sync","yaml","playbook","github",{"skillNo":211,"name":212,"nameZh":6,"description":213,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":214,"categories":215,"keywords":217,"publishedAt":56},"01M6CSV99ET2Z9W4W4DAK4CMQP","volontariapp-meta-scripts","Utiliser et faire evoluer la CLI Node de meta (setup, doctor, migrate, certs, repos, bump) et le manifeste repos.json, source unique des repos et unites du workspace.","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-meta-scripts\u002FSKILL.md",[216],{"categoryNo":24,"categoryName":25,"taxonomyType":21,"taxonomyTypeDesc":22},[218,219,220,52,221,177,222,223,224,225,226,227,228,229,230,231,232],"Meta","volontariapp meta scripts","repos","migrate","setup","meta-scripts","json","node","workspace","shell","bump","cli","doctor","draft","install",{"skillNo":234,"name":235,"nameZh":6,"description":236,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":237,"categories":238,"keywords":242,"publishedAt":56},"01M6CSTPHNTGE0WF5CPE8W7ZYQ","volontariapp-implement-async-event-flow","Guide pas-à-pas pour concevoir et implémenter un flux asynchrone complet (messaging -> outbox -> post-processors -> ws-service -> client).","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fvolontariapp-implement-async-event-flow\u002FSKILL.md",[239],{"categoryNo":240,"categoryName":241,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000009","前端开发",[38,243,244,36,245,246,181,247,248,204,42,249,55,47,64,48,49,250,52,251],"implement","volontariapp implement async event flow","event","messaging","mapping","flux","flows","architecture","runner",{"skillNo":253,"name":254,"nameZh":6,"description":255,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":256,"categories":257,"keywords":261,"publishedAt":56},"01M6CSRQHS0T9955M8CTA5BYJQ","research","Investigate a question against high-trust primary sources and capture the findings as a Markdown file in the repo. Use when the user wants a topic researched, docs or API facts gathered, or reading legwork delegated to a background agent.","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fplugins\u002Fmatt-pocock\u002Fskills\u002Fresearch\u002FSKILL.md",[258],{"categoryNo":259,"categoryName":260,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000021","资料检索与研究",[254,262,169,263,264,265,266,267,268,269,270,271],"api","file","repo","markdown","docs","capture","use","question","trust","investigate",{"skillNo":273,"name":274,"nameZh":6,"description":275,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":276,"categories":277,"keywords":281,"publishedAt":56},"01M6CSRMZ82M1YK96Z4SW1STTD","prototype","Build a throwaway prototype to answer a design question. Use when the user wants to sanity-check whether a state model or logic feels right, or explore what a UI should look like.","https:\u002F\u002Fgithub.com\u002FVolontariapp\u002Fmeta\u002Fblob\u002Fmain\u002F.agents\u002Fplugins\u002Fmatt-pocock\u002Fskills\u002Fprototype\u002FSKILL.md",[278],{"categoryNo":279,"categoryName":280,"taxonomyType":29,"taxonomyTypeDesc":30},"01JBSEEDCAT000000000000032","设计师",[274,282,169,283,268,284,269,285,286,287,288,289],"check","ui","look","design","explore","model","build","state"]