Skip to content

Commit 7d43dcf

Browse files
authored
fix: Clean up temp CSV in AthenaQuery.as_dataframe (#6344)
as_dataframe() downloaded the Athena query result to <tempdir>/<execution_id>.csv and never removed it, so every query left a file behind and repeated queries could fill the local disk. Wrap the download and read_csv in try/finally and remove the temporary file afterwards, including when the download or parsing fails. Cleanup is best-effort: an OSError is logged as a warning and does not affect the returned DataFrame. Fixes #5100
1 parent 350d1b0 commit 7d43dcf

2 files changed

Lines changed: 84 additions & 9 deletions

File tree

‎sagemaker-mlops/src/sagemaker/mlops/feature_store/athena_query.py‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Run Athena queries against Feature Store offline data and load the results."""
22

3+
import logging
34
import os
45
import tempfile
56
from dataclasses import dataclass, field
@@ -18,6 +19,8 @@
1819
from sagemaker.core.helper.session_helper import Session
1920
from sagemaker.core.telemetry import Feature, _telemetry_emitter
2021

22+
logger = logging.getLogger(__name__)
23+
2124

2225
@dataclass
2326
class AthenaQuery:
@@ -91,6 +94,9 @@ def get_query_execution(self) -> Dict[str, Any]:
9194
def as_dataframe(self, **kwargs) -> DataFrame:
9295
"""Download the result of the current query and load it into a DataFrame.
9396
97+
The query result is downloaded to a temporary local CSV file, which is removed
98+
after it has been loaded (or if downloading/loading fails).
99+
94100
Args:
95101
**kwargs (object): key arguments used for the method pandas.read_csv to be able to
96102
have a better tuning on data. For more info read:
@@ -106,12 +112,24 @@ def as_dataframe(self, **kwargs) -> DataFrame:
106112
raise RuntimeError(f"Query {self._current_query_execution_id} failed.")
107113

108114
output_file = os.path.join(tempfile.gettempdir(), f"{self._current_query_execution_id}.csv")
109-
download_athena_query_result(
110-
session=self.sagemaker_session,
111-
bucket=self._result_bucket,
112-
prefix=self._result_file_prefix,
113-
query_execution_id=self._current_query_execution_id,
114-
filename=output_file,
115-
)
116-
kwargs.pop("delimiter", None)
117-
return pd.read_csv(output_file, delimiter=",", **kwargs)
115+
try:
116+
download_athena_query_result(
117+
session=self.sagemaker_session,
118+
bucket=self._result_bucket,
119+
prefix=self._result_file_prefix,
120+
query_execution_id=self._current_query_execution_id,
121+
filename=output_file,
122+
)
123+
kwargs.pop("delimiter", None)
124+
return pd.read_csv(output_file, delimiter=",", **kwargs)
125+
finally:
126+
_remove_temp_file(output_file)
127+
128+
129+
def _remove_temp_file(path: str) -> None:
130+
"""Best-effort removal of a temporary file; never raises."""
131+
try:
132+
if os.path.exists(path):
133+
os.remove(path)
134+
except OSError as e:
135+
logger.warning("Failed to remove temporary query result file %s: %s", path, e)

‎sagemaker-mlops/tests/unit/sagemaker/mlops/feature_store/test_athena_query.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,60 @@ def test_as_dataframe_raises_when_failed(self, mock_get, athena_query):
114114

115115
with pytest.raises(RuntimeError, match="failed"):
116116
athena_query.as_dataframe()
117+
118+
@staticmethod
119+
def _write_csv(**kwargs):
120+
with open(kwargs["filename"], "w") as f:
121+
f.write("col\n1\n2\n3\n")
122+
123+
@patch("sagemaker.mlops.feature_store.athena_query.get_query_execution")
124+
@patch("sagemaker.mlops.feature_store.athena_query.download_athena_query_result")
125+
def test_as_dataframe_removes_temp_file(self, mock_download, mock_get, athena_query, tmp_path):
126+
athena_query._current_query_execution_id = "query-123"
127+
athena_query._result_bucket = "bucket"
128+
athena_query._result_file_prefix = "prefix"
129+
mock_get.return_value = {"QueryExecution": {"Status": {"State": "SUCCEEDED"}}}
130+
mock_download.side_effect = self._write_csv
131+
expected_file = tmp_path / "query-123.csv"
132+
133+
with patch("tempfile.gettempdir", return_value=str(tmp_path)):
134+
df = athena_query.as_dataframe()
135+
136+
assert df["col"].tolist() == [1, 2, 3]
137+
assert mock_download.call_args[1]["filename"] == str(expected_file)
138+
assert not expected_file.exists()
139+
140+
@patch("sagemaker.mlops.feature_store.athena_query.get_query_execution")
141+
@patch("sagemaker.mlops.feature_store.athena_query.download_athena_query_result")
142+
@patch("pandas.read_csv", side_effect=ValueError("bad csv"))
143+
def test_as_dataframe_removes_temp_file_when_read_fails(
144+
self, mock_read_csv, mock_download, mock_get, athena_query, tmp_path
145+
):
146+
athena_query._current_query_execution_id = "query-123"
147+
athena_query._result_bucket = "bucket"
148+
athena_query._result_file_prefix = "prefix"
149+
mock_get.return_value = {"QueryExecution": {"Status": {"State": "SUCCEEDED"}}}
150+
mock_download.side_effect = self._write_csv
151+
152+
with patch("tempfile.gettempdir", return_value=str(tmp_path)):
153+
with pytest.raises(ValueError, match="bad csv"):
154+
athena_query.as_dataframe()
155+
156+
assert not (tmp_path / "query-123.csv").exists()
157+
158+
@patch("sagemaker.mlops.feature_store.athena_query.get_query_execution")
159+
@patch("sagemaker.mlops.feature_store.athena_query.download_athena_query_result")
160+
def test_as_dataframe_cleanup_failure_does_not_raise(
161+
self, mock_download, mock_get, athena_query, tmp_path
162+
):
163+
athena_query._current_query_execution_id = "query-123"
164+
athena_query._result_bucket = "bucket"
165+
athena_query._result_file_prefix = "prefix"
166+
mock_get.return_value = {"QueryExecution": {"Status": {"State": "SUCCEEDED"}}}
167+
mock_download.side_effect = self._write_csv
168+
169+
with patch("tempfile.gettempdir", return_value=str(tmp_path)):
170+
with patch("os.remove", side_effect=PermissionError("file in use")):
171+
df = athena_query.as_dataframe()
172+
173+
assert len(df) == 3

0 commit comments

Comments
 (0)