Skip to content
markpaper

src/ws/userSet.ts

v0.3.0 · 2.6 KB

Download file
// Planning which tracked users get WebSocket slots and which fall back to REST polling.

import { isValidAddress } from './subscriptionKey.js';

export interface UserSlotPlan {
  /** Users that get a WS subscription: sorted, lowercase, unique. */
  ws: string[];
  /** Overflow users for REST polling, in priority order. */
  poll: string[];
  /** Inputs that are not valid addresses (never subscribe placeholders). */
  invalid: string[];
}

/**
 * Splits users into WS slots and REST polling.
 *
 * - Input ORDER is the priority: slots go to the first users, overflow goes to REST. Slots on an IP
 *   are handed out first-come by the server, not by importance: a process that starts earlier can
 *   take every slot. Put the most important subscriptions first, then accounts with open positions,
 *   then the rest.
 * - Addresses are deduplicated in lowercase and the WS set is sorted, so the same membership always
 *   yields the same set: an order that "breathes" between ticks causes needless unsubscribe/subscribe
 *   churn and ghost slots.
 * - `capacity` should stay BELOW the server limit (documented 10 per IP) to leave room for ghost
 *   slots; shards filled to the brim produce a steady stream of `Cannot track` errors and REST 429s.
 */
export function planUserSlots(users: Iterable<string>, capacity: number): UserSlotPlan {
  const seen = new Set<string>();
  const ordered: string[] = [];
  const invalid: string[] = [];
  for (const raw of users) {
    const user = String(raw).trim().toLowerCase();
    if (!isValidAddress(user)) { invalid.push(String(raw)); continue; }
    if (seen.has(user)) continue;
    seen.add(user);
    ordered.push(user);
  }
  const n = Math.max(0, Math.floor(capacity));
  return { ws: ordered.slice(0, n).sort(), poll: ordered.slice(n), invalid };
}

export interface UserSetDiff {
  add: string[];
  remove: string[];
  /** True when nothing changes: do not send anything and do not log a "degraded" line again. */
  unchanged: boolean;
}

/**
 * Difference between the current and the desired WS user sets (lowercase, sorted output). Apply
 * removals on the connection that holds the user (same-connection unsubscribe frees the slot at once)
 * and log degradation only when the set actually changes.
 */
export function diffUserSets(current: Iterable<string>, next: Iterable<string>): UserSetDiff {
  const a = new Set([...current].map((u) => u.toLowerCase()));
  const b = new Set([...next].map((u) => u.toLowerCase()));
  const add = [...b].filter((u) => !a.has(u)).sort();
  const remove = [...a].filter((u) => !b.has(u)).sort();
  return { add, remove, unchanged: add.length === 0 && remove.length === 0 };
}
All files