Skip to content

Commit 88a4576

Browse files
Merge pull request #721 from pyathena-dev/fix/s3-transaction-small-file-write
2 parents 41dd440 + a6e6b52 commit 88a4576

3 files changed

Lines changed: 213 additions & 2 deletions

File tree

pyathena/filesystem/s3.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1409,11 +1409,18 @@ def _initiate_upload(self) -> None:
14091409
)
14101410

14111411
def _upload_chunk(self, final: bool = False) -> bool:
1412+
# The return value controls whether fsspec's flush() resets self.buffer
1413+
# afterwards: it does so only when this returns a value other than False.
1414+
# Returning ``not final`` keeps the buffer intact on the final flush so a
1415+
# deferred commit() (autocommit=False, i.e. inside an fsspec transaction)
1416+
# can still read the bytes; resetting it there would upload an empty
1417+
# object for small files. Mid-stream chunks (final=False) return True so
1418+
# fsspec clears the already-uploaded buffer between parts.
14121419
if self.tell() < self.blocksize:
14131420
# Files smaller than block size in size cannot be multipart uploaded.
14141421
if self.autocommit and final:
14151422
self.commit()
1416-
return True
1423+
return not final
14171424

14181425
if not self.multipart_upload:
14191426
raise RuntimeError("Multipart upload is not initialized.")
@@ -1456,7 +1463,7 @@ def _upload_chunk(self, final: bool = False) -> bool:
14561463

14571464
if self.autocommit and final:
14581465
self.commit()
1459-
return True
1466+
return not final
14601467

14611468
def commit(self) -> None:
14621469
if self.tell() == 0:

tests/pyathena/filesystem/test_s3.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
import io
12
import os
23
import tempfile
34
import time
45
import urllib.parse
56
import urllib.request
67
import uuid
8+
from concurrent.futures import ThreadPoolExecutor
79
from datetime import datetime, timezone
810
from itertools import chain
911
from pathlib import Path
12+
from types import SimpleNamespace
13+
from unittest import mock
1014

1115
import fsspec
1216
import pytest
@@ -194,6 +198,56 @@ def test_write(self, fs, base, exp):
194198
assert len(actual) == len(data)
195199
assert actual == data
196200

