Skip to content

Commit 3f64d57

Browse files
authored
Merge pull request #1436 from transformerlab/fix/async_storage
POC: Try to make storage actually async
2 parents 52c9dbd + 0dc5452 commit 3f64d57

4 files changed

Lines changed: 70 additions & 61 deletions

File tree

api/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ dependencies = [
3333
"soundfile==0.13.1",
3434
"tensorboardX==2.6.2.2",
3535
"timm==1.0.15",
36-
"transformerlab==0.0.86",
36+
"transformerlab==0.0.87",
3737
"transformerlab-inference==0.2.52",
3838
"transformers==4.57.1",
3939
"wandb==0.23.1",

api/transformerlab/shared/ssl_utils.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import asyncio
34
import datetime as _dt
45
import ipaddress as _ip
56
from pathlib import Path
@@ -9,7 +10,6 @@
910
from cryptography.hazmat.primitives import hashes, serialization
1011
from cryptography.hazmat.primitives.asymmetric import rsa
1112
from cryptography.x509.oid import NameOID
12-
from filelock import FileLock
1313

1414
from lab.dirs import get_workspace_dir
1515
from lab import storage
@@ -18,6 +18,11 @@
1818
"ensure_persistent_self_signed_cert",
1919
]
2020

21+
# In-process lock: prevents concurrent async tasks from racing to generate
22+
# the cert simultaneously. FileLock is not suitable here because the storage
23+
# awaits inside the critical section now genuinely yield to the event loop.
24+
_cert_lock = asyncio.Lock()
25+
2126

2227
async def ensure_persistent_self_signed_cert() -> Tuple[str, str]:
2328
# Compute paths lazily to avoid asyncio.run() at module level
@@ -26,8 +31,7 @@ async def ensure_persistent_self_signed_cert() -> Tuple[str, str]:
2631
cert_path = cert_dir / "server-cert.pem"
2732
key_path = cert_dir / "server-key.pem"
2833

29-
lock = cert_dir / ".cert.lock"
30-
with FileLock(str(lock)):
34+
async with _cert_lock:
3135
if await storage.exists(str(cert_path)) and await storage.exists(str(key_path)):
3236
return str(cert_path), str(key_path)
3337
await storage.makedirs(str(cert_dir), exist_ok=True)

lab-sdk/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "transformerlab"
7-
version = "0.0.86"
7+
version = "0.0.87"
88
description = "Python SDK for Transformer Lab"
99
readme = "README.md"
1010
requires-python = ">=3.10"

lab-sdk/src/lab/storage.py

Lines changed: 61 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import os
23
import posixpath
34
import contextvars
@@ -17,7 +18,9 @@
1718
class AsyncFileWrapper:
1819
"""
1920
Wrapper to make sync file objects work with async context managers.
20-
This allows sync filesystem file objects to be used with 'async with'.
21+
22+
All I/O methods dispatch to a thread pool via asyncio.to_thread so that
23+
blocking S3/GCS network calls never stall the event loop.
2124
"""
2225

2326
def __init__(self, file_obj):
@@ -27,9 +30,10 @@ def __init__(self, file_obj):
2730
self._is_context_manager = hasattr(file_obj, "__enter__") and hasattr(file_obj, "__exit__")
2831

2932
async def __aenter__(self):
30-
# Enter the sync context manager if it is one
33+
# Enter the sync context manager in a thread — for S3 this is where
34+
# the actual HTTP connection / GetObject request is initiated.
3135
if self._is_context_manager:
32-
self.file_obj = self._file_obj.__enter__()
36+
self.file_obj = await asyncio.to_thread(self._file_obj.__enter__)
3337
else:
3438
self.file_obj = self._file_obj
3539
return self
@@ -41,55 +45,52 @@ async def __aexit__(
4145
exc_tb: Optional[TracebackType],
4246
) -> None:
4347
if self._is_context_manager:
44-
# Exit the sync context manager
45-
self._file_obj.__exit__(exc_type, exc_val, exc_tb)
48+
await asyncio.to_thread(self._file_obj.__exit__, exc_type, exc_val, exc_tb)
4649
elif self.file_obj and hasattr(self.file_obj, "close"):
47-
# Just close if no context manager protocol
48-
self.file_obj.close()
50+
await asyncio.to_thread(self.file_obj.close)
4951
self.file_obj = None
5052

