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.

src/demo/block.controller.ts — file tạm, xoá được sau khi xem xong
import { Controller, Get } from '@nestjs/common';

@Controller()
export class BlockController {
  @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';
  }
}

Thêm BlockController vào controllers: [...] của AppModule — cây thư mục ở Part 1 mục 3 không giữ lại AppControllernest new sinh ra, nên đây là controller đầu tiên nằm thẳng ở gốc ứng dụng.

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.

Terminal — hai tab, đo bằng đồng hồ
# Tab 1 — chay nen roi doi mot chut cho no kip khoi dong
( curl -s -o /dev/null -w 'block: %{time_total}s\n' http://localhost:3000/block & ); sleep 0.4

# Tab 2 — goi /ping trong luc /block dang chay
curl -s -o /dev/null -w 'ping:  %{time_total}s\n' http://localhost:3000/ping
# -> block: 5.005s
# -> ping:  4.600s      <-- mot ham `return 'pong'` ma mat 4,6 giay

# Doi chung: goi lai /ping luc event loop ranh
curl -s -o /dev/null -w 'ping:  %{time_total}s\n' http://localhost:3000/ping
# -> ping:  0.001s

Chênh lệch 0,001 giây so với 4,6 giây cho cùng một handler là con số đáng nhớ nhất của cả mục này. /ping không hề chậm đi — nó không được chạy chút nào cho tới khi /block nhả event loop ra.

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.

Và vì nó là một chương trình riêng chứ không phải một gói npm, npm i không cài được nó — phải cài ở mức hệ điều hành. Đây là phụ thuộc ngoài duy nhất của cả loạt bài:

Terminal — cài ffmpeg
brew install ffmpeg              # macOS
sudo apt install -y ffmpeg       # Ubuntu/Debian

ffmpeg -version                  # phai in ra so phien ban, khong phai "command not found"

Bước kiểm cuối đáng làm thật chứ không phải cho có: thiếu ffmpeg thì lỗi hiện ra ở tận trong worker dưới dạng spawn ffmpeg ENOENT, cách xa nguyên nhân thật, và mục 2.2 ngay dưới đây sẽ cho thấy vì sao kiểu lỗi đó đặc biệt khó chịu.

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 });

  // spawn bao loi KHOI DONG qua event 'error', KHONG qua ma thoat. Khong bat o
  // day thi thieu ffmpeg nem ra mot loi khong ai bat va giet ca worker, thay vi
  // hong mot job roi duoc thu lai nhu muc 4.1.
  const failed = new Promise<never>((_, reject) => {
    child.on('error', reject);
  });

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

  const readProgress = (async () => {
    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 closed = new Promise<number>((resolve) => child.on('close', resolve));

  // Chay dua voi `failed`: hong ngay luc khoi dong thi bat duoc lien, khong treo.
  const code = await Promise.race([
    (async () => {
      await readProgress;
      return closed;
    })(),
    failed,
  ]);

  if (code !== 0) {
    throw new Error(`ffmpeg thoat voi ma ${code}`);
  }
}
⚠️ 'error' và mã thoát là hai đường khác nhau
Đây là chỗ dễ viết thiếu nhất khi dùng spawn. Tiến trình con báo hỏng theo hai đường tách biệt: chạy được rồi thất bại thì báo qua mã thoát, còn không khởi động nổi (không tìm thấy chương trình, sai quyền, hết file descriptor) thì báo qua event 'error' — và event đó không bao giờ đi kèm một mã thoát nào.

Bỏ qua 'error' thì ENOENT trở thành lỗi không ai bắt. try/catch quanh transcode() không chạy, và cả tiến trình worker chết — tức là một job hỏng kéo theo mọi job khác đang chạy cùng worker đó. Toàn bộ cơ chế thử lại ở mục 4.1 dựng trên giả định "job hỏng thì ném lỗi"; thiếu bốn dòng này thì giả định đó sai ngay từ kiểu hỏng đầu tiên người đọc gặp phải.

Đọ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:

src/worker/transcoder.ts — THÊM vào trong transcode(), ngay sau khối bắt 'error'
// Leo thang hai buoc. `signal` chi gui SIGTERM — xin dung, cho don dep.
options.signal.addEventListener('abort', () => {
  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));
});
📝 Vì sao đoạn này nằm trong transcode()
Viết nó thành một hàm rời kiểu function stop(child: ChildProcess) nghe gọn hơn, nhưng không gọi được: child là biến cục bộ bên trong transcode(), và hàm đó không trả nó ra ngoài. Người gọi chỉ có AbortSignal trong tay, không có tiến trình con. Nên tầng leo thang phải đặt ở đúng chỗ child đang sống — nếu không thì SIGKILL không bao giờ được gửi, dù code trông vẫn đầy đủ.
Terminal — huỷ giữa chừng rồi đếm tiến trình còn sống
# Trong khi mot job dang chuyen ma, dem so tien trinh ffmpeg:
ps -ax -o command | grep -c '[f]fmpeg -hide_banner'
# -> 1        (dang chay)

