Web Totals

Học từ Facebook #57: Hàng đợi lệnh cho video và stream sắp lại chunk

Facebook nén hàng đợi lệnh play/pause/seek khi video chưa sẵn sàng; RelayAsyncStreamPool sắp lại chunk lệch thứ tự và phát lại cho người đến muộn.

hunghg255hunghg25524 min read
#facebook#video#stream#graphql#architecture
Học từ Facebook #57: Hàng đợi lệnh cho video và stream sắp lại chunk

Hai bài toán, cùng một ý: thứ tự và trạng thái cuối quan trọng hơn từng sự kiện.

  1. Trình phát video của Facebook nhận lệnh từ nhiều nơi: người dùng bấm play, feed tự phát khi cuộn tới, nút tắt tiếng, thanh tua. Lúc <video> chưa gắn vào trang (đang tải, đang đổi nguồn), các lệnh dồn lại. Chạy lại cả chuỗi play, pause, play, seek, seek… khi element sẵn sàng là thừa, và mỗi pause() chen giữa hai play() làm Promise của play() bị reject.
  2. Dữ liệu GraphQL dạng @defer/@stream về thành nhiều chunk có số thứ tự. Mạng có thể giao chunk 3 trước chunk 1. UI phải nhận đúng thứ tự, và component mount sau vẫn phải thấy các chunk đã về.

Tip

Tóm tắt

  • Hàng đợi lệnh video: khi xả, mỗi loại lệnh (seek, volume, rate…) chỉ giữ lệnh cuối; play và pause xoá lẫn nhau. Trạng thái người dùng muốn (volume, muted) được áp sau cùng, thắng trạng thái DOM.
  • Promise của video.play() được theo dõi: created, resolved, rejected (kèm lý do như autoplay bị chặn).
  • Stream pool: chunk đúng lượt thì phát và xả tiếp các chunk chờ liền sau; đến sớm thì cất. Người đăng ký muộn nhận lại toàn bộ chunk cũ.
  • Bản gốc có hai lỗi: lỗi đúng lượt bị bỏ qua khi đã có complete đến sớm (stream treo mãi), và start() được gọi sau khi đã phát lại sự kiện cũ.

#Facebook làm thế nào?

#Hàng đợi lệnh cho video

Module VideoPlayerImplementationStateMachineHostCallQueue (có trong bundle 26.VideoPlayer/all.js và vài bundle khác). Mỗi "host call" là một lệnh gửi xuống element: host_call_play, host_call_pause, host_call_set_current_time, host_call_set_volume, host_call_set_muted, host_call_set_playback_rate, host_call_set_video_quality, mỗi lệnh có hostCallID riêng.

applyOrQueueHostCall: có element thì áp ngay, không thì dispatch lệnh vào state machine (lệnh nằm trong controlledState.hostCallQueue). Khi element sẵn sàng, flushHostCallQueue nén hàng đợi rồi áp. Giải mã hàm nén:

function compact(queue) {
  let out = [];
  const dropPlayPause = () => (out = out.filter((c) => c.type !== 'host_call_play' && c.type !== 'host_call_pause'));
  const dropType = (t) => (out = out.filter((c) => c.type !== t));
  queue.forEach((cmd) => {
    switch (cmd.type) {
      case 'host_call_play':
      case 'host_call_pause': dropPlayPause(); break;     // play/pause là MỘT nhóm
      default: dropType(cmd.type);                       // seek, volume, muted, rate, quality
    }
    out = out.concat([cmd]);                             // lệnh mới luôn đứng cuối
  });
  return out;
}

function flush({ state, machine, videoElementAPI, engineExtrasAPI, reason }) {
  const desired = [
    { type: 'host_call_set_volume', payload: { volume: state.controlledState.volume, reason } },
    { type: 'host_call_set_muted', payload: { muted: state.controlledState.muted, reason } },
    { type: 'host_call_set_video_quality', payload: { selectedVideoQuality: state.controlledState.selectedVideoQuality, reason } },
  ];
  const calls = compact(state.controlledState.hostCallQueue.concat(desired));
  calls.forEach((hostCall) => applyOrQueueHostCall({ hostCall, machine, videoElementAPI, engineExtrasAPI }));
  machine.dispatch({ type: 'implementation_host_call_queue_flushed', payload: { hostCallsFlushed: calls } });
}
js

