1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
//! Opt-in propagation scheduler — deferred, ordered, glitch-free flushes under
//! an explicit [`batch`] boundary.
//!
//! Gated behind the `scheduler` feature. When the feature is off, or when code
//! runs outside a [`batch`], propagation takes hyphae's exact synchronous
//! eager-push path — this module changes nothing about the default.
//!
//! # Model
//!
//! Outside a batch the tick is inactive and [`Cell::notify`] runs the
//! synchronous cascade. Inside [`batch`], each `notify` instead **enqueues** a
//! deferred `(write_value, fanout)` op keyed by cell id, at the cell's
//! **height** (`1 + max(dep.height)`; sources are 0). Enqueues **coalesce**
//! last-write-wins per cell within the tick. The queue **drains in
//! non-decreasing height order**, settling each cell's value then running its
//! fanout (which enqueues its subscribers at strictly greater heights).
//!
//! So every height-`k` cell settles before any height-`k+1` subscriber runs,
//! and a multi-input node (a diamond's join) is popped **once**, after every
//! lower input has coalesced into it — it emits once per tick with the settled
//! value instead of once per input arrival. That is the glitch-freedom: the
//! redundant re-fires a synchronous diamond makes are collapsed.
//!
//! # Cross-thread model
//!
//! The tick queue is process-wide, not per-thread. This is load-bearing, not
//! an implementation detail: hyphae ships timer/interval constructors as the
//! documented way to build `.batched()` fan-out sources (clock/interval ticks
//! feeding a wide reactive graph — exactly the case `.batched()`'s own docs
//! recommend it for), and those timers, plus ordinary application code
//! running on an async runtime's worker pool, routinely touch overlapping
//! cell graphs from different threads. A per-thread tick queue gives each
//! thread its own "exactly once, glitch-free" guarantee in isolation, but two
//! threads' batches converging on a shared downstream cell aren't coordinated
//! with each other at all: a genuine value change from one thread's batch can
//! be silently overwritten by the other's before any subscriber observes it,
//! with no error — just updates that stop arriving, intermittently, depending
//! on how the OS scheduled the two threads that tick. That failure mode is
//! *worse* than the synchronous eager path's redundant re-fires, so the
//! scheduler serializes every thread's deferred ops through one shared queue
//! instead of promising a guarantee it can only keep within a single thread.
//!
//! Concretely: [`enqueue`] and [`drain`] share one `Mutex`-protected queue.
//! Deferral is gated by `depth > 0 || draining` (a `batch()` open anywhere,
//! or a drain in flight). Draining is **claimed at a thread's outermost
//! `batch()` close** — the close that brings this thread's [`BATCH_NEST`]
//! back to 0 — by whichever such close finds `draining` false and the queue
//! non-empty; it sets `draining` (the single-drainer mutex) and drains the
//! queue empty in short bursts, then clears `draining` the instant the queue
//! runs dry. Crucially there is **no park and no "wait for the count to
//! quiesce" loop**: a thread never pins itself as a persistent drainer, so a
//! peer that keeps opening batches can't starve it. Draining hands off across
//! close boundaries instead — an op enqueued after one drain releases is
//! drained by the next outermost close that observes pending work. This is
//! strand-free because a drain's empty-check and its `draining = false`
//! release happen together under the lock: an op enqueued before that release
//! is popped by the drain; one enqueued after is seen by the next close. The
//! lock is never held while running a popped op or a `batch()`'s `f()` — both
//! may re-enter `enqueue`/`batch` for their own fanout/nesting, which would
//! deadlock against a self-held lock otherwise.
//!
//! This means `batch()`'s "settled by the time this call returns" guarantee
//! still holds for same-thread nesting (the outer frame on the same call
//! stack drains before it returns, as before) but is *not* guaranteed for two
//! genuinely concurrent callers on different threads — a non-drainer call may
//! return slightly before the drainer has processed its contribution. No
//! update is ever lost or reordered across the height boundary because of
//! this (the drainer will still process it before giving up); only the exact
//! instant "propagation from this specific call is externally visible"
//! becomes slightly fuzzy under real cross-thread contention, which no code
//! today depends on (there was no cross-thread coordination to depend on
//! before this).
//!
//! # Wave-parallel draining, cheaply
//!
//! Two cells at the *same* height can never depend on each other (height is
//! `1 + max(dep.height)`, so a dependency would force a strictly greater
//! height), which makes same-height ops mathematically safe to run
//! concurrently — the same principle spreadsheet engines like Excel's
//! multithreaded recalculation use: build the dependency chain once, cheaply,
//! then dispatch each *known-independent* level of it in bulk.
//!
//! An earlier version of this module took that further than it needed to: it
//! sharded the *queue itself* by height (a `DashMap`-backed bucket per
//! height, with its own lock, plus bookkeeping to move a coalescing cell's
//! entry between buckets if its height changed mid-tick) so that pushes at
//! different heights wouldn't contend on one lock. Measured against both a
//! plain single-cell/diamond microbenchmark and a dedicated multi-thread
//! contention benchmark, it was slower in *every* case — the fixed per-push
//! cost of that sharding exceeded whatever contention it avoided, even under
//! genuine concurrent load (a trivial 4-cell diamond went from ~650ns to
//! ~8µs; 8-thread contention throughput dropped from ~2M elem/s to ~1M elem/s).
//! The mistake was sharding the cheap, frequent *enqueue* path to serve the
//! rarer, bulkier *drain* path.
//!
//! This version keeps [`enqueue`]'s single-lock `BTreeMap` push exactly as
//! cheap as the non-parallel design, and moves all the "can this run in
//! parallel" work to the drain side, where it only has to happen once per
//! height per tick instead of once per push: [`SharedTick::pop_min_height_groups`]
//! pulls every op at the current minimum height out in one locked pass, grouped
//! by cell, and [`run_wave`] runs those groups — sequentially if there are few
//! (the common/resting case: a join has 2 inputs, most fan-out is a handful of
//! subscribers, and a pool dispatch's overhead alone would dwarf that), or
//! across the scheduler's own dedicated [`WAVE_POOL`] if the wave crosses
//! [`WAVE_THRESHOLD`] (genuinely wide fan-out — many independent cells settling
//! at once). That threshold defaults high and the pool is built lazily and
//! sized small on purpose: real graphs are overwhelmingly deep rather than
//! wide, and dispatching resting `.batched()` timer waves through a big shared
//! pool was measured burning most of a process's CPU at idle for no gain (see
//! [`DEFAULT_WAVE_THRESHOLD`]).
//!
//! Sharding by connected component (so genuinely unrelated graphs never share
//! a lock at all, instead of sharding by a graph-agnostic height number that
//! unrelated graphs still collide on) would help the "many unrelated threads"
//! case further, but needs its own careful design — dynamic topology changes
//! would require a concurrent union-find to merge domains safely — and
//! wasn't pursued here; this hybrid captures the parallelism win for the
//! common case (one graph, a wide wave) at effectively no cost to the small
//! case.
//!
//! `join`/`join_vec`'s implementations were also hardened for this: both used
//! to have each input side independently peek at a sibling's `.get()` to
//! build a combined value, which is safe only if the sides can never run at
//! the literal same instant — true before wave-parallel draining existed, not
//! true once a wave can genuinely run two same-height ops concurrently. Both
//! now hold a lock across their entire update-and-notify sequence so
//! whichever side's push lands last in the coalescing slot is guaranteed to
//! reflect both sides' latest values, not a stale peek at one that hadn't
//! updated yet.
//!
//! # Scope (Phase 0)
//!
//! Coalescing is last-write-wins, which is correct for **behavior** cells
//! (`map`/`filter`/`join`/`switch_map` — latest-value semantics, exactly where
//! the glitch lives). It is **not** correct for **event** operators
//! (`scan`/`pairwise`/`buffer`/`zip`/`merge`), where dropping an intermediate
//! value changes the result; scoping the opt-in away from those is the next
//! phase. Height is memoized per tick (topology is assumed stable within a
//! tick); recompute-at-pop for switch_map rewiring mid-drain is a later phase
//! too.
//!
//! Because a batch defers value settlement to the drain, a cell read *inside*
//! the batch (before the closing brace) still sees its pre-batch value — the
//! glitch-free trade-off, and the reason this is opt-in.
use ;
use Mutex;
use ;
use Uuid;
use crateDepNode;
thread_local!
// Height-cache invalidation is **per-node**, not global. Each cell carries its
// own `height_epoch` (see `CellInner::height_epoch`); an edge change bumps only
// the changed cell and its transitive dependents' epochs
// (`crate::cell::invalidate_height_cone`), so a topology-churning knit no longer
// flushes every cached height in the process. There is deliberately no global
// epoch counter here anymore — a cached height is validated against its own
// node's epoch in `height_dfs`.
/// The process-wide propagation tick queue: height-ordered and
/// last-write-wins-coalesced per cell (see module docs). Every thread's
/// deferred ops share this one structure — the `Mutex` is what makes
/// [`batch`]'s glitch-free guarantee a cross-thread one instead of a
/// per-thread one that silently doesn't hold at the seams where two threads'
/// timers/dispatch converge on a shared cell.
static TICK: = new;
/// Fast, lock-free "should a fresh notify defer" flag, kept in sync with
/// `depth > 0 || draining` under the same critical sections that mutate
/// either. `notify`'s hot path ([`tick_active`]) reads this instead of
/// locking `TICK`, so the common "no batch open, no drain running" case pays
/// one relaxed atomic load instead of a mutex acquisition. It's a hint, not
/// the source of truth: [`enqueue`] re-validates under the lock before
/// deferring, so a stale `true` read (racing a batch/drain that just ended)
/// safely falls back to running synchronously instead of enqueueing into a
/// queue nobody will drain.
static TICK_ACTIVE: AtomicBool = new;
/// Set `TICK_ACTIVE` from the authoritative state under the lock. Call after
/// any mutation of `depth` or `draining`.
/// The cell's propagation height, `1 + max(dep.height)` (sources are 0), read
/// from its persistent per-node cache when the cache is current for `epoch` and
/// recomputed (and re-cached) otherwise. In a stable topology this is one atomic
/// load; `deps()` is walked only on the first read after an edge change.
/// Height DFS backing [`compute_height`]. Each node's [`DepNode::height_cache`]
/// packs `(height_epoch << 32) | height` and is validated against that node's
/// *own* [`DepNode::height_epoch`] — so a settled subgraph stays cached even
/// while an unrelated (or downstream) part of the graph is churning, and only
/// nodes whose cone was invalidated recompute. `stack` breaks dependency cycles
/// — a back-edge to a node already on the current path contributes height 0
/// rather than recursing forever. Nodes without an epoch (non-cell `DepNode`s)
/// have no stable cache slot and are recomputed each read.
/// Whether a batch is active on any thread. Cheap — one relaxed atomic load.
/// This is the single check `notify` pays on the synchronous hot path when
/// the `scheduler` feature is on but no batch is open.
pub
/// Defer `run` for cell `id` (height computed from `node`) if a batch is open
/// anywhere, re-validated under the lock; otherwise run it immediately,
/// synchronously, on the calling thread. Called by `notify` as a same-lock
/// combined check-and-act — splitting the check and the enqueue across two
/// lock acquisitions would reopen a stranding race (a batch closing in the
/// gap between them).
///
/// `terminal` is true for a `Complete`/`Error` notify. A terminal op is never
/// coalesced: an operator that emits a value **and then completes** in one
/// callback (e.g. `take`'s last element, `last`) enqueues `Value` then
/// `Complete` for the same cell in one batch, and if the terminal coalesced it
/// would last-write-wins-drop the value it follows — the final value would
/// silently vanish under `batch` (confirmed by repro). Kept non-coalescing, the
/// terminal takes a distinct later `seq`, so the value drains first and the
/// terminal after it. (A value can't legitimately follow a terminal on the same
/// cell — `notify` early-returns once the cell is completed/errored — so this
/// never leaves a terminal ordered before a live value.)
pub
/// Default minimum number of distinct-cell *groups* in one height-wave before
/// dispatching it across the wave pool is worth the cost. Overridable at
/// startup via `HYPHAE_WAVE_THRESHOLD`.
///
/// This is deliberately high. An earlier version used `8`, which made resting
/// `Source::batched()` timer ticks — which produce wide same-height waves
/// continuously — dispatch through rayon's *global* pool nonstop, burning >50%
/// of cycles in crossbeam-epoch pinning + work-steal loops (~750% CPU at idle
/// on a 24-core box) to parallelize waves whose per-group work is a trivial
/// `Cell::fanout`. Real reactive graphs are overwhelmingly deep, not wide: even
/// a heavy 1s+ rebuild wave was measured using only 1–2 cores, so parallel
/// dispatch buys little and its fixed per-wave cost is pure loss at rest. A high
/// threshold keeps the common/resting case sequential — and because
/// [`WAVE_POOL`] is built lazily, a process whose waves never cross it spawns
/// zero wave threads and pays zero idle cost.
const DEFAULT_WAVE_THRESHOLD: usize = 64;
/// Default wave-pool size cap. Kept small because the workload is deep, not
/// wide; overridable via `HYPHAE_WAVE_THREADS` (`0` disables the parallel path
/// entirely — every wave runs sequentially).
const DEFAULT_WAVE_THREADS_CAP: usize = 4;
/// Group threshold, seeded once from `HYPHAE_WAVE_THRESHOLD` or the default.
/// Atomic (not a plain `usize`) only so tests can force the parallel path at a
/// small width via [`set_wave_threshold_for_test`]; the steady-state read is a
/// single relaxed load.
static WAVE_THRESHOLD: = new;
/// Test-only knob: override the wave-parallelism group threshold at runtime so
/// the parallelism/torn-value tests can exercise the real parallel drain path at
/// a small width instead of the high production default. Not part of the stable
/// API — do not rely on it outside tests.
/// Configured wave-pool thread count, resolved once. `0` means "no
/// parallelism" — [`run_wave`] then always runs sequentially and [`WAVE_POOL`]
/// is never built.
static WAVE_THREADS: = new;
/// The scheduler's **dedicated**, named wave-parallelism pool — `None` when
/// disabled (`HYPHAE_WAVE_THREADS=0`). Built lazily on the first wave that
/// genuinely crosses [`WAVE_THRESHOLD`], so a process that never has a wide wave
/// never constructs it (and never spawns its threads).
///
/// Dedicated rather than rayon's global pool for two reasons the field data
/// surfaced: (1) sizing is decoupled from the ambient core count, so a 24-core
/// host doesn't wake 24 workers for a wave that needs 2; (2) the workers are
/// *named* (`hyphae-wave-N`) — global-pool workers inherit the name of whichever
/// thread first touches the pool (they showed up as `hyphae-timer-re`), which
/// poisons every profile taken of a process that uses hyphae timers.
static WAVE_POOL: = new;
/// Run one height's worth of ops, grouped by cell (see
/// [`SharedTick::pop_min_height_groups`]). Distinct groups are distinct cells at
/// the same height: height is `1 + max(dep.height)`, so if one group's cell
/// depended on another's it would have a strictly greater height and never land
/// in this wave — each group only writes its own cell's value and fans out to
/// strictly-higher-height subscribers, which enqueue into a later wave. So
/// distinct groups are safe to run concurrently. Within a group every op
/// targets the *same* cell and runs sequentially in arrival order — a
/// `no_coalesce` cell can queue several ops in one wave, and running its fanout
/// concurrently with itself would reorder its events and race its subscribers.
///
/// The threshold compares the number of *groups* (distinct cells), not raw ops:
/// a single event cell fired 32× is one group and stays sequential (as it must
/// to preserve order), while a genuinely wide wave that crosses
/// [`WAVE_THRESHOLD`] gets real parallelism on the dedicated [`WAVE_POOL`].
/// Panics are caught per-op so one buggy callback can't strand or corrupt
/// unrelated work; every payload seen is returned for the caller to pick one to
/// re-raise.
/// wasm is single-threaded — no real parallelism to exploit, and no
/// cross-thread race to guard against either, but this module still needs to
/// compile and behave correctly there (the `scheduler` feature isn't
/// native-only). Groups run in order, ops within each in arrival order.
/// Drain the shared queue one height-wave at a time until it is empty, then
/// release the drain (`draining = false`) and return. Called only by the
/// thread that claimed the drain at its outermost [`batch`] close (see
/// [`batch`]).
///
/// Unlike a persistent drainer, this never waits for other threads' batches
/// to quiesce — there is no park and no "wait for the count to settle" loop,
/// so nothing to starve. It drains whatever is queued right now and stops the
/// instant the queue is empty. Any op enqueued afterward (by a still-open
/// batch's `f()`, or a batch that opens later) is drained by whichever
/// outermost batch-close next observes pending work with no drain in flight:
/// draining hands off at close boundaries rather than one unlucky thread
/// being pinned as the drainer while others keep it from ever terminating.
///
/// Completeness (no op is stranded) holds because the empty-check and the
/// `draining = false` release happen together under the lock: an op enqueued
/// before that release is popped by this loop; an op enqueued after it is
/// seen by the next outermost close (which finds `!draining` and a non-empty
/// queue, and drains). While this runs, `draining` is true, so a popped op's
/// fanout `enqueue`s into this same queue (see [`enqueue`]'s gate) instead of
/// cascading eagerly.
///
/// The lock is dropped around every `run_wave` — a popped op's fanout (or a
/// nested `batch`) re-enters `enqueue`/`batch` and needs the lock. Per-op
/// panics are caught in [`run_wave`] so one buggy callback can't strand other
/// concurrent batches' queued work; the first panic seen is returned for the
/// caller to re-raise after the drain has fully completed.
/// How many height-waves one thread drains before handing the drain off to
/// another open batch (see [`drain`]). Large enough that the common
/// single-threaded case (which drains a whole tick in far fewer waves and then
/// finds the queue empty) never hits it, small enough that under a many-thread
/// batch storm no single `batch()` call is pinned draining for long.
const DRAIN_STINT_WAVES: usize = 64;
/// Construct cells that opt out of the scheduler's last-write-wins coalescing.
///
/// Every cell created while this scope is on the stack — via [`Cell::new`], and
/// so via operator `materialize` too — is stamped
/// [`Cell::no_coalesce`](crate::Cell::no_coalesce) at birth. Use it to exempt an
/// *event-semantic subgraph* as a unit: the stateful operator (a `scan`,
/// `pairwise`, or a hand-rolled edge-detector `map`) **and the sources feeding
/// it**, because coalescing a source upstream of the operator would drop the
/// intermediates before they ever reach it — tagging only the operator's own
/// cell would still starve it.
///
/// The stamp rides each cell for its lifetime, so it holds when the cell later
/// fires under [`batch`], regardless of where the batch is opened. Nestable;
/// composes with `batch` (you may open a batch inside or outside this scope).
///
/// Only cells *born inside* the closure are affected — a factory registered
/// here but invoked later builds its cells outside the scope and is **not**
/// stamped. Wrap the actual construction, not a deferred builder.
/// Whether a [`no_coalesce`] construction scope is active on this thread. Read
/// by `Cell::new`/`with_metrics` to stamp a cell's coalescing policy at birth.
pub
/// Run `f` as a single propagation batch.
///
/// Every `set`/`notify` on ANY thread while `f` runs enqueues into the one
/// shared tick instead of cascading. Draining is deferred until `f` (and any
/// batch nested inside it, on this thread or another) has fully returned —
/// drain only runs at the closing brace, never interleaved with `f`'s own
/// execution. Draining any earlier would let a diamond join fire on a partial
/// set of inputs, since the second input might not have arrived yet.
///
/// Nested `batch` calls — genuinely nested on the same thread's call stack,
/// or a different thread racing this one — join the same shared tick rather
/// than opening a second one. Draining is claimed at a thread's *outermost*
/// batch close (its [`BATCH_NEST`] returning to 0), by whichever such close
/// finds no drain already in flight and pending work; that drain runs the
/// queue empty in short bursts and hands off (see [`drain`]). A same-thread
/// outermost call still observes full settlement by the time it returns
/// (single-threaded, it always claims and fully drains its own work). A
/// genuinely concurrent thread's close may return slightly before another
/// thread's active drain has processed its contribution — no update is lost
/// or reordered by this, only the instant "settled" becomes externally
/// visible is fuzzy under real cross-thread contention that had no
/// coordination at all before this module existed.
///
/// A panic inside `f` (or a nested `batch` inside it) is caught here; the
/// close-and-drain still runs to completion — a panic must not strand other
/// threads' unrelated work already queued alongside this call's — and then
/// `f`'s panic is resumed to its caller.
///
/// Outside the `scheduler` feature this is a behavior-preserving passthrough;
/// see the module docs for the deferred semantics when the feature is on.