UNPKG

ethercalc

Version:

Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)

332 lines (310 loc) 12.6 kB
/** * HTTP-based `MigrationTarget` — writes through the live worker's * `PUT /_migrate/seed/:room` endpoint (see * `packages/worker/src/routes/migrate.ts`). Intended for local Miniflare * seeding: start `./bin/ethercalc` on one terminal, run the migrator on * another pointing at its base URL. * * Why "live target" rather than shelling out to `wrangler`: * 1. DO storage is unreachable via any remote `wrangler` verb; you * can't `wrangler kv key put` into a Durable Object. Once we * accept that an HTTP hop is required for the DO, the rest (D1, * KV-style rooms index) falls in line — the worker owns both and * mirrors synchronously on the seed path. * 2. One HTTP round-trip per room (full-fidelity payload) beats N * shell-outs per room: a 500 MB dump with 10 k rooms would spawn * ~30 k wrangler invocations, each paying its Bun startup cost. * * Target buffering: * `applyRoomStream` (see `src/apply.ts`) visits each room in the * order snapshot → log → audit → chat → ecell → setRoomIndex, and * never interleaves writes for the same room. We accumulate a single * per-room payload in `#buffers`, flush on `setRoomIndex` (always * last), and drop the entry so re-emit is free. The payload shape * matches the server's `parseSeedPayload` (see `@ethercalc/worker`). * * D1 index batching: * The seed PUT carries `skipIndex: true`, so the DO's `#postSeed` * writes storage + meta but does NOT touch the D1 `rooms` row. * Instead we queue `(room, updatedAt)` pairs locally and flush them * through `PUT /_migrate/bulk-index` in chunks. One SQL `INSERT … * VALUES (…),(…),…` per chunk collapses the per-room D1 round-trip * into ~N/chunk round-trips — the difference between a 5-hour D1- * bound migration and a 30-minute upload-bound one. * * Failure semantics: a non-2xx response aborts the run by throwing. The * caller (`runMigrate` in `cli.ts`) doesn't re-try — the dump is * deterministic, so if one room failed the fix is to change the code * or the target, then re-run (the endpoint is idempotent — rooms already * seeded will just be overwritten with their own values). */ import type { MigrationTarget } from '../apply.ts'; interface RoomBuffer { snapshot?: string; log: string[]; audit: string[]; chat: string[]; ecell: Record<string, string>; } /** Minimal fetch surface — lets tests inject a stub without importing undici. */ export type FetchLike = ( input: string, init?: RequestInit, ) => Promise<Response>; export interface HttpTargetConfig { /** Base URL of the running worker, e.g. `http://127.0.0.1:8000`. No trailing slash. */ baseUrl: string; /** Bearer token. Must match `env.ETHERCALC_MIGRATE_TOKEN` on the worker. */ token: string; /** Override for tests. Defaults to the global `fetch`. */ fetch?: FetchLike; /** * Max number of `(room, updatedAt)` pairs per `/_migrate/bulk-index` * round-trip. Default 50 — D1 enforces a hard 100-parameter cap per * prepared statement, and we bind two per entry (room + updatedAt), * so anything over 50 rows fails with a generic `500 Internal Server * Error` (the limit is not surfaced via an error message we'd see). * The earlier value of 200 silently broke every production run on * 2026-04-21 — see AGENTS.md §14. Tests can lower further to drive * flush-on-threshold deterministically. */ bulkIndexBatchSize?: number; /** * Disable the bulk-index side of the pipeline entirely. When `true`, * the target fires seed PUTs (still with `skipIndex: true` on the * body) but never enqueues or flushes `(room, updatedAt)` pairs — * i.e. it writes DO storage and leaves the D1 `rooms` table alone. * * Use this when D1 is being populated out-of-band, e.g. via a * `wrangler d1 execute --remote --file=rooms.sql` dump import that * runs minutes before this pass. See AGENTS.md §14 2026-04-21 for * the production recipe. */ skipBulkIndex?: boolean; } export class HttpTarget implements MigrationTarget { readonly #baseUrl: string; readonly #token: string; readonly #fetch: FetchLike; readonly #buffers: Map<string, RoomBuffer> = new Map(); readonly #pendingIndex: Array<{ room: string; updatedAt: number }> = []; readonly #bulkIndexBatchSize: number; readonly #skipBulkIndex: boolean; constructor(config: HttpTargetConfig) { // Strip a trailing slash so `${baseUrl}/_migrate/...` is always well-formed. this.#baseUrl = config.baseUrl.replace(/\/+$/, ''); this.#token = config.token; this.#fetch = config.fetch ?? ((input, init) => fetch(input, init)); this.#bulkIndexBatchSize = Math.max(1, config.bulkIndexBatchSize ?? 50); this.#skipBulkIndex = config.skipBulkIndex ?? false; } putSnapshot(room: string, snapshot: string): Promise<void> { this.#bucket(room).snapshot = snapshot; return Promise.resolve(); } putLog(room: string, _seq: number, cmd: string): Promise<void> { this.#bucket(room).log.push(cmd); return Promise.resolve(); } putAudit(room: string, _seq: number, cmd: string): Promise<void> { this.#bucket(room).audit.push(cmd); return Promise.resolve(); } putChat(room: string, _seq: number, msg: string): Promise<void> { this.#bucket(room).chat.push(msg); return Promise.resolve(); } putEcell(room: string, user: string, cell: string): Promise<void> { this.#bucket(room).ecell[user] = cell; return Promise.resolve(); } async setRoomIndex(room: string, updatedAt: number): Promise<void> { const buffer = this.#bucket(room); // Seed the DO with `skipIndex: true` — it writes storage + meta // but leaves the D1 `rooms` row to the batched flush below (or // skips it altogether when `skipBulkIndex` is on). await this.#flushSeed(room, buffer, updatedAt); this.#buffers.delete(room); if (this.#skipBulkIndex) return; this.#pendingIndex.push({ room, updatedAt }); if (this.#pendingIndex.length >= this.#bulkIndexBatchSize) { await this.#flushBulkIndex(); } } /** * Drain any remaining `(room, updatedAt)` pairs that haven't reached * the batch threshold. Safe to call multiple times — it's a no-op when * the queue is empty. Invoked by `applyRoomStream` at end-of-run. */ async flush(): Promise<void> { if (this.#skipBulkIndex) return; if (this.#pendingIndex.length > 0) await this.#flushBulkIndex(); } #bucket(room: string): RoomBuffer { let b = this.#buffers.get(room); if (b === undefined) { b = { log: [], audit: [], chat: [], ecell: {} }; this.#buffers.set(room, b); } return b; } async #flushSeed( room: string, buffer: RoomBuffer, updatedAt: number, ): Promise<void> { const body: Record<string, unknown> = { log: buffer.log, audit: buffer.audit, chat: buffer.chat, ecell: buffer.ecell, updatedAt, skipIndex: true, }; // Match `applyRoomStream`: only send `snapshot` when the source // yielded one. A log-only room (dump entry with no `snapshot-*` // key) ships without the field so the server's seed path skips // the snapshot write. if (buffer.snapshot !== undefined && buffer.snapshot.length > 0) { body['snapshot'] = buffer.snapshot; } const json = JSON.stringify(body); await this.#fetchWithRetry( `seed ${room}`, `${this.#baseUrl}/_migrate/seed/${encodeURIComponent(room)}`, json, ); } async #flushBulkIndex(): Promise<void> { // Splice out up to one batch's worth; leave the tail in place so a // larger-than-batch flush (e.g. if concurrency outpaced the // threshold check) still drains in bounded chunks. const chunk = this.#pendingIndex.splice(0, this.#bulkIndexBatchSize); const json = JSON.stringify({ rooms: chunk }); await this.#fetchWithRetry( `bulk-index ${chunk.length} rows`, `${this.#baseUrl}/_migrate/bulk-index`, json, ); } /** * PUT a JSON body with retry-on-5xx. CF Workers (and the load path * through a DO) do occasionally 500 under load — a single retry with * ~1s backoff clears almost every transient. Gives up after 3 tries * and surfaces the final error; 4xx is never retried because those * are deterministic (bad token, bad payload) and will fail the same * way every time. * * Response body drained on every path — Bun/undici hold the buffered * Request+Response pair alive until the body stream is consumed or * cancelled. At hundreds of thousands of PUTs × ~85 KB per pair, not * draining balloons RSS to 58 GB and segfaults (2026-04-21 repro). */ async #fetchWithRetry( label: string, url: string, jsonBody: string, ): Promise<void> { const maxAttempts = 3; let lastErrorText = ''; let lastStatus = 0; for (let attempt = 1; attempt <= maxAttempts; attempt++) { const res = await this.#fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.#token}`, }, body: jsonBody, }); if (res.ok) { await drain(res); return; } // Read the body once — drain happens automatically via `.text()`. lastErrorText = await safeText(res); lastStatus = res.status; if (res.status < 500 || attempt === maxAttempts) { throw new Error( `${label} failed: ${res.status} ${res.statusText}${lastErrorText}`, ); } // 5xx and we have attempts left: sleep with jitter, then retry. const backoffMs = 200 * attempt + Math.floor(Math.random() * 100); await new Promise((r) => setTimeout(r, backoffMs)); } // Unreachable — the loop always either returns or throws on the // final attempt. Included for the compiler's narrowing. /* istanbul ignore next */ throw new Error(`${label} failed: ${lastStatus}${lastErrorText}`); } } async function safeText(res: Response): Promise<string> { try { return await res.text(); } catch { return '<unreadable body>'; } } /** * Release a Response without inspecting its bytes. `safeText`/`.text()` * already drain the body; this is for success paths where we don't * need the payload but still must consume it or cancel the stream. * * Both Bun and undici/Node hold the buffered Request + Response bytes * alive until the body is fully read or explicitly cancelled. On a * long-running migration (hundreds of thousands of calls) this adds * up to tens of GB of retained memory and eventually crashes the * process — see AGENTS.md §14 2026-04-21 entry. * * Try `body.cancel()` first (cheapest — tells the runtime to stop * buffering); fall back to `.arrayBuffer()` on engines where cancel * is missing or where the body has no underlying stream. Any error * here is non-fatal: we've already succeeded from the caller's POV. */ async function drain(res: Response): Promise<void> { try { if (res.body !== null && typeof res.body.cancel === 'function') { await res.body.cancel(); return; } await res.arrayBuffer(); } catch { // best-effort — the underlying bytes will be reclaimed when the // Response goes out of scope regardless, this just accelerates it. } } /** * Poll `${baseUrl}/_health` until it returns a 2xx or the deadline * elapses. Used by the CLI to wait for `wrangler dev --local` to warm * up before firing the first seed. `deps.now`/`deps.sleep` are injected * so tests can drive the polling loop without real time. */ export interface WaitForHealthDeps { fetch: FetchLike; now: () => number; sleep: (ms: number) => Promise<void>; } export async function waitForHealth( baseUrl: string, timeoutMs: number, deps: WaitForHealthDeps, ): Promise<boolean> { const url = `${baseUrl.replace(/\/+$/, '')}/_health`; const deadline = deps.now() + timeoutMs; // Always make at least one attempt, even with timeoutMs=0. The deadline // guard below only short-circuits retries, not the first request. while (true) { try { const res = await deps.fetch(url); // Drain whether ok or not — see `drain()` doc for why skipping // this leaks up to a few hundred KB per health poll. await drain(res); if (res.ok) return true; } catch { // Retry — the socket may still be warming up. } if (deps.now() >= deadline) return false; await deps.sleep(200); } }