Web Totals

Học từ Facebook #83: Chia subdomain giữa các tab bằng lease, long-poll

Nhiều tab cùng giữ long-poll tới một host là nghẽn. Facebook chia 7 subdomain cho các tab bằng lease trong localStorage và lái long-poll bằng bảng chuyển.

hunghg255hunghg25535 min read
#facebook#realtime#long-polling#localstorage#multi-tab#state-machine
Học từ Facebook #83: Chia subdomain giữa các tab bằng lease, long-poll

Trước khi có WebSocket ổn định (xem bài #22 về MQTT trong trình duyệt), chat của Facebook chạy bằng long-poll (long polling): trình duyệt gửi một request, server giữ nó tới khi có tin mới hoặc hết 60 giây, trả về, rồi trình duyệt gửi request kế tiếp ngay.

Một request luôn mở nghĩa là một kết nối luôn bị chiếm. Với HTTP/1.1, trình duyệt chỉ cho khoảng 6 kết nối đồng thời tới cùng một host. Người dùng mở 6 tab Facebook là 6 kết nối tới edge-chat.facebook.com bị giữ hết. Tab thứ 7 gửi tin nhắn thì request phải xếp hàng chờ một long-poll nào đó trả về.

Bài này gồm hai mẹo trong cùng một bundle (225.js): cách các tab tự chia nhau 7 subdomain mà không cần server điều phối, và cách long-poll được lái bằng một bảng chuyển trạng thái thay vì một đống if.

Tip

Tóm tắt

  • Giới hạn kết nối tính theo host, nên 0-edge-chat, 1-edge-chat… là 7 host khác nhau, mỗi host có 6 kết nối riêng. Các tab chỉ cần không chọn trùng subdomain.
  • ChannelSubdomain chia slot bằng lease (giấy thuê có hạn) trong localStorage: khoá channel_sub:N chứa thời điểm gia hạn gần nhất, gia hạn mỗi 50 giây, quá 100 giây thì slot coi như trống, pagehide thì xoá.
  • ChannelManager là máy trạng thái (state machine) điều khiển bằng bảng: pull + ok → pull! (đi ngay), pull + error → ping (chờ backoff). Mỗi lần vào state, stateId tăng, phản hồi của request cũ về muộn bị bỏ.
  • Bản viết lại ghi kèm chủ sở hữu vào lease để phát hiện hai tab giành cùng slot, và xin lại slot khi trang quay về từ bfcache.

#Mẹo 1: lease subdomain trong localStorage

#Facebook làm thế nào?

ChannelSubdomain (bundle 225.js) chỉ có hai hàm, allocate và clear. Phần lõi, đã đổi tên biến:

const total = (maxConn - 1) * 7;                  // số slot
const start = Math.floor(Math.random() * total);  // điểm bắt đầu ngẫu nhiên
for (let i = 0; i < total; i++) {
  const s = (i + start) % total;
  if (!seen[s] || seen[s] < Date.now() - 100_000) { slot = s; break; } // trống hoặc quá 100 giây
}
// ghi ngay Date.now() vào "channel_sub:" + slot, rồi gia hạn mỗi 50 giây; pagehide thì xoá
return slot == null ? null : slot % 7;
js
Code gốc đầy đủ đã đổi tên biến: ChannelSubdomain.allocate
const PREFIX = 'channel_sub:', HOSTS = 7, TTL = 100 * 1000;
let slot = null, timer = null;

function allocate(maxConn, start, force = false) {
  const total = (maxConn - 1) * HOSTS;
  if (start == null) start = Math.floor(Math.random() * total);
  if (slot == null) {
    const ls = WebStorage.getLocalStorage();
    if (ls) {
      const seen = [];
      for (let i = 0; i < ls.length; i++) {          // quét mọi khoá channel_sub:N
        const key = ls.key(i);
        if (key?.indexOf(PREFIX) === 0) seen[parseInt(key.substr(PREFIX.length))] = parseInt(ls.getItem(key));
      }
      const staleBefore = Date.now() - TTL;
      for (let i = 0; i < total; i++) {
        const s = (i + start) % total;
        if (!seen[s] || seen[s] < staleBefore) { slot = s; break; }
      }
      if (slot != null) (function renew() {          // ghi ngay, rồi mỗi 50 giây
        if (slot == null) return;
        ls.setItem(PREFIX + slot, Date.now().toString());
        timer = setTimeoutAcrossTransitions(renew, TTL / 2);
      })();
      listen(window, 'pagehide', clear);              // clear(): xoá khoá, slot = null
    } else slot = start;                              // không có localStorage: chọn bừa
  }
  return slot == null ? null : slot % HOSTS;
}
js

ChannelManager gọi allocate(getConfig('max_conn', 2)) lúc khởi động, rồi ghép kết quả thành host: 0-edge-chat.facebook.com tới 6-edge-chat.facebook.com. Không xin được (trả null) thì dùng host chung edge-chat.facebook.com.

Ba điểm đáng chú ý:

  • Không có server điều phối. Các tab cùng origin dùng chung localStorage, nên đó là bảng phân công tự nhiên. Không cần tab nào làm "leader".
  • Lease thay vì khoá vĩnh viễn. Tab bị crash hoặc bị trình duyệt giết không kịp chạy pagehide. Nếu khoá không có hạn, slot đó mất mãi. Với lease 100 giây gia hạn mỗi 50 giây, slot của tab chết tự trống sau tối đa 100 giây.
  • Điểm bắt đầu ngẫu nhiên. Hai tab mở cùng lúc (khôi phục phiên trình duyệt chẳng hạn) đọc storage gần như cùng thời điểm. Nếu cả hai quét từ slot 0, cả hai thấy slot 0 trống. Bắt đầu ở vị trí ngẫu nhiên làm va chạm hiếm đi, nhưng không loại bỏ được.

Vì sao là (max_conn − 1) × 7? Mỗi host có 6 kết nối, nhưng kênh chỉ được dùng max_conn − 1 trong số đó cho long-poll; có lẽ phần còn lại dành cho request gửi tin, ping, tải file tới cùng host. Với max_conn = 2 (mặc định), mỗi subdomain nhận đúng một tab.

#Giới hạn (đã chạy lại module thật bằng node)

Tôi nạp nguyên văn module từ bundle, giả lập localStorage và cho từng "tab" gọi allocate:

max_conn số slot kết quả cho các tab mở lần lượt
2 7 1,6,2,3,4,0,5,null,null
1 0 null,null
3 14 0,5,0,3,6,2,4,5,1,2,3,4,6,1,null
  • Từ tab thứ 8 (với max_conn = 2) hàm trả null, tab dùng host chung. Thường ổn: hiếm ai mở hơn 7 tab Facebook có chat.
  • max_conn = 1 cho 0 slot: luôn null.
  • max_conn = 3: slot 7–13 quay về subdomain 0–6. Đó là chủ ý (mỗi subdomain nhận 2 tab), không phải thêm host.
  • Lease chỉ chứa timestamp, không chứa ai giữ. Hai tab đọc cùng lúc rồi cùng ghi một slot thì cả hai tin mình giữ slot đó, mãi mãi, vì lần gia hạn sau mỗi tab chỉ ghi đè timestamp mà không kiểm tra.
  • Trang vào bfcache (back/forward cache) thì pagehide xoá lease. Người dùng bấm Back, trang sống lại với slot = null và không ai gọi allocate lại.

#Mẹo 2: long-poll điều khiển bằng bảng chuyển

#Facebook làm thế nào?

Trong ChannelManager có một bảng state → (status → state kế tiếp). Chép lại gần đủ:

state status → state kế tiếp
idle ok → init!
init ok → pull!, error → reconnect, sys_online → init, sys_timetravel → init
pull ok → pull!, error → ping, error_missing → pull, refresh_0/110/111/117 → reconnect, refresh_112/113 → pull
ping ok → pull!, error → ping, error_stale → reconnect!
reconnect ok → init!, error → reconnect, sys_online → reconnect
_all (mọi state) error_max → shutdown!, sys_offline → idle!, sys_online → ping, sys_nonowner → idle!, sys_owner → reconnect, sys_timetravel → ping

Mỗi request xong (thành công, lỗi, hoặc server trả refresh kèm mã lý do), exitState tra transitions[state][status], không có thì tra _all, không có nữa thì vào shutdown. Hậu tố quyết định thời điểm:

  • !: đi ngay. Nhưng vẫn qua bộ đếm MovingStat(30s): nếu trung bình đã vào từ MAX_CHANNEL_STATES_PER_SEC (mặc định 1) state mỗi giây trở lên thì chờ 1000 / f ms. Đây là cầu chì cho trường hợp server trả lời ngay lập tức liên tục (proxy lỗi trả 200 rỗng chẳng hạn), không có nó thì tab quay vòng pull! hàng trăm lần mỗi giây.
  • Không hậu tố: chờ backoff min(5s · 2^(n−1), 320s) · (1 + rand · 0,5), n là số lỗi liên tiếp. Khi server trả 5xx thì dùng proxy_down_delay_millis từ cấu hình.
  • #: đánh dấu "đã chờ rồi". Khi hẹn giờ xong, nó gọi lại enterState('ping#') để lần này vào thẳng.

Và chi tiết quan trọng nhất: _enterState tăng một biến cấp module stateId (tên gốc là p), gửi nó kèm request, và exitState mở đầu bằng if (result.stateId < stateId) return;.

Code gốc đã đổi tên biến: enterState và exitState
_enterState(target) {
  stateId++;                                // biến cấp module `p`
  config.stateId = stateId;                 // gửi kèm request
  clearTimeout(this._deferredTransition);   // huỷ lần chuyển đang chờ
  // … tính delay, hoặc vào state ngay, hoặc hẹn giờ
},
exitState(result, xhr) {
  if (this.isShutdown() || result.stateId < stateId) return;   // phản hồi của request cũ
  // … tra bảng, enterState(next)
}
js

Một long-poll có thể treo 60 giây. Trong lúc đó máy mất mạng, có mạng lại, tab vào reconnect, rồi init, rồi pull mới. Request cũ lúc này mới trả lỗi. Không có stateId, lỗi đó sẽ đẩy máy trạng thái sang ping dù request mới đang chạy tốt. Với stateId, mọi phản hồi mang id cũ bị bỏ qua.

Ngoài ra có một watchdog mỗi 5 giây: không pull thành công quá STALL_THRESHOLD (180 giây) thì ghi log stall; đang ở pull mà quá JUMPSTART_THRESHOLD (90 giây) không vào state mới thì gọi thẳng enterState('init') để "đề máy" lại.

Ghi chú về bản build này: dòng đầu _enterState là if (!(/init/.test(a) || /reconnect/.test(a))) return;, tức pull và ping đã bị tắt hẳn, còn _enter_reconnect gọi thẳng onSuccess() mà không gửi request. Có lẽ đây là đường cũ được giữ lại sau khi chat chuyển sang MQTT, chỉ còn chạy phần init/reconnect để đăng ký phiên. Bảng chuyển vẫn còn nguyên và là phần đáng học.

stateDiagram-v2
  [*] --> init
  init --> pull: "ok (!)"
  init --> reconnect: "error, chờ backoff"
  pull --> pull: "ok (!) hoặc refresh_112"
  pull --> ping: "error, chờ backoff"
  pull --> reconnect: "refresh_110"
  ping --> pull: "ok (!)"
  ping --> ping: "error, chờ lâu hơn"
  ping --> reconnect: "error_stale (!)"
  reconnect --> init: "ok (!)"
  reconnect --> reconnect: "error"
  init --> shutdown: "error_max (!)"

#Bên trong trình duyệt: vì sao 6 kết nối mỗi host

  • HTTP/1.1 cho mỗi kết nối TCP xử lý một request tại một thời điểm. Trình duyệt giới hạn số kết nối song song tới một host (Chrome, Firefox, Safari đều là 6) để không làm quá tải server. Long-poll chiếm trọn một kết nối trong suốt thời gian chờ.
  • Giới hạn tính theo host (chính xác hơn là theo origin cộng thêm vài yếu tố), không phải theo domain gốc. 0-edge-chat.facebook.com và 1-edge-chat.facebook.com có hai pool riêng. Đây là mẹo "domain sharding" quen thuộc thời HTTP/1.1, áp cho kết nối dài thay vì cho ảnh.
  • Giới hạn tính chung cho mọi tab của cùng profile trình duyệt. Mười tab là mười long-poll cùng tranh 6 chỗ.
  • HTTP/2 và HTTP/3 ghép nhiều request trên một kết nối, giới hạn 6 không còn. Nhưng nếu các subdomain dùng chung chứng chỉ và IP, trình duyệt còn có thể gộp chúng vào một kết nối HTTP/2 (connection coalescing), khi đó sharding vô nghĩa. Mẹo này thuộc thời HTTP/1.1.
  • localStorage dùng chung giữa các tab cùng origin và ghi đồng bộ, nhưng không có giao dịch: đọc, quyết định, ghi là ba bước rời; tab khác chen vào giữa được.
sequenceDiagram
  participant A as Tab A
  participant S as localStorage
  participant B as Tab B
  A->>S: quét channel_sub:*
  B->>S: quét channel_sub:* (cùng lúc)
  Note over A,B: cả hai thấy slot 3 trống
  A->>S: channel_sub:3 = "A|t"
  B->>S: channel_sub:3 = "B|t" (ghi đè)
  A->>S: 50 giây sau gia hạn: đọc thấy chủ là B
  A->>S: bản viết lại: nhường, xin slot khác

#Viết lại

Lease lưu chủ|thời điểm thay vì chỉ thời điểm. Lúc gia hạn, nếu thấy chủ là người khác (và lease đó còn hạn) thì nhường, xin slot khác.

src/lib/subdomainLease.ts
    acquire() {
      if (slot != null) return slot;
      if (!storage || total === 0) return null;
      // bắt đầu từ vị trí ngẫu nhiên để các tab mở cùng lúc ít va nhau
      const start = Math.floor(random() * total);
      const t = now();
      for (let i = 0; i < total; i++) {
        const s = (start + i) % total;
        if (isFree(s, t)) {
          slot = s;
          write();
          schedule();
          return slot;
        }
      }
      return null;
    },
    renew() {
      if (slot == null || !storage) return false;
      const rec = dec(storage.getItem(prefix + slot));
      if (rec && rec.owner !== owner && rec.at >= now() - ttlMs) {
        // tab khác đã giành slot này (hai tab đọc cùng lúc, hoặc mình ngủ quá ttl)
        slot = null;
        lease.acquire();
        return false;
      }
      write();
      return true;
    },
ts

release chỉ xoá khoá nếu chủ vẫn là mình; pagehide gọi release, pageshow với persisted = true gọi lại acquire.

Máy trạng thái: enter quyết định đi ngay, chờ chặn tần suất hay chờ backoff; exit bỏ phản hồi cũ rồi tra bảng.

src/lib/subdomainLease.ts
      let delay = 0;
      let reason: 'backoff' | 'throttle' = 'backoff';
      if (target.endsWith('!')) {
        const r = rate();
        if (r >= maxStatesPerSec) {
          delay = Math.round(1000 / maxStatesPerSec);
          reason = 'throttle';
        }
      } else if (!target.endsWith('#')) {
        delay = backoffDelay(retries, minRetryMs, maxRetryMs, random());
      }

      if (delay > 0) {
        state = 'idle';
        pending = { state: name, at: now() + delay };
        onEvent?.({ type: 'defer', state: name, delayMs: delay, reason });
        // `#` = đã chờ rồi, lần sau vào thẳng
        cancelPending = schedule(() => {
          cancelPending = null;
          fsm.enter(name + '#');
        }, delay);
        return;
      }
      state = name;
      entries.push(now());
      onEvent?.({ type: 'enter', state, stateId });
      onEnter?.(state, stateId);
    },
    exit(id, status) {
      if (state === 'shutdown') return false;
      if (id < stateId) {
        onEvent?.({ type: 'stale', stateId: id, current: stateId, status });
        return false;
      }
      retries = status === 'ok' ? 0 : retries + 1;
      // đang chờ vào state X thì tra bảng theo X (giống `nextState || state` của bản gốc)
      const from = pending?.state ?? state;
      const next = table[from]?.[status] ?? table._all?.[status] ?? 'shutdown!';
      onEvent?.({ type: 'exit', from, status, to: next });
      fsm.enter(next);
      return true;
    },
