Hết Part 2, video đã nằm an toàn trên đĩa máy chủ và có thể phát lại được — nhưng vẫn nguyên định dạng gốc. Người dùng tải lên một file quay bằng điện thoại 4K nặng 1,8 GB, và bất kỳ ai muốn xem cũng phải tải về đúng 1,8 GB đó.

Part này làm phần việc nặng nhất của cả hệ thống: chuyển mã ra nhiều độ phân giải. Và ngay khi chạm vào nó, Node lộ ra giới hạn cốt lõi của mình — một luồng. Toàn bộ phần còn lại của bài là các cách khác nhau để vượt qua giới hạn đó, mỗi cách hợp với một loại công việc.

🔍 Hết Part 3 bạn sẽ có gì
Một tiến trình worker riêng gọi ffmpeg qua child_process và đọc được tiến độ thật của nó. Một hồ worker_threads làm việc nặng CPU thuần trong Node. Nhiều instance ứng dụng chạy song song sau nginx. Một hàng đợi có thử lại và có nơi chứa job hỏng. Và tiến độ chuyển mã chạy về trình duyệt theo thời gian thực, đúng tab của đúng người dùng, kể cả khi worker và WebSocket nằm ở hai tiến trình khác nhau.

1. Vì sao không thể chuyển mã ngay trong handler

Cách viết thẳng thắn nhất là gọi ffmpeg ngay sau khi nhận xong file. Nó sai theo hai tầng, và tầng thứ hai mới là tầng chết người.

Tầng thứ nhất: chuyển mã một video 10 phút mất vài phút. Giữ một kết nối HTTP mở suốt vài phút thì gần như chắc chắn có ai đó ở giữa cắt nó — nginx với proxy_read_timeout, hoặc chính trình duyệt, hoặc mạng di động của người dùng. Công việc vẫn chạy tiếp trong khi người dùng đã thấy lỗi.

Tầng thứ hai: nếu bạn dùng một thư viện chuyển mã viết bằng JavaScript, hoặc thậm chí chỉ ngồi tính hash của file trong handler, thì toàn bộ ứng dụng đứng hình. Không phải chậm — đứng. Event loop của Node là một luồng; trong lúc nó chạy vòng lặp tính toán của bạn thì không request nào khác được phục vụ, không callback nào chạy, health check cũng không trả lời nổi.

Thử nghiệm 5 giây để tự thấy
@Get('block')
block(): string {
  const end = Date.now() + 5000;
  while (Date.now() < end) {
    // Nam giay tinh toan thuan tuy — dai dien cho bat ky viec nang CPU nao.
  }
  return 'xong';
}

@Get('ping')
ping(): string {
  return 'pong';
}

Gọi /block ở một tab, rồi gọi /ping ở tab khác. /ping — một hàm trả về hằng số — sẽ chờ đủ năm giây. Đó là toàn bộ vấn đề, gói trong mười dòng.

API trả 202 ngay đẩy job Redis Stream hàng đợi lấy job Worker tiến trình riêng không phục vụ HTTP ffmpeg tiến trình OS tiến độ % Redis pub/sub worker không giữ kết nối WebSocket nào Gateway WS giữ kết nối Trình duyệt Request trả về sau vài mili giây. Việc nặng chạy ở nơi khác, tiến độ đi đường khác về.

2. child_process: gọi ffmpeg cho đúng

ffmpeg là một chương trình viết bằng C, chạy như một tiến trình riêng của hệ điều hành. Nó có bộ nhớ riêng, có luồng riêng, và quan trọng nhất: nó bận bao nhiêu cũng không ảnh hưởng gì tới event loop của Node. Node chỉ khởi động nó rồi ngồi nghe.

2.1. spawn chứ không phải exec

Bốn hàm trong node:child_process hay bị dùng lẫn lộn, và chỉ một cái đúng cho việc này.

Bốn cách tạo tiến trình con
exec    chay qua shell, gom TOAN BO output vao bo nho roi moi tra ve
        -> ffmpeg in hang nghin dong tien do => phinh bo nho
        -> chuoi lenh di qua shell => lo hong chen lenh

execFile khong qua shell (tot hon), nhung van gom het output

fork    tao mot tien trinh NODE moi, co san kenh IPC
        -> dung cho worker Node, khong dung cho ffmpeg

spawn   tra ve STREAM stdout/stderr, khong gom vao bo nho
        -> dung cho tien trinh chay lau va noi nhieu. Chinh la ffmpeg.
⚠️ exec với chuỗi ghép là lỗ hổng chèn lệnh
exec(`ffmpeg -i ${path} out.mp4`) đưa cả chuỗi cho shell diễn giải. Người dùng đặt tên file là a.mp4; rm -rf /var/media thì shell hiểu đó là hai lệnh và chạy cả hai.

spawn nhận đối số dưới dạng mảng, và không có shell nào ở giữa. Mỗi phần tử tới thẳng ffmpeg như một đối số nguyên vẹn, dấu chấm phẩy trong tên file chỉ là một ký tự trong tên file. Đây là lý do đủ để chọn spawn ngay cả khi bỏ qua mọi lý do về bộ nhớ.

2.2. Đọc tiến độ thật từ ffmpeg

ffmpeg không có API báo tiến độ. Nó in trạng thái ra stderr — vâng, stderr, dù đó không phải lỗi — theo từng dòng dạng khoá bằng giá trị nếu bạn thêm cờ -progress pipe:2. Việc của ta là đọc dòng đó và chia cho tổng thời lượng.

src/worker/transcoder.ts
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';

export interface TranscodeOptions {
  readonly input: string;
  readonly output: string;
  readonly height: 480 | 720 | 1080;
  readonly durationSeconds: number;
  readonly onProgress: (percent: number) => void;
  readonly signal: AbortSignal;
}

export async function transcode(options: TranscodeOptions): Promise<void> {
  const args = [
    '-hide_banner',
    '-nostdin', // KHONG doi input tu ban phim. Thieu co nay ffmpeg co the treo im lang.
    '-i',
    options.input,
    '-vf',
    `scale=-2:${options.height}`, // -2 = tu tinh chieu rong sao cho chia het cho 2
    '-c:v',
    'libx264',
    '-preset',
    'veryfast',
    '-crf',
    '23',
    '-c:a',
    'aac',
    '-movflags',
    '+faststart', // day metadata len dau file => trinh duyet phat duoc ngay
    '-progress',
    'pipe:2',
    '-y',
    options.output,
  ];

  const child = spawn('ffmpeg', args, { signal: options.signal });

  // Doc theo DONG. Doc theo chunk thi mot dong co the bi cat lam doi.
  const lines = createInterface({ input: child.stderr });

  for await (const line of lines) {
    // ffmpeg in "out_time_ms=12345678" — don vi thuc te la MICRO giay,
    // bat ke ten bien noi gi.
    const match = /^out_time_ms=(\d+)$/.exec(line);
    if (match?.[1] !== undefined) {
      const seconds = Number(match[1]) / 1_000_000;
      const percent = Math.min(99, Math.round((seconds / options.durationSeconds) * 100));
      options.onProgress(percent);
    }
  }

  const code = await new Promise<number>((resolve) => child.on('close', resolve));

  if (code !== 0) {
    throw new Error(`ffmpeg thoat voi ma ${code}`);
  }
}

