ethercalc
Version:
Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)
241 lines (228 loc) • 9.26 kB
text/typescript
/**
* Redis-source adapter — streams rooms out of a running RESP-speaking
* server (Redis or Zedis) that has already loaded the legacy dump.
*
* Contract: caller owns the client lifecycle (connect/close). This
* module only enumerates and fetches. One room per async yield; all
* in-memory state is bounded to (a) the set of distinct room names
* discovered via `SCAN`, and (b) one room's worth of data at a time
* during iteration.
*
* Semantics follow the legacy key layout (AGENTS.md §6.3):
* - `snapshot-<room>` + `log-<room>` + `audit-<room>` + `chat-<room>`
* + `ecell-<room>` are the per-room keys.
* - `timestamps` is a shared hash whose fields (either
* `timestamp-<room>` or bare `<room>`) carry `updated_at`.
* - Rooms appear in sorted order for output determinism.
*
* Performance knobs:
* - `SCAN` with `COUNT hint` instead of a single `KEYS` call avoids
* Redis having to materialize the entire keyspace match list in
* one response (saves ~3 GB of RSS on a 1.8M-key dump).
* - `pipeline()` batches the 5 per-room fetches so each room only
* pays one TCP round-trip (not five), roughly 4× throughput on
* loopback.
*/
import type { Room } from '../apply.ts';
import {
filterOversized,
type OversizedCallback,
} from './filter-oversized.ts';
/** Narrow shape of {@link import('../resp-client.ts').RespClient} used here. */
export interface RespLike {
sendCommand(...args: readonly (string | number)[]): Promise<unknown>;
/**
* Optional batched path — when present, the source fetches each
* room's 5 RESP replies in one round-trip. Absent (or stubbed)
* clients fall back to sequential `sendCommand` calls.
*/
pipeline?(
...commands: readonly (readonly (string | number)[])[]
): Promise<readonly unknown[]>;
}
export interface RoomsFromRedisOptions {
/** Invoked after each room is yielded. */
onProgress?: (info: { readonly done: number; readonly total: number }) => void;
/**
* `SCAN … COUNT <hint>` hint passed per cursor step. Redis treats
* this as a hint, not a cap — it may return fewer or more keys per
* batch. Defaults to 10 000, tuned for large dumps on localhost.
*/
scanCount?: number;
/**
* Max bytes per individual `log`/`audit`/`chat` entry — anything
* longer gets dropped (with optional callback notice) rather than
* shipped. Cloudflare Durable Object storage rejects values over
* 128 KiB per key; a handful of legacy `loadclipboard` audit
* entries in the real dump are 1–5 MB and would 500 the DO seed
* without this filter. Default 120 KiB (leaving ~8 KiB headroom
* under the 128 KiB ceiling for the JSON envelope).
*/
maxEntryBytes?: number;
/** Called when an entry is dropped for exceeding `maxEntryBytes`. */
onOversizedEntry?: OversizedCallback;
/**
* Legacy knob — previously skipped rooms whose snapshot exceeded
* the 128 KiB DO-per-value ceiling. The worker now chunks snapshots
* transparently (see `src/lib/snapshot-storage.ts`), so the default
* is effectively `Infinity` (no skipping). Kept for callers that
* still want to bound individual PUT body size at a lower cap.
*/
maxSnapshotBytes?: number;
/**
* Called when a whole room is skipped for an oversized snapshot.
* With the post-2026-04-22 chunked-snapshot worker this only fires
* when the caller sets an explicit {@link maxSnapshotBytes}.
*/
onSkippedRoom?: (info: {
readonly room: string;
readonly bytes: number;
}) => void;
}
/**
* Enumerate every EtherCalc room in the connected Redis and yield a
* fully-assembled {@link Room} for each. Rooms are collected from
* both `snapshot-*` and `log-*` so log-only rooms (legacy behavior)
* aren't missed.
*/
export async function* roomsFromRedis(
client: RespLike,
options: RoomsFromRedisOptions = {},
): AsyncIterable<Room> {
const scanCount = options.scanCount ?? 10_000;
const timestamps = await fetchTimestamps(client);
// Cursor-iterate both patterns, deduping into a set. Only the room
// names are held in memory during enumeration — the server never
// has to materialize the full keyspace in one reply.
const names = new Set<string>();
for await (const k of scanKeys(client, 'snapshot-*', scanCount)) {
names.add(k.slice('snapshot-'.length));
}
for await (const k of scanKeys(client, 'log-*', scanCount)) {
names.add(k.slice('log-'.length));
}
const sorted = Array.from(names).sort();
const maxEntryBytes = options.maxEntryBytes ?? 120 * 1024;
// Worker now chunks snapshots internally — default is unbounded.
// Callers can still opt in to a ceiling via explicit
// `maxSnapshotBytes` (tests do this to exercise the skip path).
const maxSnapshotBytes = options.maxSnapshotBytes ?? Infinity;
const onOversizedEntry = options.onOversizedEntry;
const onSkippedRoom = options.onSkippedRoom;
let done = 0;
for (const name of sorted) {
const [snapshot, log, audit, chat, ecellArr] = await fetchRoom(client, name);
// Snapshots are authoritative — dropping just the snapshot leaves
// the room in a weird half-state (DO exists, has logs, but no
// save). Skip the whole room instead and let the operator decide
// (e.g. split the sheet, rehydrate from audit, or accept loss).
const snapshotStr = snapshot ?? '';
const snapshotBytes = Buffer.byteLength(snapshotStr, 'utf8');
if (snapshotBytes > maxSnapshotBytes) {
onSkippedRoom?.({ room: name, bytes: snapshotBytes });
done += 1;
options.onProgress?.({ done, total: sorted.length });
continue;
}
const ecell: Record<string, string> = {};
for (let i = 0; i + 1 < ecellArr.length; i += 2) {
ecell[ecellArr[i] as string] = ecellArr[i + 1] as string;
}
const ts = timestamps.get(`timestamp-${name}`) ?? timestamps.get(name);
const updatedAt =
ts !== undefined && Number.isFinite(Number(ts)) ? Number(ts) : undefined;
const room: Room = {
name,
snapshot: snapshotStr,
log: filterOversized(log, 'log', name, maxEntryBytes, onOversizedEntry),
audit: filterOversized(audit, 'audit', name, maxEntryBytes, onOversizedEntry),
chat: filterOversized(chat, 'chat', name, maxEntryBytes, onOversizedEntry),
ecell,
...(updatedAt !== undefined ? { updatedAt } : {}),
};
yield room;
done += 1;
options.onProgress?.({ done, total: sorted.length });
}
}
/**
* Yield every key matching `pattern` via `SCAN`. The cursor protocol:
* `SCAN <cursor> MATCH <pattern> COUNT <hint>` →
* `[<nextCursor>, [<key>, <key>, …]]`
* Cursor `'0'` on the reply means "iteration complete".
*/
async function* scanKeys(
client: RespLike,
pattern: string,
count: number,
): AsyncIterable<string> {
let cursor = '0';
// `do…while` so we start the loop body on the initial cursor '0' and
// stop only after the server echoes '0' back post-scan.
do {
const resp = (await client.sendCommand(
'SCAN',
cursor,
'MATCH',
pattern,
'COUNT',
count,
)) as [string, string[]];
cursor = resp[0];
for (const k of resp[1]) yield k;
} while (cursor !== '0');
}
/**
* Fetch the 5 per-room shapes (snapshot + log + audit + chat + ecell)
* in one RESP round-trip when pipelining is supported. The sequential
* fallback is preserved for test doubles that only implement
* `sendCommand`.
*/
async function fetchRoom(
client: RespLike,
name: string,
): Promise<readonly [string | null, string[], string[], string[], string[]]> {
const commands: readonly (readonly (string | number)[])[] = [
['GET', `snapshot-${name}`],
['LRANGE', `log-${name}`, '0', '-1'],
['LRANGE', `audit-${name}`, '0', '-1'],
['LRANGE', `chat-${name}`, '0', '-1'],
['HGETALL', `ecell-${name}`],
];
if (client.pipeline !== undefined) {
const [snapshot, log, audit, chat, ecell] = await client.pipeline(...commands);
return [
snapshot as string | null,
log as string[],
audit as string[],
chat as string[],
ecell as string[],
];
}
const snapshot = (await client.sendCommand(...commands[0] as (string | number)[])) as
| string
| null;
const log = (await client.sendCommand(...commands[1] as (string | number)[])) as string[];
const audit = (await client.sendCommand(...commands[2] as (string | number)[])) as string[];
const chat = (await client.sendCommand(...commands[3] as (string | number)[])) as string[];
const ecell = (await client.sendCommand(...commands[4] as (string | number)[])) as string[];
return [snapshot, log, audit, chat, ecell];
}
/**
* Fetch the `timestamps` hash in one round-trip, flattened into a
* `field → value` map. Missing → empty map (legacy dumps omitted the
* hash entirely in early EtherCalc versions).
*/
async function fetchTimestamps(
client: RespLike,
): Promise<ReadonlyMap<string, string>> {
const resp = (await client.sendCommand('HGETALL', 'timestamps')) as
| string[]
| null;
const out = new Map<string, string>();
if (resp === null) return out;
for (let i = 0; i + 1 < resp.length; i += 2) {
out.set(resp[i] as string, resp[i + 1] as string);
}
return out;
}