Skip to content

Commit 7c568fe

Browse files
committed
fix(storage): preserve zero-row batched list datasets (OpenDCAI#550)
Compose OpenDCAI#550 with upstream main that already includes OpenDCAI#551. Integration candidate; needs current-head test and Manager review.
1 parent 05a7d9e commit 7c568fe

2 files changed

Lines changed: 234 additions & 5 deletions

File tree

‎dataflow/utils/storage.py‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,16 @@ def __init__(
962962
self._dataframe_buffer = {}
963963
if cache_type not in ["jsonl", "csv"]:
964964
raise ValueError(f"BatchedFileStorage only supports 'jsonl' and 'csv' cache types, got: {cache_type}")
965+
966+
def _load_local_file(self, file_path: str, file_type: str) -> pd.DataFrame:
967+
if file_type != "csv" or not os.path.exists(file_path):
968+
return super()._load_local_file(file_path, file_type)
969+
try:
970+
return pd.read_csv(file_path)
971+
except pd.errors.EmptyDataError:
972+
return pd.DataFrame()
973+
except Exception as e:
974+
raise ValueError(f"Failed to load {file_type} file: {str(e)}")
965975

966976
def read(self, output_type: Literal["dataframe", "dict"]="dataframe") -> Any:
967977
"""
@@ -1029,7 +1039,9 @@ def clean_surrogates(obj):
10291039

10301040
# 转换数据为DataFrame
10311041
if isinstance(data, list):
1032-
if len(data) > 0 and isinstance(data[0], dict):
1042+
if not data:
1043+
dataframe = pd.DataFrame()
1044+
elif isinstance(data[0], dict):
10331045
# 清洗列表中的每个字典
10341046
cleaned_data = [clean_surrogates(item) for item in data]
10351047
dataframe = pd.DataFrame(cleaned_data)
@@ -1049,14 +1061,20 @@ def clean_surrogates(obj):
10491061
with open(file_path, open_mode, encoding='utf-8') as f:
10501062
dataframe.to_json(f, orient="records", lines=True, force_ascii=False)
10511063
elif self.cache_type == "csv":
1052-
if self.batch_step == 0:
1053-
dataframe.to_csv(file_path, index=False)
1064+
if dataframe.empty and len(dataframe.columns) == 0:
1065+
if self.batch_step == 0:
1066+
open(file_path, 'w', encoding='utf-8').close()
10541067
else:
1055-
dataframe.to_csv(file_path, index=False, header=False, mode='a')
1068+
write_header = (
1069+
self.batch_step == 0
1070+
or not os.path.exists(file_path)
1071+
or os.path.getsize(file_path) == 0
1072+
)
1073+
open_mode = 'w' if self.batch_step == 0 else 'a'
1074+
dataframe.to_csv(file_path, index=False, header=write_header, mode=open_mode)
10561075
else:
10571076
raise ValueError(f"Unsupported file type: {self.cache_type}, output file should end with jsonl, csv")
10581077

1059-
self._dataframe_buffer.pop(self.operator_step + 1, None)
10601078
return file_path
10611079

10621080
class StreamBatchedFileStorage(BatchedFileStorage):
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import pandas as pd
2+
import pytest
3+
4+
from dataflow.operators.reasoning.generate.reasoning_pretrain_format_convert_generator import (
5+
ReasoningPretrainFormatConvertGenerator,
6+
)
7+
from dataflow.utils.storage import BatchedFileStorage
8+
9+
10+
def _zero_row_source(tmp_path):
11+
source = tmp_path / "zero_rows.csv"
12+
pd.DataFrame(columns=["question", "answer"]).to_csv(source, index=False)
13+
return source
14+
15+
16+
def _source_with_one_row(tmp_path, name="source.csv"):
17+
source = tmp_path / name
18+
pd.DataFrame({"seed": ["row"]}).to_csv(source, index=False)
19+
return source
20+
21+
22+
def _batched_storage(tmp_path, cache_type, cache_name):
23+
return BatchedFileStorage(
24+
first_entry_file_name=str(_source_with_one_row(tmp_path, f"{cache_name}-source.csv")),
25+
cache_path=str(tmp_path / cache_name),
26+
file_name_prefix=cache_name,
27+
cache_type=cache_type,
28+
).step()
29+
30+
31+
def _write_batches(storage, batches):
32+
for batch_step, batch in enumerate(batches):
33+
storage.batch_step = batch_step
34+
storage.write(batch)
35+
36+
37+
def _read_next_dataframe(storage):
38+
next_storage = storage.step()
39+
next_storage.batch_size = None
40+
next_storage.batch_step = 0
41+
return next_storage.read("dataframe")
42+
43+
44+
@pytest.mark.cpu
45+
@pytest.mark.parametrize("cache_type", ["jsonl", "csv"])
46+
def test_zero_row_list_round_trips_through_real_batched_operator(tmp_path, cache_type):
47+
"""A valid zero-row dataset must survive a real operator write and next-step read."""
48+
storage = BatchedFileStorage(
49+
first_entry_file_name=str(_zero_row_source(tmp_path)),
50+
cache_path=str(tmp_path / "cache"),
51+
file_name_prefix=f"p07_04_{cache_type}",
52+
cache_type=cache_type,
53+
).step()
54+
storage.batch_size = 8
55+
56+
# Zero rows are already a supported BatchedFileStorage input state.
57+
assert storage.read("dict") == []
58+
assert storage.record_count == 0
59+
60+
# This public operator naturally converts a zero-row dataframe to [] and writes it.
61+
ReasoningPretrainFormatConvertGenerator().run(
62+
storage=storage,
63+
input_read_key_question="question",
64+
input_read_key_answer="answer",
65+
output_key="text",
66+
)
67+
68+
# The persisted zero-row artifact must remain readable by the next real storage step.
69+
next_storage = storage.step()
70+
next_storage.batch_size = 8
71+
next_storage.batch_step = 0
72+
assert next_storage.read("dict") == []
73+
assert next_storage.record_count == 0
74+
75+
76+
@pytest.mark.cpu
77+
def test_csv_all_zero_batches_round_trip_as_empty(tmp_path):
78+
storage = _batched_storage(tmp_path, "csv", "csv-all-zero")
79+
_write_batches(storage, [[], [], []])
80+
81+
dataframe = _read_next_dataframe(storage)
82+
assert dataframe.to_dict("records") == []
83+
assert dataframe.columns.tolist() == []
84+
85+
86+
@pytest.mark.cpu
87+
def test_csv_empty_first_batch_then_non_empty_batches_preserve_header_and_records(tmp_path):
88+
storage = _batched_storage(tmp_path, "csv", "csv-empty-first")
89+
expected = [
90+
{"value": "alpha", "rank": 1},
91+
{"value": "beta", "rank": 2},
92+
{"value": "gamma", "rank": 3},
93+
]
94+
_write_batches(
95+
storage,
96+
[
97+
[],
98+
expected[:2],
99+
expected[2:],
100+
],
101+
)
102+
103+
dataframe = _read_next_dataframe(storage)
104+
assert dataframe.columns.tolist() == ["value", "rank"]
105+
assert dataframe.to_dict("records") == expected
106+
107+
108+
@pytest.mark.cpu
109+
def test_csv_empty_middle_and_trailing_batches_do_not_corrupt_prior_data(tmp_path):
110+
storage = _batched_storage(tmp_path, "csv", "csv-empty-middle-trailing")
111+
expected = [
112+
{"value": "alpha", "rank": 1},
113+
{"value": "beta", "rank": 2},
114+
]
115+
_write_batches(storage, [expected[:1], [], expected[1:], []])
116+
117+
dataframe = _read_next_dataframe(storage)
118+
assert dataframe.columns.tolist() == ["value", "rank"]
119+
assert dataframe.to_dict("records") == expected
120+
121+
122+
@pytest.mark.cpu
123+
def test_csv_non_empty_first_batch_keeps_existing_append_behavior(tmp_path):
124+
storage = _batched_storage(tmp_path, "csv", "csv-normal-first")
125+
expected = [
126+
{"value": "alpha", "rank": 1},
127+
{"value": "beta", "rank": 2},
128+
]
129+
_write_batches(storage, [expected[:1], expected[1:]])
130+
131+
dataframe = _read_next_dataframe(storage)
132+
assert dataframe.columns.tolist() == ["value", "rank"]
133+
assert dataframe.to_dict("records") == expected
134+
135+
136+
@pytest.mark.cpu
137+
def test_jsonl_multi_batch_empty_positions_preserve_existing_behavior(tmp_path):
138+
storage = _batched_storage(tmp_path, "jsonl", "jsonl-empty-positions")
139+
expected = [{"value": "alpha"}, {"value": "beta"}]
140+
_write_batches(storage, [[], expected[:1], [], expected[1:], []])
141+
142+
dataframe = _read_next_dataframe(storage)
143+
assert dataframe.columns.tolist() == ["value"]
144+
assert dataframe.to_dict("records") == expected
145+
146+
147+
@pytest.mark.cpu
148+
@pytest.mark.parametrize("cache_type", ["jsonl", "csv"])
149+
def test_batched_write_preserves_non_empty_list_and_dataframe(tmp_path, cache_type):
150+
source = _source_with_one_row(tmp_path)
151+
152+
list_storage = BatchedFileStorage(
153+
first_entry_file_name=str(source),
154+
cache_path=str(tmp_path / "list-cache"),
155+
file_name_prefix="list_contract",
156+
cache_type=cache_type,
157+
).step()
158+
list_storage.write([{"value": "alpha"}])
159+
assert list_storage.step().read("dict") == [{"value": "alpha"}]
160+
161+
dataframe_storage = BatchedFileStorage(
162+
first_entry_file_name=str(source),
163+
cache_path=str(tmp_path / "dataframe-cache"),
164+
file_name_prefix="dataframe_contract",
165+
cache_type=cache_type,
166+
).step()
167+
dataframe_storage.write(pd.DataFrame({"value": ["beta"]}))
168+
assert dataframe_storage.step().read("dict") == [{"value": "beta"}]
169+
170+
171+
@pytest.mark.cpu
172+
@pytest.mark.parametrize("unsupported", [[1], ["not-a-dict"], [None]])
173+
def test_batched_write_still_rejects_unsupported_list_elements(tmp_path, unsupported):
174+
source = _source_with_one_row(tmp_path)
175+
storage = BatchedFileStorage(
176+
first_entry_file_name=str(source),
177+
cache_path=str(tmp_path / "cache"),
178+
cache_type="jsonl",
179+
).step()
180+
181+
with pytest.raises(ValueError, match="Unsupported data type"):
182+
storage.write(unsupported)
183+
184+
185+
@pytest.mark.cpu
186+
def test_csv_missing_file_preserves_existing_error(tmp_path):
187+
storage = BatchedFileStorage(
188+
first_entry_file_name=str(tmp_path / "source.csv"),
189+
cache_path=str(tmp_path / "cache"),
190+
cache_type="csv",
191+
)
192+
missing = tmp_path / "missing.csv"
193+
194+
with pytest.raises(FileNotFoundError) as exc_info:
195+
storage._load_local_file(str(missing), "csv")
196+
197+
assert str(exc_info.value) == f"File {missing} does not exist. Please check the path."
198+
199+
200+
@pytest.mark.cpu
201+
def test_csv_malformed_file_preserves_existing_wrapped_error(tmp_path):
202+
storage = BatchedFileStorage(
203+
first_entry_file_name=str(tmp_path / "source.csv"),
204+
cache_path=str(tmp_path / "cache"),
205+
cache_type="csv",
206+
)
207+
invalid = tmp_path / "invalid.csv"
208+
invalid.write_text('a,b\n1,"unterminated\n', encoding="utf-8")
209+
210+
with pytest.raises(ValueError, match=r"^Failed to load csv file:"):
211+
storage._load_local_file(str(invalid), "csv")

0 commit comments

Comments
 (0)