Files
adam-acs/docs/migration/tr069-exchange-viewer.md
T
Kevin L'Hereec 0c83a4ccb2 feat: TR-069 Exchange Viewer - structured MongoDB storage with SSE streaming and sequence diagram UI
- MongoDB storage: cwmp_exchanges collection with indexes (deviceId+timestamp, deviceId+sequenceNumber unique, TTL 24h)
- ExchangeStoreService: CWMP method extraction, per-device queue for ordering, retry logic, message cap (5000/device)
- Exchange API: GET/DELETE/SSE stream endpoints with Last-Event-ID reconnection
- CwmpLoggerMiddleware: dual-write (MongoDB + legacy files) with fire-and-forget
- Frontend: SequenceViewer (CPE↔ACS lifelines, arrows, auto-scroll, virtual scroll) + InspectorPanel (formatted XML, copy, syntax highlighting)
- Tr069Trace page: integrated new viewer with col-6/col-6 layout
- DeviceDetail page: collapsible exchange section when device is traced
- 14 property-based tests, 40+ unit tests, 5 integration tests
- Health check includes index verification
- Config: feature flags, SSE timeouts, retry policy
2026-06-03 15:52:44 +02:00

18 KiB

Guide de migration : TR-069 Exchange Viewer

Résumé

Ce guide décrit la migration du système de trace TR-069 depuis le stockage fichier (logs .log rotatifs) vers le stockage structuré MongoDB (cwmp_exchanges). La migration suit une approche en 3 phases pour garantir zéro perte de données et un rollback possible à chaque étape.

Phase Durée Description Risque
Phase 1 — Dual-Write Semaines 1-2 Écriture simultanée fichiers + MongoDB Faible
Phase 2 — Read from MongoDB Semaines 3-4 API lit depuis MongoDB, fichiers en backup Moyen
Phase 3 — Deprecate Files Semaine 5+ Suppression du système fichier Irréversible

Prérequis

Infrastructure

  • MongoDB 5.0+ (pas besoin de replica set — le système utilise un EventEmitter in-process, pas de change streams)
  • Espace disque MongoDB : prévoir ~500 Mo pour 10 000 devices actifs avec 5 000 messages max chacun
  • L'application adam-acs doit être redémarrée après la migration de base

Variables d'environnement

# Feature flags (déjà présents dans config/exchange_store.ts)
FEATURE_EXCHANGE_PERSISTENCE=true      # Active les écritures MongoDB
FEATURE_SSE_ENABLED=true               # Active le streaming SSE
FEATURE_EXCHANGE_API_SOURCE=mongodb    # Source des données API (mongodb | file)

# Configuration optionnelle
EXCHANGE_STORE_MAX_MESSAGES=5000       # Cap messages par device
EXCHANGE_STORE_TTL_SECONDS=86400       # TTL 24h
EXCHANGE_STORE_READ_TIMEOUT_MS=5000    # Timeout requêtes API

Phase 1 : Dual-Write (Semaines 1-2)

Objectif

Écrire simultanément dans les deux systèmes (fichiers ET MongoDB) pour valider la fiabilité du nouveau système sans risque de perte.

1.1 Exécuter la migration MongoDB

La migration crée la collection cwmp_exchanges et ses index :

# Exécuter la migration
node ace migration:run --connection=mongodb

Cela exécute database/mongo_migrations/011_create_cwmp_exchanges_collection.ts qui crée :

  • Collection cwmp_exchanges avec 3 index :
    • { deviceId: 1, timestamp: 1 } — requêtes par device
    • { deviceId: 1, sequenceNumber: 1 } (unique) — numérotation séquentielle et reconnexion SSE
    • { createdAt: 1 } (TTL: 86400s) — nettoyage automatique après 24h
  • Collection cwmp_exchange_counters — compteurs atomiques de séquence

1.2 Vérifier les index

# Via mongosh
mongosh --eval "
  db.cwmp_exchanges.getIndexes().forEach(idx => {
    print(idx.name + ': ' + JSON.stringify(idx.key))
  })
"

Résultat attendu :

_id_: {"_id":1}
idx_deviceId_timestamp: {"deviceId":1,"timestamp":1}
idx_deviceId_sequenceNumber: {"deviceId":1,"sequenceNumber":1}
idx_createdAt_ttl: {"createdAt":1}

1.3 Activer le dual-write

Le dual-write est déjà implémenté dans CwmpLoggerMiddleware. Vérifier les feature flags :

