Skip to content

Commit 0a5d7d7

Browse files
committed
chore(test-tools): file-based t8n stream; replacing in-memory stdout
Replaces Python's stdout-buffered, multi-pass parse of t8n output (which held up to ~5 copies of the alloc in flight: raw stdout bytes, parsed dict, re-serialized string, re-parsed dict, validated Alloc) with a file-based LazyAllocFile that streams {address: account_dict} entries incrementally via ``ijson`` and validates each ``Account`` one at a time, preserving every existing Pydantic validator. This cuts per-test peak RSS on the unchunkified benchmark from 5.38 GB → 3.40 GB (measured, fixtures byte-identical) — ~2 GB of Python-heap pressure removed per xdist worker.
1 parent 2bc7c79 commit 0a5d7d7

6 files changed

Lines changed: 315 additions & 41 deletions

File tree

packages/testing/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ dependencies = [
5252
"ckzg>=2.1.3,<3",
5353
"tenacity>=9.0.0,<10",
5454
"Jinja2>=3,<4",
55+
"ijson>=3.3,<4",
5556
]
5657

5758
[project.urls]

packages/testing/src/execution_testing/client_clis/cli_types.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414
TypeVar,
1515
)
1616

17+
import ijson # type: ignore[import-untyped]
1718
from pydantic import Field, PlainSerializer, PlainValidator
1819