51-
# Override common I/O methods to make them async-compatible
5253
async def read(self, size=-1):
53-
"""Read from the file (async wrapper for sync read)."""
54+
"""Read bytes from the file."""
5455
if self.file_obj is None:
5556
raise ValueError("File object not initialized. Use 'async with' to open the file.")
56-
return self.file_obj.read(size)
57+
return await asyncio.to_thread(self.file_obj.read, size)
5758

5859
async def write(self, data):
59-
"""Write to the file (async wrapper for sync write)."""
60+
"""Write data to the file."""
6061
if self.file_obj is None:
6162
raise ValueError("File object not initialized. Use 'async with' to open the file.")
62-
return self.file_obj.write(data)
63+
return await asyncio.to_thread(self.file_obj.write, data)
6364

6465
async def readline(self, size=-1):
65-
"""Read a line from the file (async wrapper for sync readline)."""
66+
"""Read a single line from the file."""
6667
if self.file_obj is None:
6768
raise ValueError("File object not initialized. Use 'async with' to open the file.")
68-
return self.file_obj.readline(size)
69+
return await asyncio.to_thread(self.file_obj.readline, size)
6970

7071
async def readlines(self, hint=-1):
71-
"""Read all lines from the file (async wrapper for sync readlines)."""
72+
"""Read all lines from the file."""
7273
if self.file_obj is None:
7374
raise ValueError("File object not initialized. Use 'async with' to open the file.")
74-
return self.file_obj.readlines(hint)
75+
return await asyncio.to_thread(self.file_obj.readlines, hint)
7576

7677
async def seek(self, offset, whence=0):
77-
"""Seek to a position in the file (async wrapper for sync seek)."""
78+
"""Seek to the given position in the file."""
7879
if self.file_obj is None:
7980
raise ValueError("File object not initialized. Use 'async with' to open the file.")
80-
return self.file_obj.seek(offset, whence)
81+
return await asyncio.to_thread(self.file_obj.seek, offset, whence)
8182

8283
async def tell(self):
83-
"""Get current file position (async wrapper for sync tell)."""
84+
"""Return the current file position."""
8485
if self.file_obj is None:
8586
raise ValueError("File object not initialized. Use 'async with' to open the file.")
86-
return self.file_obj.tell()
87+
return await asyncio.to_thread(self.file_obj.tell)
8788

8889
async def flush(self):
89-
"""Flush the file buffer (async wrapper for sync flush)."""
90+
"""Flush any buffered writes to the file."""
9091
if self.file_obj is None:
9192
raise ValueError("File object not initialized. Use 'async with' to open the file.")
92-
return self.file_obj.flush()
93+
return await asyncio.to_thread(self.file_obj.flush)
9394

9495
def __getattr__(self, name):
9596
# Delegate all other attributes to the underlying file object
@@ -103,14 +104,13 @@ def __iter__(self):
103104
return iter(self.file_obj)
104105

105106
def __aiter__(self):
106-
# For async iteration, we need to wrap the sync iterator
107107
return self
108108

109109
async def __anext__(self):
110110
if self.file_obj is None:
111111
raise ValueError("File object not initialized. Use 'async with' to open the file.")
112112
try:
113-
return next(self.file_obj)
113+
return await asyncio.to_thread(next, self.file_obj)
114114
except StopIteration:
115115
raise StopAsyncIteration
116116

@@ -253,44 +253,41 @@ async def root_join(*parts: str) -> str:
253253

254254
async def exists(path: str) -> bool:
255255
fs = await filesystem()
256-
return fs.exists(path)
256+
return await asyncio.to_thread(fs.exists, path)
257257

