Skip to content

Commit 158e976

Browse files
authored
Merge pull request #1847 from transformerlab/add/chunk-file-uploads
Chunked file uploads
2 parents 359897c + 836c1fd commit 158e976

19 files changed

Lines changed: 1014 additions & 103 deletions

File tree

api/api.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
asset_versions,
5151
trackio,
5252
permissions,
53+
upload,
5354
)
5455
from transformerlab.routers.auth import get_user_and_team # noqa: E402
5556

@@ -147,6 +148,12 @@ async def lifespan(app: FastAPI):
147148
)
148149

149150
await start_remote_job_queue_worker()
151+
152+
# Sweep abandoned chunked-upload staging dirs older than 24 h
153+
from transformerlab.services.upload_service import sweep_expired_uploads
154+
155+
swept = await asyncio.to_thread(sweep_expired_uploads)
156+
print(f"✅ Upload staging sweep: removed {swept} expired upload(s)")
150157
print("FastAPI LIFESPAN: 🏁 🏁 🏁 Begin API Server 🏁 🏁 🏁", flush=True)
151158
yield
152159
# Do the following at API Shutdown:
@@ -315,6 +322,7 @@ async def validation_exception_handler(request, exc):
315322
app.include_router(asset_versions.router, dependencies=[Depends(get_user_and_team)])
316323
app.include_router(trackio.router, dependencies=[Depends(get_user_and_team)])
317324
app.include_router(permissions.router, dependencies=[Depends(get_user_and_team)])
325+
app.include_router(upload.router, dependencies=[Depends(get_user_and_team)])
318326

319327

320328
# @app.get("/")