Chú ý dòng concat(desired): volume/muted/chất lượng lấy từ controlledState (trạng thái mong muốn mà UI giữ) được nối vào cuối rồi mới nén. Lệnh volume cũ trong hàng đợi bị thay bằng giá trị mong muốn mới nhất. Nguồn sự thật là state của UI, không phải element.

Khi áp host_call_play, video.play() trả về Promise. Facebook dispatch ba sự kiện: dom_event_play_promise_created, …_resolved, …_rejected (kèm playPromiseRejectionReason, ví dụ NotAllowedError khi autoplay có tiếng bị chặn). Lệnh nào ném lỗi khi áp thì dispatch implementation_host_call_failed kèm videoElement.error, networkState, readyState để biết element đang ở trạng thái nào.

#RelayAsyncStreamPool

Module RelayAsyncStreamPool (bundle 3.js) quản lý nhiều stream theo id. Mỗi stream có events (đã phát, theo thứ tự), pendingEvents[seq] (đến sớm), observers, status (0: mở, 1: có complete/error đến sớm đang chờ, 2: đã đóng).

next(id, data, seq) {
  const s = get(id);
  if (s.status === CLOSED) return;
  if (seq === s.events.length) {               // đúng lượt
    s.events.push({ kind: 'next', data, sequenceNumber: seq });
    s.observers.forEach((o) => o.next(data));
    for (let k = seq + 1; k < s.pendingEvents.length; k++) {   // xả các chunk chờ liền sau
      const ev = s.pendingEvents[k];
      if (ev == null) break;
      s.events.push(ev);
      // complete/error: đóng stream và thoát; next: phát cho observers
    }
  } else {
    invariant(seq > s.events.length && !hasOwn(s.pendingEvents, seq), 'trùng hoặc lùi');
    s.pendingEvents[seq] = { kind: 'next', data, sequenceNumber: seq };
  }
}
subscribe(id, observer) {
  const s = get(id);
  s.events.forEach((ev) => replay(observer, ev));   // phát lại toàn bộ sự kiện cũ
  s.observers.set(nextId++, observer);
  observer.start(subscription);                    // start() gọi SAU replay
}
js

Hai chỗ có vấn đề:

  1. complete(id, seq) và error(id, err, seq) bắt đầu bằng if (status !== OPEN) return. Khi một complete đã đến sớm (status = 1, đang chờ), một error đến đúng lượt bị bỏ qua. Vị trí đó không bao giờ được lấp, complete đang chờ không bao giờ được xả: stream treo mãi, observer không nhận lỗi cũng không nhận complete. forceComplete/forceError cũng chỉ chạy khi status = 0 nên không cứu được.
  2. subscribe phát lại sự kiện cũ trước rồi mới gọi observer.start(subscription). Theo hợp đồng của Observable, start phải chạy trước mọi next, để observer có subscription trong tay (ví dụ để huỷ ngay giữa lúc phát lại). Ngoài ra, observer vẫn được thêm vào observers của stream đã đóng (không rò gì nhiều, nhưng thừa), và pendingEvents không được dọn sau khi xả.
sequenceDiagram
  participant N as "Mạng"
  participant P as "StreamPool"
  participant U as "UI (observer)"
  N->>P: "seq 2: bình luận #3"
  P->>P: "cất vào pending[2]"
  N->>P: "seq 0: bình luận #1"
  P->>U: "next #1"
  N->>P: "seq 1: bình luận #2"
  P->>U: "next #2"
  P->>U: "xả pending[2]: next #3"
  N->>P: "seq 3 complete"
  P->>U: "complete"

#Viết lại

Nén lệnh và xả hàng đợi:

