Skip to content

Commit 8ba1f2f

Browse files
BUG: write MonteCarlo input and output rows atomically (#1110) (#1125)
* BUG: write MonteCarlo input and output rows atomically (#1110) * TST: fix pylint W1113 in MonteCarlo append rollback mock (#1110)
1 parent 60ed40f commit 8ba1f2f

2 files changed

Lines changed: 69 additions & 8 deletions

File tree

‎rocketpy/simulation/monte_carlo.py‎

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,39 @@ def __setup_files(self, append):
268268
except OSError as error:
269269
raise OSError(f"Error creating files: {error}") from error
270270

271+
def _append_simulation_record(self, inputs_json, outputs_json):
272+
"""Append one simulation's inputs and outputs as a paired record.
273+
274+
Writes the inputs row first, then the outputs row. If the outputs write
275+
fails, the inputs file is truncated back to its size before this call so
276+
the two files do not drift out of alignment.
277+
278+
Parameters
279+
----------
280+
inputs_json : str
281+
Serialized inputs row, including its trailing newline.
282+
outputs_json : str
283+
Serialized outputs row, including its trailing newline.
284+
"""
285+
input_path = self.input_file
286+
output_path = self.output_file
287+
288+
try:
289+
previous_input_size = os.path.getsize(input_path)
290+
except OSError:
291+
previous_input_size = 0
292+
293+
with open(input_path, "a", encoding="utf-8") as f:
294+
f.write(inputs_json)
295+
296+
try:
297+
with open(output_path, "a", encoding="utf-8") as f:
298+
f.write(outputs_json)
299+
except Exception:
300+
with open(input_path, "rb+") as f:
301+
f.truncate(previous_input_size)
302+
raise
303+
271304
def __run_in_serial(self):
272305
"""
273306
Runs the monte carlo simulation in serial mode.
@@ -290,10 +323,7 @@ def __run_in_serial(self):
290323
inputs_json = self.__evaluate_flight_inputs(sim_monitor.count)
291324
outputs_json = self.__evaluate_flight_outputs(flight, sim_monitor.count)
292325

293-
with open(self.input_file, "a", encoding="utf-8") as f:
294-
f.write(inputs_json)
295-
with open(self.output_file, "a", encoding="utf-8") as f:
296-
f.write(outputs_json)
326+
self._append_simulation_record(inputs_json, outputs_json)
297327

298328
sim_monitor.print_update_status()
299329

@@ -432,10 +462,7 @@ def __sim_producer(self, seed, sim_monitor, mutex, error_event): # pylint: disa
432462

433463
break
434464

435-
with open(self.input_file, "a", encoding="utf-8") as f:
436-
f.write(inputs_json)
437-
with open(self.output_file, "a", encoding="utf-8") as f:
438-
f.write(outputs_json)
465+
self._append_simulation_record(inputs_json, outputs_json)
439466

440467
sim_monitor.print_update_status()
441468
finally:

‎tests/unit/simulation/test_monte_carlo.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import builtins
12
import csv
23
import json
4+
import os
35
import pathlib
46
import types
57
from collections import namedtuple
8+
from unittest.mock import patch
69

710
import matplotlib as plt
811
import numpy as np
@@ -87,6 +90,37 @@ def __init__(self):
8790
}
8891

8992

93+
def test_append_simulation_record_rolls_back_inputs_on_output_failure(tmp_path):
94+
"""If the outputs append fails, the inputs row must not remain on disk."""
95+
mc = MockMonteCarlo()
96+
input_file = tmp_path / "inputs.json"
97+
output_file = tmp_path / "outputs.json"
98+
input_file.write_text('{"index": 0}\n', encoding="utf-8")
99+
output_file.write_text('{"index": 0}\n', encoding="utf-8")
100+
mc._input_file = str(input_file)
101+
mc._output_file = str(output_file)
102+
103+
mc._append_simulation_record('{"index": 1}\n', '{"index": 1}\n')
104+
105+
original_open = builtins.open
106+
output_path = os.fspath(output_file)
107+
108+
def failing_output_open(*args, **kwargs):
109+
# Match builtins.open call shapes without keyword-before-vararg (W1113).
110+
file = args[0] if args else kwargs["file"]
111+
mode = args[1] if len(args) > 1 else kwargs.get("mode", "r")
112+
if os.fspath(file) == output_path and "a" in mode:
113+
raise OSError("no space left on device")
114+
return original_open(*args, **kwargs)
115+
116+
with pytest.raises(OSError, match="no space left on device"):
117+
with patch("builtins.open", side_effect=failing_output_open):
118+
mc._append_simulation_record('{"index": 2}\n', '{"index": 2}\n')
119+
120+
assert input_file.read_text(encoding="utf-8") == '{"index": 0}\n{"index": 1}\n'
121+
assert output_file.read_text(encoding="utf-8") == '{"index": 0}\n{"index": 1}\n'
122+
123+
90124
def test_estimate_confidence_interval_contains_known_mean():
91125
"""Checks that the confidence interval contains the known mean."""
92126
mc = MockMonteCarlo()

0 commit comments

Comments
 (0)