201+
@pytest.mark.parametrize(
202+
"size",
203+
[
204+
2**10, # < block size: one-shot PutObject path (the GH-719 regression)
205+
10 * 2**20, # > block size (5 MiB): multipart CompleteMultipartUpload path
206+
],
207+
)
208+
def test_write_transaction(self, fs, size):
209+
# Regression test for GH-719: files written inside an fsspec transaction
210+
# (autocommit=False) must round-trip with their real content (small files
211+
# were previously committed as empty objects).
212+
data = b"a" * size
213+
path = (
214+
f"s3://{ENV.s3_staging_bucket}/{ENV.s3_staging_key}{ENV.schema}/"
215+
f"filesystem/test_write_transaction/{uuid.uuid4()}"
216+
)
217+
with fs.transaction, fs.open(path, "wb") as f:
218+
f.write(data)
219+
with fs.open(path, "rb") as f:
220+
actual = f.read()
221+
assert len(actual) == len(data)
222+
assert actual == data
223+
224+
@pytest.mark.parametrize(
225+
"size",
226+
[
227+
2**10, # < block size: small-file discard() is a no-op
228+
10 * 2**20, # > block size: discard() aborts the multipart upload
229+
],
230+
)
231+
def test_write_transaction_rollback(self, fs, size):
232+
# Raising inside the transaction must leave no object behind.
233+
data = b"a" * size
234+
path = (
235+
f"s3://{ENV.s3_staging_bucket}/{ENV.s3_staging_key}{ENV.schema}/"
236+
f"filesystem/test_write_transaction_rollback/{uuid.uuid4()}"
237+
)
238+
239+
def write_then_fail():
240+
with fs.transaction:
241+
f = fs.open(path, "wb")
242+
f.write(data)
243+
f.close()
244+
raise RuntimeError("rollback")
245+
246+
with pytest.raises(RuntimeError):
247+
write_then_fail()
248+
fs.invalidate_cache(path)
249+
assert not fs.exists(path)
250+
197251
@pytest.mark.parametrize(
198252
("base", "exp"),
199253
[
@@ -820,6 +874,40 @@ def test_pandas_write_csv(self, line_count):
820874

821875

822876
class TestS3File:
877+
@staticmethod
878+
def _make_write_file(data: bytes, autocommit: bool):
879+
# Build a minimal write-mode S3File without touching AWS, bypassing
880+
# __init__ which would require a real connection.
881+
file = S3File.__new__(S3File)
882+
file.fs = mock.MagicMock(spec=S3FileSystem)
883+
file.path = "s3://bucket/key.txt"
884+
file.bucket = "bucket"
885+
file.key = "key.txt"
886+
file.s3_additional_kwargs = {}
887+
file.autocommit = autocommit
888+
file.blocksize = S3FileSystem.MULTIPART_UPLOAD_MIN_PART_SIZE
889+
file.multipart_upload = None
890+
file.multipart_upload_parts = []
891+
file.buffer = io.BytesIO(data)
892+
file.loc = len(data) # tell() returns self.loc
893+
return file
894+
895+
@staticmethod
896+
def _make_multipart_write_file(data: bytes, autocommit: bool):
897+
# A write-mode S3File set up to take the multipart branch (blocksize <
898+
# len(data)), with the executor and S3 part calls mocked so no AWS
899+
# access is needed.
900+
file = TestS3File._make_write_file(data, autocommit=autocommit)
901+
file.blocksize = 4
902+
file.fs.MULTIPART_UPLOAD_MIN_PART_SIZE = 4
903+
file.fs.MULTIPART_UPLOAD_MAX_PART_SIZE = 8
904+
file.multipart_upload = SimpleNamespace(upload_id="uploadid")
905+
file._executor = ThreadPoolExecutor(max_workers=1)
906+
file.fs._upload_part.side_effect = lambda **kw: SimpleNamespace(
907+
etag=f'"e{kw["part_number"]}"', part_number=kw["part_number"]
908+
)
909+
return file
910+
823911
@pytest.mark.parametrize(
824912
("objects", "target"),
825913
[
@@ -867,3 +955,75 @@ def test_get_ranges(self, start, end, max_workers, worker_block_size, ranges):
867955

868956
def test_format_ranges(self):
869957
assert S3File._format_ranges((0, 100)) == "bytes=0-99"
958+
959+
@pytest.mark.parametrize("autocommit", [True, False])
960+
def test_upload_chunk_small_file(self, autocommit):
961+
# Single (one-shot PutObject) upload via _upload_chunk + commit.
962+
# autocommit=True -> normal open(), committed immediately.
963+
# autocommit=False -> inside a transaction, commit() is deferred to
964+
# Transaction.complete(). This is the GH-719 case:
965+
# _upload_chunk must return False so fsspec.flush()
966+
# keeps self.buffer for the deferred commit, instead
967+
# of resetting it and uploading an empty object.
968+
data = b"hello world"
969+
file = self._make_write_file(data, autocommit=autocommit)
970+
971+
assert file._upload_chunk(final=True) is False
972+
if not autocommit:
973+
# Deferred: nothing is uploaded until commit() runs.
974+
file.fs._put_object.assert_not_called()
975+
file.commit()
976+
file.fs._put_object.assert_called_once_with(bucket="bucket", key="key.txt", body=data)
977+
978+
def test_upload_chunk_empty_file_touches(self):
979+
# An intentionally empty file (tell() == 0) is created via touch(),
980+
# never via _put_object.
981+
file = self._make_write_file(b"", autocommit=True)
982+
983+
assert file._upload_chunk(final=True) is False
984+
file.fs.touch.assert_called_once()
985+
file.fs._put_object.assert_not_called()
986+
987+
@pytest.mark.parametrize("multipart", [False, True])
988+
def test_discard(self, multipart):
989+
# Rollback (Transaction.complete(commit=False)) never creates the object:
990+
# a single small file is a no-op, while a multipart upload is aborted.
991+
if multipart:
992+
file = self._make_multipart_write_file(b"x" * 16, autocommit=False)
993+
else:
994+
file = self._make_write_file(b"hello world", autocommit=False)
995+
assert file._upload_chunk(final=True) is False
996+
997+
file.discard()
998+
999+
if multipart:
1000+
file.fs._call.assert_called_once()
1001+
assert file.fs._call.call_args.args[0] == "abort_multipart_upload"
1002+
else:
1003+
file.fs._call.assert_not_called()
1004+
file.fs._put_object.assert_not_called()
1005+
assert file.multipart_upload is None
1006+
assert file.multipart_upload_parts == []
1007+
1008+
@pytest.mark.parametrize("autocommit", [True, False])
1009+
def test_upload_chunk_multipart(self, autocommit):
1010+
# Multipart upload (CompleteMultipartUpload), completed from the uploaded
1011+
# parts; the buffer is never read for the body and no one-shot PutObject
1012+
# is issued. autocommit=True completes inside _upload_chunk; autocommit=
1013+
# False (transaction) defers completion to commit().
1014+
#
1015+
# The mid-stream assertion also guards why _upload_chunk returns
1016+
# `not final` rather than a plain False (as s3fs does): PyAthena delegates
1017+
# mid-stream buffer management to fsspec, so a non-final chunk MUST return
1018+
# True to have fsspec reset the buffer between parts. Returning False
1019+
# mid-stream would re-upload the already-sent buffer.
1020+
file = self._make_multipart_write_file(b"x" * 16, autocommit=autocommit)
1021+
1022+
assert file._upload_chunk(final=False) is True # mid-stream -> buffer reset
1023+
assert file._upload_chunk(final=True) is False # final -> buffer kept
1024+
if not autocommit:
1025+
# Deferred: the multipart upload is completed by commit().
1026+
file.fs._complete_multipart_upload.assert_not_called()
1027+
file.commit()
1028+
file.fs._complete_multipart_upload.assert_called_once()
1029+
file.fs._put_object.assert_not_called()

tests/pyathena/filesystem/test_s3_async.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,50 @@ def test_write(self, fs, base, exp):
188188
assert len(actual) == len(data)
189189
assert actual == data
190190

191+
@pytest.mark.parametrize(
192+
"size",
193+
[
194+
2**10, # < block size: one-shot PutObject path (the GH-719 regression)
195+
10 * 2**20, # > block size (5 MiB): multipart path via the async executor
196+
],
197+
)
198+
def test_write_transaction(self, fs, size):
199+
# GH-719 regression for the async filesystem: AioS3File inherits
200+
# _upload_chunk/commit from S3File, so the transaction fix must hold here
201+
# too, including the multipart path driven by the async executor.
202+
data = b"a" * size
203+
path = (
204+
f"s3://{ENV.s3_staging_bucket}/{ENV.s3_staging_key}{ENV.schema}/"
205+
f"filesystem/test_async_write_transaction/{uuid.uuid4()}"
206+
)
207+
with fs.transaction, fs.open(path, "wb") as f:
208+
f.write(data)
209+
with fs.open(path, "rb") as f:
210+
actual = f.read()
211+
assert len(actual) == len(data)
212+
assert actual == data
213+
214+
def test_write_transaction_rollback(self, fs):
215+
# Kept small-only on purpose: the multipart discard()/abort path is
216+
# already exercised by the sync test (AioS3File inherits discard()),
217+
# so there is no need to pay for another large upload here.
218+
path = (
219+
f"s3://{ENV.s3_staging_bucket}/{ENV.s3_staging_key}{ENV.schema}/"
220+
f"filesystem/test_async_write_transaction_rollback/{uuid.uuid4()}"
221+
)
222+
223+
def write_then_fail():
224+
with fs.transaction:
225+
f = fs.open(path, "wb")
226+
f.write(b"hello world")
227+
f.close()
228+
raise RuntimeError("rollback")
229+
230+
with pytest.raises(RuntimeError):
231+
write_then_fail()
232+
fs.invalidate_cache(path)
233+
assert not fs.exists(path)
234+
191235
@pytest.mark.parametrize(
192236
("base", "exp"),
193237
[

0 commit comments

Comments
 (0)