src/lib/videoCommandQueue.ts
/** Nén hàng đợi: giữ lệnh cuối mỗi loại, play/pause tính chung một loại. */
export function compactCommands(queue: VideoCommand[]): VideoCommand[] {
  let out: VideoCommand[] = [];
  for (const cmd of queue) {
    const group = cmd.type === 'play' || cmd.type === 'pause' ? 'playback' : cmd.type;
    out = out.filter((c) => (c.type === 'play' || c.type === 'pause' ? 'playback' : c.type) !== group);
    out.push(cmd);
  }
  return out;
}
ts
src/lib/videoCommandQueue.ts
  return {
    send(cmd) {
      if (video) run(cmd);
      else queue.push(cmd);
    },
    attach(el) {
      video = el;
      if (!el) return;
      const { volume, muted } = desired();
      const all = queue.concat([{ type: 'volume', volume }, { type: 'muted', muted }]);
      queue = [];
      for (const cmd of compact ? compactCommands(all) : all) run(cmd);
    },
    pending: () => queue.slice(),
  };
}
ts

Stream pool:

src/lib/streamPool.ts
  private deliver(s: Stream<T>, ev: Event<T>) {
    s.events.push(ev);
    const observers = [...s.observers.values()];
    if (ev.kind !== 'next') {
      s.done = true;
      s.observers.clear();
      s.pending.clear();
    }
    for (const o of observers) {
      if (ev.kind === 'next') o.next(ev.data);
      else if (ev.kind === 'error') o.error(ev.error);
      else o.complete();
    }
  }

  /** Nhận sự kiện có số thứ tự `seq`. Đúng lượt thì phát và xả tiếp các sự kiện chờ liền sau. */
  push(id: string, seq: number, ev: Event<T>) {
    if (!Number.isInteger(seq) || seq < 0) throw new Error(`seq không hợp lệ: ${seq}`);
    const s = this.get(id);
    if (s.done) return;
    // Bản gốc: error()/complete() return ngay khi status = "có complete đang chờ".
    if (this.options.originalBehavior && ev.kind !== 'next' && s.pending.size > 0 &&
        [...s.pending.values()].some((p) => p.kind !== 'next')) return;
    const expected = s.events.length;
    if (seq < expected || s.pending.has(seq)) throw new Error(`seq ${seq} bị trùng`);
    if (seq > expected) {
      s.pending.set(seq, ev);
      return;
    }
    this.deliver(s, ev);
    for (let k = seq + 1; !s.done && s.pending.has(k); k++) {
      const next = s.pending.get(k)!;
      s.pending.delete(k);
      this.deliver(s, next);
    }
  }
ts
Xem toàn bộ src/lib/videoCommandQueue.ts
src/lib/videoCommandQueue.ts
/**
 * Hàng đợi lệnh cho <video> có nén lệnh (ý tưởng từ VideoPlayerImplementationStateMachineHostCallQueue
 * của Facebook).
 *
 * Khi element video chưa sẵn sàng (chưa mount, đang đổi nguồn…), các lệnh play/pause/seek/volume
 * được xếp hàng. Lúc áp, không cần chạy lại cả chuỗi: chỉ trạng thái CUỐI mới quan trọng.
 * - mỗi loại lệnh (seek, volume, muted, rate) chỉ giữ lệnh cuối cùng;
 * - play và pause xoá lẫn nhau: chỉ còn lệnh cuối trong hai loại;
 * - thứ tự tương đối của các lệnh còn lại được giữ.
 */

export type VideoCommand =
  | { type: 'play' }
  | { type: 'pause' }
  | { type: 'seek'; time: number }
  | { type: 'volume'; volume: number }
  | { type: 'muted'; muted: boolean }
  | { type: 'rate'; rate: number };

/** Nén hàng đợi: giữ lệnh cuối mỗi loại, play/pause tính chung một loại. */
export function compactCommands(queue: VideoCommand[]): VideoCommand[] {
  let out: VideoCommand[] = [];
  for (const cmd of queue) {
    const group = cmd.type === 'play' || cmd.type === 'pause' ? 'playback' : cmd.type;
    out = out.filter((c) => (c.type === 'play' || c.type === 'pause' ? 'playback' : c.type) !== group);
    out.push(cmd);
  }
  return out;
}

