← Cours BD07

🔴 Exercices BD07 — Redis Fondamentaux

5 exercices · Solutions masquées

Exercice 1 — Leaderboard de jeu

Créez un leaderboard de jeu vidéo avec Redis Sorted Sets. Implémentez : addScore(userId, score), getTopN(n), getRank(userId), getScoreAround(userId, n) (n voisins au-dessus et en dessous), et une réinitialisation hebdomadaire.

Voir la solution
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

const LEADERBOARD_KEY = 'leaderboard:weekly';

async function addScore(userId, score) {
  await redis.zadd(LEADERBOARD_KEY, score, `user:${userId}`);
}

async function getTopN(n = 10) {
  const raw = await redis.zrevrange(LEADERBOARD_KEY, 0, n - 1, 'WITHSCORES');
  const result = [];
  for (let i = 0; i < raw.length; i += 2) {
    result.push({
      userId: raw[i].replace('user:', ''),
      score: parseInt(raw[i + 1]),
      rank: i / 2 + 1
    });
  }
  return result;
}

async function getRank(userId) {
  const rank = await redis.zrevrank(LEADERBOARD_KEY, `user:${userId}`);
  if (rank === null) return null;
  const score = await redis.zscore(LEADERBOARD_KEY, `user:${userId}`);
  return { rank: rank + 1, score: parseInt(score) };
}

async function getScoreAround(userId, n = 3) {
  const rank = await redis.zrevrank(LEADERBOARD_KEY, `user:${userId}`);
  if (rank === null) return [];
  const start = Math.max(0, rank - n);
  const end = rank + n;
  const raw = await redis.zrevrange(LEADERBOARD_KEY, start, end, 'WITHSCORES');
  const result = [];
  for (let i = 0; i < raw.length; i += 2) {
    result.push({
      userId: raw[i].replace('user:', ''),
      score: parseInt(raw[i + 1]),
      rank: start + i / 2 + 1,
      isCurrentUser: raw[i] === `user:${userId}`
    });
  }
  return result;
}

async function resetWeeklyLeaderboard() {
  await redis.del(LEADERBOARD_KEY);
  console.log('Leaderboard hebdomadaire réinitialisé');
}

Exercice 2 — File de tâches email

Créez un système producteur/consommateur pour envoyer des emails en arrière-plan. Le producteur ajoute des tâches dans une List, le consommateur les récupère avec BLPOP (bloquant). Gérez les dead-letter queue pour les tâches échouées.

Voir la solution
const QUEUE_KEY    = 'queue:emails';
const DLQ_KEY      = 'queue:emails:dead';
const MAX_RETRIES  = 3;

// Producteur
async function enqueueEmail(to, subject, body) {
  const task = JSON.stringify({ to, subject, body, retries: 0, createdAt: Date.now() });
  await redis.rpush(QUEUE_KEY, task);
  console.log(`Email enqueued for ${to}`);
}

// Consommateur (workers)
async function emailWorker() {
  console.log('Email worker started...');
  while (true) {
    try {
      const [, raw] = await redis.blpop(QUEUE_KEY, 5); // timeout 5s
      if (!raw) continue;

      const task = JSON.parse(raw);
      try {
        await simulateSendEmail(task);
        console.log(`Email sent to ${task.to}`);
      } catch (sendErr) {
        task.retries++;
        task.lastError = sendErr.message;
        if (task.retries >= MAX_RETRIES) {
          await redis.rpush(DLQ_KEY, JSON.stringify(task));
          console.error(`Dead letter: ${task.to} after ${MAX_RETRIES} retries`);
        } else {
          await redis.rpush(QUEUE_KEY, JSON.stringify(task)); // re-queue
          console.warn(`Retry ${task.retries}/${MAX_RETRIES} for ${task.to}`);
        }
      }
    } catch (err) {
      console.error('Worker error:', err);
      await new Promise(r => setTimeout(r, 1000));
    }
  }
}

async function simulateSendEmail({ to, subject }) {
  if (Math.random() < 0.2) throw new Error('SMTP timeout'); // 20% failure
  console.log(`  → Sending "${subject}" to ${to}`);
}

Exercice 3 — Gestion de sessions utilisateur

Créez un système de sessions avec Redis Hashes. Implémentez : createSession(userId, data), getSession(sessionId), updateSession(sessionId, fields), deleteSession(sessionId), et getUserActiveSessions(userId).

Voir la solution
const crypto = require('crypto');

const SESSION_TTL   = 86400;  // 24h en secondes
const SESSION_PREFIX = 'sess:';
const USER_SESS_PREFIX = 'user:sess:';

async function createSession(userId, data = {}) {
  const sessionId = crypto.randomBytes(32).toString('hex');
  const key = SESSION_PREFIX + sessionId;

  await redis.hset(key, {
    userId: String(userId),
    ...Object.fromEntries(Object.entries(data).map(([k, v]) => [k, JSON.stringify(v)])),
    createdAt: Date.now(),
    lastActivity: Date.now()
  });
  await redis.expire(key, SESSION_TTL);

  // Tracker les sessions actives de l'utilisateur
  await redis.sadd(USER_SESS_PREFIX + userId, sessionId);
  await redis.expire(USER_SESS_PREFIX + userId, SESSION_TTL);

  return sessionId;
}

