perf(planning): yield between asynchronous slices without a timer

Each slice of the asynchronous adapter waited for a zero-delay timer:
about 1.1 ms in Bun, and at least 4 ms in browsers once timers nest.
Browsers now yield through a message-channel task. Bun runs port
messages ahead of timers and incoming worker messages, which starved
cancellation and heartbeats, so it yields with setImmediate. Both still
let queued cancellation and heartbeat tasks run between slices.

Part of #302.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nessarandClaude Opus 5.5 committed 2026-10-18 19:58:40 +02:00
1 parent 2fcba758c8
commit b7ec411c92
3 files changed
+55 -9

No files matched your search

+30
View File
@@ -59,3 +59,33 @@ reconciliation; afterwards retention took 0.26 seconds.
The typed-obligations difference is run-to-run noise; its retention cost was
22 milliseconds.
### Yield between asynchronous slices without a timer
The asynchronous adapter yields roughly every eight milliseconds. Each yield
waited for a zero-delay timer: about 1.1 ms in Bun, and at least 4 ms in
browsers once timers nest. Browsers now yield through a message-channel task.
Bun runs port messages ahead of timers and incoming worker messages, so a
message-channel yield there kept cancellation and the heartbeat from running
until the calculation ended. Bun therefore yields with `setImmediate`, which
costs about 0.01 ms and lets both run between slices.
| Scenario | Before (s) | After (s) |
| --------------------------- | ---------: | --------: |
| representative year | 0.37 | 0.31 |
| typed obligations | 4.74 | 4.02 |
| chained earned entitlements | 0.02 | 0.01 |
| deliberate conflict | 0.01 | 0.00 |
| loaded year | 53.80 | 46.90 |
| two years | 1.23 | 1.04 |
| three years | 3.86 | 3.21 |
The browser path was checked in headless Chromium 149 on the same machine, in a
dedicated worker with a 50 ms heartbeat timer, running the bundled core before
and after this step, alternating, twice each. The `three years` calculation took
3.92 and 3.89 seconds before and 2.70 and 2.66 seconds after; `representative
year` took 0.51 and 0.45 seconds before and 0.30 seconds after. The heartbeat's
largest gap stayed between 58 and 67 ms in every run. Cancelling `three years`
one second after the request returned the retained plan 3.9 to 4.0 ms after the
cancel message reached the worker, against 7.2 to 8.7 ms before. This is desktop
evidence, not a reference-phone measurement.
+1 -1
View File
@@ -6,7 +6,7 @@ Issue [#58](https://gitea.nessar.fr/nessar/vac-optimizer/issues/58) implements t
## Stopping CPU work
The core exposes resumable generator steps. `calculatePlanningAsyncInternal` drives those same steps in roughly eight-millisecond slices and yields to the worker event loop at phase boundaries and between preparation years. A `cancel` message sets the active run's cancellation flag; the next resume throws the cancellation signal into the suspended calculation. Recursive obligation and preference searches, quality passes, and calendar preparation unwind through their existing cleanup blocks. An individual synchronous operation, such as the holiday library's work for one year, finishes before the next pause.
The core exposes resumable generator steps. `calculatePlanningAsyncInternal` drives those same steps in roughly eight-millisecond slices and yields to the worker event loop at phase boundaries and between preparation years. Browsers yield through a message-channel task, because nested timers are clamped to at least 4 ms; Bun yields with `setImmediate`, because it runs port messages ahead of timers and incoming worker messages. A `cancel` message sets the active run's cancellation flag; the next resume throws the cancellation signal into the suspended calculation. Recursive obligation and preference searches, quality passes, and calendar preparation unwind through their existing cleanup blocks. An individual synchronous operation, such as the holiday library's work for one year, finishes before the next pause.
The synchronous `calculatePlanningInternal` adapter drains the same steps without pauses for existing direct core callers. The public non-browser calculation context uses the asynchronous driver so its cancellation method also works.
+24 -8
View File
@@ -4120,16 +4120,32 @@ export async function calculatePlanningAsyncInternal(
explanationCallback,
true,
);
let next = steps.next();
let pauseAt = now() + 8;
while (!next.done) {
if (next.value || now() >= pauseAt) {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
pauseAt = now() + 8;
// Slices yield so cancellation and heartbeat tasks can run, without a timer:
// browsers clamp nested timers to 4 ms, so they yield through a message task.
// Bun runs port messages ahead of timers and worker messages, so it uses setImmediate.
const channel = typeof setImmediate === 'function' ? undefined : new MessageChannel();
let resume = () => {};
if (channel) channel.port1.onmessage = () => resume();
const pause = () =>
new Promise<void>((resolve) => {
if (!channel) return void setImmediate(resolve);
resume = resolve;
channel.port2.postMessage(undefined);
});
try {
let next = steps.next();
let pauseAt = now() + 8;
while (!next.done) {
if (next.value || now() >= pauseAt) {
await pause();
pauseAt = now() + 8;
}
next = isCancelled() ? steps.throw(new UserCancelled()) : steps.next();
}
next = isCancelled() ? steps.throw(new UserCancelled()) : steps.next();
return next.value;
} finally {
channel?.port1.close();
}
return next.value;
}
function* prepareCalendar(