Đọc bằng readline chứ không bắt sự kiện 'data' là chi tiết dễ bỏ qua. Stream chia dữ liệu thành các mẩu theo kích thước bộ đệm, hoàn toàn không quan tâm tới dấu xuống dòng — nên một mẩu có thể kết thúc giữa chữ out_ti và mẩu sau bắt đầu bằng me_ms=.... Biểu thức chính quy trượt, tiến độ đứng im ở những chỗ ngẫu nhiên, và bạn mất một buổi chiều để tìm ra tại sao.

🔍 Vì sao chặn ở 99% chứ không cho lên 100%
ffmpeg còn phải ghi nốt bộ đệm và đóng file sau khi mã hoá xong khung hình cuối. Nếu để giao diện hiện 100% ở thời điểm đó thì người dùng bấm xem ngay và gặp một file chưa hoàn chỉnh.

100% chỉ được đặt sau khi close trả về mã 0. Nguyên tắc chung: phần trăm là ước lượng, mã thoát là sự thật.

2.3. Huỷ, hết giờ, và tiến trình mồ côi

Một job chuyển mã có thể chạy mãi không dứt: file hỏng làm ffmpeg quay vòng, hoặc người dùng tải lên thứ gì đó khiến nó chờ input. Phải có giới hạn cứng, và phải chắc rằng khi bỏ cuộc thì tiến trình con thật sự chết.

src/worker/transcoder.ts — giới hạn thời gian
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30 * 60 * 1000);

try {
  await transcode({ ...options, signal: controller.signal });
} finally {
  clearTimeout(timer); // Quen dong nay thi tien trinh song them 30 phut vo ich.
}

signal gửi SIGTERM. Phần lớn tiến trình dừng ngay, nhưng ffmpeg đang ở giữa một thao tác ghi đĩa dài có thể chưa xử lý tín hiệu. Nếu quá trình dừng cũng bị treo, cần một tầng nữa:

Dừng hai bước: xin tử tế rồi mới ép
function stop(child: ChildProcess): void {
  child.kill('SIGTERM'); // Xin dung, cho don dep

  const force = setTimeout(() => {
    // Van con song sau 10 giay -> SIGKILL. Khong the bat, khong the bo qua.
    if (child.exitCode === null) child.kill('SIGKILL');
  }, 10_000);

  child.on('close', () => clearTimeout(force));
}
⚠️ Tiến trình mồ côi khi worker chết
Worker bị SIGKILL — do container hết bộ nhớ chẳng hạn — thì ffmpeg không chết theo. Nó thành mồ côi và tiếp tục ăn CPU cho tới khi xong một việc mà không còn ai quan tâm. Khởi động lại vài lần là máy chủ đầy ffmpeg ma.

Trong Docker, cách chắc nhất là chạy container với --init: một tiến trình khởi tạo tối giản đứng ở PID 1, nhận nuôi tiến trình mồ côi và dọn chúng đi. Đây cũng chính là lý do docker-compose.yml của dự án đặt init: true cho dịch vụ worker.

3. worker_threads: khi việc nặng nằm trong chính JavaScript

ffmpeg giải quyết được phần chuyển mã vì nó là chương trình có sẵn. Nhưng có những việc bạn phải tự viết bằng JavaScript và chúng vẫn nặng CPU: tính hash SHA-256 của file 2 GB để phát hiện trùng lặp, dựng dải ảnh thu nhỏ cho thanh tua, so khớp vân tay nội dung.

Đẻ hẳn một tiến trình Node mới cho mỗi việc như vậy thì tốn: mỗi tiến trình là một bản V8 riêng, khoảng 30–40 MB và mất hàng chục mili giây để khởi động. worker_threads nhẹ hơn nhiều — cùng tiến trình, nhưng mỗi luồng có event loop riêng, nên vòng lặp tính toán của nó không đụng gì tới luồng chính.

src/worker/hash.worker.ts
import { parentPort, workerData } from 'node:worker_threads';
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';

interface HashTask {
  readonly filePath: string;
}

const task = workerData as HashTask;
const hash = createHash('sha256');

createReadStream(task.filePath)
  .on('data', (chunk) => hash.update(chunk))
  .on('end', () => parentPort?.postMessage({ digest: hash.digest('hex') }))
  .on('error', (error: Error) => parentPort?.postMessage({ error: error.message }));

Tạo một worker cho mỗi file thì lại rơi vào đúng cái bẫy chi phí khởi động vừa nói, chỉ nhỏ hơn. Cách làm đúng là một hồ worker dựng sẵn, cỡ hồ bằng số nhân CPU:

src/worker/worker-pool.ts
import { Worker } from 'node:worker_threads';
import { availableParallelism } from 'node:os';

interface Pending<T> {
  readonly data: unknown;
  readonly resolve: (value: T) => void;
  readonly reject: (error: Error) => void;
}

export class WorkerPool<T> {
  private readonly idle: Worker[] = [];
  private readonly queue: Pending<T>[] = [];
  private spawned = 0;

  // availableParallelism ton trong gioi han CPU cua container.
  // os.cpus().length dem nhan cua CA MAY CHU — trong Docker la con so sai.
  constructor(
    private readonly script: string,
    private readonly size = availableParallelism(),
  ) {}

  private create(): Worker {
    this.spawned += 1;
    return new Worker(this.script);
  }

  run(data: unknown): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      this.queue.push({ data, resolve, reject });
      this.pump();
    });
  }

  private pump(): void {
    const job = this.queue.shift();
    if (job === undefined) return;

    const worker = this.idle.pop() ?? (this.spawned < this.size ? this.create() : undefined);
    if (worker === undefined) {
      this.queue.unshift(job); // Het worker ranh — xep lai hang, cho luot sau.
      return;
    }

    worker.once('message', (result: { digest?: string; error?: string }) => {
      if (result.error !== undefined) job.reject(new Error(result.error));
      else job.resolve(result as T);

      this.idle.push(worker);
      this.pump(); // Tra worker ve ho roi keo viec tiep theo.
    });

    worker.postMessage(job.data);
  }
}

3.1. Chọn cái nào

child_process hay worker_threads
                    child_process           worker_threads
Bo nho              rieng hoan toan         chung, chia se duoc buffer
Khoi dong           30-50ms                 3-5ms
Truyen du lieu      serialize qua IPC       SharedArrayBuffer: 0 chi phi
                                            postMessage: van phai copy
Su co               mot tien trinh chet     mot luong chet co the keo
                    khong anh huong ai      ca tien trinh theo
Chay duoc gi        BAT KY chuong trinh nao chi JavaScript
Dung khi            goi ffmpeg, imagemagick hash, nen anh, tinh toan
                    bat cu binary nao       thuan JS trong Node

Quy tắc rút gọn: chương trình bên ngoài thì child_process, JavaScript của chính bạn thì worker_threads. Dự án này dùng cả hai vì nó có cả hai loại việc — ffmpeg là chương trình bên ngoài, còn hàm băm là JavaScript.

💡 Dòng dữ liệu duy nhất không phải trả giá sao chép
postMessage mặc định sao chép dữ liệu — gửi một buffer 500 MB sang worker là tốn thêm 500 MB và một khoảng lặng đáng kể. Có hai cách tránh: SharedArrayBuffer để hai luồng cùng nhìn một vùng nhớ, hoặc danh sách transfer để chuyển quyền sở hữu:

worker.postMessage(buffer, [buffer]);

Sau dòng này, buffer ở luồng gửi trở thành rỗng — nó đã sang bên kia, không có bản sao nào được tạo. Kiểu dữ liệu không cảnh báo bạn về điều đó, nên đây là một trong số ít chỗ phải nhớ bằng đầu.

4. Hàng đợi: tách công việc khỏi request

Giờ ghép lại. Endpoint chuyển mã không chạy ffmpeg — nó ghi một dòng vào bảng jobs, đẩy một thông điệp vào hàng đợi, rồi trả về ngay.

src/job/job.controller.ts
import { Controller, Get, HttpCode, HttpStatus, Param, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RateLimitGuard } from '../common/rate-limit/rate-limit.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { JwtPayload } from '../auth/jwt-payload';
import { JobService, type JobState } from './job.service';

@Controller()
@UseGuards(JwtAuthGuard, RateLimitGuard)
export class JobController {
  constructor(private readonly jobs: JobService) {}

  @Post('media/:videoId/transcode')
  @HttpCode(HttpStatus.ACCEPTED) // 202: da nhan, chua lam xong
  async transcode(
    @Param('videoId') videoId: string,
    @CurrentUser() user: JwtPayload,
  ): Promise<{ jobId: string }> {
    const job = await this.jobs.enqueue(videoId, user.sub);
    return { jobId: job.id };
  }

  // Nguon su that goi lai luc connect/reconnect — xem muc 6-7.
  @Get('jobs/active')
  async active(@CurrentUser() user: JwtPayload): Promise<JobState[]> {
    return this.jobs.findActiveForUser(user.sub);
  }
}
src/job/job.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Job } from './job.entity';
import { JobQueue } from './job.queue';

export interface JobState {
  readonly id: string;
  readonly videoTitle: string;
  readonly status: 'queued' | 'processing' | 'completed' | 'failed';
  readonly percent: number;
}

@Injectable()
export class JobService {
  constructor(
    @InjectRepository(Job) private readonly jobs: Repository<Job>,
    private readonly queue: JobQueue,
  ) {}

  async enqueue(videoId: string, userId: string, height: 480 | 720 | 1080 = 720): Promise<Job> {
    const job = await this.jobs.save(this.jobs.create({ videoId }));
    await this.queue.push({ id: job.id, videoId, userId, height, attempt: 0 });
    return job;
  }

  async markFailed(jobId: string, error: string): Promise<void> {
    await this.jobs.update(jobId, { status: 'failed', error, finishedAt: new Date() });
  }

  // Nguon su that cho FE khi connect/reconnect (muc 6) — WebSocket chi tang
  // toc, bang jobs moi la thu luon dung. "Active" nghia la job chua xong
  // (queued/processing) hoac vua xong/hong trong 24 gio gan nhat, de UI con
  // thay duoc buoc chuyen trang thai cuoi cung sau khi reconnect.
  async findActiveForUser(userId: string): Promise<JobState[]> {
    const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);

    const jobs = await this.jobs
      .createQueryBuilder('job')
      .innerJoinAndSelect('job.video', 'video')
      .where('video.user_id = :userId', { userId })
      .andWhere("(job.status IN ('queued', 'processing') OR job.updated_at >= :cutoff)", { cutoff })
      .orderBy('job.created_at', 'DESC')
      .getMany();

    return jobs.map((job) => ({
      id: job.id,
      videoTitle: job.video.originalName ?? job.videoId,
      status: job.status,
      // Phan tram song chi ton tai qua Redis pub/sub trong luc dang chay —
      // khong luu vao Postgres. WebSocket se day gia tri that ngay sau khi
      // connect (xem useJobProgress.ts o muc 7).
      percent: job.status === 'completed' ? 100 : 0,
    }));
  }
}
📝 JobControllerJobService chưa từng xuất hiện
this.jobs.enqueue(...) ở trên giả định một service đã tồn tại sẵn, nhưng JobService — nơi thật sự ghi dòng vào bảng jobs rồi đẩy vào JobQueue — chưa từng được viết ra ở đâu trong cả bốn phần. Khai báo đầy đủ ở trên, cùng với việc bọc endpoint vào một JobController thật — kèm luôn GET /jobs/active mà mục 6-7 phía dưới dùng để đồng bộ lại sau khi mất kết nối, cũng chưa từng có route nào phục vụ nó.

202 Accepted chứ không phải 200 OK. Khác biệt không chỉ nằm ở con số: nó nói với client rằng kết quả chưa có, và client phải theo dõi bằng cách khác — chính là WebSocket ở mục 6.

Hàng đợi ở đây dùng Redis Streams thay vì danh sách. Lý do là XACK: một thông điệp lấy ra nhưng chưa được xác nhận vẫn nằm trong danh sách chờ, nên worker chết giữa chừng thì job không biến mất. Với LPOP thì thông điệp rời hàng đợi ngay lúc đọc, worker chết là mất luôn.

src/job/job.queue.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { RedisService } from '../redis/redis.service';

export interface TranscodeJob {
  readonly id: string; // trung voi Job.id trong Postgres
  readonly videoId: string;
  readonly userId: string;
  readonly height: 480 | 720 | 1080;
  readonly attempt: number;
  // Gan boi pull() sau khi doc tu Redis Streams — push() khong co gia tri nay.
  readonly messageId?: string;
  // Chi co khi day sang hang doi chet (muc 4.1).
  readonly lastError?: string;
}

const STREAM = 'jobs:transcode';
const GROUP = 'workers';

function parseMessage(fields: string[]): TranscodeJob {
  // xadd luu 'data' <json> — fields la mang phang [key, value, key, value, ...].
  const raw = fields[fields.indexOf('data') + 1] as string;
  return JSON.parse(raw) as TranscodeJob;
}

@Injectable()
export class JobQueue implements OnModuleInit {
  constructor(private readonly redis: RedisService) {}

  async onModuleInit(): Promise<void> {
    // MKSTREAM: tao stream neu chua co. '$' = bo qua nhung message co tu
    // truoc, chi doc nhung gi den sau khi group nay duoc tao.
    try {
      await this.redis.xgroup('CREATE', STREAM, GROUP, '$', 'MKSTREAM');
    } catch (err) {
      // BUSYGROUP: group da ton tai tu lan khoi dong truoc — khong phai loi that.
      if (!String(err).includes('BUSYGROUP')) throw err;
    }
  }

  async push(payload: TranscodeJob): Promise<string> {
    const id = await this.redis.xadd(STREAM, '*', 'data', JSON.stringify(payload));
    if (id === null) throw new Error('XADD khong tra ve id.');
    return id;
  }

  async pull(consumerName: string): Promise<TranscodeJob | null> {
    // BLOCK 5000: cho toi 5 giay neu hang doi rong, khong quay vong hoi lien tuc.
    // Chu thich kieu tra ve bang tay: chu ky bien the nay cua xreadgroup khong
    // duoc ioredis suy ra dung, TypeScript thay "unknown" neu khong ep kieu.
    const result = (await this.redis.xreadgroup(
      'GROUP', GROUP, consumerName,
      'COUNT', 1,
      'BLOCK', 5000,
      'STREAMS', STREAM, '>',
    )) as [string, [string, string[]][]][] | null;

    if (result === null || result.length === 0) return null;

    const [messageId, fields] = result[0]![1][0]!;
    return { ...parseMessage(fields), messageId };
  }