export interface DesiredState {
  volume: number;
  muted: boolean;
}

export type PlayEvent =
  | { type: 'play_promise_created'; id: number }
  | { type: 'play_promise_resolved'; id: number }
  | { type: 'play_promise_rejected'; id: number; reason: string }
  | { type: 'command_failed'; command: VideoCommand; error: string; readyState: number; networkState: number }
  | { type: 'applied'; command: VideoCommand };

function apply(video: HTMLVideoElement, cmd: VideoCommand, id: number, emit: (e: PlayEvent) => void) {
  switch (cmd.type) {
    case 'play': {
      const p = video.play();
      // play() trả Promise: bị chặn autoplay hay bị pause() chen ngang thì reject
      emit({ type: 'play_promise_created', id });
      p.then(
        () => emit({ type: 'play_promise_resolved', id }),
        (err: unknown) => emit({ type: 'play_promise_rejected', id, reason: err instanceof Error ? err.name : String(err) }),
      );
      break;
    }
    case 'pause':
      video.pause();
      break;
    case 'seek':
      video.currentTime = cmd.time;
      break;
    case 'volume':
      video.volume = cmd.volume;
      break;
    case 'muted':
      video.muted = cmd.muted;
      break;
    case 'rate':
      video.playbackRate = cmd.rate;
      break;
  }
}

export interface VideoCommandQueue {
  /** áp ngay nếu có element, không thì xếp hàng */
  send(cmd: VideoCommand): void;
  /** gắn element (hoặc null khi gỡ); có element thì xả hàng đợi */
  attach(video: HTMLVideoElement | null): void;
  /** các lệnh đang chờ */
  pending(): VideoCommand[];
}

export function createVideoCommandQueue({
  desired,
  compact = true,
  onEvent = () => {},
}: {
  /** trạng thái người dùng muốn (volume/muted): luôn được áp sau cùng khi xả, thắng trạng thái DOM */
  desired: () => DesiredState;
  compact?: boolean;
  onEvent?: (e: PlayEvent) => void;
}): VideoCommandQueue {
  let video: HTMLVideoElement | null = null;
  let queue: VideoCommand[] = [];
  let nextId = 1;

  const run = (cmd: VideoCommand) => {
    if (!video) return;
    try {
      apply(video, cmd, nextId++, onEvent);
      onEvent({ type: 'applied', command: cmd });
    } catch (e) {
      // ghi kèm readyState/networkState để biết element đang ở trạng thái nào khi lỗi
      onEvent({
        type: 'command_failed',
        command: cmd,
        error: e instanceof Error ? e.message : String(e),
        readyState: video.readyState,
        networkState: video.networkState,
      });
    }
  };

  return {
    send(cmd) {
      if (video) run(cmd);
      else queue.push(cmd);
    },
    attach(el) {
      video = el;
      if (!el) return;
      const { volume, muted } = desired();
      const all = queue.concat([{ type: 'volume', volume }, { type: 'muted', muted }]);
      queue = [];
      for (const cmd of compact ? compactCommands(all) : all) run(cmd);
    },
    pending: () => queue.slice(),
  };
}
ts
Xem toàn bộ src/lib/streamPool.ts
src/lib/streamPool.ts
/**
 * Bộ đệm sắp lại chunk của stream theo số thứ tự và phát lại cho người đăng ký muộn
 * (ý tưởng từ RelayAsyncStreamPool của Facebook: dữ liệu @defer/@stream của GraphQL
 * có thể về lệch thứ tự, UI vẫn phải nhận đúng thứ tự).
 *
 * Khác bản gốc (xem bài viết):
 * - lỗi/complete đúng lượt vẫn được xử lý khi đã có complete "đến sớm" đang chờ
 *   (bản gốc bỏ qua, stream treo mãi);
 * - gọi start() TRƯỚC khi phát lại sự kiện cũ cho observer;
 * - dọn pending sau khi xả.
 */

