Skip to content

Commit 8c86e4c

Browse files
authored
Merge pull request #442 from EliasTalcott/419-exporting-a-large-figure-hangs
fix: exporting a large figure hangs
2 parents 466abc2 + 37378b6 commit 8c86e4c

3 files changed

Lines changed: 129 additions & 31 deletions

File tree

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
1-
v1.3.0rc0
1+
## Unreleased
2+
3+
### Fixed
4+
- Fix issue where exporting large figures could cause hang [[#442](https://github.com/plotly/Kaleido/pull/442)], with thanks to @EliasTalcott for the contribution!
5+
6+
## v1.3.0rc0
27
- Significant refactor, better organization
38
- `write_fig` and `_from_object` now take an additional argument:
49
`cancel_on_error: bool, default False`. See docs.
510
- Unused `path` argument for `calc_fig` was deprecated.
611
- Fixed race condition where two render tasks would choose the same filename
712

813

9-
v1.2.0
14+
## v1.2.0
1015
- Try to use plotly JSON encoder instead of default
1116

12-
v1.1.0
17+
## v1.1.0
1318
- Add testing
1419
- Fix a variety of type bugs
1520
- Change order of browser closer to fix hang
@@ -20,51 +25,51 @@ v1.1.0
2025
- Add option to silence warnings in start/stop_sync_server
2126
- Fix bug where attribute was inconsistently named
2227

23-
v1.1.0rc0
28+
## v1.1.0rc0
2429
- Improve verbosity of errors when starting kaleido improperly
2530
- Add new api functions start/stop_sync_server
2631

27-
v1.0.0
32+
## v1.0.0
2833
- Add warning if using incompatible Plotly version
2934

30-
v1.0.0rc15
35+
## v1.0.0rc15
3136
- BUG: Add regex sanitization for auto-filename generation
3237
- Further santiize title to filename conversion
3338

34-
v1.0.0rc14
39+
## v1.0.0rc14
3540
- Pass `plotlyjs` option through from Kaleido() to PageGenerator()
3641

37-
v1.0.0rc13
42+
## v1.0.0rc13
3843
- Pass mathjax option through when using default plotly.js template
3944

40-
v1.0.0rc12
45+
## v1.0.0rc12
4146
- Add `kopts` args to top-level shortcuts to pass args to `Kaleido(**kopts)`
4247

43-
v1.0.0rc11
48+
## v1.0.0rc11
4449
- Write mocker tool to parameterize opts in tests
4550
- Crop page to pdf size
4651
- Add type checks to user input for improved error messages
4752
- Fix latex strings in PDF bolding
4853
- Add some choreographer errors to kaleido.errors
4954

50-
v1.0.0rc10
55+
## v1.0.0rc10
5156
- Allow user to pass Figure-like dicts
5257
- Fix bug by which calc fig rejected plotly figures
5358
- Improve testing
5459

55-
v1.0.0rc9
60+
## v1.0.0rc9
5661
- Fix v1.0.0rc7 for logic reversal (was conditional error)
5762

58-
v1.0.0rc8
63+
## v1.0.0rc8
5964
- Add kaleido.calc_fig to return bytes, not write file automatically
6065
- Add calc_fig[_sync], write_fig_sync, and write_fig_from_object_sync to kaleido API
6166

62-
v1.0.0rc7
67+
## v1.0.0rc7
6368
- Use new choreo is_isolated() to improve platform support (snap /tmp sandboxing)
6469

65-
v1.0.0rc6
70+
## v1.0.0rc6
6671
- Allow PageGenerator(force_cdn=True) to not use plotly.py's installed js
6772

68-
v1.0.0rc5
73+
## ## v1.0.0rc5
6974
- Fix bug by which plotly.py's internal js was always ignored
7075
- Adds testing for PageGenerator

‎src/py/kaleido/_kaleido_tab/_tab.py‎

Lines changed: 82 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import TYPE_CHECKING
55

66
import logistro
7+
import orjson
78

89
from . import _devtools_utils as _dtools
910
from . import _js_logger
@@ -19,10 +20,18 @@
1920

2021

2122
_TEXT_FORMATS = ("svg", "json") # eps
23+
_CHUNK_SIZE = 10 * 1024 * 1024 # 10 MB
2224

2325
_logger = logistro.getLogger(__name__)
2426

2527

28+
def _orjson_default(obj):
29+
"""Fallback for types orjson can't handle natively (e.g. NumPy string arrays)."""
30+
if hasattr(obj, "tolist"):
31+
return obj.tolist()
32+
raise TypeError(f"Type is not JSON serializable: {type(obj).__name__}")
33+
34+
2635
def _subscribe_new(tab: choreo.Tab, event: str) -> asyncio.Future:
2736
"""Create subscription to tab clearing old ones first: helper function."""
2837
new_future = tab.subscribe_once(event)
@@ -117,22 +126,38 @@ async def _calc_fig(
117126
render_prof,
118127
stepper,
119128
) -> bytes:
120-
# js script
121-
kaleido_js_fn = (
122-
r"function(spec, ...args)"
123-
r"{"
124-
r"return kaleido_scopes.plotly(spec, ...args).then(JSON.stringify);"
125-
r"}"
126-
)
127-
render_prof.profile_log.tick("sending javascript")
128-
result = await _dtools.exec_js_fn(
129-
self.tab,
130-
self._current_js_id,
131-
kaleido_js_fn,
129+
render_prof.profile_log.tick("serializing spec")
130+
spec_str = orjson.dumps(
132131
spec,
133-
topojson,
134-
stepper,
135-
)
132+
default=_orjson_default,
133+
option=orjson.OPT_SERIALIZE_NUMPY,
134+
).decode()
135+
render_prof.profile_log.tick("spec serialized")
136+
137+
render_prof.profile_log.tick("sending javascript")
138+
if len(spec_str) <= _CHUNK_SIZE:
139+
kaleido_js_fn = (
140+
r"function(specStr, ...args)"
141+
r"{"
142+
r"return kaleido_scopes"
143+
r".plotly(JSON.parse(specStr), ...args)"
144+
r".then(JSON.stringify);"
145+
r"}"
146+
)
147+
result = await _dtools.exec_js_fn(
148+
self.tab,
149+
self._current_js_id,
150+
kaleido_js_fn,
151+
spec_str,
152+
topojson,
153+
stepper,
154+
)
155+
else:
156+
result = await self._calc_fig_chunked(
157+
spec_str,
158+
topojson=topojson,
159+
stepper=stepper,
160+
)
136161
_raise_error(result)
137162
render_prof.profile_log.tick("javascript sent")
138163

@@ -154,3 +179,45 @@ async def _calc_fig(
154179
render_prof.data_out_size = len(res)
155180
render_prof.js_log = self.js_logger.log
156181
return res
182+
183+
async def _calc_fig_chunked(
184+
self,
185+
spec_str: str,
186+
*,
187+
topojson: str | None,
188+
stepper,
189+
):
190+
_raise_error(
191+
await _dtools.exec_js_fn(
192+
self.tab,
193+
self._current_js_id,
194+
r"function() { window.__kaleido_chunks = []; }",
195+
)
196+
)
197+
198+
for i in range(0, len(spec_str), _CHUNK_SIZE):
199+
chunk = spec_str[i : i + _CHUNK_SIZE]
200+
_raise_error(
201+
await _dtools.exec_js_fn(
202+
self.tab,
203+
self._current_js_id,
204+
r"function(c) { window.__kaleido_chunks.push(c); }",
205+
chunk,
206+
)
207+
)
208+
209+
kaleido_js_fn = (
210+
r"function(...args)"
211+
r"{"
212+
r"var spec = JSON.parse(window.__kaleido_chunks.join(''));"
213+
r"delete window.__kaleido_chunks;"
214+
r"return kaleido_scopes.plotly(spec, ...args).then(JSON.stringify);"
215+
r"}"
216+
)
217+
return await _dtools.exec_js_fn(
218+
self.tab,
219+
self._current_js_id,
220+
kaleido_js_fn,
221+
topojson,
222+
stepper,
223+
)

‎src/py/tests/test_large_fig.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import numpy as np
2+
import plotly.graph_objects as go
3+
import pytest
4+
5+
import kaleido
6+
7+
TOTAL_POINTS = 5_000_000
8+
9+
10+
@pytest.mark.parametrize(
11+
("num_traces", "num_points"),
12+
[
13+
(1, TOTAL_POINTS),
14+
(1_000, TOTAL_POINTS / 1_000),
15+
],
16+
)
17+
async def test_large_fig(num_traces, num_points):
18+
fig = go.Figure()
19+
for _ in range(num_traces):
20+
fig.add_trace(
21+
go.Scatter(
22+
x=np.arange(num_points, dtype=float),
23+
y=np.arange(num_points, dtype=float),
24+
)
25+
)
26+
assert isinstance(await kaleido.calc_fig(fig), bytes)

0 commit comments

Comments
 (0)