  async ack(messageId: string): Promise<void> {
    await this.redis.xack(STREAM, GROUP, messageId);
  }
}
📝 TranscodeJob, parseMessage và lớp bao quanh không có sẵn
Ba hàm trên dùng this.redis nên phải nằm trong một class thật, và TranscodeJob — kiểu dữ liệu xuất hiện trong cả ba chữ ký hàm — chưa từng được định nghĩa ở đâu trước đó. Khai báo đầy đủ ở trên để phần còn lại của mục này biên dịch được.

4.1. Thử lại, và nơi chứa job không bao giờ chạy được

Job thất bại có hai loại rất khác nhau. Loại thứ nhất là tạm thời: hết chỗ trống trên đĩa, mạng chớp tắt, database bận. Thử lại là chữa được. Loại thứ hai là vĩnh viễn: file không phải video, codec không hỗ trợ. Thử lại một nghìn lần vẫn hỏng — và nếu bạn cứ thử, một job hỏng sẽ chiếm worker mãi mãi và chặn tất cả các job phía sau.

Hai loại thất bại cần hai nơi khác nhau để đợi: một hàng đợi trễ cho lần thử lại kế tiếp, một hàng đợi chết cho thứ không thử lại nữa. Thêm hai method vào JobQueue đã viết ở mục 4:

src/job/job.queue.ts — thêm hàng đợi trễ & hàng đợi chết
// them vao class JobQueue o muc 4
private readonly DELAYED_KEY = 'jobs:delayed'; // sorted set: score = thoi diem (ms) duoc phep chay
private readonly DEAD_LETTER_STREAM = 'jobs:dead';

// Khong day thang vao stream chinh — xep vao sorted-set voi diem la thoi
// diem duoc phep chay. promoteDelayed() (goi dinh ky) day nhung job da toi
// han sang stream chinh.
async pushDelayed(payload: TranscodeJob, delayMs: number): Promise<void> {
  await this.redis.zadd(this.DELAYED_KEY, Date.now() + delayMs, JSON.stringify(payload));
}

async promoteDelayed(): Promise<number> {
  const due = await this.redis.zrangebyscore(this.DELAYED_KEY, 0, Date.now());
  for (const raw of due) {
    // ZREM truoc: hai worker cung quet dung luc thi chi mot ben con xoa
    // duoc, ben kia nhan 0 — khong day trung.
    const removed = await this.redis.zrem(this.DELAYED_KEY, raw);
    if (removed > 0) await this.push(JSON.parse(raw) as TranscodeJob);
  }
  return due.length;
}

// Khong dung consumer group — day la noi con nguoi doc lai bang XRANGE,
// khong ai XACK ca.
async pushDeadLetter(payload: TranscodeJob): Promise<string> {
  const id = await this.redis.xadd(this.DEAD_LETTER_STREAM, '*', 'data', JSON.stringify(payload));
  if (id === null) throw new Error('XADD khong tra ve id.');
  return id;
}

promoteDelayed() cần một nơi gọi định kỳ — giống hệt OutboxRelay ở Part 4, chỉ khác nó chạy trong tiến trình worker:

src/job/delayed-job-promoter.ts
import { Injectable } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { JobQueue } from './job.queue';

@Injectable()
export class DelayedJobPromoter {
  constructor(private readonly queue: JobQueue) {}

  @Interval(1000)
  async flush(): Promise<void> {
    await this.queue.promoteDelayed();
  }
}

Giờ mới tới vòng lặp chính — một method riêng của JobRunner ở mục 4.4, không phải hàm rời:

src/worker/job.runner.ts — trích đoạn processOne()
const MAX_ATTEMPTS = 3;

// Method cua JobRunner, KHONG PHAI mot ham doc lap — mot function() thuong
// khong co `this` gan san, trong khi than ham ben duoi dung this.queue,
// this.jobs. Xem class JobRunner day du o muc 4.4.
private async processOne(job: TranscodeJob): Promise<void> {
  try {
    await this.runTranscode(job);
    await this.queue.ack(job.messageId!);
  } catch (error) {
    const attempt = job.attempt + 1;

    if (attempt >= MAX_ATTEMPTS) {
      // Het luot: day sang hang doi chet. KHONG xoa — con nguoi can xem lai.
      await this.queue.pushDeadLetter({ ...job, lastError: String(error) });
      await this.queue.ack(job.messageId!);
      await this.jobs.markFailed(job.id, String(error));
      return;
    }

    // Cho lau dan: 2s, 4s, 8s. Thu lai ngay lap tuc chi lam nang them
    // dung cai he thong dang gap su co.
    const delayMs = 2 ** attempt * 1000;
    await this.queue.pushDelayed({ ...job, attempt }, delayMs);
    await this.queue.ack(job.messageId!);
  }
}
🔍 Hàng đợi chết không phải là thùng rác
Nó là nơi bạn tìm thấy lỗi của mình. Một job nằm đó có nghĩa hệ thống gặp thứ nó không xử lý được — có thể là định dạng chưa hỗ trợ, có thể là một lỗi thật trong code. Xoá đi là vứt luôn thông tin đó, và người dùng vẫn ngồi chờ một video không bao giờ xong.

Việc tối thiểu cần làm: đặt cảnh báo khi hàng đợi chết có phần tử, và giữ đủ ngữ cảnh (id video, thông báo lỗi cuối) để đẩy lại được sau khi vá.

4.2. Tính bình thản khi thử lại

Có một cái bẫy tinh vi. Worker chuyển mã xong, trừ credit của người dùng, rồi chết trước khi kịp XACK. Redis thấy thông điệp chưa được xác nhận nên giao lại cho worker khác — và người dùng bị trừ tiền lần thứ hai cho cùng một video.

Đây lại đúng là gia đình của lỗi ở Part 1, chỉ khác là lần này thủ phạm không phải hai request đồng thời mà là một thông điệp được xử lý hai lần. Lời giải là làm cho thao tác bình thản: chạy hai lần cho kết quả giống hệt chạy một lần.

src/billing/billing.service.ts — trừ tiền bình thản
export interface ChargeForJobResult {
  readonly charged: boolean;
  readonly balance: number;
}

async chargeForJob(jobId: string, userId: string, amount: number): Promise<ChargeForJobResult> {
  return this.dataSource.transaction(async (manager) => {
    // So cai append-only tu Part 1, gio them mot rang buoc duy nhat tren job_id.
    // Lan chay thu hai va vao rang buoc nay va bi tu choi.
    const result = await manager
      .createQueryBuilder()
      .insert()
      .into(CreditEntry)
      .values({ userId, jobId, delta: -amount, reason: 'transcode' })
      .orIgnore() // ON CONFLICT DO NOTHING
      .execute();

    // Bi bo qua vi trung job_id thi RETURNING khong tra dong nao — result.raw rong.
    const charged = result.raw.length > 0;

    const row = await manager
      .createQueryBuilder(CreditEntry, 'e')
      .select('COALESCE(SUM(e.delta), 0)', 'balance')
      .where('e.user_id = :userId', { userId })
      .getRawOne<{ balance: string }>();

    return { charged, balance: Number(row?.balance ?? 0) };
  });
}

