Skip to content

Commit 3a6255c

Browse files
committed
Add cache mechanism for rosidl code generation
1 parent 903d028 commit 3a6255c

4 files changed

Lines changed: 442 additions & 5 deletions

File tree

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2026 Open Source Robotics Foundation, Inc.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import hashlib
17+
import json
18+
import os
19+
import pathlib
20+
import pickle
21+
import shutil
22+
import tempfile
23+
from typing import Any, Optional, TypedDict
24+
25+
26+
class CacheConfig(TypedDict):
27+
cache_dir: Optional[str]
28+
cache_debug: bool
29+
cache_max_size: int
30+
31+
32+
_cache_config = None
33+
34+
35+
def get_cache_config() -> CacheConfig:
36+
global _cache_config
37+
if _cache_config is not None:
38+
return _cache_config
39+
40+
config: CacheConfig = {
41+
'cache_dir': None,
42+
'cache_debug': False,
43+
'cache_max_size': 1073741824
44+
}
45+
46+
config_file = os.environ.get('ROSIDL_CACHE_CONFIG')
47+
if config_file and os.path.exists(config_file):
48+
with open(config_file, 'r') as f:
49+
file_config = json.load(f)
50+
for key in config.keys():
51+
if key in file_config:
52+
config[key] = file_config[key]
53+
54+
# Override with environment variables
55+
for key in config.keys():
56+
env_key = 'ROSIDL_' + key.upper()
57+
env_value = os.environ.get(env_key)
58+
if env_value is not None:
59+
if isinstance(config[key], bool):
60+
config[key] = env_value.lower() in ('1', 'true', 'yes')
61+
elif isinstance(config[key], int):
62+
config[key] = int(env_value)
63+
else:
64+
config[key] = env_value
65+
66+
_cache_config = config
67+
return config
68+
69+
70+
def debug_print(msg: str) -> None:
71+
if get_cache_config()['cache_debug']:
72+
print(msg)
73+
74+
75+
def get_cache_dir(subdirectory: str) -> Optional[pathlib.Path]:
76+
cache_dir_str = get_cache_config()['cache_dir']
77+
if not cache_dir_str:
78+
return None
79+
80+
cache_dir = pathlib.Path(cache_dir_str) / subdirectory
81+
return cache_dir
82+
83+
84+
def cleanup_cache_if_needed(subdirectory: str):
85+
config = get_cache_config()
86+
cache_dir = get_cache_dir(subdirectory)
87+
max_size = config['cache_max_size']
88+
89+
if not cache_dir or not cache_dir.exists():
90+
return
91+
92+
# Collect cache entry directories with sizes and modification times
93+
entries = []
94+
total_size = 0
95+
96+
for entry_dir in cache_dir.iterdir():
97+
if not entry_dir.is_dir():
98+
continue
99+
100+
try:
101+
dir_size = sum(
102+
f.stat().st_size for f in entry_dir.rglob('*') if f.is_file()
103+
)
104+
mtime = max(
105+
(f.stat().st_mtime for f in entry_dir.rglob('*') if f.is_file()),
106+
default=0.0
107+
)
108+
entries.append({
109+
'path': entry_dir,
110+
'size': dir_size,
111+
'mtime': mtime
112+
})
113+
total_size += dir_size
114+
except (OSError, PermissionError):
115+
continue
116+
117+
if total_size <= max_size:
118+
return
119+
120+
# Sort by modification time (oldest first)
121+
entries.sort(key=lambda e: e['mtime'])
122+
123+
# Delete oldest entries until we're below 80% of max_size
124+
target_size = int(max_size * 0.8)
125+
current_size = total_size
126+
127+
for entry in entries:
128+
if current_size <= target_size:
129+
break
130+
131+
try:
132+
shutil.rmtree(entry['path'])
133+
current_size -= entry['size']
134+
debug_print(f'[rosidl cache] Removed old cache entry: {entry["path"].name}')
135+
except (OSError, PermissionError) as e:
136+
debug_print(f'[rosidl cache] Failed to remove cache entry: {e}')
137+
138+
139+
def compute_cache_key(*args) -> Optional[str]:
140+
if not get_cache_config()['cache_dir']:
141+
return None
142+
143+
hasher = hashlib.sha256()
144+
145+
for arg in args:
146+
if isinstance(arg, pathlib.Path):
147+
with open(arg, 'rb') as f:
148+
hasher.update(f.read())
149+
elif isinstance(arg, dict):
150+
hasher.update(json.dumps(arg, sort_keys=True).encode('utf-8'))
151+
else:
152+
hasher.update(pickle.dumps(arg, protocol=pickle.HIGHEST_PROTOCOL))
153+
154+
return hasher.hexdigest()
155+
156+
157+
def save_object_to_cache(cache_key: str, cache_subdir: str, obj: Any) -> None:
158+
cache_dir = get_cache_dir(cache_subdir)
159+
if not cache_dir:
160+
return
161+
cache_dir.mkdir(parents=True, exist_ok=True)
162+
cache_entry_dir = cache_dir / cache_key
163+
tmp_dir = None
164+
try:
165+
tmp_dir = pathlib.Path(tempfile.mkdtemp(dir=cache_dir))
166+
cache_file = tmp_dir / 'object.pkl'
167+
with open(cache_file, 'wb') as f:
168+
pickle.dump(obj, f, protocol=pickle.HIGHEST_PROTOCOL)
169+
if cache_entry_dir.exists():
170+
shutil.rmtree(cache_entry_dir)
171+
tmp_dir.rename(cache_entry_dir)
172+
except Exception as e:
173+
debug_print(f'[rosidl cache] Failed to save to cache: {e}')
174+
if tmp_dir and tmp_dir.exists():
175+
shutil.rmtree(tmp_dir, ignore_errors=True)
176+
return
177+
cleanup_cache_if_needed(cache_subdir)
178+
179+
180+
def restore_object_from_cache(cache_key: str, cache_subdir: str) -> Optional[Any]:
181+
cache_dir = get_cache_dir(cache_subdir)
182+
if not cache_dir:
183+
return None
184+
cache_entry_dir = cache_dir / cache_key
185+
cache_file = cache_entry_dir / 'object.pkl'
186+
if not cache_file.exists():
187+
return None
188+
try:
189+
with open(cache_file, 'rb') as f:
190+
return pickle.load(f)
191+
except Exception as e:
192+
debug_print(f'[rosidl cache] Failed to load from cache: {e}')
193+
return None
194+
195+
196+
def save_files_to_cache(
197+
cache_key: str,
198+
cache_subdir: str,
199+
files: list,
200+
base_dir: str
201+
) -> None:
202+
cache_dir = get_cache_dir(cache_subdir)
203+
if not cache_dir:
204+
return
205+
206+
cache_dir.mkdir(parents=True, exist_ok=True)
207+
cache_entry_dir = cache_dir / cache_key
208+
tmp_dir = None
209+
try:
210+
tmp_dir = pathlib.Path(tempfile.mkdtemp(dir=cache_dir))
211+
base_path = pathlib.Path(base_dir)
212+
for rel_path in files:
213+
src = base_path / rel_path
214+
dest = tmp_dir / rel_path
215+
dest.parent.mkdir(parents=True, exist_ok=True)
216+
shutil.copy2(src, dest)
217+
if cache_entry_dir.exists():
218+
shutil.rmtree(cache_entry_dir)
219+
tmp_dir.rename(cache_entry_dir)
220+
except Exception as e:
221+
debug_print(f'[rosidl cache] Failed to save files to cache: {e}')
222+
if tmp_dir and tmp_dir.exists():
223+
shutil.rmtree(tmp_dir, ignore_errors=True)
224+
return
225+
226+
cleanup_cache_if_needed(cache_subdir)
227+
228+
229+
def restore_files_from_cache(
230+
cache_key: str,
231+
cache_subdir: str,
232+
output_dir: str
233+
) -> Optional[list]:
234+
cache_dir = get_cache_dir(cache_subdir)
235+
if not cache_dir:
236+
return None
237+
238+
cache_entry_dir = cache_dir / cache_key
239+
if not cache_entry_dir.exists():
240+
return None
241+
242+
output_path = pathlib.Path(output_dir)
243+
restored_files = []
244+
245+
for src in cache_entry_dir.rglob('*'):
246+
if not src.is_file():
247+
continue
248+
rel_path = src.relative_to(cache_entry_dir)
249+
dest = output_path / rel_path
250+
dest.parent.mkdir(parents=True, exist_ok=True)
251+
try:
252+
shutil.copy2(src, dest)
253+
restored_files.append(str(dest))
254+
except Exception as e:
255+
debug_print(f'[rosidl cache] Failed to restore file {rel_path}: {e}')
256+
return None
257+
258+
return restored_files