export interface StreamObserver<T> {
  start?(sub: { unsubscribe(): void }): void;
  next(value: T): void;
  error(err: unknown): void;
  complete(): void;
}

type Event<T> = { kind: 'next'; data: T } | { kind: 'error'; error: unknown } | { kind: 'complete' };

interface Stream<T> {
  done: boolean;
  events: Event<T>[];
  pending: Map<number, Event<T>>;
  observers: Map<number, StreamObserver<T>>;
}

export class StreamPool<T> {
  private streams = new Map<string, Stream<T>>();
  private nextObserverId = 0;
  /** mô phỏng hành vi bản gốc để so sánh trong demo */
  constructor(private readonly options: { originalBehavior?: boolean } = {}) {}

  private get(id: string): Stream<T> {
    let s = this.streams.get(id);
    if (!s) {
      s = { done: false, events: [], pending: new Map(), observers: new Map() };
      this.streams.set(id, s);
    }
    return s;
  }

  private deliver(s: Stream<T>, ev: Event<T>) {
    s.events.push(ev);
    const observers = [...s.observers.values()];
    if (ev.kind !== 'next') {
      s.done = true;
      s.observers.clear();
      s.pending.clear();
    }
    for (const o of observers) {
      if (ev.kind === 'next') o.next(ev.data);
      else if (ev.kind === 'error') o.error(ev.error);
      else o.complete();
    }
  }

  /** Nhận sự kiện có số thứ tự `seq`. Đúng lượt thì phát và xả tiếp các sự kiện chờ liền sau. */
  push(id: string, seq: number, ev: Event<T>) {
    if (!Number.isInteger(seq) || seq < 0) throw new Error(`seq không hợp lệ: ${seq}`);
    const s = this.get(id);
    if (s.done) return;
    // Bản gốc: error()/complete() return ngay khi status = "có complete đang chờ".
    if (this.options.originalBehavior && ev.kind !== 'next' && s.pending.size > 0 &&
        [...s.pending.values()].some((p) => p.kind !== 'next')) return;
    const expected = s.events.length;
    if (seq < expected || s.pending.has(seq)) throw new Error(`seq ${seq} bị trùng`);
    if (seq > expected) {
      s.pending.set(seq, ev);
      return;
    }
    this.deliver(s, ev);
    for (let k = seq + 1; !s.done && s.pending.has(k); k++) {
      const next = s.pending.get(k)!;
      s.pending.delete(k);
      this.deliver(s, next);
    }
  }

  next(id: string, seq: number, data: T) {
    this.push(id, seq, { kind: 'next', data });
  }
  error(id: string, seq: number, error: unknown) {
    this.push(id, seq, { kind: 'error', error });
  }
  complete(id: string, seq: number) {
    this.push(id, seq, { kind: 'complete' });
  }

  /** Đăng ký: nhận lại mọi sự kiện đã phát (đúng thứ tự), rồi nhận tiếp sự kiện mới. */
  subscribe(id: string, observer: StreamObserver<T>) {
    const s = this.get(id);
    const key = this.nextObserverId++;
    const sub = { unsubscribe: () => s.observers.delete(key) };
    const replay = () => {
      for (const ev of s.events) {
        if (ev.kind === 'next') observer.next(ev.data);
        else if (ev.kind === 'error') observer.error(ev.error);
        else observer.complete();
      }
    };
    if (this.options.originalBehavior) {
      // bản gốc: phát lại trước rồi mới start(), và vẫn thêm observer vào stream đã đóng
      replay();
      s.observers.set(key, observer);
      observer.start?.(sub);
    } else {
      observer.start?.(sub);
      replay();
      if (!s.done) s.observers.set(key, observer);
    }
    return sub;
  }

  /** Số sự kiện đang chờ (để demo thấy pending được dọn). */
  pendingCount(id: string) {
    return this.streams.get(id)?.pending.size ?? 0;
  }
  isDone(id: string) {
    return this.streams.get(id)?.done ?? false;
  }
  clear(id: string) {
    this.streams.delete(id);
  }
}
ts