Một chỉ mục duy nhất trên job_id và một mệnh đề ON CONFLICT DO NOTHING — thế là xong. Không cần cờ, không cần bảng phụ theo dõi "đã xử lý chưa", không có cửa sổ đua nào. Database giữ ràng buộc, và nó giữ đúng kể cả khi hai worker chạy cùng lúc trên hai máy khác nhau.

⚠️ result.identifiers nói dối khi bị ON CONFLICT DO NOTHING bỏ qua
Bản năng đầu tiên là kiểm result.identifiers.length > 0 để biết dòng có được chèn hay không. Đo thật trên Postgres cho thấy điều ngược lại: khi bị xung đột bỏ qua, identifiers không phải mảng rỗng — nó là mảng có đúng một phần tử, mang giá trị null ([null]), nên .length > 0 vẫn đúng ở cả hai lần gọi và luôn báo charged: true. Tín hiệu đáng tin là result.raw: rỗng khi bị bỏ qua, có một dòng khi chèn thành công, vì nó phản ánh đúng những gì mệnh đề RETURNING thực sự trả về.

4.3. Worker là một tiến trình như thế nào

Từ đầu part này worker được nhắc tới như một thứ có sẵn. Nó không có sẵn — phải tự dựng, và điểm hay là nó dùng lại toàn bộ module của ứng dụng, chỉ bỏ đi phần HTTP.

src/worker/main.ts
import { NestFactory } from '@nestjs/core';
import { WorkerModule } from './worker.module';
import { JobRunner } from './job.runner';

async function bootstrap(): Promise<void> {
  // createApplicationContext, KHONG phai create(). Co DI, co ConfigService,
  // co TypeORM — nhung khong mo cong nao, khong co controller nao.
  const app = await NestFactory.createApplicationContext(WorkerModule);
  app.enableShutdownHooks();

  const runner = app.get(JobRunner);
  await runner.loop();
}

void bootstrap();

Đây là chỗ trả cổ tức cho quyết định ở Part 1: chia thư mục theo miền nghiệp vụ chứ không theo loại file. WorkerModule nhập BillingModuleJobModule y như AppModule làm, nên hàm trừ credit mà worker gọi là chính xác hàm mà API gọi — không có bản sao nào để trôi khỏi nhau.

package.json — thêm hai lệnh
{
  "scripts": {
    "start:dev": "nest start --watch",
    "start:worker": "node dist/worker/main.js",
    "start:worker:dev": "nest start --watch --entryFile worker/main"
  }
}
docker-compose.yml — dịch vụ worker
services:
  worker:
    build: .
    command: npm run start:worker
    init: true # PID 1 tu te — don tien trinh ffmpeg mo coi, xem canh bao muc 2.3
    environment:
      DATABASE_URL: postgres://app:secret@postgres:5432/media
      REDIS_URL: redis://redis:6379
    volumes:
      - media:/var/media # dung chung thu muc voi API
    depends_on:
      postgres: { condition: service_healthy }
      redis: { condition: service_started }
    deploy:
      replicas: 2 # hai worker chay song song, Redis Streams tu chia viec
src/worker/worker.module.ts
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
import { AppConfigModule } from '../config/config.module';
import { sharedOptions } from '../database/typeorm.options';
import type { AppConfig } from '../config/configuration';
import { RedisModule } from '../redis/redis.module';
import { BillingModule } from '../billing/billing.module';
import { JobModule } from '../job/job.module';
import { Video } from '../media/video.entity';
import { Job } from '../job/job.entity';
import { DelayedJobPromoter } from '../job/delayed-job-promoter';
import { JobRunner } from './job.runner';

// Dung lai toan bo module nghiep vu — chia thu muc theo mien nghiep vu
// tra co tuc dung o day (xem Part 1, muc 1).
@Module({
  imports: [
    AppConfigModule,
    TypeOrmModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService<AppConfig, true>) => ({
        ...sharedOptions,
        url: config.get('DATABASE_URL', { infer: true }),
      }),
    }),
    // Video CUNG Job — JobRunner tiem thang Repository<Job>, va JobModule
    // khong export forFeature cua rieng no nen phai khai lai o day.
    TypeOrmModule.forFeature([Video, Job]),
    ScheduleModule.forRoot(), // bat buoc de @Interval o DelayedJobPromoter chay that
    RedisModule,
    BillingModule,
    JobModule,
  ],
  providers: [JobRunner, DelayedJobPromoter],
})
export class WorkerModule {}
⚠️ Thiếu ScheduleModule.forRoot() thì @Interval im lặng không chạy
@Interval (dùng ở DelayedJobPromoter trên, và ở OutboxRelay tại Part 4) đến từ gói @nestjs/schedule — cần npm i @nestjs/schedule và đăng ký ScheduleModule.forRoot() trong module gốc của mỗi tiến trình dùng nó. Thiếu bước này ứng dụng vẫn khởi động bình thường, không báo lỗi gì cả — chỉ riêng hàm gắn @Interval không bao giờ được gọi. Đây là kiểu lỗi khó phát hiện nhất: mọi thứ trông như chạy đúng cho tới khi có ai hỏi vì sao hàng đợi trễ không bao giờ được đẩy lại.

4.4. Dừng worker mà không bỏ dở job

Triển khai bản mới nghĩa là gửi SIGTERM cho worker. Nếu nó chết ngay lập tức, job đang chuyển mã dở dang bị bỏ lại — thông điệp chưa XACK nên rồi cũng được giao lại, nhưng công sức mã hoá mấy phút vừa rồi mất trắng, và có một file tạm nằm lại trên đĩa.

src/worker/job.runner.ts
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { hostname } from 'node:os';
import { dirname, join } from 'node:path';
import { Job } from '../job/job.entity';
import { JobQueue } from '../job/job.queue';
import { JobService } from '../job/job.service';
import type { TranscodeJob } from '../job/job.queue';
import { Video } from '../media/video.entity';
import { BillingService } from '../billing/billing.service';
import { RedisService } from '../redis/redis.service';
import { transcode } from './transcoder';

// Gia tren moi do phan giai — dung cho demo, thuc te se doc tu bang gia.
const PRICE_BY_HEIGHT: Record<TranscodeJob['height'], number> = { 480: 5, 720: 10, 1080: 20 };

// So lan thu toi da truoc khi day sang hang doi chet (muc 4.1).
const MAX_ATTEMPTS = 3;

@Injectable()
export class JobRunner implements OnApplicationShutdown {
  private stopping = false;
  private current: Promise<void> | null = null;
  private readonly consumerName = `${hostname()}-${process.pid}`;

  constructor(
    private readonly queue: JobQueue,
    private readonly jobs: JobService,
    @InjectRepository(Job) private readonly jobRepo: Repository<Job>,
    @InjectRepository(Video) private readonly videos: Repository<Video>,
    private readonly billing: BillingService,
    private readonly redis: RedisService,
  ) {}

  async loop(): Promise<void> {
    while (!this.stopping) {
      const job = await this.queue.pull(this.consumerName);
      if (job === null) continue; // het 5 giay cho ma khong co viec

      this.current = this.processOne(job);
      await this.current;
      this.current = null;
    }
  }

  // Nest goi ham nay khi nhan SIGTERM (nho enableShutdownHooks).
  async onApplicationShutdown(): Promise<void> {
    this.stopping = true; // khong nhan viec moi nua
    await this.current; // nhung lam not viec dang do
  }