258258

259259
async def isdir(path: str, fs=None) -> bool:
260260
try:
261261
filesys = fs if fs is not None else await filesystem()
262-
return filesys.isdir(path)
262+
return await asyncio.to_thread(filesys.isdir, path)
263263
except Exception:
264264
return False
265265

266266

267267
async def isfile(path: str) -> bool:
268268
try:
269269
fs = await filesystem()
270-
return fs.isfile(path)
270+
return await asyncio.to_thread(fs.isfile, path)
271271
except Exception:
272272
return False
273273

274274

275275
async def makedirs(path: str, exist_ok: bool = True) -> None:
276276
fs = await filesystem()
277277
try:
278-
fs.makedirs(path, exist_ok=exist_ok)
278+
await asyncio.to_thread(fs.makedirs, path, exist_ok=exist_ok)
279279
except TypeError:
280280
# Some filesystems don't support exist_ok parameter
281281
if not exist_ok or not await exists(path):
282-
fs.makedirs(path)
282+
await asyncio.to_thread(fs.makedirs, path)
283283

284284

285285
async def ls(path: str, detail: bool = False, fs=None):
286286
# Use provided filesystem or get default
287287
filesys = fs if fs is not None else await filesystem()
288-
# Let fsspec parse the URI
289-
paths = filesys.ls(path, detail=detail)
290-
# Dont include the current path in the list
288+
paths = await asyncio.to_thread(filesys.ls, path, detail=detail)
291289
# Ensure paths are full URIs for remote filesystems
292290
if path.startswith(("s3://", "gs://", "abfs://", "gcs://")):
293-
# For remote filesystems, ensure returned paths are full URIs
294291
full_paths = []
295292
for p in paths:
296293
if not p.startswith(("s3://", "gs://", "abfs://", "gcs://")):
@@ -307,51 +304,55 @@ async def ls(path: str, detail: bool = False, fs=None):
307304

308305
async def find(path: str) -> list[str]:
309306
fs = await filesystem()
310-
return fs.find(path)
307+
return await asyncio.to_thread(fs.find, path)
311308

312309

313310
async def walk(path: str, maxdepth=None, topdown=True, on_error="omit"):
314311
"""
315-
Walk directory tree, yielding (root, dirs, files) tuples.
312+
Walk directory tree, returning a list of (root, dirs, files) tuples.
316313
317314
Args:
318315
path: Root directory to start the walk
319316
maxdepth: Maximum recursion depth (None for no limit)
320317
topdown: If True, traverse top-down; if False, bottom-up
321318
on_error: Error behavior ('omit', 'raise', or callable)
322319
323-
Yields:
324-
(root, dirs, files) tuples similar to os.walk()
320+
Returns:
321+
List of (root, dirs, files) tuples similar to os.walk()
325322
"""
326323
fs = await filesystem()
327-
return fs.walk(path, maxdepth=maxdepth, topdown=topdown, on_error=on_error)
324+
# Materialise the generator in a thread so the blocking filesystem
325+
# traversal never stalls the event loop.
326+
return await asyncio.to_thread(lambda: list(fs.walk(path, maxdepth=maxdepth, topdown=topdown, on_error=on_error)))
328327

329328

330329
async def rm(path: str) -> None:
331330
if await exists(path):
332331
fs = await filesystem()
333-
fs.rm(path)
332+
await asyncio.to_thread(fs.rm, path)
334333

335334

336335
async def rm_tree(path: str) -> None:
337336
if await exists(path):
338337
fs = await filesystem()
339338
try:
340-
fs.rm(path, recursive=True)
339+
await asyncio.to_thread(fs.rm, path, recursive=True)
341340
except TypeError:
342341
# Some filesystems don't support recursive parameter
343342
# Use find() to get all files and remove them individually
344343
files = await find(path)
345344
for file_path in reversed(files): # Remove files before directories
346-
fs.rm(file_path)
345+
await asyncio.to_thread(fs.rm, file_path)
347346

348347