ts
Xem toàn bộ src/lib/subdomainLease.ts
src/lib/subdomainLease.ts
/**
 * Hai mẹo của kênh long-poll cũ trên Facebook (ChannelSubdomain + ChannelManager):
 * 1. Chia subdomain giữa các tab bằng "lease" trong localStorage, để các tab không
 *    dồn long-poll vào cùng một host (trình duyệt chỉ cho khoảng 6 kết nối mỗi host).
 * 2. Máy trạng thái long-poll điều khiển bằng bảng chuyển: hậu tố `!` = đi ngay (có chặn tần suất),
 *    không hậu tố = chờ backoff có jitter; stateId tăng mỗi lần vào state để bỏ phản hồi muộn.
 * Khác bản gốc: lease ghi kèm chủ sở hữu nên phát hiện được hai tab giành cùng slot,
 * xin lại slot khi trang quay về từ bfcache, đồng hồ/storage/timer tiêm vào được.
 */

// ───────────────────────── 1. Lease subdomain ─────────────────────────

export interface LeaseStorage {
  readonly length: number;
  key(index: number): string | null;
  getItem(key: string): string | null;
  setItem(key: string, value: string): void;
  removeItem(key: string): void;
}

export interface SubdomainLeaseOptions {
  storage: LeaseStorage | null;
  /** số kết nối mỗi host dành cho kênh; giữ lại 1 cho request thường (bản gốc: max_conn = 2) */
  maxConn: number;
  /** số subdomain có thật, vd 0-edge-chat … 6-edge-chat (bản gốc: 7) */
  hosts?: number;
  /** lease không được gia hạn quá lâu thì coi là trống (bản gốc: 100 giây) */
  ttlMs?: number;
  prefix?: string;
  owner?: string;
  now?: () => number;
  random?: () => number;
  setTimer?: (fn: () => void, ms: number) => unknown;
  clearTimer?: (handle: unknown) => void;
  /** true: không tự hẹn giờ gia hạn, không nghe pagehide/pageshow (test, mô phỏng) */
  manual?: boolean;
}

