fix: Prevent exponential custom-option id growth on re-customization#6904
Conversation
…tion Re-customizing a retained, already-customized object via .opts() mints a new id of old_id + max(all_ids) + 2. When the object already holds the store's largest id this doubles the maximum id on every call, so ids grow exponentially over a long-running session. This test asserts per-call growth of the maximum custom-option id stays bounded; it currently fails (growth follows 2^(i+1)), pinning the defect before the fix.
When an object's children carry custom-option ids that are absent from the target backend's store (e.g. children customized in bokeh, then the container re-customized in matplotlib), every such id takes the fresh-tree branch of create_custom_trees, which assigns them all the same id (offset). The children are thereby aliased onto a single option tree and lose their distinct styles. This regression test asserts the children keep distinct ids and styles. It fails against the current old_id + offset + 1 / clones[offset] logic, with both children collapsed onto one id.
A streaming overlay that re-styles itself each cycle while only a subset of its curves receives fresh data (e.g. message-arrival jitter) retains the non-updating curves across cycles. Restyling relocates their ids, and deriving the new id from the old value doubled them every cycle -- the production trigger, with no static/reference element involved. This test drives round-robin partial updates and asserts the maximum id stays polynomial; it grows exponentially (doubling per cycle) before the fix.
…value create_custom_trees relocated an object's existing custom-option trees to new ids computed as old_id + offset + 1, where offset is one above the store's current maximum id. When the object already held the store's largest id -- the normal case when re-styling the retained result of a previous .opts() call -- the new id was roughly twice the maximum, so the maximum id doubled on every re-customization and grew exponentially. Ids are opaque keys into Store._custom_options; their absolute value carries no meaning. The relocation only needs new ids disjoint from existing keys and a bijection over the object's own id set. Assigning a fresh contiguous block above the offset (indexed by position) satisfies both and keeps the maximum id linear in the number of live customizations, avoiding the unbounded bignum growth and the Python 3.11 4300-digit integer-to-string ValueError it eventually triggered.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6904 +/- ##
==========================================
+ Coverage 89.26% 89.30% +0.04%
==========================================
Files 344 344
Lines 74870 74993 +123
==========================================
+ Hits 66829 66974 +145
+ Misses 8041 8019 -22 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Customizing HoloViews objects from multiple threads concurrently must not
collide on relocated custom-option ids. The test drives eight threads
re-customizing their own elements and asserts none raises.
It guards against an id-relocation scheme that recomputes a shared
max(store)+1 offset per call: concurrent calls receive the same block, one
thread's freshly stored tree is reclaimed by another, and the id is gone by
the time it is propagated ("New option id ... does not match any option
trees").
… old-id value" This reverts commit a375344. The contiguous-block relocation assigned new ids from a freshly recomputed `max(store) + 1` offset. That fixes the exponential growth, but the offset is not concurrency-safe: two threads customizing at the same time compute the same offset and receive overlapping id blocks, so one thread's just-stored tree is reclaimed by the other and the id is gone by the time it is propagated -- raising "New option id ... does not match any option trees" in multi-threaded apps (e.g. a live dashboard). The preceding thread-safety test demonstrates the failure. Reverting to the original logic before applying a thread-safe replacement.
create_custom_trees relocated an object's existing option trees to new ids computed as old_id + offset + 1, where offset is one above the store's current maximum id. When the object already held the store's largest id -- the normal case when re-styling the retained result of a previous .opts() call, including streaming overlays whose curves update only partially each cycle -- the new id was ~2x the maximum, so it doubled on every re-customization and grew exponentially. Over a long-running session this pins a CPU core on bignum arithmetic and, on Python 3.11+, raises the 4300-digit integer-to-string ValueError from any repr()/str() of the object. Ids are opaque keys into Store._custom_options; their value carries no meaning. Allocate relocated ids from a process-wide monotonic counter instead, handing each customization a fresh contiguous block. The counter never decreases, so ids are never reused and concurrent customizations receive disjoint blocks rather than colliding on a recomputed offset (the failure the reverted contiguous-block attempt introduced). Allocation is guarded by a lock. The maximum id now grows linearly with the number of re-customizations rather than exponentially. This also fixes a latent aliasing bug: when a container was re-customized in a different backend (children's ids absent from the target backend's store), the previous logic mapped them all to a single offset, collapsing their per-backend options together.
| # before it, so the final value blows far past any polynomial bound. | ||
| assert max_ids[-1] < 500, f"id growth is not polynomial: {max_ids}" | ||
|
|
||
| def test_concurrent_recustomization_is_thread_safe(self): |
There was a problem hiding this comment.
This runs ~7s or so, should it be marked as slow/skipped somehow?
There was a problem hiding this comment.
I assume this is to test the threading.Lock in reserve_ids? I think we can make it more focused.
n_workers = 8
barrier = threading.Barrier(n_workers)
def worker(w):
barrier.wait()
items = []
for i in range(120):
start_id = hv.StoreOptions.reserve_ids(i)
items.extend(range(start_id, start_id + i))
return items
with ThreadPoolExecutor(max_workers=n_workers) as executor:
total_items = list(executor.map(worker, range(n_workers)))
a = sum(total_items, []) # noqa: RUF017
assert len(a) == len(set(a))With that being said, I cannot get this or the current implementation to fail when I change the Lock to nullcontext. Is the lock only needed on Python Freethreading? if so we should do something like this:
diff --git a/holoviews/core/options.py b/holoviews/core/options.py
index 304bb6b0b..d2ea01098 100644
--- a/holoviews/core/options.py
+++ b/holoviews/core/options.py
@@ -37,11 +37,12 @@ from __future__ import annotations
import difflib
import inspect
import pickle
+import sysconfig
import threading
import traceback
import typing as t
from collections import defaultdict
-from contextlib import contextmanager
+from contextlib import contextmanager, nullcontext
import numpy as np
import param
@@ -56,6 +57,9 @@ if t.TYPE_CHECKING:
from ..util.settings import OutputSettings
+_PYTHON_FREETHREADING = bool(sysconfig.get_config_var("Py_GIL_DISABLED"))
+
+
def __getattr__(name):
if name == "Opts":
from ..util.warnings import deprecated
@@ -1218,7 +1222,7 @@ class Store:
# its allocation. Ids are never reused, so concurrent customizations
# receive disjoint id blocks instead of colliding on a recomputed offset.
_id_counter = 0
- _id_lock = threading.Lock()
+ _id_lock = threading.Lock() if _PYTHON_FREETHREADING else nullcontext()
# Backend option caches
_lookup_cache = {}There was a problem hiding this comment.
Is the lock only needed on Python Freethreading?
I am using regular Python 3.11.
I had a different fix initially (see 4th commit in the branch, a375344). This lead to exceptions about unknown IDs (sporadic, not always) in our dashboard (background thread, potentially multiple connected sessions). 7b3015e was the answer to this, but I have not tested it extensively without the lock.
There was a problem hiding this comment.
Good call on the focused test -- I've added essentially your version as test_concurrent_id_reservation_yields_disjoint_blocks, exercising reserve_ids directly. For now I kept the slower end-to-end test as well, since that's the one that can fail on main (the focused one needs reserve_ids, which doesn't exist pre-fix); happy for it to be dropped or skipped later if the ~8s isn't worth it.
On whether the lock is needed off free-threading: I'd keep it unconditional rather than gate it on Py_GIL_DISABLED.
reserve_ids is a read-modify-write that spans several bytecodes:
base = max(Store._id_counter, cls.id_offset()) # load + call id_offset()
Store._id_counter = base + n # separate storeThe GIL only makes a single bytecode atomic. id_offset() is a function call that iterates backends and builds lists -- guaranteed thread-switch points -- and the store of _id_counter is a separate op from the load. So two threads can read the same counter, compute the same base, and both write base + n, handing out overlapping id blocks. That's a plain lost update; the GIL does not prevent it.
The reason it's hard to reproduce is timing, not absence: a normal opts() call tends to finish within one thread-scheduling quantum (~5 ms default switch interval), so in practice the read→write rarely gets interrupted at the wrong moment. Force the scheduler to switch and it shows up. The end-to-end concurrency test demonstrates this on unfixed code: at 08006b8fd (the test refactor sitting on top of the revert, before the fix is reapplied) test_concurrent_recustomization_keeps_styles_distinct fails on a regular GIL build. That commit reverts the whole fix, so it shows the bug exists, not specifically that the lock is the cure.
To isolate the lock itself, test_concurrent_id_reservation_yields_disjoint_blocks drives reserve_ids directly with sys.setswitchinterval(1e-6): with the lock it's always zero duplicates, and swapping only Lock→nullcontext on the fixed code reliably yields tens of thousands of duplicate ids. That no-lock state isn't a commit (the test needs reserve_ids, which doesn't exist pre-fix), but it's a one-line local change to check. This all matches the crashes we saw in a threaded dashboard on a regular GIL build.
So nullcontext on GIL builds wouldn't be an optimization -- it would reintroduce a real, if rare, race. An uncontended threading.Lock is a few tens of ns here, probably nothing to worry about?
Use ThreadPoolExecutor and future.result() instead of collecting worker exceptions into a list. A failing worker now re-raises in the main thread (surfacing the actual error directly), and the defensive except branch -- which never ran on a passing build -- is gone, so the test no longer leaves uncovered lines.
hoxbro
left a comment
There was a problem hiding this comment.
I have left some comments.
For the tests, I think we make the test as self explaining as possible. When I see a comment spanning multiple lines I tend to not read it 🙃, and potentially it can also get out of sync with the actual code. Maybe add a link to this PR in the class docstring if people wants to know more.
Have you verified that these tests fails on main?
| @mpl_skip | ||
| class TestStoreOptionsCustomIdGrowth: | ||
| """ | ||
| Re-customizing a retained, already-customized object must not grow the | ||
| custom-option ids exponentially. The buggy behaviour minted a new id of | ||
| ``old_id + max(all_ids) + 2``, which doubles the maximum id on every call | ||
| when the object already holds the store's largest id. | ||
| """ | ||
|
|
||
| def setup_method(self): | ||
| hv.Store.current_backend = "matplotlib" |
There was a problem hiding this comment.
We should change this back in teardown method. Though, I can see this is not done for the other ones in the file either.
def setup_method(self):
self._backend = hv.Store.current_backend
hv.Store.set_current_backend("matplotlib")
def teardown_method(self):
hv.Store.current_backend = self._backend| # before it, so the final value blows far past any polynomial bound. | ||
| assert max_ids[-1] < 500, f"id growth is not polynomial: {max_ids}" | ||
|
|
||
| def test_concurrent_recustomization_is_thread_safe(self): |
There was a problem hiding this comment.
I assume this is to test the threading.Lock in reserve_ids? I think we can make it more focused.
n_workers = 8
barrier = threading.Barrier(n_workers)
def worker(w):
barrier.wait()
items = []
for i in range(120):
start_id = hv.StoreOptions.reserve_ids(i)
items.extend(range(start_id, start_id + i))
return items
with ThreadPoolExecutor(max_workers=n_workers) as executor:
total_items = list(executor.map(worker, range(n_workers)))
a = sum(total_items, []) # noqa: RUF017
assert len(a) == len(set(a))With that being said, I cannot get this or the current implementation to fail when I change the Lock to nullcontext. Is the lock only needed on Python Freethreading? if so we should do something like this:
diff --git a/holoviews/core/options.py b/holoviews/core/options.py
index 304bb6b0b..d2ea01098 100644
--- a/holoviews/core/options.py
+++ b/holoviews/core/options.py
@@ -37,11 +37,12 @@ from __future__ import annotations
import difflib
import inspect
import pickle
+import sysconfig
import threading
import traceback
import typing as t
from collections import defaultdict
-from contextlib import contextmanager
+from contextlib import contextmanager, nullcontext
import numpy as np
import param
@@ -56,6 +57,9 @@ if t.TYPE_CHECKING:
from ..util.settings import OutputSettings
+_PYTHON_FREETHREADING = bool(sysconfig.get_config_var("Py_GIL_DISABLED"))
+
+
def __getattr__(name):
if name == "Opts":
from ..util.warnings import deprecated
@@ -1218,7 +1222,7 @@ class Store:
# its allocation. Ids are never reused, so concurrent customizations
# receive disjoint id blocks instead of colliding on a recomputed offset.
_id_counter = 0
- _id_lock = threading.Lock()
+ _id_lock = threading.Lock() if _PYTHON_FREETHREADING else nullcontext()
# Backend option caches
_lookup_cache = {}| import threading | ||
| from concurrent.futures import ThreadPoolExecutor |
| # Monotonic high-water mark for custom-option ids and the lock guarding | ||
| # its allocation. Ids are never reused, so concurrent customizations | ||
| # receive disjoint id blocks instead of colliding on a recomputed offset. |
There was a problem hiding this comment.
| # Monotonic high-water mark for custom-option ids and the lock guarding | |
| # its allocation. Ids are never reused, so concurrent customizations | |
| # receive disjoint id blocks instead of colliding on a recomputed offset. |
I don't think this comment is needed. Maybe it could be limited to one line.
There was a problem hiding this comment.
Agreed -- trimmed to a single line pointing at reserve_ids, where the monotonic, disjoint allocation is documented. I kept a one-line breadcrumb rather than dropping it entirely so the two bare class attributes (_id_counter, _id_lock) still hint at where the logic lives.
| # Relocate the object's trees to a fresh contiguous block above the | ||
| # offset. Ids are opaque keys into Store._custom_options; the only | ||
| # requirements are that the new ids are disjoint from existing keys | ||
| # (guaranteed by the offset) and that distinct old ids map to distinct | ||
| # new ids. Indexing by position keeps the maximum id linear in the | ||
| # number of live customizations; deriving it from the old id's value | ||
| # (tree_id + offset + 1) doubles the maximum on every re-customization. | ||
| for new_id, tree_id in enumerate(obj_ids, start=offset): |
There was a problem hiding this comment.
| # Relocate the object's trees to a fresh contiguous block above the | |
| # offset. Ids are opaque keys into Store._custom_options; the only | |
| # requirements are that the new ids are disjoint from existing keys | |
| # (guaranteed by the offset) and that distinct old ids map to distinct | |
| # new ids. Indexing by position keeps the maximum id linear in the | |
| # number of live customizations; deriving it from the old id's value | |
| # (tree_id + offset + 1) doubles the maximum on every re-customization. | |
| for new_id, tree_id in enumerate(obj_ids, start=offset): |
There was a problem hiding this comment.
Done, removed. The rationale it carried -- why ids are relocated by position (enumerate) rather than by old-id value, and how the old tree_id + offset + 1 doubled the maximum on every re-customization -- moved into the commit message for the cleanup commit, since that's change history rather than something the remaining code needs to explain.
Yes, that is why the tests are separate commits before the fixing commit. You can also see from the commit history that the initial fix (which did not use a lock) was not sufficient (lead to crashes in our threaded dashboard). |
…counter" This reverts commit 7b3015e.
…rministic Restore the current backend in teardown, lift the threading imports to module scope, and drop comments that merely restate the test body. Replace the concurrency test's reliance on a rare propagation error with a check that concurrent customizations each keep their own style. A tiny thread-switch interval forces the id race to surface instead of hiding within a single scheduling quantum, so the test fails reliably on the unfixed code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… counter" This reverts commit 2a4f8ef.
Reserving id blocks from many threads must never hand the same id to two callers. This exercises reserve_ids directly with a tiny thread-switch interval, so the read-modify-write race surfaces deterministically and fails fast when the lock is absent, complementing the slower end-to-end concurrency test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The relocation assigns each tree a fresh id by position from a reserved offset (enumerate), keeping the maximum id linear in the number of live customizations. Deriving the new id from the old id's value (tree_id + offset + 1) instead doubled the maximum on every re-customization; that history belongs here, not in the code. The remaining code is self-explanatory given reserve_ids, which already documents the monotonic, disjoint allocation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Pushed some small updates in c69b057. Which mainly just reduced down the number of items and thread in the concurrent tests. Verified they fail by changing the thread lock to a null context. |
Description
Each time
.opts()is applied to an object that already carries a custom-option id,StoreOptions.create_custom_treesrelocates the object's existing option trees to fresh ids so the new customization does not mutate shared trees. The new ids were derived from the value of the old id (old_id + offset + 1, whereoffsetis one above the store's current maximum id) instead of from a fresh contiguous range. Because ids are opaque keys intoStore._custom_options, their absolute value carries no meaning, and computing the new id from it makes the maximum id roughly double on every re-customization of a retained object.The trigger, in one sentence: on every update, the same retained element is re-customized by an option that matches it. The element then holds the store's largest id, so its new id
old_id + offset + 1is~2 × max, and because it is retained it compounds — the maximum id grows as2^n. The number of live entries stays bounded (superseded entries are reclaimed by the weakref finalizer), but the integer magnitude explodes. Over a long-running session this pins a CPU core in bignum arithmetic and, on Python 3.11+, eventually raisesValueError: Exceeds the limit (4300 digits) for integer string conversionfrom anyrepr()/str()of the object. Interactive "customize once, then display" usage never triggers it, which is why it goes unnoticed in notebooks but bites long-running streaming servers.Reproducer 1 — a streaming overlay with jittered/partial updates (no static element required). A dashboard overlays several curves and re-styles the overlay on each update, but only a subset of the curves receives fresh data each cycle (e.g. message-arrival jitter). Curves that skip an update are the same retained objects carrying their previous ids, so they double every cycle:
Reproducer 2 — a retained reference element overlaid with fresh data. Keeping any element around and overlaying it with newly-built data each update, with styling that matches it, is enough — the kept element need not even be pre-styled:
What does not trigger it (all stay linear/polynomial): building a fresh element every update; applying
.opts()to theDynamicMaponce and then streaming; updating every curve in the overlay each cycle (no retention); or styling that does not match the retained element (e.g. anOverlay-level option, or a retainedVLinewith aCurve-level option).Fix. Allocate the relocated ids from a process-wide monotonic counter (lock-guarded), handing each customization a fresh contiguous block instead of deriving ids from the old id's value. This satisfies the only two properties the relocation requires — new ids disjoint from existing keys, and a bijection over the object's own id set — and adds two more that the old
offset = max(store)+1scheme lacked:ValueErrorare gone. (The id still increases monotonically; it is linear, not bounded — truly bounding it would require recycling freed slots, unnecessary at these magnitudes.)max(store)+1offset is not safe here: two threads compute the same offset, one thread's freshly stored tree is reclaimed by the other, and the id is gone when it is propagated (New option id ... does not match any option trees). The commit history shows this dead end — a simpler contiguous-block attempt, the thread-safety test that exposes it, its revert, and this replacement. Note that id allocation was never formally thread-safe, including before this PR; the previous exponential ids just made a damaging collision astronomically unlikely (sparse, per-object-unique values), so it was effectively immune in practice rather than correct. This change replaces that accident with an actual lock.The same root cause could also alias sibling elements onto a single id: when a container is re-customized in a different backend (so the children's ids are absent from the target backend's store), the previous logic mapped all of them to
offset, collapsing their per-backend options together. Distinct ids per element resolve this too. Regression tests cover the streaming-overlay growth, the cross-backend collision, thread-safety, and the single-object case.AI Disclosure
Tool & Model: Claude Code + Opus 4.8
Usage: Confirmed the defect against
main, ran the reproducer, traced the mechanism throughcreate_custom_trees/update_backends, and drafted the tests, fix, and this description. All reviewed and verified locally by the author.I have tested all AI-generated content in my PR.
I take responsibility for all AI-generated content in my PR.
Checklist