  // Method cua JobRunner, KHONG PHAI mot ham doc lap — mot function() thuong
  // khong co `this` gan san, ma than ham duoi day dung this.queue, this.jobs.
  private async processOne(job: TranscodeJob): Promise<void> {
    try {
      await this.runTranscode(job);
      await this.queue.ack(job.messageId!);
    } catch (error) {
      const attempt = job.attempt + 1;

      if (attempt >= MAX_ATTEMPTS) {
        // Het luot: day sang hang doi chet. KHONG xoa — con nguoi can xem lai.
        await this.queue.pushDeadLetter({ ...job, lastError: String(error) });
        await this.queue.ack(job.messageId!);
        await this.jobs.markFailed(job.id, String(error));
        return;
      }

      // Cho lau dan: 2s, 4s, 8s. Thu lai ngay lap tuc chi lam nang them
      // dung cai he thong dang gap su co.
      const delayMs = 2 ** attempt * 1000;
      await this.queue.pushDelayed({ ...job, attempt }, delayMs);
      await this.queue.ack(job.messageId!);
    }
  }

  private async runTranscode(job: TranscodeJob): Promise<void> {
    const video = await this.videos.findOneOrFail({ where: { id: job.videoId } });
    await this.jobRepo.update(job.id, { status: 'processing', startedAt: new Date() });

    const controller = new AbortController();
    const output = join(dirname(video.originalKey), `${video.id}-${job.height}p.mp4`);

    await transcode({
      input: video.originalKey,
      output,
      height: job.height,
      durationSeconds: video.duration ?? 60,
      signal: controller.signal,
      onProgress: (percent) => {
        // Worker khong biet gi ve WebSocket. No chi phat ra Redis (muc 6).
        void this.redis.publish('progress', JSON.stringify({ userId: job.userId, jobId: job.id, percent }));
      },
    });

    // Tru credit BINH THAN — an toan neu job nay bi xu ly lai (muc 4.2).
    await this.billing.chargeForJob(job.id, job.userId, PRICE_BY_HEIGHT[job.height]);
    await this.jobRepo.update(job.id, { status: 'completed', finishedAt: new Date() });

    // Kenh RIENG voi 'progress' — client can biet job da xong HOAN TOAN,
    // khong chi la mot moc phan tram nua trong hang dai cac moc trung gian.
    void this.redis.publish('job:done', JSON.stringify({ userId: job.userId, jobId: job.id }));
  }
}
⚠️ Thiếu sự kiện job:done thì UI kẹt ở "đang xử lý" mãi mãi
useJobProgress.ts ở mục 7 nghe 'job:progress' 'job:done' như hai sự kiện tách biệt — chỉ nhánh job:done mới chuyển status sang 'completed'. Nếu worker chỉ phát 'progress' (kể cả lúc 100%), phía client vẫn thấy thanh chạy đủ nhưng nhãn trạng thái đứng nguyên ở 'processing' — vì không nhánh nào trong useJobProgress.ts đọc percent để tự suy ra job đã xong.
📝 processOne được ghép vào từ mục 4.1
loop() gọi this.processOne(job) — thân của nó đã viết riêng ở mục 4.1 khi bàn về thử lại, và vừa được ghép vào ngay trên, ngay trước runTranscode. Cùng với runTranscode — nơi ghép transcode() (mục 2.2), RedisService.publish (mục 6), và BillingService.chargeForJob (mục 4.2) lại với nhau — class JobRunner ở trên giờ đầy đủ.
⚠️ Thời gian ân hạn của bộ điều phối
Docker cho 10 giây giữa SIGTERMSIGKILL; Kubernetes cho 30 giây. Một job chuyển mã cần vài phút — nên đoạn code trên sẽ bị cắt ngang bất kể nó lịch sự đến đâu.

Phải nâng hạn: stop_grace_period: 10m trong Compose, hoặc terminationGracePeriodSeconds: 600 trong Kubernetes. Và vẫn phải chấp nhận rằng một job đủ dài sẽ bị cắt — đó là lý do XACK đặt sau khi làm xong chứ không phải trước, và là lý do thao tác trừ tiền phải bình thản như mục 4.2.
docker-compose.yml — thêm thời gian ân hạn
services:
  worker:
    # ... build, command, init, environment, volumes, depends_on da chot o muc 4.3 ...
    stop_grace_period: 10m # doi toi 10 phut truoc khi Docker gui SIGKILL
    deploy:
      replicas: 2

5. cluster: dùng hết số nhân CPU

Worker lo phần nặng rồi, nhưng bản thân API vẫn chỉ chạy trên một luồng, tức là một nhân. Máy chủ 8 nhân thì 7 nhân ngồi không.

cluster nhân bản tiến trình: một tiến trình chính đẻ ra N tiến trình con, tất cả cùng nghe trên một cổng, và hệ điều hành chia kết nối cho chúng.

Mỗi tiến trình con phải tự chạy được một bản NestJS đầy đủ — tức là gọi lại đúng đoạn NestFactory.create(AppModule)app.listen(...) đã thấy ở Part 1 và Part 2. Bọc đoạn đó vào một hàm export function bootstrap() trong src/main.ts, và chỉ tự gọi nó khi file chạy trực tiếp — để cluster.ts phía dưới import lại đúng hàm này mà không chạy nó thêm một lần nữa lúc import:

src/main.ts — bọc bootstrap để cluster.ts import lại
export async function bootstrap(): Promise<void> {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  app.set('trust proxy', 1); // xem Part 2, muc 4.2
  await app.listen(process.env.PORT ?? 3000);
}

// Chi tu chay khi file nay la entry point that su (npm run start:dev goi
// thang main.ts) — tranh chay bootstrap() hai lan khi cluster.ts import ham nay.
if (require.main === module) {
  void bootstrap();
}
src/cluster.ts
import { bootstrap } from './main';
import cluster from 'node:cluster';
import { availableParallelism } from 'node:os';

if (cluster.isPrimary) {
  const count = availableParallelism();

  for (let i = 0; i < count; i += 1) {
    cluster.fork();
  }

  cluster.on('exit', (worker, code, signal) => {
    // Chet vi loi thi dung ngay mot cai moi. Con neu la dung co y
    // (SIGTERM khi trien khai ban moi) thi de yen cho no thoat.
    if (signal !== 'SIGTERM') {
      console.error(`worker ${worker.process.pid} chet (${code}), dung lai`);
      cluster.fork();
    }
  });
} else {
  // bootstrap() CUA src/main.ts (tien trinh API, co app.listen()) — KHONG
  // PHAI bootstrap() cua src/worker/main.ts (tien trinh worker, khong HTTP,
  // xem muc 4.3). Hai file dinh nghia hai ham trung ten nhung khac nhau.
  void bootstrap();
}

Nhưng khi có nhiều tiến trình, ba thứ trước đây đúng bỗng thành sai:

Ba thứ vỡ khi chuyển từ một sang nhiều tiến trình
1. Trang thai trong bo nho
   Cache bang Map trong tien trinh -> 8 ban khac nhau, khong dong bo.
   => Redis.

2. Ket noi WebSocket
   Nguoi dung noi vao tien trinh 3. Tien do phat tu worker khong den duoc.
   => Redis pub/sub (muc 6).