api/test/api/test_upload.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import os
2+
3+
4+
def test_init_upload(client):
5+
resp = client.post("/upload/init", json={"filename": "model.zip", "total_size": 1024})
6+
assert resp.status_code == 200
7+
body = resp.json()
8+
assert "upload_id" in body
9+
assert body["chunk_size"] == 64 * 1024 * 1024
10+
11+
12+
def test_chunk_upload_and_status(client):
13+
init = client.post("/upload/init", json={"filename": "data.bin", "total_size": 6}).json()
14+
uid = init["upload_id"]
15+
16+
resp = client.put(
17+
f"/upload/{uid}/chunk?chunk_index=0",
18+
content=b"ABC",
19+
headers={"Content-Type": "application/octet-stream"},
20+
)
21+
assert resp.status_code == 200
22+
assert resp.json()["received"] == [0]
23+
24+
status = client.get(f"/upload/{uid}/status").json()
25+
assert status["received"] == [0]
26+
assert status["complete"] is False
27+
28+
29+
def test_complete_upload(client):
30+
init = client.post("/upload/init", json={"filename": "data.bin", "total_size": 6}).json()
31+
uid = init["upload_id"]
32+
33+
client.put(
34+
f"/upload/{uid}/chunk?chunk_index=0",
35+
content=b"ABC",
36+
headers={"Content-Type": "application/octet-stream"},
37+
)
38+
client.put(
39+
f"/upload/{uid}/chunk?chunk_index=1",
40+
content=b"DEF",
41+
headers={"Content-Type": "application/octet-stream"},
42+
)
43+
44+
resp = client.post(f"/upload/{uid}/complete", json={"total_chunks": 2})
45+
assert resp.status_code == 200
46+
body = resp.json()
47+
assert "temp_path" in body
48+
49+
assert os.path.isfile(body["temp_path"])
50+
with open(body["temp_path"], "rb") as f:
51+
assert f.read() == b"ABCDEF"
52+
53+
54+
def test_complete_fails_with_missing_chunks(client):
55+
init = client.post("/upload/init", json={"filename": "data.bin", "total_size": 6}).json()
56+
uid = init["upload_id"]
57+
client.put(
58+
f"/upload/{uid}/chunk?chunk_index=0",
59+
content=b"ABC",
60+
headers={"Content-Type": "application/octet-stream"},
61+
)
62+
63+
resp = client.post(f"/upload/{uid}/complete", json={"total_chunks": 2})
64+
assert resp.status_code == 400
65+
assert "Missing chunks" in resp.json()["detail"]
66+
67+
68+
def test_delete_upload(client):
69+
init = client.post("/upload/init", json={"filename": "data.bin", "total_size": 1}).json()
70+
uid = init["upload_id"]
71+
72+
resp = client.delete(f"/upload/{uid}")
73+
assert resp.status_code == 200
74+
assert resp.json()["status"] == "deleted"
75+
76+
status = client.get(f"/upload/{uid}/status")
77+
assert status.status_code == 404
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import json
2+
import os
3+
import pytest
4+
5+
6+
# Point staging at a temp dir for tests
7+
@pytest.fixture(autouse=True)
8+
def isolated_staging(tmp_path, monkeypatch):
9+
monkeypatch.setattr(
10+
"transformerlab.services.upload_service.STAGING_ROOT",
11+
str(tmp_path / "staging"),
12+
)
13+
yield
14+
15+
16+
def test_init_creates_staging_dir():
17+
import asyncio
18+
from transformerlab.services.upload_service import init_upload, STAGING_ROOT
19+
20+
result = asyncio.run(init_upload("model.zip", 1024 * 1024))
21+
22+
assert "upload_id" in result
23+
assert result["chunk_size"] == 64 * 1024 * 1024
24+
staging = os.path.join(STAGING_ROOT, result["upload_id"])
25+
assert os.path.isdir(staging)
26+
with open(os.path.join(staging, "meta.json")) as f:
27+
meta = json.load(f)
28+
assert meta["filename"] == "model.zip"
29+
assert meta["total_size"] == 1024 * 1024
30+
31+
32+
def test_save_chunk_writes_file():
33+
import asyncio
34+
from transformerlab.services.upload_service import init_upload, save_chunk, STAGING_ROOT
35+
36+
r = asyncio.run(init_upload("data.bin", 200))
37+
uid = r["upload_id"]
38+
39+
received = asyncio.run(save_chunk(uid, 0, b"hello chunk 0"))
40+
41+
chunk_path = os.path.join(STAGING_ROOT, uid, "0")
42+
assert os.path.isfile(chunk_path)
43+
assert open(chunk_path, "rb").read() == b"hello chunk 0"
44+
assert received == [0]
45+
46+
47+
def test_save_chunk_idempotent():
48+
import asyncio
49+
from transformerlab.services.upload_service import init_upload, save_chunk
50+
51+
r = asyncio.run(init_upload("data.bin", 200))
52+
uid = r["upload_id"]
53+
54+
asyncio.run(save_chunk(uid, 0, b"first write"))
55+
asyncio.run(save_chunk(uid, 0, b"second write"))
56+
57+
from transformerlab.services.upload_service import STAGING_ROOT
58+
59+
chunk_path = os.path.join(STAGING_ROOT, uid, "0")
60+
assert open(chunk_path, "rb").read() == b"second write"
61+
62+
63+
def test_get_status_returns_received_chunks():
64+
import asyncio
65+
from transformerlab.services.upload_service import init_upload, save_chunk, get_status
66+
67+
r = asyncio.run(init_upload("data.bin", 300))
68+
uid = r["upload_id"]
69+
asyncio.run(save_chunk(uid, 0, b"a"))
70+
asyncio.run(save_chunk(uid, 2, b"c"))
71+
72+
status = asyncio.run(get_status(uid))
73+
74+
assert status["upload_id"] == uid
75+
assert status["received"] == [0, 2]
76+
assert status["complete"] is False
77+
78+
79+
def test_assemble_upload_concatenates_chunks():
80+
import asyncio
81+
from transformerlab.services.upload_service import init_upload, save_chunk, assemble_upload_sync, STAGING_ROOT
82+
83+
r = asyncio.run(init_upload("data.bin", 6))
84+
uid = r["upload_id"]
85+
asyncio.run(save_chunk(uid, 0, b"ABC"))
86+
asyncio.run(save_chunk(uid, 1, b"DEF"))
87+
88+
path = assemble_upload_sync(uid, total_chunks=2)
89+
90+
assert open(path, "rb").read() == b"ABCDEF"
91+
# _staging_dir returns os.path.realpath(), so compare against the resolved path.
92+
assert path == os.path.realpath(os.path.join(STAGING_ROOT, uid, "assembled"))
93+
94+
95+
def test_assemble_upload_raises_on_missing_chunks():
96+
import asyncio
97+
from transformerlab.services.upload_service import init_upload, save_chunk, assemble_upload_sync
98+
99+
r = asyncio.run(init_upload("data.bin", 9))
100+
uid = r["upload_id"]
101+
asyncio.run(save_chunk(uid, 0, b"AAA"))
102+
# chunk 1 is missing
103+
104+
with pytest.raises(ValueError, match="Missing chunks"):
105+
assemble_upload_sync(uid, total_chunks=2)
106+
107+
108+
def test_delete_upload_removes_dir():
109+
import asyncio
110+
from transformerlab.services.upload_service import init_upload, delete_upload, STAGING_ROOT
111+
112+
r = asyncio.run(init_upload("data.bin", 10))
113+
uid = r["upload_id"]
114+
115+
asyncio.run(delete_upload(uid))
116+
117+
assert not os.path.isdir(os.path.join(STAGING_ROOT, uid))
118+
119+
120+
def test_get_assembled_path_returns_path_when_assembled():
121+
import asyncio
122+
from transformerlab.services.upload_service import init_upload, save_chunk, assemble_upload_sync, get_assembled_path
123+
124+
r = asyncio.run(init_upload("data.bin", 3))
125+
uid = r["upload_id"]
126+
asyncio.run(save_chunk(uid, 0, b"XYZ"))
127+
assembled = assemble_upload_sync(uid, total_chunks=1)
128+
129+
result = asyncio.run(get_assembled_path(uid))
130+
131+
assert result == assembled
132+
133+
134+
def test_get_assembled_path_raises_when_not_assembled():
135+
import asyncio
136+
from transformerlab.services.upload_service import init_upload, get_assembled_path
137+
138+
r = asyncio.run(init_upload("data.bin", 3))
139+
uid = r["upload_id"]
140+
141+
with pytest.raises(ValueError, match="has not been assembled"):
142+
asyncio.run(get_assembled_path(uid))
143+
144+
145+
def test_save_chunk_raises_on_unknown_upload_id():
146+
import asyncio
147+
from transformerlab.services.upload_service import save_chunk
148+
149+
# Must be a valid 32-char hex UUID format but refer to an upload that doesn't exist.
150+
with pytest.raises(ValueError, match="not found"):
151+
asyncio.run(save_chunk("a" * 32, 0, b"data"))
152+
153+
154+
def test_save_chunk_raises_on_invalid_upload_id():
155+
import asyncio
156+
from transformerlab.services.upload_service import save_chunk
157+
158+
with pytest.raises(ValueError, match="Invalid upload_id"):
159+
asyncio.run(save_chunk("../../etc/passwd", 0, b"data"))
160+
161+
162+
def test_get_filename_returns_filename():
163+
import asyncio
164+
from transformerlab.services.upload_service import init_upload, get_filename
165+
166+
r = asyncio.run(init_upload("my_model.zip", 500))
167+
uid = r["upload_id"]
168+
169+
result = asyncio.run(get_filename(uid))
170+
171+
assert result == "my_model.zip"
172+
173+
174+
def test_sweep_removes_old_uploads(tmp_path, monkeypatch):
175+
import asyncio
176+
from transformerlab.services import upload_service as svc
177+
178+
monkeypatch.setattr(svc, "STAGING_ROOT", str(tmp_path / "staging"))
179+
180+
r = asyncio.run(svc.init_upload("old.bin", 10))
181+
uid = r["upload_id"]
182+
183+
# Backdating meta.json created_at by 25 hours
184+
from datetime import datetime, timezone, timedelta
185+
186+
meta_path = os.path.join(svc.STAGING_ROOT, uid, "meta.json")
187+
with open(meta_path) as f:
188+
meta = json.load(f)
189+
meta["created_at"] = (datetime.now(timezone.utc) - timedelta(hours=25)).isoformat()
190+
with open(meta_path, "w") as f:
191+
json.dump(meta, f)
192+
193+
count = svc.sweep_expired_uploads(max_age_hours=24)
194+
195+
assert count == 1
196+
assert not os.path.isdir(os.path.join(svc.STAGING_ROOT, uid))

api/transformerlab/routers/compute_provider/launch.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Sub-router for compute provider launch logic, including sweep dispatch."""
22

3+
from typing import Optional
4+
35
from fastapi import APIRouter, Depends, File, UploadFile
46
from sqlalchemy.ext.asyncio import AsyncSession
57
from transformerlab.shared.models.user_model import get_async_session
@@ -22,13 +24,14 @@
2224
async def upload_task_file_for_provider(
2325
provider_id: str,
2426
task_id: str,
25-
file: UploadFile = File(...),
27+
upload_id: Optional[str] = None,
28+
file: Optional[UploadFile] = File(None),
2629
user_and_team=Depends(get_user_and_team),
2730
session: AsyncSession = Depends(get_async_session),
2831
):
2932
"""Upload a single file for a provider-backed task."""
3033
team_id = user_and_team["team_id"]
31-
return await upload_task_file_service(session, team_id, provider_id, task_id, file)
34+
return await upload_task_file_service(session, team_id, provider_id, task_id, file, upload_id)
3235

3336

3437
@router.post("/")

0 commit comments

Comments
 (0)