export interface SlotRecord {
  slot: number;
  owner: string;
  at: number;
}

export interface SubdomainLease {
  readonly owner: string;
  /** slot đang giữ, null nếu không xin được (dùng host chung) */
  readonly slot: number | null;
  /** số subdomain tương ứng (slot % hosts), null = host chung */
  readonly host: number | null;
  acquire(): number | null;
  /** ghi lại thời điểm; nếu slot đã bị tab khác ghi đè thì xin slot khác. Trả false nếu mất slot */
  renew(): boolean;
  release(): void;
}

const enc = (owner: string, at: number) => `${owner}|${at}`;
function dec(raw: string | null): { owner: string; at: number } | null {
  if (!raw) return null;
  const bar = raw.lastIndexOf('|');
  // bản gốc chỉ lưu timestamp: vẫn đọc được, chủ là ''
  if (bar < 0) return { owner: '', at: parseInt(raw, 10) || 0 };
  return { owner: raw.slice(0, bar), at: parseInt(raw.slice(bar + 1), 10) || 0 };
}

/** Đọc mọi lease đang có trong storage (để debug, demo). */
export function readSlots(storage: LeaseStorage, prefix = 'channel_sub:'): SlotRecord[] {
  const out: SlotRecord[] = [];
  for (let i = 0; i < storage.length; i++) {
    const key = storage.key(i);
    if (!key || !key.startsWith(prefix)) continue;
    const rec = dec(storage.getItem(key));
    const slot = parseInt(key.slice(prefix.length), 10);
    if (rec && Number.isInteger(slot)) out.push({ slot, ...rec });
  }
  return out.sort((a, b) => a.slot - b.slot);
}