# .env — Phase 1
FEATURE_EXCHANGE_PERSISTENCE=true
FEATURE_SSE_ENABLED=true
FEATURE_EXCHANGE_API_SOURCE=file    # GARDER 'file' en Phase 1

Le middleware écrit dans les deux systèmes :

// app/middleware/cwmp_logger_middleware.ts (extrait)
if (isTraced && deviceId) {
  // Nouveau système : MongoDB (fire-and-forget)
  exchangeStore.persistExchange({ deviceId, direction, sessionId, rawXml })
    .catch(() => {})

  // Ancien système : fichiers (gardé pour rollback)
  traceService.writeExchange(deviceId, direction, sessionId, prettyXml)
}

1.4 Redémarrer l'application

docker compose build app
docker compose up -d app

1.5 Vérifier le fonctionnement

Activer le trace sur un device et déclencher un Inform :

# Vérifier que MongoDB reçoit les données
mongosh --eval "db.cwmp_exchanges.countDocuments({})"

# Vérifier les derniers échanges
mongosh --eval "
  db.cwmp_exchanges.find({}).sort({timestamp: -1}).limit(3).forEach(doc => {
    print(doc.deviceId + ' | ' + doc.direction + ' | ' + doc.cwmpMethod)
  })
"

1.6 Vérification de cohérence (Dual-Write)

Pendant la Phase 1, vérifier régulièrement que les deux systèmes restent synchronisés :

Script de comparaison

#!/bin/bash
# scripts/verify_dual_write.sh
# Compare le nombre d'échanges entre fichiers et MongoDB pour chaque device tracé

DEVICE_ID=$1

if [ -z "$DEVICE_ID" ]; then
  echo "Usage: $0 <deviceId>"
  exit 1
fi

# Compteur fichier (nombre de séparateurs '═' = nombre d'échanges)
FILE_COUNT=$(grep -c '═════' "logs/tr069/${DEVICE_ID}.log" 2>/dev/null || echo "0")

# Compteur MongoDB
MONGO_COUNT=$(mongosh --quiet --eval "db.cwmp_exchanges.countDocuments({deviceId: '${DEVICE_ID}'})")

echo "Device: ${DEVICE_ID}"
echo "  Fichiers : ${FILE_COUNT} échanges"
echo "  MongoDB  : ${MONGO_COUNT} échanges"

if [ "$FILE_COUNT" != "$MONGO_COUNT" ]; then
  DIFF=$((FILE_COUNT - MONGO_COUNT))
  echo "  ⚠️  Écart : ${DIFF} messages"
  if [ "${DIFF#-}" -gt $((FILE_COUNT / 20)) ]; then
    echo "  🚨 Écart > 5% — Investigation requise !"
  fi
else
  echo "  ✅ Cohérent"
fi

Critères d'acceptation Phase 1

Critère Seuil Action si dépassé
Écart fichier/MongoDB < 5% Vérifier les logs d'erreur ExchangeStore
Erreurs de persistance < 0.1% Vérifier la connectivité MongoDB
Latence MongoDB p95 < 100ms Vérifier la charge et les index
Messages perdus (critiques) 0 après retries Vérifier les logs de retry exhaustion

Durée recommandée

Maintenir le dual-write pendant minimum 1 semaine avec des vérifications quotidiennes. Passer en Phase 2 uniquement si les critères ci-dessus sont respectés de manière constante.


Phase 2 : Read from MongoDB (Semaines 3-4)

Objectif

Basculer la lecture des données vers MongoDB tout en gardant les fichiers en écriture backup.

2.1 Changer la source de lecture API

# .env — Phase 2
FEATURE_EXCHANGE_PERSISTENCE=true
FEATURE_SSE_ENABLED=true
FEATURE_EXCHANGE_API_SOURCE=mongodb    # ← Changer de 'file' à 'mongodb'

2.2 Redémarrer l'application

docker compose restart app

2.3 Vérifier le fonctionnement

# Vérifier que l'API retourne des données depuis MongoDB
curl -s "http://localhost:8080/api/v1/tr069-trace/<DEVICE_ID>/exchanges?limit=5" | \
  python3 -c "