rosidl_parser/rosidl_parser/parser.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@
3232
from lark.tree import pydot__tree_to_png
3333
from lark.tree import Tree
3434

35+
from rosidl_parser.cache import compute_cache_key
36+
from rosidl_parser.cache import restore_object_from_cache
37+
from rosidl_parser.cache import save_object_to_cache
3538
from rosidl_parser.definition import AbstractNestableType
3639
from rosidl_parser.definition import AbstractNestedType
3740
from rosidl_parser.definition import AbstractType
@@ -87,12 +90,20 @@
8790

8891
def parse_idl_file(locator: IdlLocator, png_file: Optional[str] = None) -> IdlFile:
8992
string = locator.get_absolute_path().read_text(encoding='utf-8')
93+
cache_key = None if png_file is not None else compute_cache_key(string)
94+
if cache_key:
95+
cached_result = restore_object_from_cache(cache_key, 'idl_parse')
96+
if cached_result is not None:
97+
return cached_result
9098
try:
9199
content = parse_idl_string(string, png_file=png_file)
92100
except Exception as e:
93101
print(str(e), str(locator.get_absolute_path()), file=sys.stderr)
94102
raise
95-
return IdlFile(locator, content)
103+
result = IdlFile(locator, content)
104+
if cache_key:
105+
save_object_to_cache(cache_key, 'idl_parse', result)
106+
return result
96107

97108

98109
def parse_idl_string(idl_string: str, png_file: Optional[str] = None) -> IdlContent:

0 commit comments

Comments
 (0)