async function getSession(sessionId) {
  const key = SESSION_PREFIX + sessionId;
  const data = await redis.hgetall(key);
  if (!data || !data.userId) return null;

  // Renouveler le TTL (sliding expiration)
  await redis.expire(key, SESSION_TTL);
  await redis.hset(key, 'lastActivity', Date.now());
  return data;
}

async function updateSession(sessionId, fields) {
  const key = SESSION_PREFIX + sessionId;
  const exists = await redis.exists(key);
  if (!exists) return false;
  await redis.hset(key, { ...fields, lastActivity: Date.now() });
  await redis.expire(key, SESSION_TTL);
  return true;
}

async function deleteSession(sessionId) {
  const key = SESSION_PREFIX + sessionId;
  const data = await redis.hgetall(key);
  if (data?.userId) {
    await redis.srem(USER_SESS_PREFIX + data.userId, sessionId);
  }
  return await redis.del(key) > 0;
}

async function getUserActiveSessions(userId) {
  const sessionIds = await redis.smembers(USER_SESS_PREFIX + userId);
  const sessions = await Promise.all(
    sessionIds.map(async (sid) => {
      const data = await redis.hgetall(SESSION_PREFIX + sid);
      return data?.userId ? { sessionId: sid, ...data } : null;
    })
  );
  return sessions.filter(Boolean);
}

Exercice 4 — Compteurs et statistiques temps réel

Créez un système de statistiques de page vues en temps réel : compteurs par page, par heure et par jour. Implémentez une fonction de rapport qui retourne les 10 pages les plus vues et les pics de trafic horaires.

Voir la solution
async function trackPageView(path, userId = null) {
  const now = new Date();
  const hour = `${now.getFullYear()}${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}${String(now.getHours()).padStart(2,'0')}`;
  const day  = hour.slice(0, 8);

  const multi = redis.multi();
  // Total par page
  multi.zincrby('stats:pages:total', 1, path);
  // Par heure
  multi.zincrby(`stats:pages:hour:${hour}`, 1, path);
  // Par jour
  multi.zincrby(`stats:pages:day:${day}`, 1, path);
  // TTL automatique
  multi.expire(`stats:pages:hour:${hour}`, 7 * 86400);
  multi.expire(`stats:pages:day:${day}`, 90 * 86400);

  if (userId) {
    // Utilisateurs uniques (HyperLogLog)
    multi.pfadd(`stats:uv:${day}`, userId);
    multi.expire(`stats:uv:${day}`, 90 * 86400);
  }
  await multi.exec();
}

async function getTopPages(n = 10) {
  const raw = await redis.zrevrange('stats:pages:total', 0, n - 1, 'WITHSCORES');
  const result = [];
  for (let i = 0; i < raw.length; i += 2) {
    result.push({ path: raw[i], views: parseInt(raw[i+1]) });
  }
  return result;
}

async function getHourlyReport(date) {
  // date format: '20240115'
  const hours = [];
  for (let h = 0; h < 24; h++) {
    const hour = date + String(h).padStart(2, '0');
    const total = await redis.zcard(`stats:pages:hour:${hour}`);
    hours.push({ hour: h, pages: total });
  }
  return hours;
}

Exercice 5 — Cache avec invalidation par tag

Implémentez un système de cache Redis avec invalidation par tags. cacheSet(key, data, tags[], ttl) stocke les données et associe des tags. invalidateByTag(tag) supprime toutes les clés marquées avec ce tag.

Voir la solution
const TAG_PREFIX = 'cache:tag:';
const DATA_PREFIX = 'cache:data:';

async function cacheSet(key, data, tags = [], ttl = 300) {
  const dataKey = DATA_PREFIX + key;
  const multi = redis.multi();

  multi.set(dataKey, JSON.stringify(data), 'EX', ttl);

  for (const tag of tags) {
    const tagKey = TAG_PREFIX + tag;
    multi.sadd(tagKey, key);
    multi.expire(tagKey, ttl * 2); // Tag TTL plus long que les données
  }

  await multi.exec();
}

async function cacheGet(key) {
  const raw = await redis.get(DATA_PREFIX + key);
  return raw ? JSON.parse(raw) : null;
}

async function invalidateByTag(tag) {
  const tagKey = TAG_PREFIX + tag;
  const keys = await redis.smembers(tagKey);

  if (keys.length) {
    const multi = redis.multi();
    keys.forEach(k => multi.del(DATA_PREFIX + k));
    multi.del(tagKey);
    await multi.exec();
    console.log(`Invalidated ${keys.length} cache entries for tag: ${tag}`);
  }
  return keys.length;
}

// Usage
await cacheSet('products:cat:electronics', productList, ['products', 'category:electronics']);
await cacheSet('product:42', singleProduct, ['products', 'product:42']);

// Invalider tout le cache "products" quand on modifie un produit
await invalidateByTag('products');