# ...goi controller.abort(), roi doi vai giay va dem lai:
ps -ax -o command | grep -c '[f]fmpeg -hide_banner'
# -> 0        (khong con tien trinh mo coi nao)

Đây là phép thử đáng làm vì nó bắt được đúng thứ khó thấy nhất: transcode() ném AbortError ngay lập tức, nên nhìn từ phía code thì mọi thứ có vẻ đã dừng — nhưng tiến trình ffmpeg thật sự chết hay không thì chỉ ps mới trả lời được.

⚠️ 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 } from 'node:worker_threads';
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';

interface HashTask {
  readonly filePath: string;
}

// NGHE tung viec mot, thay vi doc workerData. Ho worker o duoi tai su dung
// mot worker cho nhieu viec, ma workerData thi chi dat duoc DUNG MOT LAN
// luc tao Worker — dung no thi worker chi lam duoc dung mot viec roi thanh
// vo dung.
parentPort?.on('message', (task: 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 }));
});
⚠️ workerDatapostMessage là hai mô hình khác nhau
Đây là chỗ rất dễ viết lệch mà TypeScript không cứu được. workerData là ảnh chụp một lần, đóng băng lúc new Worker(...) — nó hợp với worker kiểu làm một việc rồi chết. Còn hồ worker ở ngay dưới đây tồn tại chính là để tái sử dụng, nên nó phải gửi việc bằng postMessage, và worker phải có parentPort.on('message') để nghe.

Trộn hai mô hình thì biên dịch vẫn sạch nhưng chạy là hỏng ngay: new Worker(script) không truyền workerData nên taskundefined, worker chết với TypeError: Cannot read properties of undefined (reading 'filePath'), và pool.run() treo vĩnh viễn vì không bao giờ có 'message' nào quay về.

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);
  }

  // Moi Worker la mot handle giu event loop song. Khong co ham nay thi tien
  // trinh KHONG BAO GIO tu thoat sau khi lam xong viec — xem muc 4.4, noi
  // worker phai dung tu te trong gioi han thoi gian cua Docker.
  async destroy(): Promise<void> {
    const all = [...this.idle];
    this.idle.length = 0;
    await Promise.all(all.map((worker) => worker.terminate()));
    this.spawned -= all.length;
  }
}

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.

Ba class vừa viết — JobController, JobService, JobQueue — vẫn cần một module công bố chúng, giống hệt MediaModule ở Part 2 mục 6:

src/job/job.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Job } from './job.entity';
import { JobQueue } from './job.queue';
import { JobService } from './job.service';
import { JobController } from './job.controller';
import { AuthModule } from '../auth/auth.module';

@Module({
  // AuthModule vi JobController dung JwtAuthGuard — cung ly do nhu MediaModule
  // o Part 2 muc 6.
  imports: [TypeOrmModule.forFeature([Job]), AuthModule],
  controllers: [JobController],
  providers: [JobQueue, JobService],
  // WorkerModule o muc 4.3 tiem lai ca hai.
  exports: [JobQueue, JobService],
})
export class JobModule {}

Thêm JobModule vào imports của AppModule, rồi thử ngay:

Terminal — đẩy một job vào hàng đợi thật
curl -sk --resolve media-forge.local:8443:127.0.0.1 \
  -X POST "https://media-forge.local:8443/media/$VID/transcode" \
  -H "Authorization: Bearer $TOK" -w '\nhttp=%{http_code}\n'
# -> {"jobId":"06f95a5e-..."}
# -> http=202

# Thong diep da nam trong Redis Streams chua?
docker exec forge-redis redis-cli XLEN jobs:transcode
# -> 1

curl -sk --resolve media-forge.local:8443:127.0.0.1 \
  "https://media-forge.local:8443/jobs/active" -H "Authorization: Bearer $TOK"
# -> [{"id":"06f95a5e-...","videoTitle":"...","status":"queued","percent":0}]