export function createSubdomainLease(options: SubdomainLeaseOptions): SubdomainLease {
  const {
    storage,
    hosts = 7,
    ttlMs = 100_000,
    prefix = 'channel_sub:',
    now = Date.now,
    random = Math.random,
    setTimer = (fn, ms) => setTimeout(fn, ms),
    clearTimer = (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
    manual = false,
  } = options;
  const owner = options.owner ?? random().toString(36).slice(2, 10);
  const total = Math.max(0, options.maxConn - 1) * hosts;
  let slot: number | null = null;
  let timer: unknown = null;

  const isFree = (s: number, t: number) => {
    const rec = dec(storage!.getItem(prefix + s));
    return !rec || rec.owner === owner || rec.at < t - ttlMs;
  };

  const write = () => {
    try {
      storage!.setItem(prefix + slot, enc(owner, now()));
    } catch {
      /* đầy bộ nhớ: vẫn giữ slot trong bộ nhớ, lần gia hạn sau thử lại */
    }
  };

  const schedule = () => {
    if (manual) return;
    if (timer != null) clearTimer(timer);
    timer = setTimer(() => {
      timer = null;
      lease.renew();
      if (slot != null) schedule();
    }, ttlMs / 2);
  };

  const lease: SubdomainLease = {
    owner,
    get slot() {
      return slot;
    },
    get host() {
      return slot == null ? null : slot % hosts;
    },
    acquire() {
      if (slot != null) return slot;
      if (!storage || total === 0) return null;
      // bắt đầu từ vị trí ngẫu nhiên để các tab mở cùng lúc ít va nhau
      const start = Math.floor(random() * total);
      const t = now();
      for (let i = 0; i < total; i++) {
        const s = (start + i) % total;
        if (isFree(s, t)) {
          slot = s;
          write();
          schedule();
          return slot;
        }
      }
      return null;
    },
    renew() {
      if (slot == null || !storage) return false;
      const rec = dec(storage.getItem(prefix + slot));
      if (rec && rec.owner !== owner && rec.at >= now() - ttlMs) {
        // tab khác đã giành slot này (hai tab đọc cùng lúc, hoặc mình ngủ quá ttl)
        slot = null;
        lease.acquire();
        return false;
      }
      write();
      return true;
    },
    release() {
      if (timer != null) clearTimer(timer);
      timer = null;
      if (slot != null && storage) {
        const rec = dec(storage.getItem(prefix + slot));
        // chỉ xoá lease của chính mình
        if (!rec || rec.owner === owner) storage.removeItem(prefix + slot);
      }
      slot = null;
    },
  };

  if (!manual && typeof window !== 'undefined') {
    window.addEventListener('pagehide', () => lease.release());
    // bản gốc không xin lại khi trang quay về từ back/forward cache
    window.addEventListener('pageshow', (e) => {
      if (e.persisted) lease.acquire();
    });
  }
  return lease;
}

// ───────────────────────── 2. Máy trạng thái long-poll ─────────────────────────

/** state → (status → state kế tiếp). `!` = đi ngay, không hậu tố = chờ backoff. */
export type TransitionTable = Record<string, Record<string, string>>;

/** Bảng của ChannelManager, giữ nguyên tên status. */
export const CHANNEL_TABLE: TransitionTable = {
  idle: { ok: 'init!' },
  init: { ok: 'pull!', error: 'reconnect', sys_online: 'init', sys_timetravel: 'init' },
  pull: {
    ok: 'pull!',
    error: 'ping',
    error_missing: 'pull',
    error_msg_type: 'pull',
    refresh_0: 'reconnect',
    refresh_110: 'reconnect',
    refresh_111: 'reconnect',
    refresh_112: 'pull',
    refresh_113: 'pull',
    refresh_117: 'reconnect',
  },
  ping: { ok: 'pull!', error: 'ping', error_stale: 'reconnect!' },
  reconnect: { ok: 'init!', error: 'reconnect', sys_online: 'reconnect', sys_timetravel: 'reconnect' },
  shutdown: {},
  _all: {
    error_max: 'shutdown!',
    error_shutdown: 'shutdown!',
    sys_owner: 'reconnect',
    sys_nonowner: 'idle!',
    sys_online: 'ping',
    sys_offline: 'idle!',
    sys_timetravel: 'ping',
  },
};

export type FsmEvent =
  | { type: 'enter'; state: string; stateId: number }
  | { type: 'defer'; state: string; delayMs: number; reason: 'backoff' | 'throttle' }
  | { type: 'stale'; stateId: number; current: number; status: string }
  | { type: 'exit'; from: string; status: string; to: string };

export interface ChannelFsmOptions {
  table?: TransitionTable;
  now?: () => number;
  random?: () => number;
  /** hẹn chạy fn sau ms, trả hàm huỷ */
  schedule?: (fn: () => void, ms: number) => () => void;
  minRetryMs?: number;
  maxRetryMs?: number;
  /** quá tần suất này (trung bình trên rateWindowMs) thì `!` cũng phải chờ */
  maxStatesPerSec?: number;
  rateWindowMs?: number;
  /** vào state thật (gửi request…); gọi exit(stateId, status) khi xong */
  onEnter?: (state: string, stateId: number) => void;
  onEvent?: (event: FsmEvent) => void;
}

export interface ChannelFsm {
  readonly state: string;
  readonly stateId: number;
  readonly retries: number;
  /** state đang chờ hẹn giờ (đang ở idle), nếu có */
  readonly pending: { state: string; at: number } | null;
  enter(target: string): void;
  /** kết quả của request thuộc stateId; trả false nếu là phản hồi muộn bị bỏ */
  exit(stateId: number, status: string): boolean;
  /** sự kiện hệ thống (sys_online, sys_offline…) áp cho state hiện tại */
  signal(status: string): void;
  /** chờ backoff tiếp theo sẽ là bao lâu (chưa jitter) */
  nextBackoffMs(): number;
  dispose(): void;
}

export function backoffDelay(retries: number, minMs: number, maxMs: number, rand: number) {
  const base = Math.min(minMs * 2 ** Math.max(0, retries - 1), maxMs);
  return Math.floor(base * (1 + rand * 0.5));
}

export function createChannelFsm(options: ChannelFsmOptions = {}): ChannelFsm {
  const {
    table = CHANNEL_TABLE,
    now = Date.now,
    random = Math.random,
    schedule = (fn, ms) => {
      const id = setTimeout(fn, ms);
      return () => clearTimeout(id);
    },
    minRetryMs = 5_000,
    maxRetryMs = 320_000,
    maxStatesPerSec = 1,
    rateWindowMs = 30_000,
    onEnter,
    onEvent,
  } = options;

  let state = 'idle';
  let stateId = 0;
  let retries = 0;
  let pending: { state: string; at: number } | null = null;
  let cancelPending: (() => void) | null = null;
  const entries: number[] = []; // thời điểm vào state, để đo tần suất

  const rate = () => {
    const t = now();
    while (entries.length && entries[0] < t - rateWindowMs) entries.shift();
    return entries.length / (rateWindowMs / 1000);
  };

  const fsm: ChannelFsm = {
    get state() {
      return state;
    },
    get stateId() {
      return stateId;
    },
    get retries() {
      return retries;
    },
    get pending() {
      return pending;
    },
    enter(target) {
      const name = target.replace(/\W*$/, '');
      if (!table[name]) throw new Error('invalid state: ' + name);
      // mỗi lần vào state (kể cả chỉ để chờ) đều làm mọi callback cũ hết hiệu lực
      stateId++;
      cancelPending?.();
      cancelPending = null;
      pending = null;

      let delay = 0;
      let reason: 'backoff' | 'throttle' = 'backoff';
      if (target.endsWith('!')) {
        const r = rate();
        if (r >= maxStatesPerSec) {
          delay = Math.round(1000 / maxStatesPerSec);
          reason = 'throttle';
        }
      } else if (!target.endsWith('#')) {
        delay = backoffDelay(retries, minRetryMs, maxRetryMs, random());
      }

      if (delay > 0) {
        state = 'idle';
        pending = { state: name, at: now() + delay };
        onEvent?.({ type: 'defer', state: name, delayMs: delay, reason });
        // `#` = đã chờ rồi, lần sau vào thẳng
        cancelPending = schedule(() => {
          cancelPending = null;
          fsm.enter(name + '#');
        }, delay);
        return;
      }
      state = name;
      entries.push(now());
      onEvent?.({ type: 'enter', state, stateId });
      onEnter?.(state, stateId);
    },
    exit(id, status) {
      if (state === 'shutdown') return false;
      if (id < stateId) {
        onEvent?.({ type: 'stale', stateId: id, current: stateId, status });
        return false;
      }
      retries = status === 'ok' ? 0 : retries + 1;
      // đang chờ vào state X thì tra bảng theo X (giống `nextState || state` của bản gốc)
      const from = pending?.state ?? state;
      const next = table[from]?.[status] ?? table._all?.[status] ?? 'shutdown!';
      onEvent?.({ type: 'exit', from, status, to: next });
      fsm.enter(next);
      return true;
    },
    signal(status) {
      fsm.exit(stateId, status);
    },
    nextBackoffMs() {
      return Math.min(minRetryMs * 2 ** Math.max(0, retries - 1), maxRetryMs);
    },
    dispose() {
      cancelPending?.();
      cancelPending = null;
      pending = null;
      state = 'shutdown';
    },
  };
  return fsm;
}
ts

#Đi qua code

  • isFree coi slot là trống khi chưa có khoá, khoá đã quá ttlMs, hoặc khoá là của chính mình (trường hợp gọi acquire lại sau bfcache).
  • write nuốt lỗi QuotaExceededError: tab vẫn giữ slot trong bộ nhớ, lần gia hạn sau thử lại. Không ghi được thì lease hết hạn sau 100 giây và tab khác có thể lấy trùng, chấp nhận được vì hậu quả chỉ là hai long-poll chung một host.
  • renew là nơi phát hiện va chạm. Người thắng là người ghi sau cùng (trong demo: Y), người thua chuyển slot ở lần gia hạn kế tiếp, tức trùng nhau tối đa 50 giây.
  • release chỉ xoá khoá nếu khoá vẫn là của mình, để tab vừa bị giành slot không xoá lease của người thắng.
  • enter tăng stateId trước khi quyết định chờ hay đi. Bản gốc cũng vậy: ngay cả một lần chuyển đang chờ cũng làm mọi phản hồi cũ mất hiệu lực.
  • exit tra bảng theo state đang chờ (nếu có) chứ không theo idle, giống transitions[this.nextState || state] của bản gốc. Nhờ vậy tín hiệu sys_offline tới lúc đang chờ vào ping vẫn tra đúng hàng.
  • Đếm tần suất bằng một mảng thời điểm trong cửa sổ 30 giây, đơn giản hơn MovingStat của bản gốc nhưng cùng ý.

Khác bản gốc:

  • Lease ghi chủ sở hữu, gia hạn có kiểm tra, nên hai tab va nhau tự tách ra.
  • Xin lại slot ở pageshow khi persisted = true.
  • Không có localStorage thì trả null (dùng host chung) thay vì chọn bừa một slot: chọn bừa thì mười tab có thể chọn trùng mà không ai biết.
  • Đồng hồ, bộ hẹn giờ, storage, hàm ngẫu nhiên đều tiêm vào được; manual: true để mô phỏng như trong demo.
  • Máy trạng thái không gắn với transport: onEnter(state, stateId) gửi request gì là việc của người dùng, xong thì gọi exit(stateId, status).

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

  • Một tab làm leader, các tab khác nhận tin qua BroadcastChannel: chỉ một kết nối cho cả trình duyệt, tốt hơn hẳn về tài nguyên. Nhưng phải bầu leader, xử lý leader chết, chuyển tiếp mọi tin giữa các tab. Facebook có làm kiểu này ở chỗ khác; với long-poll thời đó, chia subdomain đơn giản hơn nhiều.
  • SharedWorker giữ kết nối chung: cùng ý tưởng, nhưng Safari bỏ SharedWorker nhiều năm (có lại từ Safari 16), không dùng được cho mọi người ở thời điểm kênh này ra đời.
  • Random subdomain mỗi tab, không lease: 7 tab chọn ngẫu nhiên trong 7 host thì xác suất không trùng là 7!/7⁷ ≈ 0,6%. Gần như chắc chắn có host bị hai tab.
  • Lease không hết hạn, chỉ xoá ở pagehide/beforeunload: tab crash không chạy sự kiện nào, slot mất vĩnh viễn (xem bài #79 về chuyện tab chết không kịp nói).
  • switch lồng nhau thay cho bảng: chạy được, nhưng bảng cho phép đọc toàn bộ hành vi ở một chỗ, kiểm thử từng ô, và đổi chính sách (ví dụ thêm mã refresh_117) mà không đụng logic.
  • Huỷ request cũ bằng AbortController thay cho stateId: nên làm cả hai. Huỷ giải phóng kết nối, nhưng callback của request đã trả về một nửa vẫn có thể chạy; stateId là lớp chặn cuối cùng.

#Edge case và cạm bẫy

  • Máy ngủ quá 100 giây: lease hết hạn, tab khác có thể lấy. Khi máy thức, renew của bản viết lại thấy chủ khác và nhường. Bản gốc thì cứ ghi đè.
  • Tab ẩn lâu bị bóp timer: Chrome có thể dồn timer của tab ẩn về khoảng một lần mỗi phút. 50 giây gia hạn so với 100 giây hạn vẫn còn biên, nhưng nếu bạn hạ TTL thì nhớ tính tới chuyện này.
  • storage event: bản này không nghe sự kiện storage, vì quét lại lúc xin slot là đủ. Nếu muốn tab ở host chung chuyển sang slot vừa trống ngay lập tức, nghe storage khi một khoá channel_sub:* bị xoá.
  • Backoff dồn quá lâu: sau 7 lỗi liên tiếp, chờ đã tới trần 320 giây (hơn 5 phút). Khi có tín hiệu online hoặc tab được hiện lại, nên reset bộ đếm và thử ngay, như ChannelConnection làm khi tab VISIBLE trở lại.
  • Bảng thiếu ô: status lạ rơi xuống shutdown!. Đó là chủ ý (an toàn hơn đoán), nhưng nên log lại như bản gốc (terminal_transition).
  • Đệ quy: exit gọi enter, enter gọi onEnter, onEnter có thể gọi exit đồng bộ (request lỗi ngay). Bản gốc có cờ _inEnterState để log trường hợp này. Nếu transport của bạn có thể lỗi đồng bộ, hãy đẩy exit qua queueMicrotask.

#Dùng trong dự án

import { createChannelFsm, createSubdomainLease } from './subdomainLease';

const lease = createSubdomainLease({ storage: localStorage, maxConn: 2 });
lease.acquire();
const host = () => (lease.host == null ? 'poll.example.com' : `${lease.host}-poll.example.com`);

const fsm = createChannelFsm({
  onEnter(state, id) {
    if (state === 'idle' || state === 'shutdown') return;
    fetch(`https://${host()}/${state}`, { credentials: 'include' })
      .then((r) => r.json())
      .then((body) => fsm.exit(id, body.refresh ? `refresh_${body.reason}` : 'ok'))
      .catch(() => fsm.exit(id, 'error')); // id cũ thì tự bị bỏ
  },
});
addEventListener('offline', () => fsm.signal('sys_offline'));
addEventListener('online', () => fsm.signal('sys_online'));
fsm.enter('init!');
ts

Nên kèm AbortController để huỷ request của state cũ: stateId chặn kết quả sai, còn huỷ thì trả lại kết nối.

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import { createChannelFsm, createSubdomainLease, type LeaseStorage } from './subdomainLease';

function mem(): LeaseStorage & { map: Map<string, string> } {
  const map = new Map<string, string>();
  return { map, get length() { return map.size; }, key: (i) => [...map.keys()][i] ?? null,
    getItem: (k) => map.get(k) ?? null, setItem: (k, v) => void map.set(k, v), removeItem: (k) => void map.delete(k) };
}

describe('createSubdomainLease', () => {
  const opts = (storage: LeaseStorage, owner: string, now: () => number) =>
    ({ storage, maxConn: 2, owner, now, manual: true } as const);

  it('7 tab nhận 7 host khác nhau, tab thứ 8 dùng host chung', () => {
    const s = mem();
    const hosts = Array.from({ length: 8 }, (_, i) => createSubdomainLease(opts(s, 't' + i, () => 0)).acquire());
    expect(new Set(hosts.slice(0, 7)).size).toBe(7);
    expect(hosts[7]).toBeNull();
  });

  it('lease hết hạn thì slot trống lại', () => {
    const s = mem(); let t = 0;
    const tab = (owner: string) => createSubdomainLease({ ...opts(s, owner, () => t), random: () => 0 });
    expect(tab('a').acquire()).toBe(0);  // a không bao giờ gia hạn
    t = 50_000;
    expect(tab('b').acquire()).toBe(1);  // slot 0 còn hạn
    t = 100_001;
    expect(tab('c').acquire()).toBe(0);  // slot 0 đã quá 100 giây
  });

  it('hai tab giành cùng slot: người thua nhường ở lần gia hạn', () => {
    const s = mem();
    const a = createSubdomainLease({ ...opts(s, 'a', () => 0), random: () => 0 });
    const b = createSubdomainLease({ ...opts(s, 'b', () => 0), random: () => 0 });
    a.acquire();
    s.map.clear(); // giả lập b đọc trước khi a ghi
    b.acquire();
    expect(a.slot).toBe(b.slot);
    expect(a.renew()).toBe(false);
    expect(a.slot).not.toBe(b.slot);
    expect(b.renew()).toBe(true);
  });

  it('max_conn = 1 thì không có slot', () => {
    expect(createSubdomainLease({ storage: mem(), maxConn: 1, manual: true }).acquire()).toBeNull();
  });
});

describe('createChannelFsm', () => {
  function setup() {
    let t = 0; const timers: { at: number; fn: () => void }[] = [];
    const fsm = createChannelFsm({ now: () => t, random: () => 0,
      schedule: (fn, ms) => { const x = { at: t + ms, fn }; timers.push(x); return () => timers.splice(timers.indexOf(x), 1); } });
    const run = () => { const x = timers.shift()!; t = x.at; x.fn(); };
    return { fsm, run, timers };
  }

  it('ok đi ngay, lỗi thì chờ backoff tăng gấp đôi', () => {
    const { fsm, run, timers } = setup();
    fsm.enter('init!');
    fsm.exit(fsm.stateId, 'ok');
    expect(fsm.state).toBe('pull');
    fsm.exit(fsm.stateId, 'error');
    expect(fsm.pending).toEqual({ state: 'ping', at: 5000 });
    run();
    fsm.exit(fsm.stateId, 'error');
    expect(timers[0].at - 5000).toBe(10_000);
  });

  it('bỏ phản hồi của request cũ', () => {
    const { fsm } = setup();
    fsm.enter('init!');
    const old = fsm.stateId;
    fsm.exit(old, 'ok'); // sang pull
    expect(fsm.exit(old, 'error')).toBe(false);
    expect(fsm.state).toBe('pull');
  });

  it('status không có trong bảng thì shutdown', () => {
    const { fsm } = setup();
    fsm.enter('init!');
    fsm.exit(fsm.stateId, 'không-biết');
    expect(fsm.state).toBe('shutdown');
  });
});
ts

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

  • Web Locks API (navigator.locks.request('slot-3', { ifAvailable: true }, …)): lock thật giữa các tab cùng origin, tự nhả khi tab chết, không cần TTL hay gia hạn. Có trên mọi trình duyệt chính (tính tới 10/2026). Nếu viết mới hôm nay, chia slot bằng Web Locks gọn hơn lease trong localStorage.
  • BroadcastChannel / SharedWorker: gom về một kết nối cho mọi tab, giải quyết tận gốc thay vì chia host.
  • HTTP/2, HTTP/3, WebSocket, Server-Sent Events qua HTTP/2: không còn giới hạn 6 kết nối kiểu cũ; SSE qua HTTP/1.1 vẫn dính giới hạn này.
  • XState: thư viện máy trạng thái đầy đủ (state lồng nhau, guard, delay after). Bảng của Facebook chỉ là một phần nhỏ của nó, đủ cho long-poll mà không kéo thêm phụ thuộc.
  • Backoff có jitter: công thức base · (1 + rand · 0,5) là jitter một phía, chỉ cộng thêm tối đa 50%. Bài #86 so các kiểu jitter khác.

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