Repository navigation
Speculative Kernel as Job Store
Speculative design. Open, not settled — thinking and proposals, not shipped behavior. See Roadmap for what's in flight.
Status: spiked, not adopted (2026-10-01), in Chromium, Firefox and Safari. A kernel on the web as a store in IndexedDB that several workers claim calls from, with no coordinator. It works, but calls go by message instead, and IndexedDB keeps the session journal: see Sessions as Data. This page is the experiment's record: what was built, and what it measured.
Kernels and Front Ends §2 defines engine and kernel; this page keeps those meanings.
-
Engine worker: a
SharedWorkerholding one engine. It claims calls and evaluates them. It holds no session of its own and isn't a kernel. - Kernel: the logical backend. It is the store plus the engine workers that serve it. No one process is the kernel.
- Store: IndexedDB, shared by every page and worker on the origin. It holds the calls and their answers.
-
Bell: a
BroadcastChannel. It says only "look at the store": a missed ring loses nothing, because the store holds the state.
-
inputs: one record per call as it was written, keyedversion|format|text. A record holds:- the status: pending, claimed or done;
- who owns the call, and on which attempt;
- its progress;
- once the call is done, a pointer to its value record. When its value isn't kept, the record holds the value itself.
-
values: one record per canonical expression, keyedversion|canonical MathJSON. The engine writes the key, since only the engine can canonicalise. Inputs that canonicalise alike share one value record:1000+7and7+1000evaluate once.
A page writes a pending input and rings. Every idle engine worker reacts. A claim is a
read-write transaction that flips the oldest pending input to claimed. Read-write
transactions on one store run one at a time, so a call is claimed exactly once, without a lock
or a coordinator. When the claiming worker is done, it writes the value record and the input in
one transaction, then rings done.
A page that finds a done input reads its answer with no worker involved.
The type system already says what an evaluation does: expr.effects, with no per-head flag.
- No effects: kept.
-
random: not kept. A seeded draw is discharged byWithRandomSeed, reports no effects, and is kept. -
console: not kept, for now. A print may be incidental, where the value is what matters and keeping it is fine. It may also be the point of the call, which must run again. The effect system may need to tell those two apart. Each effect gets its own cache rule; a single "has effects" bit isn't enough. -
The rest (
time,state,scope,network, the file system), and an expression whose effects are unknown ("any", e.g.RandomInteger), are not kept.
A value record carries its effects and cost (time to evaluate, which worker). That is the start of per-head performance data.
A claimed call has no fixed lease. Instead, the worker running it writes progress into the input record at a set interval (250 ms in the spike; at most about a second, depending on the cost of a write). That record is how a front end shows progress, and how a stall is noticed.
A call whose progress goes quiet longer than the stall interval (1 s in the spike) is re-run:
-
An attempt number fences it. The re-run bumps
attempt, and a worker writes only while its attempt still holds the record. When the stalled worker comes back, its late answer is refused. - The re-run is in a safer mode: more debuggable, and profiled where that helps.
-
When the safe run stalls too, the call is not re-run again. It is marked stalled for the
front end, and what happens next is a decision:
- rewrite the query to be incremental;
- extend the time allowed, e.g. on the user's word;
- give up.
The progress stream also opens a window onto performance: how long each head takes, where calls stall, and which engine workers are loaded.
Measured blocker. evaluateAsync yields only where a head has an async handler.
Sum has one: it runs its fold through runAsync, which yields every 16 ms. N doesn't:
it is lazy and calls the operand's .N(), so everything under it runs synchronously. With
compute-engine 0.142, while a 50 ms timer counted ticks:
-
Sum(1/k^2, k=1..400000), numeric: 2.9 s, 51 ticks; -
N(Sum(…)), the spike's test: 2.2 s, 0 ticks. No progress is written, so the call looks stalled.
Without the cap of one re-run, the watchdog sent that sum to every worker in turn (22.8 s in all). With the cap it takes 4.7 s: two runs, then the call is marked stalled.
It's broader than N. Add(Sum(…), 1) and Evaluate(Sum(…)) block the same way, and of
the lazy operators only 13 have an async handler (Block, If, Loop, Which, the logical
connectives, List, Set, Sum, Product); 143 don't. The upstream ask is N's handler
first, then a default async handler for lazy heads that only evaluate their operands
(cortex-js/compute-engine#392). Until
then, a call yields only when every head from its root down to the long work has one, so an
engine can't count on progress for an arbitrary expression. Where a head has no async path, the stall interval has to come from the expected
cost. A direct ping to the worker doesn't help: it waits in the same blocked event loop.
Measured with the spike (web/public/spike/ on #561):
4 engine workers, compute-engine 0.142, macOS. Times are median / p95, in ms. Safari and
Firefox round performance.now() to 1 ms on a page that isn't cross-origin isolated, so
their small numbers are coarse.
| Chromium 152 | Firefox 157 | Safari 26.6 | |
|---|---|---|---|
direct postMessage round trip |
0.0 / 0.1 | 1 / 2 | 1 / 2 |
| store, miss (write, ring, claim, evaluate, write, ring, read) | 2.0 / 2.7 | 2 / 4 | 8 / 41 |
| store, hit | 0.3 / 0.5 | 0 / 1 | 1 / 5 |
| 200 calls at once, total | 270–300 | 302 | 1154 |
Every browser gave the same answers:
- Contention: each of the 200 calls was evaluated exactly once, split about 50 per worker.
-
Canonical sharing:
Add(7, 1000)is answered fromAdd(1000, 7)'s value record. -
Effects: as §3. A seeded draw is kept and its repeat is a hit;
Random()andPrint(1)are not kept. - Stall: a worker that blocks without progress is superseded after 1.0–1.2 s. The safe re-run answers, and the stalled worker's late answer is refused.
- Progress: no heartbeat in any browser, for the reason in §4.
Chromium and Firefox add about 2 ms over a direct message. That is noise beside a keystroke or a plot, and less as more is lifted into the page (compiled code, client-side evaluation).
Safari is the outlier: a miss is about 4× slower, with a long tail (p95 41 ms), and a burst of 200 takes about 4× as long. The likely cause, not yet measured on its own, is that its IndexedDB transactions cost more, and every call takes several: a claim, the answer, the reads. Batching claims would help most there: a worker takes several pending calls per transaction, and writes their answers in one.
Every step goes in batches, not only the claim. The cost on every route is the transaction, not the record.
- Submits: a page puts every call it asks for in one tick in one transaction, and rings once.
- Claims: a worker takes up to B pending calls in one transaction.
- Answers: written in one transaction at the end of the batch, or sooner once a flush interval (16 ms) has passed, so a long batch still answers its first calls early.
- The bell carries the answers, so a page reads nothing back. The store stays the truth: a page that missed the bell finds the answer there.
- One watchdog per page: it reads every claimed call and every worker's heartbeat in one transaction a tick. Before, each waiting call ran its own timer.
Liveness is per worker, not per call. A call queued behind others in a batch has no progress of its own to report. Each worker sends a heartbeat on the bell (every 250 ms), and when it goes quiet the watchdog re-runs that worker's unanswered calls:
- the first in claim order, the one it's stuck on, in the safer mode;
- the rest as they were. Per-call progress (elements done) is still what a front end shows, but it doesn't decide a stall.
The heartbeat can't live in the store. A write a worker issues just before a blocking
evaluation can't commit until the worker comes back: its success event waits on the
worker's thread. Its lock holds up every transaction on that store, the watchdog's included.
With heartbeats in a workers store, a stall was noticed only when the stalled worker
finished (2.5 s for a 2.5 s stall, in all three browsers). On the bell it's noticed at about
1 s again. The same rule holds for any write: an engine evaluates only once every
transaction it opened has completed.
Measured with 200 calls asked for at once, in ms:
| batch B | Chromium 152 | Firefox 157 | Safari 26.6 | |
|---|---|---|---|---|
| unbatched spike | total | 270–300 | 302 | 1154 |
| 1 | total / first / median | 148–176 / 22–29 / 87–103 | 148 / 21 / 90 | 461 / 55 / 249 |
| 8 | total / first / median | 83–91 / 29–32 / 54–62 | 109 / 25 / 75 | 245 / 63 / 161 |
| 32 | total / first / median | 75–82 / 43–45 / 63–65 | 98 / 57 / 79 | 218 / 97 / 169 |
A single call through the store, median / p95: Chromium 2 / 3, Firefox 1 / 2, Safari 5 / 11 (8 / 41 unbatched).
With page-side batching and the single watchdog alone (B = 1), the burst runs about 2× as fast in Chromium and 2.5× in Safari. Claiming 8 halves it again in Safari. A larger batch trades first-answer latency for throughput, and past 8 there's little left to gain, so B should follow the queue: 1 when it's short, about 8 when it's deep.
What else the batched spike found:
-
A numeric
Sumkeeps its worker alive in all three browsers. Its async handler yields, the heartbeat gets through, and it runs once (3.2–4.3 s).N(Sum(…))still blocks, and is re-run (§4). - The stall fence holds for a batch. The stuck call and the three claimed behind it are re-run elsewhere. The stalled worker's four late answers are all refused.
-
expr.effectscan be the string"any", not only a list (an unknown head). It counts as effectful. -
"Oldest pending" is really key order: an index on status alone orders by primary key.
Fair claiming needs a
[status, submitted]index. -
A hidden page slows a yielding evaluation, erratically. In a hidden Chromium page, the
numeric
Sumtook 2.6–30 s against about 3 s visible; a blocking one slowed only about 1.3×. AsetTimeout(0)yield measured 5 ms in a SharedWorker there, against 0.01 ms for aMessageChannelorscheduler.yield(). That doesn't account for 30 s, so the cause is open. compute-engine'srunAsyncyields withsetTimeout(0).
The store answers every call it is asked, exactly once, across browsers, and survives a stalled worker. It is still the wrong transport. A call costs 1–5 ms against 0.1–1 ms for a message, and it took batching at every step to be acceptable in Safari. What it bought messaging gets more simply, and what it was good for is state, not transport. Both are in Sessions as Data §1, with what carries over (§4).
- Sessions: pin a session to a worker (claims filtered by owner), or keep its bindings in the store so any worker can serve it.
-
Effect classes: how an incidental
consoleis told apart from a print that is the point of the call. Whether the cache rule belongs to the effect, the call or the caller. - Batch size (§6): how B follows the queue, and whether a long call should leave a batch (a slow call holds up the calls claimed behind it until the watchdog re-runs them).
-
Hidden pages (§6): what slows a yielding evaluation in a background tab, and whether
runAsyncshould yield withscheduler.yield()or aMessageChannelinstead ofsetTimeout(0). - Eviction: the store grows, so it needs a size budget, with entries chosen by cost to recompute and by last use, and a version bump that clears every value.
- Async handlers upstream (§4), and what an engine reports as progress beyond "alive": the elements done, the depth reached.
-
Where there's no
SharedWorker(older Chrome on Android, Samsung Internet), dedicated workers per page serve the same store, and calls stay shared across tabs. - Node: the same protocol over worker threads, with SQLite or memory as the store.