Chưa có worker nào chạy nên job đứng ở queued — đúng như mong đợi. Nó nằm yên trong stream chờ người tới lấy, và đó chính là điều 202 hứa với client.

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;

    // Ghi so lan da thu vao CSDL. Redis giu con so nay trong thong diep, nhung
    // nguoi di dieu tra mot job hong thi nhin vao bang `jobs` truoc tien —
    // de cot `attempts` dung im o 0 la de no noi doi.
    await this.jobRepo.update(job.id, { attempts: attempt });

    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();

Xong thì có ba dấu vết kiểm được: jobs.status chuyển sang completed với cả started_at lẫn finished_at; một file <videoId>-720p.mp4 xuất hiện trong uploads/; và một dòng credit_entries với delta = -10 kèm job_id — đúng giá 720p trong PRICE_BY_HEIGHT.

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.

Giờ JobRunner đã đủ: vòng lặp, xử lý một job, và dừng tử tế. Chạy thật để xem nó ăn việc — đây là lần đầu worker có mọi mảnh nó cần.

Đâ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"
  }
}
⚠️ Hai tiến trình --watch dùng chung một dist/
Từ đây trở đi phải chạy hai tiến trình cùng lúc — API ở một cửa sổ, worker ở cửa sổ khác — vì đó là cách duy nhất để thử hàng đợi. Nhưng cả hai lệnh --watch đều build vào cùng thư mục dist/, mà nest-cli.json từ Part 1 đang đặt "deleteOutDir": true. Bật worker lên là nó xoá sạch dist/ và giết luôn tiến trình API đang chạy: Error: Cannot find module '.../dist/main'.

Triệu chứng dễ đi lạc: mọi request sau đó trả 502 Bad Gateway từ nginx, nên thoạt nhìn giống lỗi nginx chứ không giống lỗi của cái vừa bật. Cách chữa gọn nhất là tắt cờ đó đi:

// nest-cli.json
"compilerOptions": { "deleteOutDir": false, ... }
docker-compose.yml — dịch vụ worker (bước đóng gói, xem callout dưới)
services:
  worker:
    build: . # can mot Dockerfile — xem callout ngay duoi
    command: npm run start:worker
    init: true # PID 1 tu te — don tien trinh ffmpeg mo coi, xem canh bao muc 2.3
    environment:
      # PHAI khop voi dich vu postgres da dung o Part 1 muc 4.5:
      # user forge, mat khau forge, database media_forge.
      DATABASE_URL: postgres://forge:forge@postgres:5432/media_forge
      REDIS_URL: redis://redis:6379
      UPLOAD_DIR: /var/media # trong container, khac ./uploads o may minh
    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

# Khai bao o cap cao nhat, ngang hang voi `services:` — thieu khoi nay thi
# Compose bao volume `media` khong ton tai.
volumes:
  forge-pgdata: # da co tu Part 1
  media: # moi, dung chung giua API va worker
⚠️ build: . cần một Dockerfile, và nó phải có ffmpeg
Cho tới đây loạt bài vẫn chạy ứng dụng thẳng trên máy (npm run start:dev) và chỉ để Postgres, Redis, nginx trong Docker — nên chưa có Dockerfile nào cả. build: . cần một file như vậy, và điểm đáng lưu ý là image của worker phải có ffmpeg bên trong, không chỉ Node:

FROM node:24-alpine
RUN apk add --no-cache ffmpeg  # BAT BUOC cho worker
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build


Nếu chỉ muốn thử hàng đợi chứ chưa cần đóng gói, cách nhanh hơn nhiều là bỏ qua cả khối Compose này và chạy worker thẳng trên máy bằng npm run start:worker:dev — phần còn lại của mục 4 và mục 6 đều hoạt động y hệt.

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 }));
  }
}

JobRunner đến đây mới đủ mảnh: vòng lặp, xử lý một job, và dừng tử tế. Giờ mới chạy thật được — trước mục này file job.runner.ts chưa tồn tại đầy đủ, mà worker.module.ts thì đã khai providers: [JobRunner] từ mục 4.3.

Terminal — chạy worker trên máy và xem nó ăn việc thật
# Cua so 1: API (nhu tu truoc toi gio)
npm run start:dev

# Cua so 2: worker
npm run start:worker:dev

# Cua so 3: day mot job cho video da upload o Part 2
curl -sk --resolve media-forge.local:8443:127.0.0.1 \
  -X POST "https://media-forge.local:8443/media/$VID/transcode" \
  -H "Authorization: Bearer $TOK"