349348
async def open(path: str, mode: str = "r", fs=None, uncached: bool = False, **kwargs):
350349
"""
351350
Open a file for reading or writing.
352351
353352
For local files, uses aiofiles for truly async file I/O.
354-
For remote files (S3, GCS, etc.), uses fsspec sync file objects.
353+
For remote files (S3, GCS, etc.), dispatches the blocking open() call to a
354+
thread pool and wraps the result in AsyncFileWrapper whose I/O methods are
355+
also thread-dispatched, so no S3 network call ever blocks the event loop.
355356
356357
Args:
357358
path: Path to the file
@@ -361,7 +362,7 @@ async def open(path: str, mode: str = "r", fs=None, uncached: bool = False, **kw
361362
**kwargs: Additional arguments passed to filesystem.open()
362363
363364
Returns:
364-
File-like object (context manager for remote, async context manager for local)
365+
Async context manager wrapping the file object.
365366
"""
366367
if uncached:
367368
# Create an uncached filesystem instance
@@ -374,12 +375,13 @@ async def open(path: str, mode: str = "r", fs=None, uncached: bool = False, **kw
374375
is_local = isinstance(filesys, fsspec.implementations.local.LocalFileSystem)
375376

376377
if is_local:
377-
# Use aiofiles for local files to get truly async file I/O
378+
# Use aiofiles for local files — already truly async, no change needed
378379
return aiofiles.open(path, mode=mode, **kwargs)
379380
else:
380-
# Use sync filesystem open method, but wrap it in async context manager
381-
# so it can be used with 'async with'
382-
sync_file = filesys.open(path, mode=mode, **kwargs)
381+
# Open the remote file in a thread (the open() call itself initiates the
382+
# S3/GCS connection), then wrap it so subsequent reads/writes are also
383+
# dispatched to threads via AsyncFileWrapper.
384+
sync_file = await asyncio.to_thread(filesys.open, path, mode, **kwargs)
383385
return AsyncFileWrapper(sync_file)
384386

385387

@@ -494,16 +496,18 @@ def _get_fs_for_path(path: str):
494496

495497
async def copy_file(src: str, dest: str) -> None:
496498
"""Copy a single file from src to dest across arbitrary filesystems."""
497-
# Use streaming copy to be robust across different filesystems
498-
# Get sync filesystems with proper storage_options handling
499+
# Run the entire streaming copy in a thread — src_fs.open() and dest_fs.open()
500+
# both make blocking network calls for remote filesystems.
499501
src_fs, _ = _get_fs_for_path(src)
500502
dest_fs, _ = _get_fs_for_path(dest)
501503

502-
# Use sync filesystem methods (wrapped in async function for API compatibility)
503-
with src_fs.open(src, "rb") as r:
504-
with dest_fs.open(dest, "wb") as w:
505-
for chunk in iter_chunks(r):
506-
w.write(chunk)
504+
def _do_copy():
505+
with src_fs.open(src, "rb") as r:
506+
with dest_fs.open(dest, "wb") as w:
507+
for chunk in iter_chunks(r):
508+
w.write(chunk)
509+
510+
await asyncio.to_thread(_do_copy)
507511

508512

509513
def iter_chunks(file_obj, chunk_size: int = 8 * 1024 * 1024):
@@ -527,11 +531,12 @@ async def copy_dir(src_dir: str, dest_dir: str) -> None:
527531
src_protocol = src_dir.split("://", 1)[0]
528532

529533
try:
530-
src_files = src_fs.find(src_dir)
534+
src_files = await asyncio.to_thread(src_fs.find, src_dir)
531535
except Exception:
532536
# If find is not available, fall back to listing via walk
533537
src_files = []
534-
for _, _, files in src_fs.walk(src_dir):
538+
walk_result = await asyncio.to_thread(lambda: list(src_fs.walk(src_dir)))
539+
for _, _, files in walk_result:
535540
for f in files:
536541
src_files.append(f)
537542

0 commit comments

Comments
 (0)