The session reconciler today — detailed analysis#
Companion to the
declarative session reconciler proposal.
This document pins the claims made there to code, as of main before
#1220 merged. Line numbers
drift; symbol names are the stable reference.
The pass and its six concerns#
PlotGridTabs._poll_for_plot_updates
(src/ess/livedata/dashboard/widgets/plot_grid_tabs.py) runs on every wake or
housekeeping tick of a session, inside one batched update. It interleaves:
Tab reconcile — rebuild the tab strip when the shared topology’s grid-level composition changed (
_reconcile_topology), preserving the focused tab by identity, honoring a pending local-creation focus.Cell composition diff — per cell, compare a signature (geometry, user title, layer ids) against a memo to detect layer add/remove/reconfigure and title changes (
_cell_signature).Layer lifecycle tracking — per layer, compare
PlotDataServicesnapshot versions against a per-session memo (SessionLayer.last_seen_version) to detect plotter swaps and lifecycle transitions that require a widget rebuild.Viewer-token maintenance — acquire tokens for the visible grid’s layers, release the rest (
PlotOrchestrator.activate_layer). This step has a side effect: the first token on a layer (0→1) synchronously computes the plot and bumps the very version that concern 3 reads — deliberately, so the same pass sees fresh state, but it means step order inside the pass is load-bearing (see the comment about sampling time bounds after activation).Data flush — push pending plot data into the visible tab’s figures, gated on the grid’s frame generation having advanced or the visible tab having changed, so one data burst repaints in one frame.
Freshness aging — update the per-cell freshness pill and per-layer time labels, on flush, on rebuild, or on a 2 s wall-clock stall cadence.
Concerns 2 and 3 feed one cells_to_rebuild dict; the rebuild+insert runs at
the end of the pass, after an orphan sweep (concern 1’s cell-level
counterpart).
The bookkeeping inventory#
Every field below is a hand-maintained invariant of the form “this record reflects what my widgets currently show”. Each is written in a different place in the pass; forgetting one write is a silent bug bounded only by the 5 s unconditional full pass.
Field |
Mirrors |
Invariant maintained by hand |
|---|---|---|
|
orchestrator topology version |
tabs + cells reflect this topology version |
|
grid ids/titles/enabled, in order |
tab strip matches; distinguishes cell-level bumps from tab-level ones |
|
per-cell composition |
widget matches composition; left stale on purpose by the #1220 fix to force a later rebuild |
|
topology’s cell→grid mapping |
a vanished cell can still be removed from its grid widget |
|
per-layer snapshot version |
widget was built against this lifecycle state |
|
frame clock, per grid |
visible figures show this data burst |
|
this session’s tab index |
detects tab switches between passes |
|
|
gate for concern 3; must be snapshotted before the pass and recorded after, or mid-pass bumps are absorbed unrendered |
|
wall clock |
stall-aging cadence |
(_pending_focus_grid_id is related but different in kind: a pending local
intent, not a mirror of shared state.)
The _last_layer_version row’s before/after choreography is documented in a
12-line comment in the pass — correct, subtle, and exactly the kind of
reasoning the proposal wants to make unnecessary.
The mirrored predicate#
_has_pending_work exists so that a wake meant for another session’s tab
costs nothing. It must re-state, by hand, every gate the pass applies:
topology version, aggregate layer version, active-grid change, frame
generation, freshness stall. Its docstring says so explicitly (“Mirrors the
gates inside _poll_for_plot_updates”). A gate added to the pass but not the
predicate freezes that update until the next full pass; the reverse wastes
wakes. Nothing checks the two stay in sync.
Defect walkthrough: #1216#
The wasted-build loop, with the components involved:
flowchart TD
Bump["Any layer version bump<br/>(job restart, plotter swap, error)<br/>PlotDataService._apply"]
Wake["WakeupHub.wake_all"]
Poll["_poll_for_plot_updates<br/>concern 3: version moved"]
Build["_build_cell → CellWidget()<br/>for a hidden grid: placeholder,<br/>plotter has no computed state"]
Reveal["user reveals the grid"]
Token["activate_layer(..., True)<br/>set_active returns 'first token'<br/>(plot_data_service.set_active)"]
Refresh["_refresh_layer:<br/>compute plot now"]
Bump2["data_arrived → version += 1"]
Build2["same pass rebuilds the cell<br/>with the real plot"]
Discard["hidden build discarded"]
Bump --> Wake --> Poll --> Build
Reveal --> Token --> Refresh --> Bump2 --> Build2 --> Discard
Build -.->|"the placeholder<br/>never survives"| Discard
classDef waste fill:#ffebee,stroke:#c62828,color:#b71c1c;
class Build,Discard waste;
Key facts:
The rebuild in the pass was ungated by visibility; only compute and flush were gated. This was documented as intentional: “hidden grids do no display work at all between switches … this pass only keeps their structure reconciled”.
Frame flushes skip layers without viewer tokens (
PlotOrchestrator.flush_frames), so a hidden layer’s plotter holds no computed state, so the hidden build renders a placeholder.The reveal is a 0→1 token transition for a sole viewer (
PlotDataService.set_activereturnsnot was_active), which triggers_refresh_layer→data_arrived→ version bump → rebuild in the same pass. First reveal and tenth reveal are structurally identical.Measured cost (15-cell fixture): 272 ms page load, 89 ms per bump, per session, serialized on the shared loop.
The #1220 fix gates hidden-cell rebuilds on
PlotDataService.has_viewers — build only when another session already
watches, i.e. when the build will survive. Correct, minimal — and it adds
a sixth gate and relies on deliberately-stale memo records for the deferred
rebuild, deepening the pattern this proposal removes.
Defect walkthrough: #1219#
PlotGrid (src/ess/livedata/dashboard/widgets/plot_grid.py) decides free
positions from _occupied_cells, populated only by insert_widget_at.
Topology is the actual authority. When they disagree — cross-session wizard
race today, reproducibly after #1216’s gate defers builds — the grid offers
“free” cells over occupied positions; completing the wizard there hits
PlotOrchestrator.add_cell’s overlap ValueError, which the success handler
did not catch. Two fixes: catch the error (done in #1220; the simultaneous-
wizard race needs it regardless), and derive occupancy from topology (the
structural half — phase 1 of the migration plan, merged as
#1221).
Defect walkthrough: #1224#
Found while measuring the #1220 fix; a teardown leak, not a decision bug —
see the proposal’s “where the pattern
stops” for why the reconciler would not
have prevented it. CellWidget.dispose() released only the autoscale
controller; nothing severed the subscription the widget’s
pn.pane.HoloViews registers on the layer’s hv.streams.Pipe when it
renders, and PlotGrid.insert_widget_at writes the replacement into the
GridSpec slot without Panel running the displaced pane’s cleanup. From then
on every SessionLayer.update_pipe drives the dead plot alongside the live
one — ~85 % of a poll pass is update_pipe, so each leak costs roughly one
extra live layer, forever. Hidden grids are immune (an unrendered widget
never subscribes), which is why the leak needed a rebuild landing on a
visible grid — a job restart, plotter swap, or title change — and why no
page-load measurement could see it.
Point fix in #1226:
dispose() keeps a handle on the pane and runs the rendered plots’
Plot.cleanup(). One subtlety worth keeping in view: holoviews wraps
plot-refresh subscribers weakly and treats them all as reapable, so
Plot.cleanup() severs every such subscriber on the touched streams, not
only its own. That is safe only because a layer’s pipe is per session and
per cell, and _build_cell disposes the displaced widget before the
replacement renders — an ordering invariant any rewrite of the apply step
must carry over.
Five representations of “is anyone looking”#
# |
Mechanism |
Scope |
Where |
|---|---|---|---|
1 |
Viewer tokens |
shared, per (session, layer) |
|
2 |
Active-tab arithmetic |
session |
|
3 |
Lazy tab rendering ( |
browser/Bokeh |
only the active tab’s models exist |
4 |
Modal guard |
session |
|
5 |
Watcher predicate for pre-warm |
shared |
|
They interlock: 2+4 decide token acquisition (1), 1 gates compute, 3 gates model materialization, 5 patches the hole the others left. A category error between them — #1216 classified widget construction under “structure” when its value depended on 1 — is invisible locally, because no single site owns the question.
The parts that are essential complexity#
To be fair to the current code, much of its weight cannot be refactored away and the proposal does not claim otherwise:
Session-bound objects may only be touched from their own loop (the empirically-derived constraint behind the whole per-session pull architecture; Panel cannot resolve a session context from another thread).
Batching (
hold+ model freeze) is required to avoid per-widget patch storms; lazy tabs (dynamic=True) are required to avoid multi-second freezes. Both respond to measured failures, referenced in code comments.Teardown is two-tier (shared-state release safe on any thread; widget disposal marshalled to the session loop) because sessions die in two ways (clean disconnect vs. reaper). The inventory of what disposal must release is however hand-maintained, and #1224 was a hole in it.
The wake-before-load guard, the modal container parenting rules, and the markup-pane reveal workaround each encode a real framework trap.
The proposal’s target is specifically the consumer-side accretion — the memo fields, the mirrored predicate, the smeared build decision — not this floor.