3. Cong viec dinh ky
   @Cron chay o CA 8 tien trinh -> gui email 8 lan.
   => Khoa trong Redis, hoac tach han ra mot tien trinh scheduler rieng.
💡 cluster hay nhiều container
Trong môi trường có bộ điều phối container (Kubernetes, ECS, hay đơn giản là docker compose --scale), cách thường dùng là một tiến trình Node cho mỗi container và để bộ điều phối nhân bản container. Bạn được kiểm tra sức khoẻ, triển khai cuốn chiếu và giới hạn tài nguyên từng bản — những thứ cluster không có.

cluster vẫn là lựa chọn hợp lý khi chạy trên một máy chủ đơn lẻ, hoặc khi muốn tận dụng hết nhân của một container lớn. Điều đáng nói là ba vấn đề ở trên giống hệt nhau trong cả hai cách — nên hiểu chúng ở đây thì lên Kubernetes không gặp bất ngờ nào.

5.1. nginx cân tải nhiều instance

nginx/media-forge.conf — mở rộng khối upstream
upstream app {
    least_conn;              # gui toi instance dang it ket noi nhat
    server app1:3000 max_fails=3 fail_timeout=30s;
    server app2:3000 max_fails=3 fail_timeout=30s;
    server app3:3000 max_fails=3 fail_timeout=30s;
    keepalive 32;
}

least_conn hợp hơn kiểu chia lượt đều cho hệ thống này, vì thời gian xử lý mỗi request rất lệch nhau: một lượt đăng nhập mất mấy mili giây, một lượt upload mất mấy phút. Chia đều theo lượt thì instance nào xui nhận liên tiếp mấy upload sẽ quá tải trong khi các instance khác rảnh.

max_fails=3 fail_timeout=30s nghĩa là ba lần lỗi liên tiếp thì nginx tạm loại instance đó khỏi vòng quay 30 giây. Một instance đang khởi động lại không kéo theo lỗi cho người dùng.

6. Realtime: đẩy tiến độ về đúng người

Người dùng bấm chuyển mã và nhận về 202. Giờ họ cần thấy thanh tiến trình nhích. Cách thô sơ là gọi hỏi liên tục mỗi giây — nhưng một nghìn người xem tiến độ là một nghìn request mỗi giây cho một con số hầu như không đổi.

WebSocket đảo chiều: một kết nối mở, server chủ động đẩy khi có gì mới. Nhưng nó đặt ra một câu hỏi mà mục 5 đã báo trước — worker biết tiến độ, nhưng kết nối WebSocket nằm ở một tiến trình khác.