# -> {"jobId":"1639da5a-..."}  http=202

# Nghe tien do worker phat ra, trong luc no chay
docker exec forge-redis redis-cli SUBSCRIBE progress
# -> {"userId":"...","jobId":"1639da5a-...","percent":22}
# -> {"userId":"...","jobId":"1639da5a-...","percent":47}
# -> {"userId":"...","jobId":"1639da5a-...","percent":70}
⚠️ 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ẢN ĐẦY ĐỦ sau khi bọc, giữ nguyên mọi thứ đã dựng
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import { TimeoutInterceptor } from './common/timeout/timeout.interceptor';
import type { NestExpressApplication } from '@nestjs/platform-express';
import type { AppConfig } from './config/configuration';

export async function bootstrap(): Promise<void> {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);

  app.set('trust proxy', 1); // Part 2, muc 4.2
  app.useGlobalPipes(
    new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
  ); // Part 2, muc 1
  app.useGlobalInterceptors(new TimeoutInterceptor()); // Part 2, muc 5.2

  const config = app.get(ConfigService<AppConfig, true>);
  await app.listen(config.get('PORT', { infer: true }));
}

// 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();
}
⚠️ Bọc lại, đừng viết lại
Thay đổi thật sự của mục này chỉ có hai: thêm chữ export trước async function bootstrap, và bọc lời gọi tự động trong if (require.main === module). Mọi thứ khác trong main.ts phải giữ nguyên — bỏ ValidationPipe là mọi DTO thành đồ trang trí trở lại (Part 2 mục 1), bỏ TimeoutInterceptor là mất tầng giữa của ngân sách thời gian (Part 2 mục 5.2).

Và đọc cổng vẫn phải qua ConfigService, không quay lại process.env.PORT: cờ noPropertyAccessFromIndexSignature bật từ Part 1 mục 5 khiến đúng dòng đó không biên dịch được — TS4111: Property 'PORT' comes from an index signature. Đó chính là lỗi mẫu mà Part 1 đã dùng để giới thiệu cờ này.
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();
}
Terminal — chạy cluster và đo lại đúng thí nghiệm ở mục 1
npx nest build
node dist/cluster.js

# Bao nhieu ban NestJS da khoi dong?
# -> mot dong "Nest application successfully started" cho MOI nhan CPU

# Va bay gio lam lai dung phep thu o muc 1:
( curl -s -o /dev/null -w 'block: %{time_total}s\n' http://localhost:3000/block & ); sleep 0.5
curl -s -o /dev/null -w 'ping:  %{time_total}s\n' http://localhost:3000/ping
# -> ping:  0.004s      <-- muc 1 la 4,6 giay
# -> block: 5.004s

Đây là chỗ mục 1 và mục 5 khép lại thành một vòng. Cùng hai endpoint đó, cùng cách gọi đó: ở mục 1 /ping phải chờ 4,6 giây vì chỉ có một event loop, còn bây giờ nó trả lời trong 0,004 giây — vì hệ điều hành đưa request sang một tiến trình con khác, tiến trình đang không bận quay vòng.

Lưu ý cái này không chữa được nguyên nhân gốc: tiến trình bị chiếm vẫn đứng hình đúng năm giây, và nếu đủ người gọi /block cùng lúc thì cả tám tiến trình đều tắc. cluster mua thêm chỗ chịu tải, không xoá bỏ việc phải đẩy việc nặng ra ngoài như mục 2 và mục 3 đã làm.

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.

Terminal — các gói Part 3 cần
# Cho @Interval o DelayedJobPromoter (muc 4.1)
npm i @nestjs/schedule

# Cho WebSocket o muc nay
npm i @nestjs/websockets @nestjs/platform-socket.io socket.io
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.

Hai class trên vẫn cần một module khai chúng ra, và nó vướng đúng cái bẫy đã gặp ở Part 2 mục 6: ProgressGateway tiêm JwtService, nên module phải import AuthModule.

src/gateway/gateway.module.ts
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ProgressGateway } from './progress.gateway';
import { ProgressSubscriber } from './progress.subscriber';

@Module({
  // AuthModule vi ProgressGateway tiem JwtService de xac thuc bat tay —
  // cung ly do nhu MediaModule o Part 2 muc 6.
  imports: [AuthModule],
  providers: [ProgressGateway, ProgressSubscriber],
})
export class GatewayModule {}

Thêm GatewayModule vào imports của AppModule — lưu ý là của AppModule chứ không phải WorkerModule: worker chỉ phát ra Redis, nó không giữ kết nối WebSocket nào.