#Đi qua code

  • compactCommands gán mỗi lệnh một "nhóm" (play/pause chung nhóm playback), xoá lệnh cũ cùng nhóm rồi thêm lệnh mới vào cuối. Thứ tự tương đối giữa các nhóm được giữ: seek trước play vẫn là seek trước play.
  • attach(el) nối trạng thái mong muốn (desired()) vào cuối rồi mới nén, giống flushHostCallQueue.
  • apply theo dõi Promise của play() và báo sự kiện; run bắt lỗi và ghi kèm readyState/networkState.
  • StreamPool.push gộp next/error/complete thành một đường: đúng lượt thì deliver và xả tiếp; đến sớm thì cất vào pending (một Map, không phải mảng thưa). deliver của error/complete đánh dấu done, xoá observer, dọn pending.
  • subscribe gọi start trước, phát lại sau, và chỉ thêm observer khi stream còn mở.

Khác bản gốc:

  • error/complete đúng lượt luôn được xử lý, kể cả khi đã có complete đến sớm. Demo "Complete đến sớm rồi lỗi": bản gốc để stream "vẫn mở (treo)", observer chỉ nhận hai bình luận; bản sửa đóng stream và báo lỗi cho cả người đăng ký đầu lẫn người đăng ký muộn.
  • start() trước khi phát lại. Demo cho thấy người đăng ký muộn ở chế độ "giống bản gốc" nhận bình luận #1, #2 rồi mới tới ▶ start.
  • Bản viết lại không có video_quality (thuộc engine phát DASH riêng của Facebook) và không có state machine; hàng đợi là một mảng trong closure.

#Tại sao không làm cách khác?

Áp lệnh ngay, không cần hàng đợi? Element chưa có thì không áp được. Bỏ lệnh thì mất ý định của người dùng (bấm tắt tiếng lúc video đang tải).

Chạy lại cả hàng đợi, không nén? Demo: 11 lệnh thô thành 13 lần gọi lên element (thêm volume/muted mong muốn), một play() bị reject vì bị pause() chen ngang; bản nén chỉ 5 lần gọi, không reject nào, trạng thái cuối giống hệt. Trên trình phát thật, mỗi seek thừa là một lần tải lại đoạn video.

Chỉ lưu "trạng thái mong muốn" ({ playing, time, volume }) thay vì hàng đợi lệnh? Gần như tương đương, và là cách khai báo (declarative) gọn hơn. Facebook giữ hàng đợi có lẽ vì mỗi lệnh có hostCallID và reason để log, và vì seek là sự kiện (tua tới 10s) chứ không hẳn là trạng thái (suy đoán).

Stream: sắp xếp lại ở UI? Mỗi component tự sắp thì trùng code, và component mount muộn không có dữ liệu cũ. Pool dùng chung giữ thứ tự một lần, phát lại cho mọi người.

#Edge case và cạm bẫy

  • Autoplay có tiếng bị chặn. play() reject với NotAllowedError. Phải bắt Promise, không thì có lỗi "Uncaught (in promise)" trong console. Thường xử lý bằng cách tắt tiếng rồi play() lại.
  • pause() ngay sau play() làm Promise của play() reject với AbortError ("The play() request was interrupted by a call to pause()"). Nén lệnh tránh được phần lớn trường hợp này.
  • Seek trước khi có metadata. Gán currentTime khi readyState === 0 có thể bị bỏ qua hoặc áp muộn tuỳ trình duyệt. Xả hàng đợi sau sự kiện loadedmetadata an toàn hơn sau khi vừa gắn element.
  • Số thứ tự trùng hoặc lùi là lỗi giao thức: cả bản gốc (invariant) và bản viết lại đều ném lỗi.
  • Pending phình ra nếu một chunk ở giữa không bao giờ tới. Cần timeout ở tầng mạng để gọi error và đóng stream.

#Dùng trong dự án