src/gateway/progress.gateway.ts
import { OnGatewayConnection, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import type { Server, Socket } from 'socket.io';
import { JwtService } from '@nestjs/jwt';
import type { JwtPayload } from '../auth/jwt-payload';

@WebSocketGateway({ path: '/ws', cors: false })
export class ProgressGateway implements OnGatewayConnection {
  @WebSocketServer() private readonly server!: Server;

  constructor(private readonly jwt: JwtService) {}

  async handleConnection(client: Socket): Promise<void> {
    try {
      // Xac thuc NGAY luc bat tay. Ket noi chua xac thuc thi dong luon —
      // de no mo la de nguoi la giu tai nguyen cua ban.
      const token = String(client.handshake.auth['token'] ?? '');
      const payload = await this.jwt.verifyAsync<JwtPayload>(token);

      // Moi nguoi dung mot phong rieng. Khong ai nghe duoc tien do cua ai.
      await client.join(`user:${payload.sub}`);
    } catch {
      client.disconnect(true);
    }
  }

  // Duoc goi khi co thong diep tu Redis pub/sub.
  emitProgress(userId: string, jobId: string, percent: number): void {
    this.server.to(`user:${userId}`).emit('job:progress', { jobId, percent });
  }

  // Su kien RIENG, khac 'job:progress' — bao cho client biet job da xong
  // hoan toan (kem tru credit), khong chi la mot moc phan tram nua.
  emitDone(userId: string, jobId: string): void {
    this.server.to(`user:${userId}`).emit('job:done', { jobId });
  }
}
📝 Không có tham số redis ở đây
Kết nối Redis dùng để lắng nghe kênh progress thuộc về ProgressSubscriber ngay bên dưới, không phải ProgressGateway — gateway chỉ cần JwtService để xác thực bắt tay. Thêm tham số redis: RedisService vào constructor mà không dùng tới sẽ biên dịch lỗi TS6138 dưới noUnusedLocals mà chính Part 1 khuyến nghị bật.
src/worker/progress-publisher.ts — phía worker
// Worker khong biet gi ve WebSocket. No chi phat ra Redis.
await this.redis.publish(
  'progress',
  JSON.stringify({ userId: job.userId, jobId: job.id, percent }),
);
src/gateway/progress.subscriber.ts — phía gateway
import { Injectable, OnModuleInit } from '@nestjs/common';
import type { Redis } from 'ioredis';
import { RedisService } from '../redis/redis.service';
import { ProgressGateway } from './progress.gateway';

interface ProgressEvent {
  readonly userId: string;
  readonly jobId: string;
  readonly percent: number;
}

interface DoneEvent {
  readonly userId: string;
  readonly jobId: string;
}

@Injectable()
export class ProgressSubscriber implements OnModuleInit {
  private readonly subscriber: Redis;

  constructor(
    private readonly redis: RedisService,
    private readonly gateway: ProgressGateway,
  ) {
    // MOT ket noi Redis RIENG chi de dang ky nghe. Ket noi dang o che do
    // subscribe khong chay duoc lenh thuong nao khac.
    this.subscriber = this.redis.duplicate();
  }

  onModuleInit(): void {
    // Dang ky ca hai kenh — tham so channel cua callback tu phan biet duoc
    // thong diep nao thuoc kenh nao, khong can hai handler rieng.
    this.subscriber.subscribe('progress', 'job:done');

    this.subscriber.on('message', (channel: string, raw: string) => {
      if (channel === 'job:done') {
        const event = JSON.parse(raw) as DoneEvent;
        this.gateway.emitDone(event.userId, event.jobId);
        return;
      }

      const event = JSON.parse(raw) as ProgressEvent;
      // Tien trinh nao dang giu ket noi cua nguoi do thi gui duoc.
      // Cac tien trinh con lai goi to() vao mot phong rong — khong sao ca.
      this.gateway.emitProgress(event.userId, event.jobId, event.percent);
    });
  }
}
📝 redis.duplicate() — nơi kết nối riêng thật sự được tạo
this.subscriber ở trên chưa từng được gán giá trị nếu chỉ nhìn đoạn onModuleInit. RedisService kế thừa từ ioredis nên có sẵn .duplicate() — tạo một kết nối mới cùng cấu hình, đúng thứ cần cho một kênh luôn ở chế độ subscribe.

Cả tám tiến trình đều nhận được thông điệp, nhưng chỉ tiến trình đang thật sự giữ kết nối của người dùng đó mới gửi được gì. Bảy tiến trình còn lại phát vào một phòng trống — vô hại, và đó chính là điều làm cho mô hình này đơn giản đến mức đáng tin.

6.1. nginx và cái bắt tay nâng cấp giao thức

WebSocket bắt đầu bằng một request HTTP thường có header Upgrade. nginx mặc định dùng HTTP/1.0 cho upstream và cắt bỏ các header liên quan đến kết nối — nên nếu không cấu hình, WebSocket sẽ không bao giờ bắt tay xong.

nginx/media-forge.conf — khối WebSocket
location /ws {
    proxy_pass http://app;

    proxy_http_version 1.1;              # bat buoc: 1.0 khong nang cap duoc
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host       $host;

    # Mac dinh 60s. Ket noi im lang qua 60s se bi cat — ma WebSocket thi
    # im lang la binh thuong.
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
}
⚠️ Cân tải WebSocket cần dính phiên
Socket.IO mặc định thử long-polling trước rồi mới nâng lên WebSocket, và giai đoạn đó gồm nhiều request HTTP phải tới cùng một instance. Với least_conn, chúng bị rải ra khắp nơi và kết nối thất bại một cách rất khó hiểu — thường biểu hiện là client cứ kết nối rồi rớt thành vòng lặp.

Thuật toán cân tải là thuộc tính của cả khối upstream, không thể khác nhau theo từng location dùng chung một khối. Hai cách chữa: tách một khối upstream riêng chỉ để /ws dùng —

upstream app_ws {
  ip_hash;
  server app1:3000;
  server app2:3000;
  server app3:3000;
}


— rồi đổi proxy_pass trong location /ws sang http://app_ws (khối app còn lại ở mục 5.1 giữ nguyên least_conn cho các request thường), hoặc ép client dùng thẳng WebSocket: io(url, { transports: ['websocket'] }) — bỏ hẳn giai đoạn polling và bỏ luôn vấn đề.

6.2. Mất kết nối và những gì bỏ lỡ

WebSocket sẽ rớt: wifi chuyển sang 4G, máy tính ngủ, nginx khởi động lại. Kết nối lại thì tự động, nhưng những sự kiện phát ra trong lúc mất kết nối thì đã trôi qua — pub/sub không lưu gì cả.

Cách chữa không nằm ở WebSocket mà ở chỗ khác: coi kênh realtime là thứ tăng tốc, không phải nguồn sự thật. Trạng thái thật luôn nằm trong bảng jobs.

api() gọi dưới đây là hàm tự làm mới token đã viết ở Part 2, mục 8.1 — mọi request qua GET /jobs/active đi kèm access token mới nhất mà không cần lặp lại logic refresh ở đây.

src/lib/progress.ts — phía client
import { api } from './api';

socket.on('connect', async () => {
  // Moi lan noi lai — ke ca lan dau — hoi mot phat trang thai hien tai.
  // Sau do WebSocket lo phan cap nhat.
  const res = await api('/jobs/active');
  const jobs = (await res.json()) as JobState[];
  setJobs(jobs);
});

socket.on('job:progress', (event: { jobId: string; percent: number }) => {
  setJobs((prev) =>
    prev.map((job) => (job.id === event.jobId ? { ...job, percent: event.percent } : job)),
  );
});

Bốn dòng trong connect khiến toàn bộ bài toán "bỏ lỡ sự kiện" biến mất. Mất mạng bao lâu cũng được: nối lại là đồng bộ lại từ đầu, rồi chạy tiếp bằng sự kiện.

7. Mốc #3: bảng tiến độ chạy theo thời gian thực

src/hooks/useJobProgress.ts
import { useEffect, useState } from 'react';
import { io, type Socket } from 'socket.io-client';
import { api } from '../lib/api';

export interface JobState {
  readonly id: string;
  readonly videoTitle: string;
  readonly status: 'queued' | 'processing' | 'completed' | 'failed';
  readonly percent: number;
}

export function useJobProgress(accessToken: string): readonly JobState[] {
  const [jobs, setJobs] = useState<readonly JobState[]>([]);

  useEffect(() => {
    const socket: Socket = io('/', {
      path: '/ws',
      transports: ['websocket'], // bo qua long-polling, xem canh bao o muc 6.1
      auth: { token: accessToken },
    });

    socket.on('connect', () => {
      void api('/jobs/active')
        .then((res) => res.json() as Promise<JobState[]>)
        .then(setJobs);
    });

    socket.on('job:progress', ({ jobId, percent }: { jobId: string; percent: number }) => {
      setJobs((prev) =>
        prev.map((job) => (job.id === jobId ? { ...job, percent, status: 'processing' } : job)),
      );
    });

    socket.on('job:done', ({ jobId }: { jobId: string }) => {
      setJobs((prev) =>
        prev.map((job) => (job.id === jobId ? { ...job, percent: 100, status: 'completed' } : job)),
      );
    });

    // Dong ket noi khi component bi go bo. Quen dong nay thi moi lan
    // dieu huong lai mo them mot socket nua.
    return () => {
      socket.disconnect();
    };
  }, [accessToken]);

  return jobs;
}
src/components/JobList.tsx
export function JobList({ token }: { token: string }): JSX.Element {
  const jobs = useJobProgress(token);

  return (
    <ul className="space-y-3">
      {jobs.map((job) => (
        <li key={job.id} className="rounded-lg border border-slate-200 p-4">
          <div className="flex justify-between text-sm">
            <span className="font-medium">{job.videoTitle}</span>
            <span className="text-slate-500">{job.percent}%</span>
          </div>
          <div className="mt-2 h-2 overflow-hidden rounded-full bg-slate-200">
            <div
              className="h-full bg-emerald-500 transition-[width] duration-300"
              style={{ width: `${job.percent}%` }}
            />
          </div>
        </li>
      ))}
    </ul>
  );
}

transition-[width] duration-300 làm thanh tiến trình trượt mượt giữa hai lần cập nhật. Không có nó, tiến độ nhảy giật từng nấc mỗi khi ffmpeg báo về, và cảm giác là hệ thống đang lag chứ không phải đang chạy.

🔍 Điều đáng nhớ nhất từ Part 3
Bốn công cụ của part này trông rất khác nhau, nhưng chúng trả lời cùng một câu hỏi: việc này chạy ở đâu để không chặn event loop.

child_process — ở một tiến trình của hệ điều hành. worker_threads — ở một luồng khác trong cùng tiến trình. cluster — ở một bản sao của chính ứng dụng. Hàng đợi — ở một thời điểm khác, tách hẳn khỏi request.

Chọn sai công cụ thì hoặc trả giá vô ích (đẻ tiến trình cho một việc 5 ms), hoặc chẳng giải quyết được gì (đưa việc nặng CPU sang một hàm async — nó vẫn chạy trên chính luồng đó).
💡 Part 4 tiếp tục từ đâu
Sản phẩm đã chạy trọn một vòng: đăng nhập, upload, chuyển mã, xem tiến độ, phát lại. Tất cả vẫn nằm trong một khối mã và một database — và điều đó đang là ưu điểm chứ không phải khuyết điểm.

Part 4 tách nó ra thành các dịch vụ riêng nối với nhau bằng gRPC, và nhìn thẳng vào cái giá phải trả: transaction của Part 1 vỡ ngay khi hai thao tác rơi vào hai dịch vụ khác nhau. Chỗ đó là nơi outbox pattern lần đầu tiên có nghĩa thật, thay vì là một sơ đồ đẹp trong sách.

Các phần trong loạt bài

Part 1: Nền móng, thiết kế CSDL & ACID Part 2: Xác thực, nginx, rate limit & streaming Part 4: Tách microservice, gRPC & outbox pattern Quay lại Blog

Bình luận