Skip to content

Commit a94454f

Browse files
jopemachineclaude
andcommitted
refactor(BA-6156): stream PATCH body via common StreamReader adapter
Wrap the raw TUS PATCH body in TusChunkUploadStreamReader (in storage/types.py, mirroring MultipartFileUploadStreamReader) so it conforms to the common StreamReader abstraction, and drain it through TusUploadSession.write_temp_chunk instead of a one-off reader protocol + free function. This keeps the reader a pure byte source and concentrates all chunk file I/O in the session, consistent with the existing storage upload/download readers. Also update the completed-upload assertion to match the race-safe cleanup: the session directory and its info.json marker are intentionally kept while chunk data is reclaimed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 11dd83c commit a94454f

3 files changed

Lines changed: 45 additions & 11 deletions

File tree

src/ai/backend/storage/api/client.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,8 @@
5050
from ai.backend.storage.services.file_stream.zip import (
5151
ZipArchiveStreamReader,
5252
)
53-
from ai.backend.storage.services.upload.tus_session import (
54-
TusUploadSession,
55-
stream_chunk_to_temp,
56-
)
57-
from ai.backend.storage.types import SENTINEL
53+
from ai.backend.storage.services.upload.tus_session import TusUploadSession
54+
from ai.backend.storage.types import SENTINEL, TusChunkUploadStreamReader
5855
from ai.backend.storage.utils import (
5956
CheckParamSource,
6057
build_attachment_headers,
@@ -426,11 +423,13 @@ class Params(TypedDict):
426423
)
427424
await session.ensure_initialized()
428425

429-
temp_chunk = session.open_temp_chunk(client_offset)
426+
upload_stream = TusChunkUploadStreamReader(
427+
request.content, request.content_type, DEFAULT_CHUNK_SIZE
428+
)
429+
temp_chunk, length, sha256 = await session.write_temp_chunk(
430+
client_offset, upload_stream
431+
)
430432
try:
431-
length, sha256 = await stream_chunk_to_temp(
432-
request.content, temp_chunk.path, DEFAULT_CHUNK_SIZE
433-
)
434433
if client_offset + length > total_size:
435434
raise UploadOffsetMismatchError(
436435
f"Chunk at offset {client_offset} with length {length} "

src/ai/backend/storage/types.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import attrs
1111
import trafaret as t
1212
from aiohttp import BodyPartReader, MultipartReader, web
13+
from aiohttp import StreamReader as AiohttpStreamReader
1314

1415
from ai.backend.common import validators as tx
1516
from ai.backend.common.types import QuotaConfig, StreamReader, VFolderID
@@ -148,3 +149,33 @@ async def read(self) -> AsyncIterator[bytes]:
148149
@override
149150
def content_type(self) -> str | None:
150151
return self._content_type
152+
153+
154+
class TusChunkUploadStreamReader(StreamReader):
155+
"""
156+
Adapts a raw TUS PATCH request body (``application/offset+octet-stream``)
157+
into the common :class:`StreamReader` by reading it in fixed-size chunks,
158+
so the upload writer holds only one chunk in memory at a time.
159+
"""
160+
161+
def __init__(
162+
self,
163+
content: AiohttpStreamReader,
164+
content_type: str | None,
165+
chunk_size: int,
166+
) -> None:
167+
self._content = content
168+
self._content_type = content_type
169+
self._chunk_size = chunk_size
170+
171+
@override
172+
async def read(self) -> AsyncIterator[bytes]:
173+
while True:
174+
chunk = await self._content.read(self._chunk_size)
175+
if not chunk:
176+
break
177+
yield chunk
178+
179+
@override
180+
def content_type(self) -> str | None:
181+
return self._content_type

tests/unit/storage/api/test_tus_upload.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,12 @@ async def test_single_chunk_upload_completes_and_assembles(self, patch_env: _Pat
212212
assert response.headers["Upload-Offset"] == str(len(payload))
213213
final_path = patch_env.vfpath / "result.bin"
214214
assert final_path.read_bytes() == payload
215-
# Session directory must be cleaned up after assembly.
216-
assert not patch_env.session_dir.exists()
215+
# After assembly the session reclaims chunk data but keeps the small
216+
# completed marker (cleanup() no longer rmtree's the dir, so a late
217+
# duplicate PATCH observes status=="completed" instead of racing a
218+
# directory teardown).
219+
assert patch_env.session_dir.exists()
220+
assert list((patch_env.session_dir / "chunks").glob("*.dat")) == []
217221

218222
async def test_two_chunks_assemble_in_order(self, patch_env: _PatchEnv) -> None:
219223
token_data = _token_data(session_id="test-session", total_size=2048, relpath="result.bin")

0 commit comments

Comments
 (0)