Skip to content

Commit ac4c435

Browse files
hellpanderrrclaude
andcommitted
TTS: fail fast + spread load across the Cloudflare worker farm
The 6-proxy farm 'rot' identically under concurrent load: a single worker's synthesis jobs contend for CPU and trip Cloudflare's hard ~21s request ceiling, so a weighted-down request wedges ~21s then dies. The rotator had no per-worker timeout (fetch waited out the full ~21s kill) and #getBestWorker() piled concurrent jobs onto one worker. - #fetchWithTimeout(): 9s per-worker timeout (Promise.race), so a hung worker fails fast and the loop rotates to the next worker. - #getBestWorker(): random round-robin so concurrent requests spread across the farm instead of stacking on the least-recently-used worker. Recorded in CLAUDE.md self-correcting notes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent a097327 commit ac4c435

2 files changed

Lines changed: 40 additions & 5 deletions

File tree

wiktionary_pron/CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ Each of these cost real debugging time in past sessions. Check this list before
142142
- **Regex char classes with `-` between Unicode literals form ranges**: `[^\p{L}\p{M}'’-‿]` parsed `’-‿` as U+2019–U+203F and stripped ASCII hyphens from every word. Put `-` last in the class. (Was a live bug in `sanitize()` for years.)
143143
- **`#header > a > i` selects the HOME link's icon** — the dark-mode toggle on macronizer.html restyled the wrong button for this reason. Use `#dark_mode i`.
144144
- **Duplicated language lists in `main.js` drift**: `lang === "Lituanian"` (typo, missing h) appears in several copies of the multi-value language list — Lithuanian silently loses features in some modes. If touching those lists, extract one shared constant.
145+
- **Cloudflare Workers kill a request at a hard ~21s wall, not a clean error.** Under concurrent load a single worker's synthesis jobs contend for CPU and trip this ceiling → one job per burst wedges ~21s then dies (`000` / truncated audio). This made the 6-proxy TTS farm "rot" identically; it's a platform throttle, not per-worker config. Fixes (in `tts.js`): a **per-worker client timeout** (~9s via `Promise.race`) so a hung worker fails fast and the rotator moves on instead of waiting out the ~21s kill; and **shuffle the farm per call** so concurrent requests spread instead of piling onto the least-recently-used worker. Worker side (CF `silent-unit-b6ca`): reject on socket close before `turn.end` (the promise was hanging forever) and retry with fresh `Sec-MS-GEC`/`ConnectionId`; keep `Sec-MS-GEC-Version` in step with the current Edge release (`…3650.75``…3650.96`).
145146

146147
**IndexedDB performance (measured in Chromium, 100k rows)**
147148
- Row-per-entry `put()` with a secondary index is the killer: ~58s/100k. `durability: 'relaxed'` changes nothing (already default). Grouping by unique key: 1.8×. **Packing ~1000 rows per record: 20×** (2.8s/100k). This is why both the macronizer wordlist and the app lexicons use sorted range-chunk records.

wiktionary_pron/scripts/tts.js

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,44 @@ class StreamingTTS {
8585
}
8686

8787
#getBestWorker() {
88-
return this.#workers.reduce((best, current) => {
89-
return (current.lastUsed < best.lastUsed) ? current : best;
88+
// Round-robin starting from a random point, so concurrent requests spread
89+
// across the whole farm instead of piling onto whichever worker has the
90+
// oldest lastUsed timestamp (which stacks concurrent jobs on one worker
91+
// and trips Cloudflare's ~21s request ceiling -> the observed hangs).
92+
const startIdx = Math.floor(Math.random() * this.#workers.length);
93+
return this.#workers[(startIdx + 1) % this.#workers.length];
94+
}
95+
96+
// Abort a request to one worker after this long so a hung worker fails fast
97+
// and the rotation loop moves on, instead of waiting out Cloudflare's full
98+
// ~21s platform kill before failing over.
99+
#workerTimeoutMs = 9000;
100+
101+
// Promise.race wrapper: settles on the worker response OR the abort signal OR
102+
// a per-worker timeout. A timed-out worker counts as a failed attempt so the
103+
// loop rotates to the next worker.
104+
async #fetchWithTimeout(url, init) {
105+
const controller = this.#currentAbortController;
106+
let timer = null;
107+
let timedOut = false;
108+
const timeout = new Promise((_, reject) => {
109+
timer = setTimeout(() => {
110+
timedOut = true;
111+
reject(new Error('Worker timeout'));
112+
}, this.#workerTimeoutMs);
113+
controller.signal.addEventListener('abort', () => clearTimeout(timer), {once: true});
90114
});
115+
try {
116+
const result = await Promise.race([
117+
fetch(url, { ...init, signal: controller.signal }),
118+
timeout
119+
]);
120+
if (!timedOut) clearTimeout(timer);
121+
return result;
122+
} finally {
123+
// swallows any leftover timer rejection after the race is won
124+
timeout.catch(() => {});
125+
}
91126
}
92127

93128
get isPlaying() {
@@ -205,7 +240,7 @@ class StreamingTTS {
205240
worker.lastUsed = Date.now();
206241

207242
try {
208-
const response = await fetch(`${worker.base}/tts`, {
243+
const response = await this.#fetchWithTimeout(`${worker.base}/tts`, {
209244
method: "POST",
210245
headers: {"Content-Type": "application/json"},
211246
body: JSON.stringify({
@@ -214,8 +249,7 @@ class StreamingTTS {
214249
rate: rateStr,
215250
pitch: pitchStr,
216251
volume: "+0%"
217-
}),
218-
signal: this.#currentAbortController.signal
252+
})
219253
});
220254

221255
if (!response.ok) {

0 commit comments

Comments
 (0)