Skip to content

perf(web): run all solves in the worker pool, async live-calc, result… - #77

Merged
diegokingston merged 1 commit into
mainfrom
perf/web-worker-solves
Jul 30, 2026
Merged

perf(web): run all solves in the worker pool, async live-calc, result…#77
diegokingston merged 1 commit into
mainfrom
perf/web-worker-solves

Conversation

@diegokingston

Copy link
Copy Markdown
Collaborator

… memo

Single solves and live-calc ran synchronously on the main thread — a large model froze the UI for the whole solve. The worker pool only served 3D combos. Now:

  • solver-worker handles solve/solve3d messages (objects structured-cloned both ways, NaN guard in-worker); pool routes jobs least-busy and exposes solve2D/3DInWorker. PoolUnavailableError falls back to the old sync call, so Node/vitest and no-Worker browsers behave exactly as before. Failed initPool no longer poisons the pool; tests can inject fake workers.
  • solver-service: validateAndSolve* split into shared prepare (main thread)
    • finalize; new async variants run the engine call in the pool with identical result shapes and string-error semantics.
  • live-calc fully async with stale-response discipline (request counter + modelVersion gates on every store write; manual solve supersedes in-flight live calc).
  • LRU(6) memo keyed by wire input ('2d:'/'3d:' + JSON.stringify) covering undo/redo and no-op edits; errors never cached; test hooks exported.

Tests: new solver-pool-async.test.ts (10 — pool/main parity, error parity, memo hit/miss, stale discard, no-Worker fallback); one spy retargeted to the async entry. Full suite 2685 passed / 7 skipped; build + tsc clean.

@Batuis

Batuis commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Short version: this is the strongest PR of the three and the responsiveness win is real and measurable. It still cannot merge, because a constrained PRO 3D model can now fail to solve at all.

Audited commit 6f23d66504fe940d53bc25feb07bcc740694a43f, reviewed incrementally against #76 (223a6ba461…). No engine changes — this one is pure web.

The win, measured

Largest gap between requestAnimationFrame callbacks during a 3D solve of the Industrial Building model (232 nodes, 633 members), which is the honest proxy for how frozen the UI feels:

#77  226 ms   (second solve 161 ms)
#76  537 ms   (second solve 293 ms)
#75  676 ms   (second solve 561 ms)

Rendered results were identical across all three. The memo also absorbs repeated work — ten rapid Solve dispatches produced zero engine invocations on #77 against ten on #76. The stale-response discipline in live-calc.ts (model version plus a monotonic request id, checked after every await), the initPromise retry fix at solver-pool.ts:124, and the setWorkerFactoryForTests seam are all good, and the new test file is a clear step up from what #75 and #76 shipped.

The blocker

#76 left a defect where the 3D wire carries a live Svelte constraints proxy that cannot be structured-cloned. On #76 that only affected the parallel-combinations path, which caught the error and fell back to sequential — correct results, just slower. #77 routes the primary single 3D solve through the same transport, and that path has no fallback for clone errors, so the solve fails outright. This PR materially worsens the inherited problem rather than introducing it.

To reproduce: PRO mode, load RC QA Diagnostic (PRO), delete its combinations so the single-solve branch is taken, then Solve.

#77: Solver 3D error: DataCloneError: Failed to execute 'postMessage' on 'Worker':
     [object Array] could not be cloned          → no result at all
#76: Estructura 3D resuelta en 32.7 ms — 18 nodos, 26 elementos

The reason nothing catches it is solver-service.ts:723 (2D) and :1664 (3D): only PoolUnavailableError triggers the fallback. That class is raised by ensurePool() for initialization problems, so a DataCloneError from postMessage, a worker crash, or a malformed reply all propagate and surface as svc.solver3dError — while the perfectly usable synchronous solver sits unused.

What I would change

Scope note

The title says all solves move to the worker pool, but several routes are still synchronous: the 2D manual solve in Toolbar.svelte:134 and ToolbarResults.svelte:88 (including the Enter key), all 2D combinations, the advanced analyses in ToolbarAdvanced.svelte, What-If, and Edu mode. Worth adjusting the description, or routing the 2D button through the same path so both 2D entry points behave alike.

Not covered

I could not drive rapid editing under live calculation from automation, so out-of-order live-calc results are unverified beyond the unit test. Worker crash, malformed reply, and teardown with pending jobs have no injection hook either. One full-suite run out of five reported a single failure that did not reproduce across four subsequent clean runs; it coincided with heavy concurrent load from my own browser testing and I could not identify the test, so I would treat it as unattributed rather than blame this PR.

Once the transport is fixed and the fallback covers it, the measured responsiveness gain makes this well worth landing.

@diegokingston
diegokingston changed the base branch from perf/wasm-jsvalue-boundary to main July 30, 2026 20:13
… memo

Single solves and live-calc ran synchronously on the main thread — a large
model froze the UI for the whole solve. The worker pool only served 3D
combos. Now:

- solver-worker handles solve/solve3d messages (objects structured-cloned
  both ways, NaN guard in-worker); pool routes jobs least-busy and exposes
  solve2D/3DInWorker. PoolUnavailableError falls back to the old sync call,
  so Node/vitest and no-Worker browsers behave exactly as before. Failed
  initPool no longer poisons the pool; tests can inject fake workers.
- solver-service: validateAndSolve* split into shared prepare (main thread)
  + finalize; new async variants run the engine call in the pool with
  identical result shapes and string-error semantics.
- live-calc fully async with stale-response discipline (request counter +
  modelVersion gates on every store write; manual solve supersedes
  in-flight live calc).
- LRU(6) memo keyed by wire input ('2d:'/'3d:' + JSON.stringify) covering
  undo/redo and no-op edits; errors never cached; test hooks exported.

Tests: new solver-pool-async.test.ts (10 — pool/main parity, error parity,
memo hit/miss, stale discard, no-Worker fallback); one spy retargeted to the
async entry. Full suite 2685 passed / 7 skipped; build + tsc clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants