|
| 1 | +import io |
1 | 2 | import os |
2 | 3 | import tempfile |
3 | 4 | import time |
4 | 5 | import urllib.parse |
5 | 6 | import urllib.request |
6 | 7 | import uuid |
| 8 | +from concurrent.futures import ThreadPoolExecutor |
7 | 9 | from datetime import datetime, timezone |
8 | 10 | from itertools import chain |
9 | 11 | from pathlib import Path |
| 12 | +from types import SimpleNamespace |
| 13 | +from unittest import mock |
10 | 14 |
|
11 | 15 | import fsspec |
12 | 16 | import pytest |
@@ -194,6 +198,56 @@ def test_write(self, fs, base, exp): |
194 | 198 | assert len(actual) == len(data) |
195 | 199 | assert actual == data |
196 | 200 |
|
| 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 | + |
197 | 251 | @pytest.mark.parametrize( |
198 | 252 | ("base", "exp"), |
199 | 253 | [ |
@@ -820,6 +874,40 @@ def test_pandas_write_csv(self, line_count): |
820 | 874 |
|
821 | 875 |
|
822 | 876 | 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 | + |
823 | 911 | @pytest.mark.parametrize( |
824 | 912 | ("objects", "target"), |
825 | 913 | [ |
@@ -867,3 +955,75 @@ def test_get_ranges(self, start, end, max_workers, worker_block_size, ranges): |
867 | 955 |
|
868 | 956 | def test_format_ranges(self): |
869 | 957 | 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() |
0 commit comments