Kết quả chạy thật — một client WebSocket nghe trọn một job
>>> WS da ket noi
>>> transcode 202 {"jobId":"f919b4e9-a6d5-4473-a048-d95623e8bb06"}
>>> job:progress 22
>>> job:progress 47
>>> job:progress 70
>>> job:progress 94
>>> job:progress 99
>>> job:done f919b4e9-a6d5-4473-a048-d95623e8bb06

Đường đi của mỗi con số đó dài hơn vẻ ngoài của nó: ffmpeg in ra stderr trong tiến trình worker → transcode() đọc bằng readline (mục 2.2) → redis.publish('progress', ...) → tiến trình API nghe qua kết nối duplicate()server.to('user:...') → trình duyệt. Hai tiến trình hoàn toàn tách biệt, nối với nhau bằng đúng một kênh Redis — và 99 rồi mới tới job:done đúng như nguyên tắc "phần trăm là ước lượng, mã thoát là sự thật" ở mục 2.2.

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 },
    });

    const sync = (): void => {
      void api('/jobs/active')
        .then((res) => res.json() as Promise<JobState[]>)
        .then(setJobs);
    };

    socket.on('connect', sync);

    socket.on('job:progress', ({ jobId, percent }: { jobId: string; percent: number }) => {
      setJobs((prev) => {
        // Job MOI, tao sau luc connect: chua co trong danh sach nen map() se
        // khong khop gi ca. Phai hoi lai /jobs/active de lay ten video, neu
        // khong no VO HINH cho toi khi tai lai trang.
        if (!prev.some((job) => job.id === jobId)) {
          sync();
          return prev;
        }
        return 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
import type { JSX } from 'react';
import { useJobProgress } from '../hooks/useJobProgress';

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>
  );
}
⚠️ Job tạo sau khi kết nối thì không tự hiện ra
Hook chỉ hỏi /jobs/active đúng một lần lúc connect, còn hai handler kia đều là prev.map(...) — gặp một jobId chưa có trong danh sách thì chúng im lặng không làm gì. Hệ quả đúng vào kịch bản dùng chính của mốc này: upload xong, bấm chuyển mã, worker chạy và ghi completed vào database — mà bảng không nhúc nhích, phải tải lại trang job mới hiện ra. Vài dòng if (!prev.some(...)) sync() ở trên là để đóng đúng lỗ hổng đó.
⚠️ Proxy của Vite phải mở thêm /ws/jobs
io('/', { path: '/ws' })api('/jobs/active') đều là đường dẫn tương đối, tức là chúng đi tới dev server của Vite trước. Thiếu cấu hình thì hỏng theo hai kiểu đều im lặng: WebSocket báo WebSocket connection to 'ws://localhost:5173/ws/...' failed, còn /jobs/active trả về 200 kèm nguyên trang index.html (SPA fallback) thay vì JSON — bảng chỉ hiện ra rỗng, không có lỗi nào đỏ lên.

Bổ sung vào vite.config.ts đã dựng ở Part 2 mục 8.2:

'/jobs': gateway,  // JobController khai @Controller() khong tien to
'/ws': { ...gateway, ws: true },

Cuối cùng, ráp JobList vào màn hình đã dựng ở Part 2 mục 8.2 — truyền token lấy từ getAccessToken(), và thêm một nút gọi POST /media/:videoId/transcode để có cách tạo job từ giao diện:

src/components/UploadScreen.tsx — thêm nút chuyển mã và bảng tiến độ
import { api, getAccessToken } from '../lib/api';
import { JobList } from './JobList';

// ...trong component, sau khi upload xong:
async function startTranscode(id: string): Promise<void> {
  await api(`/media/${id}/transcode`, { method: 'POST' });
}

const token = getAccessToken();

// ...trong phan JSX:
{videoId !== null && (
  <button type="button" onClick={() => void startTranscode(videoId)}
          className="rounded bg-emerald-600 px-3 py-2 text-sm text-white">
    Chuyển mã 720p
  </button>
)}

{token !== null && (
  <div className="space-y-2">
    <h2 className="text-sm font-semibold text-slate-700">Tiến độ chuyển mã</h2>
    <JobList token={token} />
  </div>
)}

Giờ mốc #3 mới thật sự nhìn thấy được: chọn một file, bấm Chuyển mã 720p, và một dòng mới xuất hiện trong bảng rồi chạy tới 100% — không cần chạm vào nút tải lại trang.

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