- 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
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_exchangesavec 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
- Retirer l'import
xml-formatterdu middleware (le formatage n'est plus nécessaire côté persistance) - Supprimer les appels à
Tr069TraceService.writeExchange()dans le middleware - NE PAS supprimer
Tr069TraceServicelui-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.
- Revert du commit qui a supprimé le dual-write dans
CwmpLoggerMiddleware - Redéployer l'application
- 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
createdAtbasé 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.