1920
from execution_testing.base_types import (
21+
Account,
2022
Bloom,
2123
Bytes,
2224
CamelModel,
@@ -458,6 +460,31 @@ def validate(self) -> Alloc:
458460
return Alloc.model_validate_json(self.raw)
459461

460462

463+
class LazyAllocFile(LazyAlloc[Path]):
464+
"""
465+
Lazy allocation backed by a filesystem path.
466+
467+
Streams `{address: account_or_null}` entries from the file and validates
468+
each `Account` incrementally via `Account.model_validate`. Keeps only one
469+
in-flight account dict in memory at a time, avoiding the multi-GB peak
470+
that `LazyAllocStr` incurs by holding the full JSON string alongside the
471+
validated `Alloc`.
472+
"""
473+
474+
def validate(self) -> Alloc:
475+
"""Validate the alloc by streaming entries from the backing file."""
476+
accumulated: Dict[str, Account | None] = {}
477+
with open(self.raw, "rb") as f:
478+
for address_str, account_data in ijson.kvitems(f, ""):
479+
if account_data is None:
480+
accumulated[address_str] = None
481+
else:
482+
accumulated[address_str] = Account.model_validate(
483+
account_data
484+
)
485+
return Alloc.model_validate(accumulated)
486+
487+
461488
@dataclass
462489
class TransitionToolInput:
463490
"""Transition tool input."""
@@ -478,6 +505,10 @@ def to_files(
478505
alloc_contents = self.alloc.model_dump_json(**model_dump_config)
479506
elif isinstance(self.alloc, LazyAllocStr):
480507
alloc_contents = self.alloc.raw
508+
elif isinstance(self.alloc, LazyAllocFile):
509+
alloc_contents = self.alloc.get().model_dump_json(
510+
**model_dump_config
511+
)
481512
else:
482513
raise Exception(f"Invalid alloc type: {type(self.alloc)}")
483514

@@ -513,6 +544,10 @@ def model_dump_json(self, **model_dump_config: Any) -> str:
513544
alloc_contents = self.alloc.model_dump_json(**model_dump_config)
514545
elif isinstance(self.alloc, LazyAllocStr):
515546
alloc_contents = self.alloc.raw
547+
elif isinstance(self.alloc, LazyAllocFile):
548+
alloc_contents = self.alloc.get().model_dump_json(
549+
**model_dump_config
550+
)
516551
else:
517552
raise Exception(f"Invalid alloc type: {type(self.alloc)}")
518553

@@ -547,6 +582,10 @@ def model_dump(self, mode: str, **model_dump_config: Any) -> Any:
547582
)
548583
elif isinstance(self.alloc, LazyAllocJson):
549584
alloc_contents = self.alloc.raw
585+
elif isinstance(self.alloc, LazyAllocFile):
586+
alloc_contents = self.alloc.get().model_dump(
587+
mode=mode, **model_dump_config
588+
)
550589
else:
551590
raise Exception(f"Invalid alloc type: {type(self.alloc)}")
552591

@@ -582,13 +621,19 @@ def model_validate_files(
582621
"""
583622
Validate the model from the file system where each key is a
584623
different JSON file.
624+
625+
`alloc.json` is referenced by path and parsed incrementally on
626+
`.get()` via `LazyAllocFile`, so the full file is never held in
627+
memory alongside the validated `Alloc`.
585628
"""
586-
alloc_data = (directory_path / "alloc.json").read_text()
587629
result_data = (directory_path / "result.json").read_text()
588630
result = Result.model_validate_json(
589631
json_data=result_data, context=context
590632
)
591-
alloc = LazyAllocStr(raw=alloc_data, _state_root=result.state_root)
633+
alloc = LazyAllocFile(
634+
raw=directory_path / "alloc.json",
635+
_state_root=result.state_root,
636+
)
592637
output = cls(result=result, alloc=alloc)
593638
return output
594639

packages/testing/src/execution_testing/client_clis/file_utils.py

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Methods to work with the filesystem and json."""
22

33
import os
4+
import shutil
45
import stat
56
from json import dump
67
from pathlib import Path
@@ -9,6 +10,7 @@
910
from pydantic import BaseModel, RootModel
1011

1112
from execution_testing.client_clis.cli_types import (
13+
LazyAllocFile,
1214
LazyAllocJson,
1315
LazyAllocStr,
1416
TransitionToolInput,
@@ -28,27 +30,29 @@ def dump_files_to_directory(output_path: Path, files: Dict[str, Any]) -> None:
2830
if rel_path:
2931
os.makedirs(output_path / rel_path, exist_ok=True)
3032
file_path = output_path / file_rel_path
31-
with open(file_path, "w") as f:
32-
if isinstance(file_contents, (LazyAllocStr, LazyAllocJson)):
33-
if isinstance(file_contents, LazyAllocJson):
34-
dump(file_contents.raw, f, ensure_ascii=True, indent=4)
35-
else:
36-
f.write(file_contents.raw)
37-
38-
elif isinstance(
39-
file_contents, (BaseModel, RootModel, TransitionToolInput)
40-
):
41-
f.write(
42-
file_contents.model_dump_json(
43-
indent=4,
44-
exclude_none=True,
45-
by_alias=True,
33+
if isinstance(file_contents, LazyAllocFile):
34+
shutil.copyfile(file_contents.raw, file_path)
35+
else:
36+
with open(file_path, "w") as f:
37+
if isinstance(file_contents, (LazyAllocStr, LazyAllocJson)):
38+
if isinstance(file_contents, LazyAllocJson):
39+
dump(file_contents.raw, f, ensure_ascii=True, indent=4)
40+
else:
41+
f.write(file_contents.raw)
42+
elif isinstance(
43+
file_contents, (BaseModel, RootModel, TransitionToolInput)
44+
):
45+
f.write(
46+
file_contents.model_dump_json(
47+
indent=4,
48+
exclude_none=True,
49+
by_alias=True,
50+
)
4651
)
47-
)
48-
elif isinstance(file_contents, str):
49-
f.write(file_contents)
50-
else:
51-
dump(file_contents, f, ensure_ascii=True, indent=4)
52+
elif isinstance(file_contents, str):
53+
f.write(file_contents)
54+
else:
55+
dump(file_contents, f, ensure_ascii=True, indent=4)
5256
if flags:
5357
file_mode = os.stat(file_path).st_mode
5458
if "x" in flags:

packages/testing/src/execution_testing/client_clis/tests/test_transition_tool.py

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Test the transition tool and subclasses."""
22

3+
import json
34
import shutil
45
import subprocess
56
from pathlib import Path
@@ -17,10 +18,14 @@
1718
)
1819
from execution_testing.client_clis.cli_types import (
1920
LazyAlloc,
21+
LazyAllocFile,
2022
LazyAllocJson,
2123
LazyAllocStr,
24+
Result,
25+
TransitionToolInput,
26+
TransitionToolOutput,
2227
)
23-
from execution_testing.test_types import Alloc
28+
from execution_testing.test_types import Alloc, Environment
2429

2530

2631
def test_default_tool() -> None:
@@ -128,3 +133,124 @@ def test_lazy_alloc(ty: Type[LazyAlloc], raw: Any) -> None:
128133
lazy_instance = ty(raw=raw, _state_root=TEST_ALLOC_STATE_ROOT)
129134
assert lazy_instance.get() == TEST_ALLOC
130135
assert lazy_instance.state_root() == TEST_ALLOC_STATE_ROOT
136+
137+
138+
def test_lazy_alloc_file(tmp_path: Path) -> None:
139+
"""LazyAllocFile streams the alloc from disk and yields the same Alloc."""
140+
alloc_path = tmp_path / "alloc.json"
141+
alloc_path.write_text(TEST_ALLOC.model_dump_json())
142+
lazy_instance = LazyAllocFile(
143+
raw=alloc_path, _state_root=TEST_ALLOC_STATE_ROOT
144+
)
145+
assert lazy_instance.get() == TEST_ALLOC
146+
assert lazy_instance.state_root() == TEST_ALLOC_STATE_ROOT
147+
148+
149+
def test_lazy_alloc_file_handles_mixed_entries(tmp_path: Path) -> None:
150+
"""
151+
LazyAllocFile correctly reconstructs an Alloc containing None entries,
152+
non-empty storage, long code, and multiple accounts.
153+
"""
154+
alloc = Alloc.model_validate(
155+
{
156+
0xA: {
157+
"balance": 1,
158+
"nonce": 2,
159+
"code": "0x" + "ab" * 128,
160+
"storage": {"0x01": "0x02", "0x03": "0x04"},
161+
},
162+
0xB: None,
163+
0xC: {"balance": "0xff", "nonce": 0, "code": "0x"},
164+
}
165+
)
166+
state_root = alloc.state_root()
167+
alloc_path = tmp_path / "alloc.json"
168+
alloc_path.write_text(alloc.model_dump_json())
169+
lazy_instance = LazyAllocFile(raw=alloc_path, _state_root=state_root)
170+
assert lazy_instance.get() == alloc
171+
assert lazy_instance.state_root() == state_root
172+
173+
174+
def _write_minimal_result(path: Path, state_root: Any) -> None:
175+
"""Write a minimal valid Result JSON to `path` using `state_root`."""
176+
result = Result.model_validate(
177+
{
178+
"stateRoot": state_root,
179+
"txRoot": bytes(32),
180+
"receiptsRoot": bytes(32),
181+
"logsHash": bytes(32),
182+
"logsBloom": bytes(256),
183+
"receipts": [],
184+
"gasUsed": 0,
185+
}
186+
)
187+
path.write_text(result.model_dump_json(by_alias=True, exclude_none=True))
188+
189+
190+
def test_model_validate_files_uses_lazy_alloc_file(tmp_path: Path) -> None:
191+
"""
192+
model_validate_files backs the alloc with the on-disk file, not a
193+
multi-GB string in Python memory.
194+
"""
195+
alloc_path = tmp_path / "alloc.json"
196+
alloc_path.write_text(TEST_ALLOC.model_dump_json())
197+
_write_minimal_result(tmp_path / "result.json", TEST_ALLOC_STATE_ROOT)
198+
199+
output = TransitionToolOutput.model_validate_files(tmp_path)
200+
201+
assert isinstance(output.alloc, LazyAllocFile)
202+
assert output.alloc.raw == alloc_path
203+
assert output.alloc.get() == TEST_ALLOC
204+
205+
206+
def test_transition_tool_input_serializes_lazy_alloc_file(
207+
tmp_path: Path,
208+
) -> None:
209+
"""
210+
A `TransitionToolInput` carrying a `LazyAllocFile` (as happens when
211+
one t8n invocation's output is chained into the next's input) can be
212+
serialized via both `to_files` and `model_dump_json` without pulling
213+
the alloc file into memory at `TransitionToolInput` construction.
214+
"""
215+
source_alloc = tmp_path / "source_alloc.json"
216+
source_alloc.write_text(TEST_ALLOC.model_dump_json())
217+
lazy_alloc = LazyAllocFile(
218+
raw=source_alloc, _state_root=TEST_ALLOC_STATE_ROOT
219+
)
220+
221+
input_data = TransitionToolInput(
222+
alloc=lazy_alloc, txs=[], env=Environment()
223+
)
224+
225+
out_dir = tmp_path / "out"
226+
out_dir.mkdir()
227+
paths = input_data.to_files(out_dir, by_alias=True, exclude_none=True)
228+
written_alloc = Alloc.model_validate_json(Path(paths["alloc"]).read_text())
229+
assert written_alloc == TEST_ALLOC
230+
231+
dumped = input_data.model_dump_json(by_alias=True, exclude_none=True)
232+
parsed = json.loads(dumped)
233+
assert Alloc.model_validate(parsed["alloc"]) == TEST_ALLOC
234+
235+
236+
def test_dump_files_to_directory_copies_lazy_alloc_file(
237+
tmp_path: Path,
238+
) -> None:
239+
"""
240+
`dump_files_to_directory` copies the backing file when given a
241+
`LazyAllocFile`, preserving exact bytes without round-tripping the
242+
alloc through Python memory.
243+
"""
244+
from execution_testing.client_clis.file_utils import (
245+
dump_files_to_directory,
246+
)
247+
248+
source = tmp_path / "source_alloc.json"
249+
source_text = TEST_ALLOC.model_dump_json()
250+
source.write_text(source_text)
251+
lazy = LazyAllocFile(raw=source, _state_root=TEST_ALLOC_STATE_ROOT)
252+
253+
dump_dir = tmp_path / "dump"
254+
dump_files_to_directory(dump_dir, {"output/alloc.json": lazy})
255+
256+
assert (dump_dir / "output" / "alloc.json").read_text() == source_text

0 commit comments

Comments
 (0)