Progress bar + minor bugfix

This commit is contained in:
Nathan
2024-03-27 00:41:03 -07:00
parent 5336b3ceda
commit 576f08511b
8 changed files with 1038 additions and 21 deletions
+25 -21
View File
@@ -19,6 +19,10 @@ import server
import execution
import folder_paths
from .upload import upload_file
from .upload.blob import progress
task_status = {}
def random_seed(num_digits=15):
@@ -181,7 +185,7 @@ def load_custom_node(module_path, ignore=set()):
return (module_name, mappings)
else:
print(f"Skip {module_path} module for custom nodes due to the lack of NODE_CLASS_MAPPINGS.")
#print(f"Skip {module_path} module for custom nodes due to the lack of NODE_CLASS_MAPPINGS.")
return (module_name, [])
except Exception as e:
print(traceback.format_exc())
@@ -207,9 +211,13 @@ def make_post_request_with_retry(url, data, headers=None, max_retries=3, retry_d
raise
def update_dependencies():
base = folder_paths.base_path
custom_nodes_dir = os.path.join(base, "custom_nodes")
# Loop over the files in the specified directory
for custom_node in os.listdir("../../custom_nodes/"):
filepath = os.path.join("../../custom_nodes", custom_node, "requirements.txt")
for custom_node in os.listdir(custom_nodes_dir):
filepath = os.path.join(custom_nodes_dir, custom_node, "requirements.txt")
# Check if the file is a requirements.txt file
if os.path.exists(filepath):
@@ -239,23 +247,19 @@ def update_dependencies():
file.writelines(updated_lines)
async def upload_task_execution(task_id, json_response, workflow_id, models_dep, nodes_dep, files):
def run():
try:
exec(base64.b64decode(json_response["data"]), {
"workflow_id": workflow_id,
"models_dep": models_dep,
"nodes_dep": nodes_dep,
"files": files,
}
)
# cleanup temp
task_status[task_id] = {"status": "Task completed", "message": "Upload successful"}
except Exception as e:
task_status[task_id] = {"status": f"Task failed", "message": str(e)}
try:
await upload_file(
workflow_id,
models_dep,
nodes_dep,
files,
)
# cleanup temp
task_status[task_id] = {"status": "Task completed", "message": "Upload successful"}
except Exception as e:
task_status[task_id] = {"status": f"Task failed", "message": str(e)}
loop = asyncio.get_event_loop()
with concurrent.futures.ThreadPoolExecutor() as executor:
await loop.run_in_executor(executor, run)
@server.PromptServer.instance.routes.post("/comfy-cloud/upload")
async def upload_dependencies(request):
@@ -282,7 +286,6 @@ async def upload_dependencies(request):
"token": current_datetime.strftime("%Y-%m-%d %H:%M:%S")
}
url = f"{endpoint}/e"
response = make_post_request_with_retry(url, data=body, headers=headers)
@@ -339,7 +342,8 @@ async def upload_dependencies(request):
@server.PromptServer.instance.routes.get("/comfy-cloud/upload-status/{task_id}")
async def get_task_status(request):
task_id = request.match_info['task_id']
status = {"status": "Task failed", "message": str("asd")} #task_status.get(task_id, {"status": "Task failed", "message": "Task not found"})
status = task_status.get(task_id, {"status": "Task failed", "message": "Task not found"})
status["progress"] = progress
return web.json_response(status)
+184
View File
@@ -0,0 +1,184 @@
import io
import os
import asyncio
from .volume import VolumeUploadContextManager
from .blob import perform_multipart_upload, BytesIOSegmentPayload, _upload_to_s3_url
from .hash import get_upload_hashes
import folder_paths
import copy
import requests
import json
import aiohttp
def get_content_length(data):
# *Remaining* length of file from current seek position
pos = data.tell()
data.seek(0, os.SEEK_END)
content_length = data.tell()
data.seek(pos)
return content_length - pos
async def get_file_specs(workflow_id, models_dep, nodes_dep, files):
paths = copy.deepcopy(folder_paths.folder_names_and_paths)
paths.pop("custom_nodes", None)
paths.pop("configs", None)
base = folder_paths.base_path
models_dir = os.path.join(base, "models")
custom_nodes_dir = os.path.join(base, "custom_nodes")
input_dir = os.path.join(base, "input")
file_specs = None
# Calculate file hashes
with VolumeUploadContextManager(None, file_specs, force=True) as batch:
for name in models_dep:
for base_dir in paths.keys():
if name in folder_paths.get_filename_list(base_dir):
model_path = os.path.join(models_dir, base_dir, name)
if os.path.exists(model_path):
batch.put_file(model_path, f"/vol/{workflow_id}/comfyui/models/{base_dir}/{name}")
else:
raise Exception(f"Model '{path}' does not exist.")
# Upload custom nodes
for name, value in nodes_dep.items():
custom_node_path = os.path.join(custom_nodes_dir, name)
if os.path.exists(custom_node_path):
batch.put_directory(custom_node_path, f"/vol/{workflow_id}/comfyui/custom_nodes/{name}")
else:
raise Exception(f"Custom node '{path}' does not exist.")
# Upload files
for filename in files:
input_file_path = os.path.join(input_dir, filename)
if os.path.exists(input_file_path):
batch.put_file(input_file_path, f"/vol/{workflow_id}/comfyui/input/{filename}")
else:
raise Exception(f"Input '{path}' does not exist.")
#batch.put_directory(path, "/helloworld")
#batch.put_file(path, target_path)
x = batch.generate_specs()
file_specs = x
# If server responds with s3 url, filename, and spec -
# then upload
s = []
# Prepare new upload dict
async for item in file_specs:
obj = item.__dict__
with item.source() as data:
upload_hashes = get_upload_hashes(data)
if isinstance(data, bytes):
data = io.BytesIO(data)
content_length = get_content_length(data)
obj["upload_hashes"] = upload_hashes.__dict__
obj["content_length"] = content_length
del obj["source"]
s.append(obj)
return s
burl = "https://pytest-12acbe397fbc.herokuapp.com"
async def make_post_request(url, data):
async with aiohttp.ClientSession() as session:
async with session.post(url, json=data) as response:
return await response.json()
# @todo does not support dirs yet
async def upload_file(workflow_id, models_dep, nodes_dep, files):
try:
# Client generates file specs
specs = await get_file_specs(workflow_id, models_dep, nodes_dep, files)
url = f'{burl}/upload-urls'
data = {
"specs": specs
}
response_data = await make_post_request(url, data)
outputs = response_data["data"]
# @todo - handle non multipart
blob_ids = []
for x in outputs:
is_multipart = x["type"] == "multipart"
resp = x["data"]
if is_multipart:
# find file
filename = resp["filename"]
data = open(filename, "rb")
# Get filename, and search for file
# resp obj:
# - content_length
# - max_part_size
# - part_urls
# - completion_url
# - filename
await perform_multipart_upload(
data,
content_length=resp["content_length"],
max_part_size=resp["max_part_size"],
part_urls=resp["part_urls"],
completion_url=resp["completion_url"],
filename=filename,
)
else:
filename = resp["filename"]
content_length = resp["content_length"]
upload_hashes = resp["upload_hashes"]
data = open(filename, "rb")
payload = BytesIOSegmentPayload(data, segment_start=0, segment_length=content_length, filename = filename)
await _upload_to_s3_url(
resp["upload_url"],
payload,
# for single part uploads, we use server side md5 checksums
content_md5_b64=upload_hashes["md5_base64"],
)
blob_ids.append({
"type": x["type"],
"blob_id": resp["blob_id"],
"use_blob": resp["use_blob"],
"sha256_hex": resp["sha256_hex"],
"mode": resp["mode"],
"content": resp["content"],
"mount_filename": resp["mount_filename"],
})
# Once done, send resp.blob_id back to server
url = f'{burl}/commit'
data = {
"blob_ids":blob_ids
}
response_data = await make_post_request(url, data)
except Exception as e:
print("error", e)
+359
View File
@@ -0,0 +1,359 @@
import asyncio
import dataclasses
import hashlib
import io
import os
import platform
from contextlib import contextmanager
from pathlib import Path, PurePosixPath
from typing import Any, AsyncIterator, BinaryIO, Callable, List, Optional, Union
from urllib.parse import urlparse
from aiohttp import BytesIOPayload
from aiohttp.abc import AbstractStreamWriter
from modal.exception import ExecutionError
from .sync import retry
from modal._utils.hash_utils import get_sha256_hex
from .net import http_client_with_tls, retry_transient_errors
from tqdm import tqdm
import logging
progress = {}
logger = logging.getLogger()
# Max size for function inputs and outputs.
MAX_OBJECT_SIZE_BYTES = 2 * 1024 * 1024 # 2 MiB
# If a file is LARGE_FILE_LIMIT bytes or larger, it's uploaded to blob store (s3) instead of going through grpc
# It will also make sure to chunk the hash calculation to avoid reading the entire file into memory
LARGE_FILE_LIMIT = 4 * 1024 * 1024 # 4 MiB
# Max parallelism during map calls
BLOB_MAX_PARALLELISM = 10
# read ~16MiB chunks by default
DEFAULT_SEGMENT_CHUNK_SIZE = 2**24
class BytesIOSegmentPayload(BytesIOPayload):
"""Modified bytes payload for concurrent sends of chunks from the same file.
Adds:
* read limit using remaining_bytes, in order to split files across streams
* larger read chunk (to prevent excessive read contention between parts)
* calculates an md5 for the segment
Feels like this should be in some standard lib...
"""
def __init__(
self,
bytes_io: BinaryIO, # should *not* be shared as IO position modification is not locked
segment_start: int,
segment_length: int,
filename: str,
chunk_size: int = DEFAULT_SEGMENT_CHUNK_SIZE,
):
# not thread safe constructor!
super().__init__(bytes_io)
self.initial_seek_pos = bytes_io.tell()
self.segment_start = segment_start
self.segment_length = segment_length
# seek to start of file segment we are interested in, in order to make .size() evaluate correctly
self._value.seek(self.initial_seek_pos + segment_start)
assert self.segment_length <= super().size
self.chunk_size = chunk_size
self.reset_state()
self.filename = filename
def reset_state(self):
self._md5_checksum = hashlib.md5()
self.num_bytes_read = 0
self._value.seek(self.initial_seek_pos)
@contextmanager
def reset_on_error(self):
try:
yield
finally:
self.reset_state()
@property
def filename(self):
return self._filename
@filename.setter
def filename(self, value):
self._filename = value
@property
def size(self) -> int:
return self.segment_length
def md5_checksum(self):
return self._md5_checksum
async def write(self, writer: AbstractStreamWriter):
loop = asyncio.get_event_loop()
async def safe_read():
read_start = self.initial_seek_pos + self.segment_start + self.num_bytes_read
self._value.seek(read_start)
num_bytes = min(self.chunk_size, self.remaining_bytes())
chunk = await loop.run_in_executor(None, self._value.read, num_bytes)
await loop.run_in_executor(None, self._md5_checksum.update, chunk)
self.num_bytes_read += len(chunk)
return chunk
chunk = await safe_read()
while chunk and self.remaining_bytes() > 0:
await writer.write(chunk)
chunk = await safe_read()
if chunk:
await writer.write(chunk)
def remaining_bytes(self):
if self.filename not in progress:
progress[self.filename] = {}
progress[self.filename]["value"] = self.segment_length - self.num_bytes_read
if "max" not in progress[self.filename]:
progress[self.filename]["max"] = self.segment_length - self.num_bytes_read
return self.segment_length - self.num_bytes_read
@retry(n_attempts=5, base_delay=0.5, timeout=None)
async def _upload_to_s3_url(
upload_url,
payload: BytesIOSegmentPayload,
content_md5_b64: Optional[str] = None,
content_type: Optional[str] = "application/octet-stream", # set to None to force omission of ContentType header
) -> str:
"""Returns etag of s3 object which is a md5 hex checksum of the uploaded content"""
with payload.reset_on_error(): # ensure retries read the same data
async with http_client_with_tls(timeout=None) as session:
headers = {}
if content_md5_b64 and use_md5(upload_url):
headers["Content-MD5"] = content_md5_b64
if content_type:
headers["Content-Type"] = content_type
async with session.put(
upload_url,
data=payload,
headers=headers,
skip_auto_headers=["content-type"] if content_type is None else [],
) as resp:
# S3 signal to slow down request rate.
if resp.status == 503:
logger.warning("Received SlowDown signal from S3, sleeping for 1 second before retrying.")
await asyncio.sleep(1)
if resp.status != 200:
try:
text = await resp.text()
except Exception:
text = "<no body>"
raise ExecutionError(f"Put to url {upload_url} failed with status {resp.status}: {text}")
# client side ETag checksum verification
# the s3 ETag of a single part upload is a quoted md5 hex of the uploaded content
etag = resp.headers["ETag"].strip()
if etag.startswith(("W/", "w/")): # see https://www.rfc-editor.org/rfc/rfc7232#section-2.3
etag = etag[2:]
if etag[0] == '"' and etag[-1] == '"':
etag = etag[1:-1]
remote_md5 = etag
local_md5_hex = payload.md5_checksum().hexdigest()
if local_md5_hex != remote_md5:
raise ExecutionError(
f"Local data and remote data checksum mismatch ({local_md5_hex} vs {remote_md5})"
)
return remote_md5
async def perform_multipart_upload(
data_file: Union[BinaryIO, io.BytesIO, io.FileIO],
*,
content_length: int,
max_part_size: int,
part_urls: List[str],
completion_url: str,
filename: str,
upload_chunk_size: int = DEFAULT_SEGMENT_CHUNK_SIZE,
):
upload_coros = []
file_offset = 0
num_bytes_left = content_length
# Give each part its own IO reader object to avoid needing to
# lock access to the reader's position pointer.
data_file_readers: List[BinaryIO]
if isinstance(data_file, io.BytesIO):
view = data_file.getbuffer() # does not copy data
data_file_readers = [io.BytesIO(view) for _ in range(len(part_urls))]
else:
filename = data_file.name
data_file_readers = [open(filename, "rb") for _ in range(len(part_urls))]
for part_number, (data_file_rdr, part_url) in enumerate(zip(data_file_readers, part_urls), start=1):
part_length_bytes = min(num_bytes_left, max_part_size)
part_payload = BytesIOSegmentPayload(
data_file_rdr,
segment_start=file_offset,
segment_length=part_length_bytes,
chunk_size=upload_chunk_size,
filename=filename,
)
upload_coros.append(_upload_to_s3_url(part_url, payload=part_payload, content_type=None))
num_bytes_left -= part_length_bytes
file_offset += part_length_bytes
part_etags = await asyncio.gather(*upload_coros)
# The body of the complete_multipart_upload command needs some data in xml format:
completion_body = "<CompleteMultipartUpload>\n"
for part_number, etag in enumerate(part_etags, 1):
completion_body += f"""<Part>\n<PartNumber>{part_number}</PartNumber>\n<ETag>"{etag}"</ETag>\n</Part>\n"""
completion_body += "</CompleteMultipartUpload>"
# etag of combined object should be md5 hex of concatendated md5 *bytes* from parts + `-{num_parts}`
bin_hash_parts = [bytes.fromhex(etag) for etag in part_etags]
expected_multipart_etag = hashlib.md5(b"".join(bin_hash_parts)).hexdigest() + f"-{len(part_etags)}"
async with http_client_with_tls(timeout=None) as session:
resp = await session.post(
completion_url, data=completion_body.encode("ascii"), skip_auto_headers=["content-type"]
)
if resp.status != 200:
try:
msg = await resp.text()
except Exception:
msg = "<no body>"
raise ExecutionError(f"Error when completing multipart upload: {resp.status}\n{msg}")
else:
response_body = await resp.text()
if expected_multipart_etag not in response_body:
raise ExecutionError(
f"Hash mismatch on multipart upload assembly: {expected_multipart_etag} not in {response_body}"
)
def get_content_length(data: BinaryIO):
# *Remaining* length of file from current seek position
pos = data.tell()
data.seek(0, os.SEEK_END)
content_length = data.tell()
data.seek(pos)
return content_length - pos
@retry(n_attempts=5, base_delay=0.1, timeout=None)
async def _download_from_url(download_url) -> bytes:
async with http_client_with_tls(timeout=None) as session:
async with session.get(download_url) as resp:
# S3 signal to slow down request rate.
if resp.status == 503:
logger.warning("Received SlowDown signal from S3, sleeping for 1 second before retrying.")
await asyncio.sleep(1)
if resp.status != 200:
text = await resp.text()
raise ExecutionError(f"Get from url failed with status {resp.status}: {text}")
return await resp.read()
@dataclasses.dataclass
class FileUploadSpec:
source: Callable[[], BinaryIO]
source_description: Any
mount_filename: str
use_blob: bool
content: Optional[bytes] # typically None if using blob, required otherwise
sha256_hex: str
mode: int # file permission bits (last 12 bits of st_mode)
size: int
def _get_file_upload_spec(
source: Callable[[], BinaryIO], source_description: Any, mount_filename: PurePosixPath, mode: int
) -> FileUploadSpec:
with source() as fp:
# Current position is ignored - we always upload from position 0
fp.seek(0, os.SEEK_END)
size = fp.tell()
fp.seek(0)
if size >= LARGE_FILE_LIMIT:
use_blob = True
content = None
sha256_hex = get_sha256_hex(fp)
else:
use_blob = False
content = fp.read()
sha256_hex = get_sha256_hex(content)
return FileUploadSpec(
source=source,
source_description=source_description,
mount_filename=mount_filename.as_posix(),
use_blob=use_blob,
content=content,
sha256_hex=sha256_hex,
mode=mode & 0o7777,
size=size,
)
def get_file_upload_spec_from_path(
filename: Path, mount_filename: PurePosixPath, mode: Optional[int] = None
) -> FileUploadSpec:
# Python appears to give files 0o666 bits on Windows (equal for user, group, and global),
# so we mask those out to 0o755 for compatibility with POSIX-based permissions.
mode = mode or os.stat(filename).st_mode & (0o7777 if platform.system() != "Windows" else 0o7755)
return _get_file_upload_spec(
lambda: open(filename, "rb"),
filename,
mount_filename,
mode,
)
def get_file_upload_spec_from_fileobj(fp: BinaryIO, mount_filename: PurePosixPath, mode: int) -> FileUploadSpec:
def source():
# We ignore position in stream and always upload from position 0
fp.seek(0)
return fp
return _get_file_upload_spec(
source,
str(fp),
mount_filename,
mode,
)
def use_md5(url: str) -> bool:
"""This takes an upload URL in S3 and returns whether we should attach a checksum.
It's only a workaround for missing functionality in moto.
https://github.com/spulec/moto/issues/816
"""
host = urlparse(url).netloc.split(":")[0]
if host.endswith(".amazonaws.com"):
return True
elif host in ["127.0.0.1", "localhost", "172.21.0.1"]:
return False
else:
raise Exception(f"Unknown S3 host: {host}")
+57
View File
@@ -0,0 +1,57 @@
import base64
import dataclasses
import hashlib
from typing import IO, Union
HASH_CHUNK_SIZE = 4096
def _update(hashers, data: Union[bytes, IO[bytes]]):
if isinstance(data, bytes):
for hasher in hashers:
hasher.update(data)
else:
pos = data.tell()
while 1:
chunk = data.read(HASH_CHUNK_SIZE)
if not isinstance(chunk, bytes):
raise ValueError(f"Only accepts bytes or byte buffer objects, not {type(chunk)} buffers")
if not chunk:
break
for hasher in hashers:
hasher.update(chunk)
data.seek(pos)
def get_sha256_hex(data: Union[bytes, IO[bytes]]) -> str:
hasher = hashlib.sha256()
_update([hasher], data)
return hasher.hexdigest()
def get_sha256_base64(data: Union[bytes, IO[bytes]]) -> str:
hasher = hashlib.sha256()
_update([hasher], data)
return base64.b64encode(hasher.digest()).decode("ascii")
def get_md5_base64(data: Union[bytes, IO[bytes]]) -> str:
hasher = hashlib.md5()
_update([hasher], data)
return base64.b64encode(hasher.digest()).decode("utf-8")
@dataclasses.dataclass
class UploadHashes:
md5_base64: str
sha256_base64: str
def get_upload_hashes(data: Union[bytes, IO[bytes]]) -> UploadHashes:
md5 = hashlib.md5()
sha256 = hashlib.sha256()
_update([md5, sha256], data)
return UploadHashes(
md5_base64=base64.b64encode(md5.digest()).decode("ascii"),
sha256_base64=base64.b64encode(sha256.digest()).decode("ascii"),
)
+128
View File
@@ -0,0 +1,128 @@
import asyncio
import contextlib
import certifi
import socket
import ssl
import time
import uuid
from aiohttp import ClientSession, ClientTimeout, TCPConnector
from aiohttp.web import Application
from aiohttp.web_runner import AppRunner, SockSite
from grpclib import GRPCError
from grpclib import GRPCError, Status
from grpclib.exceptions import StreamTerminatedError
from typing import (
Any,
AsyncIterator,
Dict,
List,
Optional,
Type,
TypeVar,
)
RETRYABLE_GRPC_STATUS_CODES = [
Status.DEADLINE_EXCEEDED,
Status.UNAVAILABLE,
Status.CANCELLED,
Status.INTERNAL,
]
def http_client_with_tls(timeout: Optional[float]) -> ClientSession:
"""Create a new HTTP client session with standard, bundled TLS certificates.
This is necessary to prevent client issues on some system where Python does
not come pre-installed with specific TLS certificates that are necessary to
connect to AWS S3 bucket URLs.
Specifically: the error "unable to get local issuer certificate" when making
an aiohttp request.
"""
ssl_context = ssl.create_default_context(cafile=certifi.where())
connector = TCPConnector(ssl=ssl_context)
return ClientSession(connector=connector, timeout=ClientTimeout(total=timeout))
@contextlib.asynccontextmanager
async def run_temporary_http_server(app: Application):
# Allocates a random port, runs a server in a context manager
# This is used in various tests
sock = socket.socket()
sock.bind(("", 0))
port = sock.getsockname()[1]
host = f"http://127.0.0.1:{port}"
runner = AppRunner(app)
await runner.setup()
site = SockSite(runner, sock=sock)
await site.start()
try:
yield host
finally:
await runner.cleanup()
async def retry_transient_errors(
fn,
*args,
base_delay: float = 0.1,
max_delay: float = 1,
delay_factor: float = 2,
max_retries: Optional[int] = 3,
additional_status_codes: list = [],
attempt_timeout: Optional[float] = None, # timeout for each attempt
total_timeout: Optional[float] = None, # timeout for the entire function call
attempt_timeout_floor=2.0, # always have at least this much timeout (only for total_timeout)
):
"""Retry on transient gRPC failures with back-off until max_retries is reached.
If max_retries is None, retry forever."""
delay = base_delay
n_retries = 0
status_codes = [*RETRYABLE_GRPC_STATUS_CODES, *additional_status_codes]
idempotency_key = str(uuid.uuid4())
t0 = time.time()
if total_timeout is not None:
total_deadline = t0 + total_timeout
else:
total_deadline = None
while True:
metadata = [("x-idempotency-key", idempotency_key), ("x-retry-attempt", str(n_retries))]
if n_retries > 0:
metadata.append(("x-retry-delay", str(time.time() - t0)))
timeouts = []
if attempt_timeout is not None:
timeouts.append(attempt_timeout)
if total_timeout is not None:
timeouts.append(max(total_deadline - time.time(), attempt_timeout_floor))
if timeouts:
timeout = min(timeouts) # In case the function provided both types of timeouts
else:
timeout = None
try:
return await fn(*args, metadata=metadata, timeout=timeout)
except (StreamTerminatedError, GRPCError, socket.gaierror, asyncio.TimeoutError) as exc:
if isinstance(exc, GRPCError) and exc.status not in status_codes:
raise exc
if max_retries is not None and n_retries >= max_retries:
raise exc
if total_deadline and time.time() + delay + attempt_timeout_floor >= total_deadline:
# no point sleeping if that's going to push us past the deadline
raise exc
n_retries += 1
await asyncio.sleep(delay)
delay = min(delay * delay_factor, max_delay)
+81
View File
@@ -0,0 +1,81 @@
import asyncio
import concurrent.futures
import functools
import inspect
import sys
import time
import typing
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator, Callable, Iterator, List, Optional, Set, TypeVar, cast
import synchronicity
from typing_extensions import ParamSpec
synchronizer = synchronicity.Synchronizer()
def synchronize_api(obj, target_module=None):
if inspect.isclass(obj):
blocking_name = obj.__name__.lstrip("_")
elif inspect.isfunction(object):
blocking_name = obj.__name__.lstrip("_")
elif isinstance(obj, TypeVar):
blocking_name = "_BLOCKING_" + obj.__name__
else:
blocking_name = None
if target_module is None:
target_module = obj.__module__
return synchronizer.create_blocking(obj, blocking_name, target_module=target_module)
def retry(direct_fn=None, *, n_attempts=3, base_delay=0, delay_factor=2, timeout=90):
"""Decorator that calls an async function multiple times, with a given timeout.
If a `base_delay` is provided, the function is given an exponentially
increasing delay on each run, up until the maximum number of attempts.
Usage:
```
@retry
async def may_fail_default():
# ...
pass
@retry(n_attempts=5, base_delay=1)
async def may_fail_delay():
# ...
pass
```
"""
def decorator(fn):
@functools.wraps(fn)
async def f_wrapped(*args, **kwargs):
delay = base_delay
for i in range(n_attempts):
t0 = time.time()
try:
return await asyncio.wait_for(fn(*args, **kwargs), timeout=timeout)
except asyncio.CancelledError:
logger.debug(f"Function {fn} was cancelled")
raise
except Exception as e:
if i >= n_attempts - 1:
raise
logger.debug(
f"Failed invoking function {fn}: {e}"
f" (took {time.time() - t0}s, sleeping {delay}s"
f" and trying {n_attempts - i - 1} more times)"
)
await asyncio.sleep(delay)
delay *= delay_factor
return f_wrapped
if direct_fn is not None:
# It's invoked like @retry
return decorator(direct_fn)
else:
# It's invoked like @retry(n_attempts=...)
return decorator
+144
View File
@@ -0,0 +1,144 @@
import io
import copy
import os
import base64
from aiohttp import web
import asyncio
import concurrent.futures
import time
from pathlib import Path, PurePosixPath
from typing import (
IO,
AsyncGenerator,
AsyncIterator,
BinaryIO,
Callable,
Generator,
List,
Optional,
Sequence,
Type,
Union,
Any,
)
import aiostream
from .sync import synchronize_api
from .blob import (
get_file_upload_spec_from_fileobj,
get_file_upload_spec_from_path,
)
class _VolumeUploadContextManager:
"""Context manager for batch-uploading files to a Volume."""
_volume_id: str
_force: bool
def __init__(self, volume_id: str, file_specs, force: bool = False):
"""mdmd:hidden"""
self._volume_id = volume_id
self._client = None #client
self._upload_generators = []
self._force = force
self.files_stream = file_specs
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
if not exc_val:
# Flatten all the uploads yielded by the upload generators in the batch
def gen_upload_providers():
for gen in self._upload_generators:
yield from gen
async def gen_file_upload_specs(): # -> AsyncGenerator[FileUploadSpec, None]:
loop = asyncio.get_event_loop()
with concurrent.futures.ThreadPoolExecutor() as exe:
# TODO: avoid eagerly expanding
futs = [loop.run_in_executor(exe, f) for f in gen_upload_providers()]
for fut in asyncio.as_completed(futs):
yield await fut
async def generate_specs(self):
# Flatten all the uploads yielded by the upload generators in the batch
def gen_upload_providers():
for gen in self._upload_generators:
yield from gen
async def gen_file_upload_specs(): # -> AsyncGenerator[FileUploadSpec, None]:
loop = asyncio.get_event_loop()
with concurrent.futures.ThreadPoolExecutor() as exe:
# TODO: avoid eagerly expanding
futs = [loop.run_in_executor(exe, f) for f in gen_upload_providers()]
for fut in asyncio.as_completed(futs):
yield await fut
files_stream = aiostream.stream.iterate(gen_file_upload_specs())
self.files_stream = files_stream
return files_stream
def put_file(
self,
local_file: Union[Path, str, BinaryIO],
remote_path: Union[PurePosixPath, str],
mode: Optional[int] = None,
):
"""Upload a file from a local file or file-like object.
Will create any needed parent directories automatically.
If `local_file` is a file-like object it must remain readable for the lifetime of the batch.
"""
remote_path = PurePosixPath(remote_path).as_posix()
if remote_path.endswith("/"):
raise ValueError(f"remote_path ({remote_path}) must refer to a file - cannot end with /")
def gen():
if isinstance(local_file, str) or isinstance(local_file, Path):
yield lambda: get_file_upload_spec_from_path(local_file, PurePosixPath(remote_path), mode)
else:
yield lambda: get_file_upload_spec_from_fileobj(local_file, PurePosixPath(remote_path), mode or 0o644)
self._upload_generators.append(gen())
def get_files_stream(self):
return self.files_stream
def put_directory(
self,
local_path: Union[Path, str],
remote_path: Union[PurePosixPath, str],
recursive: bool = True,
):
"""
Upload all files in a local directory.
Will create any needed parent directories automatically.
"""
local_path = Path(local_path)
assert local_path.is_dir()
remote_path = PurePosixPath(remote_path)
def create_file_spec_provider(subpath):
relpath_str = subpath.relative_to(local_path)
return lambda: get_file_upload_spec_from_path(subpath, remote_path / relpath_str)
def gen():
glob = local_path.rglob("*") if recursive else local_path.glob("*")
for subpath in glob:
# Skip directories and unsupported file types (e.g. block devices)
if subpath.is_file():
yield create_file_spec_provider(subpath)
self._upload_generators.append(gen())
VolumeUploadContextManager = synchronize_api(_VolumeUploadContextManager)