import sys, json
data = json.load(sys.stdin)
print(f\"Source: MongoDB\")
print(f\"Exchanges: {len(data.get('exchanges', []))} messages\")
print(f\"Metadata: {data.get('metadata', {})}\")
"

2.4 Vérifier le SSE

# Ouvrir une connexion SSE (ctrl+C pour arrêter)
curl -N -H "Accept: text/event-stream" \
  "http://localhost:8080/api/v1/tr069-trace/<DEVICE_ID>/exchanges/stream"

Déclencher un Inform sur le device tracé et vérifier qu'un événement SSE arrive en temps réel.

2.5 Vérifier le fallback

Si MongoDB est indisponible, le frontend doit retomber sur les fichiers :

// Logique dans DeviceDetail.vue
const exchanges = await api.getExchanges(deviceId)
  .catch(() => legacyService.readExchanges(deviceId))

2.6 Critères de passage Phase 2 → Phase 3

Critère Seuil Vérifié
API MongoDB fonctionne sans erreur 48h continues ☐
SSE pousse les événements en < 500ms p95 vérifié ☐
Aucune perte de message critique 0 sur 48h ☐
Reconnexion SSE (Last-Event-ID) valide Testé manuellement ☐
Purge (DELETE) fonctionne 204 retourné ☐

Phase 3 : Deprecate Old System (Semaine 5+)

Objectif

Supprimer le système fichier une fois que MongoDB est validé en production.

3.1 Supprimer le dual-write du middleware

Dans app/middleware/cwmp_logger_middleware.ts, retirer les appels au système fichier :

// AVANT (dual-write)
if (isTraced && deviceId) {
  exchangeStore.persistExchange({ deviceId, direction, sessionId, rawXml }).catch(() => {})
  traceService.writeExchange(deviceId, direction, sessionId, prettyXml)  // ← SUPPRIMER
}

// APRÈS (MongoDB uniquement)
if (isTraced && deviceId) {
  exchangeStore.persistExchange({ deviceId, direction, sessionId, rawXml }).catch(() => {})
}

3.2 Supprimer les dépendances obsolètes

  1. Retirer l'import xml-formatter du middleware (le formatage n'est plus nécessaire côté persistance)
  2. Supprimer les appels à Tr069TraceService.writeExchange() dans le middleware
  3. NE PAS supprimer Tr069TraceService lui-même — il est encore utilisé pour gérer l'état trace (start/stop)

3.3 Nettoyer les fichiers de logs

# Archiver les anciens logs avant suppression
tar -czf logs/tr069-archive-$(date +%Y%m%d).tar.gz logs/tr069/
rm -rf logs/tr069/*.log*

3.4 Mise à jour de la configuration

# .env — Phase 3 (état final)
FEATURE_EXCHANGE_PERSISTENCE=true
FEATURE_SSE_ENABLED=true
FEATURE_EXCHANGE_API_SOURCE=mongodb

3.5 Vérification finale

# Vérifier qu'aucun fichier .log n'est créé
ls -la logs/tr069/

# Vérifier que le système fonctionne uniquement via MongoDB
curl -s "http://localhost:8080/api/v1/health/tr069-exchanges" | python3 -m json.tool

Procédures de Rollback

Rollback Phase 1 → Système fichier seul

Situation : MongoDB pose problème, il faut revenir au système fichier uniquement.

# 1. Désactiver la persistance MongoDB
FEATURE_EXCHANGE_PERSISTENCE=false

# 2. Redémarrer
docker compose restart app

# 3. Vérifier que les fichiers continuent d'être écrits
ls -la logs/tr069/

Impact : Les données déjà en MongoDB restent accessibles mais ne sont plus alimentées. Le SSE ne fonctionne plus. L'UI retombe sur les fichiers.

Temps de rollback : < 2 minutes (changement d'env + restart)

Rollback Phase 2 → Phase 1 (Dual-Write)

Situation : L'API MongoDB a des problèmes, revenir au dual-write avec lecture fichier.

# 1. Changer la source API
FEATURE_EXCHANGE_API_SOURCE=file

# 2. Redémarrer
docker compose restart app

Impact : L'API lit les fichiers au lieu de MongoDB. Le dual-write continue. Aucune perte de données.

Temps de rollback : < 2 minutes

Rollback Phase 3 → Phase 2

Situation : Après suppression du dual-write, MongoDB pose problème.

⚠️ ATTENTION : Ce rollback nécessite un redéploiement du code.

  1. Revert du commit qui a supprimé le dual-write dans CwmpLoggerMiddleware
  2. Redéployer l'application
  3. Vérifier que les fichiers sont à nouveau écrits
# Identifier le commit de suppression du dual-write
git log --oneline -- app/middleware/cwmp_logger_middleware.ts

# Revert
git revert <commit-hash>
docker compose build app
docker compose up -d app

Impact : Nécessite un redéploiement (~5 minutes). Les échanges entre la suppression et le rollback ne sont PAS dans les fichiers.

Temps de rollback : 5-10 minutes

Rollback SSE uniquement

Situation : Le SSE cause des problèmes (connexions, mémoire) mais MongoDB est stable.

# Désactiver uniquement le SSE
FEATURE_SSE_ENABLED=false

# Redémarrer
docker compose restart app

Impact : Le frontend bascule automatiquement en REST polling (toutes les 5 secondes). Les données MongoDB sont toujours disponibles. Pas de perte.

Rollback complet (retour système fichier)

Situation : Problème majeur, tout désactiver.

# Tout désactiver
FEATURE_EXCHANGE_PERSISTENCE=false
FEATURE_SSE_ENABLED=false
FEATURE_EXCHANGE_API_SOURCE=file

# Redémarrer
docker compose restart app

Impact : Retour complet au système fichier. MongoDB n'est plus utilisé. L'UI affiche les logs comme avant la migration.


Migration des données historiques (optionnel)

Si vous souhaitez importer les logs fichier existants dans MongoDB, utilisez le script ci-dessous. Cette étape est optionnelle car les anciens logs disparaîtront naturellement via le TTL 24h.

Script d'import

// scripts/migrate_file_logs_to_mongodb.ts
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { getMongoDb } from '#providers/mongodb_provider'

const LOG_DIR = join(process.cwd(), 'logs', 'tr069')
const SEPARATOR = '═'.repeat(80)

interface ParsedEntry {
  deviceId: string
  timestamp: string
  direction: 'CPE→ACS' | 'ACS→CPE'
  sessionId: string
  rawXml: string
}

function parseLogFile(deviceId: string, content: string): ParsedEntry[] {
  const entries: ParsedEntry[] = []
  const blocks = content.split(SEPARATOR).filter((b) => b.trim())

  for (let i = 0; i < blocks.length - 1; i += 2) {
    const header = blocks[i].trim()
    const xml = blocks[i + 1]?.trim() || ''

    // Parse header: [2025-01-15T10:30:00.123Z] CPE→ACS | 10.0.0.1:54321->192.168.1.1:8080
    const headerMatch = header.match(
      /\[(.+?)\]\s+(CPE→ACS|ACS→CPE)\s+\|\s+(.+)/
    )
    if (!headerMatch) continue

    entries.push({
      deviceId,
      timestamp: headerMatch[1],
      direction: headerMatch[2] as 'CPE→ACS' | 'ACS→CPE',
      sessionId: headerMatch[3],
      rawXml: xml,
    })
  }

  return entries
}

async function migrateAll(): Promise<void> {
  const db = getMongoDb()
  const collection = db.collection('cwmp_exchanges')
  const counters = db.collection('cwmp_exchange_counters')

  const files = readdirSync(LOG_DIR).filter((f) => f.endsWith('.log'))
  let totalMigrated = 0

  for (const file of files) {
    const deviceId = file.replace('.log', '')
    const content = readFileSync(join(LOG_DIR, file), 'utf-8')
    const entries = parseLogFile(deviceId, content)

    if (entries.length === 0) continue

    // Get or initialize sequence counter
    const counterResult = await counters.findOneAndUpdate(
      { _id: deviceId as any },
      { $inc: { seq: entries.length } },
      { upsert: true, returnDocument: 'after' }
    )
    const startSeq = (counterResult!.seq as number) - entries.length + 1

    // Build documents
    const docs = entries.map((entry, idx) => ({
      ...entry,
      sequenceNumber: startSeq + idx,
      cwmpMethod: extractMethod(entry.rawXml),
      createdAt: new Date(entry.timestamp),
    }))

    // Bulk insert
    await collection.insertMany(docs, { ordered: false }).catch((err) => {
      console.warn(`  ⚠️  Duplicates ignored for ${deviceId}: ${err.message}`)
    })

    totalMigrated += docs.length
    console.log(`  ✅ ${deviceId}: ${docs.length} échanges migrés`)
  }

  console.log(`\n📊 Total: ${totalMigrated} échanges migrés depuis ${files.length} fichiers`)
}

function extractMethod(xml: string): string {
  if (!xml || xml.trim() === '') return 'Empty'
  const match = xml.match(/<cwmp:(\w+)[\s/>]/)
  return match ? match[1] : 'Unknown'
}

migrateAll().catch(console.error)

Exécution

# Depuis la racine du projet
npx ts-node scripts/migrate_file_logs_to_mongodb.ts

Notes :

  • Le script est idempotent : les doublons sont ignorés (grâce à l'index unique deviceId + sequenceNumber)
  • Les documents importés auront un createdAt basé sur le timestamp du log, donc ils seront supprimés par le TTL 24h si ils datent de plus de 24 heures
  • Pour conserver les données historiques plus longtemps, ajustez temporairement EXCHANGE_STORE_TTL_SECONDS

Vérifications post-migration

Checklist

# Vérification Commande Attendu
1 Collection existe db.cwmp_exchanges.stats() Collection présente
2 Index créés db.cwmp_exchanges.getIndexes() 4 index (dont _id)
3 TTL actif db.cwmp_exchanges.getIndexes() expireAfterSeconds: 86400
4 Compteurs fonctionnent db.cwmp_exchange_counters.find() Entrées par device
5 API accessible GET /api/v1/tr069-trace/:id/exchanges 200 avec données
6 SSE connecte GET /api/v1/tr069-trace/:id/exchanges/stream Event stream ouvert
7 Health check GET /api/v1/health/tr069-exchanges status: "healthy"

Commandes de diagnostic

# Statistiques collection
mongosh --eval "
  const stats = db.cwmp_exchanges.stats()
  print('Documents:', stats.count)
  print('Taille:', (stats.size / 1024 / 1024).toFixed(2), 'Mo')
  print('Index size:', (stats.totalIndexSize / 1024 / 1024).toFixed(2), 'Mo')
"

# Vérifier le TTL monitor
mongosh --eval "
  db.cwmp_exchanges.aggregate([
    { \$group: { _id: null, oldest: { \$min: '\$createdAt' }, newest: { \$max: '\$createdAt' } } }
  ]).forEach(doc => {
    print('Plus ancien:', doc.oldest)
    print('Plus récent:', doc.newest)
    const ageH = (Date.now() - new Date(doc.oldest).getTime()) / 3600000
    print('Âge max:', ageH.toFixed(1), 'heures (devrait être < 24h)')
  })
"

# Vérifier les compteurs de séquence
mongosh --eval "
  db.cwmp_exchange_counters.find().sort({seq: -1}).limit(5).forEach(doc => {
    print(doc._id + ': seq=' + doc.seq)
  })
"

Résumé des feature flags

Flag Phase 1 Phase 2 Phase 3 Rollback
FEATURE_EXCHANGE_PERSISTENCE true true true false
FEATURE_SSE_ENABLED true true true false
FEATURE_EXCHANGE_API_SOURCE file mongodb mongodb file

FAQ

Que se passe-t-il si MongoDB tombe pendant le dual-write ?

Les écritures MongoDB échouent silencieusement (fire-and-forget). Le système fichier continue normalement. Les messages critiques sont retentés 3 fois avec backoff exponentiel (1s, 2s, 4s) avant d'être abandonnés. La session CWMP n'est jamais interrompue.

Le TTL va-t-il supprimer des données pendant la migration ?

Oui. Le TTL de 24h s'applique dès la création de l'index. Si vous importez des logs historiques de plus de 24h, ils seront supprimés par le TTL monitor MongoDB (qui tourne ~toutes les 60 secondes). Augmentez temporairement EXCHANGE_STORE_TTL_SECONDS si nécessaire.

Puis-je migrer les données fichier vers MongoDB ?

Oui, voir la section "Migration des données historiques". C'est optionnel car le système fonctionne immédiatement avec les nouveaux échanges. Les anciens logs fichier restent lisibles via FEATURE_EXCHANGE_API_SOURCE=file.

Combien d'espace MongoDB est nécessaire ?

Estimation : ~1 Ko par échange (métadonnées) + taille du XML brut (typiquement 2-50 Ko). Avec le cap de 5 000 messages par device et le TTL 24h, la croissance est bornée. Pour 100 devices tracés simultanément : ~500 Mo maximum.

Le dual-write a-t-il un impact sur les performances CWMP ?

Non. Les écritures MongoDB sont fire-and-forget (catch(() => {})) et ne bloquent jamais le traitement CWMP. En cas de lenteur MongoDB, le middleware continue sans attendre.