// Video: mọi nơi gửi lệnh qua hàng đợi, không đụng thẳng element
const player = createVideoCommandQueue({
  desired: () => ({ volume: store.volume, muted: store.muted }),
  onEvent: (e) => {
    if (e.type === 'play_promise_rejected' && e.reason === 'NotAllowedError') {
      store.muted = true;
      player.send({ type: 'muted', muted: true });
      player.send({ type: 'play' });
    }
  },
});
// JSX: <video ref={(el) => player.attach(el)} />  (hoặc gọi attach trong onLoadedMetadata)

// Stream: tầng mạng đẩy chunk theo seq, UI chỉ subscribe
const pool = new StreamPool<Comment>();
socket.on('chunk', ({ id, seq, data, done, error }) =>
  error ? pool.error(id, seq, error) : done ? pool.complete(id, seq) : pool.next(id, seq, data));
const sub = pool.subscribe('comments:42', { next: addComment, error: showError, complete: hideSpinner });
ts

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import { compactCommands } from './videoCommandQueue';
import { StreamPool } from './streamPool';

describe('compactCommands', () => {
  it('giữ lệnh cuối mỗi loại, play/pause xoá lẫn nhau', () => {
    expect(
      compactCommands([
        { type: 'play' }, { type: 'seek', time: 1 }, { type: 'pause' },
        { type: 'volume', volume: 0.2 }, { type: 'seek', time: 5 }, { type: 'play' }, { type: 'volume', volume: 0.8 },
      ]),
    ).toEqual([{ type: 'seek', time: 5 }, { type: 'play' }, { type: 'volume', volume: 0.8 }]);
  });
});

describe('StreamPool', () => {
  const collect = (pool: StreamPool<string>) => {
    const got: string[] = [];
    pool.subscribe('s', { start: () => got.push('start'), next: (v) => got.push(v), error: () => got.push('ERR'), complete: () => got.push('DONE') });
    return got;
  };

  it('sắp lại chunk lệch thứ tự', () => {
    const pool = new StreamPool<string>();
    const got = collect(pool);
    pool.next('s', 2, 'c'); pool.next('s', 0, 'a'); pool.next('s', 1, 'b'); pool.complete('s', 3);
    expect(got).toEqual(['start', 'a', 'b', 'c', 'DONE']);
  });

  it('lỗi đúng lượt vẫn được xử lý khi đã có complete đến sớm', () => {
    const pool = new StreamPool<string>();
    const got = collect(pool);
    pool.next('s', 0, 'a'); pool.complete('s', 5); pool.error('s', 1, 'x');
    expect(got).toEqual(['start', 'a', 'ERR']);
    expect(pool.pendingCount('s')).toBe(0);
  });

  it('người đăng ký muộn nhận start trước rồi mới tới dữ liệu cũ', () => {
    const pool = new StreamPool<string>();
    pool.next('s', 0, 'a');
    expect(collect(pool)).toEqual(['start', 'a']);
  });
});
ts

#So với thư viện và chuẩn web

  • HTMLMediaElement.play() trả Promise ở mọi trình duyệt hiện đại; chính sách autoplay của Chrome cho phép tự phát khi tắt tiếng.
  • @defer/@stream của GraphQL (incremental delivery) được Relay và các client khác như Apollo, urql hỗ trợ theo cách riêng; điểm chung là phải ghép các phần trả về theo đúng vị trí, dù chúng về lệch thứ tự.
  • Video.js, Shaka Player, hls.js tự quản trạng thái element; khi tự viết trình phát trên <video>, một hàng đợi lệnh như trên tránh được phần lớn lỗi "play() interrupted".

Bài liên quan: Học từ Facebook #22: MQTT ngay trong trình duyệt (cũng là chuyện ghép lại dữ liệu đến từng mảnh).


Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).

Xem nhanh danh sách bài trong series

Phần 1

Phần 2

Phần 3

Phần 4–6

Phần 7–10

Phần 11–15

Code + demo: examples/facebook-patterns · Mở trên StackBlitz