Skip to content

Commit 88bb5a1

Browse files
committed
Use tarred assets on the remote s3 bucket. This simplifies lazy asset loading. Add script to generate tarballs and checksum files
1 parent c3de490 commit 88bb5a1

3 files changed

Lines changed: 152 additions & 239 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#!/bin/bash
2+
# Package a lazily-loaded asset directory (see
3+
# src/flygym/utils/assets_lazy_loading.py) into the <name>.tar + <name>.checksum
4+
# pair expected on the S3 bucket. The two output files are written next to the
5+
# input folder, named after it, ready to be uploaded as-is.
6+
#
7+
# Usage: scripts/dev/make_tar_for_lazy_loaded_assets.sh <path/to/asset_dir>
8+
set -e
9+
10+
if [ $# -ne 1 ]; then
11+
echo "Usage: $0 <path/to/asset_dir>" >&2
12+
exit 1
13+
fi
14+
15+
src_dir="$1"
16+
if [ ! -d "$src_dir" ]; then
17+
echo "Error: '$src_dir' is not a directory." >&2
18+
exit 1
19+
fi
20+
21+
# Strip a trailing slash so basename gives the asset's name, not "".
22+
src_dir="${src_dir%/}"
23+
name="$(basename "$src_dir")"
24+
out_dir="$(dirname "$src_dir")"
25+
tar_path="$out_dir/$name.tar"
26+
checksum_path="$out_dir/$name.checksum"
27+
28+
# Archive the directory's *contents*, not the directory itself, so archive
29+
# members are bare file names (lazy_load_asset_dir extracts straight into the
30+
# cache dir and expects files there directly, e.g. `mesh_dir / "a.stl"`).
31+
tar -cf "$tar_path" -C "$src_dir" .
32+
sha256sum "$tar_path" | cut -d' ' -f1 > "$checksum_path"
33+
34+
echo "Wrote $tar_path ($(du -h "$tar_path" | cut -f1)) and $checksum_path ($(cat "$checksum_path"))"

src/flygym/utils/assets_lazy_loading.py

Lines changed: 83 additions & 161 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,29 @@
11
"""Lazy download of large asset files from a public S3 bucket.
22
3-
Most asset files (configs, poses, and the simplified default NeuroMechFly meshes)
4-
are small enough to ship inside the ``flygym`` package. The high-resolution
5-
``fullsize`` meshes -- especially the FlyBody ``.obj`` meshes, which are an order
6-
of magnitude larger than everything else combined -- would bloat the package and
7-
the git repository, so they are hosted on an institution-managed S3 bucket and
8-
pulled in *the first time they are needed*, similar to how PyTorch downloads
9-
pretrained weights.
10-
11-
Downloaded files are cached on disk (see :func:`get_cache_root`) so the download
12-
happens only once per machine. The bucket is public and served over a standard
13-
S3-compatible HTTP endpoint, so plain ``urllib`` is enough -- no extra
14-
dependencies (boto3 etc.) are required.
15-
16-
The bucket stores each remotely hosted asset directory as a flat, *versioned*
17-
sub-prefix of :data:`S3_ROOT_PREFIX`, so future revisions can be uploaded under a
18-
new name without disturbing existing releases. Bump the version constants below to
19-
point a release at a new version. Example:
20-
21-
bucket: flygym_assets/neuromechfly_fullsize_meshes_20260623a/<file>
22-
cache: ~/.cache/flygym_assets/neuromechfly_fullsize_meshes_20260623a/<file>
3+
The high-resolution `fullsize` meshes are too large to ship inside the
4+
`flygym` package, so they are hosted on a public S3 bucket and downloaded the
5+
first time they are needed, then cached on disk (see :func:`get_cache_root`).
6+
The bucket is served over plain HTTP(S), so `urllib` is enough -- no boto3.
7+
8+
Each asset directory lives on the bucket as a `<name>.tar` archive plus a
9+
`<name>.checksum` sidecar holding the tar's sha256 hex digest, both generated
10+
by `scripts/dev/make_tar_for_lazy_loaded_assets.sh`. Names are versioned
11+
(e.g. `neuromechfly_fullsize_meshes_20260623a`) so new revisions can be
12+
uploaded without disturbing existing releases; bump the version constants in
13+
the fly model modules to point a release at a new asset set.
2314
"""
2415

2516
import hashlib
2617
import os
27-
import shutil
18+
import tarfile
2819
import tempfile
2920
from pathlib import Path
3021
from urllib.parse import quote
3122
from urllib.request import urlopen
32-
from xml.etree import ElementTree
3323

3424
from loguru import logger
3525

36-
__all__ = ["get_cache_root", "lazy_load_asset_dir", "prefetch_meshes"]
26+
__all__ = ["get_cache_root", "lazy_load_asset_dir", "download_all_assets"]
3727

3828
#: Base HTTP(S) endpoint of the S3-compatible object store.
3929
S3_ENDPOINT = "https://datasets.epfl.ch"
@@ -42,29 +32,26 @@
4232
#: Top-level key prefix within the bucket under which all assets live.
4333
S3_ROOT_PREFIX = "flygym_assets"
4434

45-
46-
# S3 ListObjectsV2 responses are namespaced; this is the namespace MinIO/S3 use.
47-
_S3_XML_NS = {"s3": "http://s3.amazonaws.com/doc/2006-03-01/"}
35+
#: Read/write files in chunks of this size while streaming a download.
36+
_CHUNK_SIZE = 1024 * 1024
37+
#: How many times to (re)try a download before giving up. A dropped connection
38+
#: yields a truncated tar that fails the checksum; the endpoint is flaky enough
39+
#: that a single such failure shouldn't abort the whole run.
40+
_MAX_ATTEMPTS = 3
41+
#: Per-request timeout (seconds). Bounds how long a stalled connection can hang
42+
#: before it errors out and the attempt is retried, rather than blocking forever.
43+
_TIMEOUT = 30
4844

4945

5046
def get_cache_root() -> Path:
51-
"""Return the directory under which downloaded assets are cached.
52-
53-
Resolution order:
54-
55-
1. ``$FLYGYM_ASSET_CACHE_DIR`` if set (useful for CI caching or shared,
56-
read-only installs);
57-
2. ``$XDG_CACHE_HOME/flygym_assets`` if ``XDG_CACHE_HOME`` is set;
58-
3. ``~/.cache/flygym_assets`` otherwise.
59-
60-
The directory is named ``flygym_assets`` to match the bucket's top-level
61-
prefix (:data:`S3_ROOT_PREFIX`).
47+
"""Return the directory under which downloaded assets are cached:
48+
`$FLYGYM_ASSET_CACHE_DIR` if set (useful for CI caching), else
49+
`$XDG_CACHE_HOME/flygym_assets`, else `~/.cache/flygym_assets`.
6250
"""
6351
env = os.environ.get("FLYGYM_ASSET_CACHE_DIR")
6452
if env:
6553
return Path(env).expanduser()
66-
# Per the XDG Base Directory spec, a relative XDG_CACHE_HOME is invalid and
67-
# must be ignored (as is an unset/empty value).
54+
# Per the XDG spec, a relative XDG_CACHE_HOME is invalid and must be ignored.
6855
xdg = os.environ.get("XDG_CACHE_HOME")
6956
if xdg and os.path.isabs(xdg):
7057
return Path(xdg) / S3_ROOT_PREFIX
@@ -76,150 +63,85 @@ def _object_url(key: str) -> str:
7663
return f"{S3_ENDPOINT}/{S3_BUCKET}/{quote(key)}"
7764

7865

79-
def _list_s3_prefix(prefix: str) -> list[dict]:
80-
"""List every object under ``prefix`` via the public ListObjectsV2 API.
81-
82-
Returns a list of ``{"key", "size", "etag"}`` dicts. Handles pagination via
83-
continuation tokens. The bucket is public, so the request is unsigned.
84-
"""
85-
if not prefix.endswith("/"):
86-
prefix += "/"
87-
objects: list[dict] = []
88-
continuation_token: str | None = None
89-
while True:
90-
url = f"{S3_ENDPOINT}/{S3_BUCKET}?list-type=2&prefix={quote(prefix, safe='')}"
91-
if continuation_token is not None:
92-
url += f"&continuation-token={quote(continuation_token, safe='')}"
93-
with urlopen(url) as response:
94-
tree = ElementTree.fromstring(response.read())
95-
for contents in tree.findall("s3:Contents", _S3_XML_NS):
96-
key = contents.findtext("s3:Key", namespaces=_S3_XML_NS)
97-
if key is None or key.endswith("/"):
98-
continue # skip "directory" placeholder keys
99-
size = int(contents.findtext("s3:Size", default="0", namespaces=_S3_XML_NS))
100-
etag = contents.findtext("s3:ETag", default="", namespaces=_S3_XML_NS)
101-
objects.append({"key": key, "size": size, "etag": etag.strip('"')})
102-
is_truncated = (
103-
tree.findtext("s3:IsTruncated", default="false", namespaces=_S3_XML_NS)
104-
== "true"
105-
)
106-
if not is_truncated:
107-
break
108-
continuation_token = tree.findtext(
109-
"s3:NextContinuationToken", namespaces=_S3_XML_NS
110-
)
111-
if not continuation_token:
112-
break
113-
return objects
114-
115-
116-
def _is_up_to_date(path: Path, size: int, etag: str) -> bool:
117-
"""Return True if ``path`` already holds the object described by (size, etag).
118-
119-
For non-multipart uploads the S3 ETag is the MD5 hex digest of the content,
120-
which we verify. Multipart ETags contain a ``-`` and are not plain MD5, so we
121-
fall back to a size check for those.
66+
def _download_tar(name: str, dest: Path) -> None:
67+
"""Download `<name>.tar` to `dest` and verify it against `<name>.checksum`,
68+
retrying on transient network errors and truncated (checksum-mismatched)
69+
downloads.
12270
"""
123-
if not path.is_file():
124-
return False
125-
if path.stat().st_size != size:
126-
return False
127-
if etag and "-" not in etag:
128-
digest = hashlib.md5(path.read_bytes()).hexdigest()
129-
return digest == etag
130-
return True
131-
132-
133-
def _download_object(key: str, dest: Path, size: int, etag: str) -> None:
134-
"""Download a single object to ``dest`` atomically."""
135-
dest.parent.mkdir(parents=True, exist_ok=True)
136-
fd, tmp_name = tempfile.mkstemp(dir=dest.parent, suffix=".part")
137-
tmp_path = Path(tmp_name)
138-
try:
139-
with os.fdopen(fd, "wb") as out, urlopen(_object_url(key)) as response:
140-
shutil.copyfileobj(response, out)
141-
if not _is_up_to_date(tmp_path, size, etag):
142-
raise OSError(
143-
f"Downloaded asset failed integrity check: {key} "
144-
f"(expected {size} bytes, etag {etag!r})"
145-
)
146-
tmp_path.replace(dest)
147-
finally:
148-
tmp_path.unlink(missing_ok=True)
149-
71+
checksum_url = _object_url(f"{S3_ROOT_PREFIX}/{name}.checksum")
72+
with urlopen(checksum_url, timeout=_TIMEOUT) as response:
73+
expected = response.read().decode().split()[0]
15074

151-
def _download_prefix(s3_prefix: str, dest_dir: Path) -> Path:
152-
"""Download every object under ``s3_prefix`` into ``dest_dir`` (skipping files
153-
that are already present and up to date). Returns ``dest_dir``.
154-
"""
155-
objects = _list_s3_prefix(s3_prefix)
156-
if not objects:
157-
raise FileNotFoundError(
158-
f"No assets found on S3 under prefix '{s3_prefix}'. The bucket may be "
159-
"unreachable or the asset may have been moved."
160-
)
161-
prefix = s3_prefix if s3_prefix.endswith("/") else s3_prefix + "/"
162-
pending = []
163-
for obj in objects:
164-
rel_key = obj["key"][len(prefix) :]
165-
dest = dest_dir / rel_key
166-
if not _is_up_to_date(dest, obj["size"], obj["etag"]):
167-
pending.append((obj, dest))
168-
169-
if pending:
170-
total_mb = sum(obj["size"] for obj, _ in pending) / 1e6
171-
logger.info(
172-
f"Downloading {len(pending)} FlyGym asset file(s) "
173-
f"({total_mb:.1f} MB) from S3 to {dest_dir} (one-time download)..."
174-
)
175-
for obj, dest in pending:
176-
_download_object(obj["key"], dest, obj["size"], obj["etag"])
177-
logger.info("Finished downloading FlyGym assets.")
178-
return dest_dir
75+
tar_url = _object_url(f"{S3_ROOT_PREFIX}/{name}.tar")
76+
for attempt in range(1, _MAX_ATTEMPTS + 1):
77+
digest = hashlib.sha256()
78+
try:
79+
with (
80+
urlopen(tar_url, timeout=_TIMEOUT) as response,
81+
open(dest, "wb") as out,
82+
):
83+
while chunk := response.read(_CHUNK_SIZE):
84+
digest.update(chunk)
85+
out.write(chunk)
86+
except OSError as e:
87+
reason = f"download failed ({e})"
88+
else:
89+
if digest.hexdigest() == expected:
90+
return
91+
reason = (
92+
f"integrity check failed "
93+
f"(expected sha256 {expected}, got {digest.hexdigest()})"
94+
)
95+
dest.unlink(missing_ok=True)
96+
if attempt == _MAX_ATTEMPTS:
97+
raise OSError(f"Could not download {name}.tar: {reason}")
98+
logger.warning(f"Retrying {name}.tar ({attempt}/{_MAX_ATTEMPTS}): {reason}")
17999

180100

181101
def lazy_load_asset_dir(rel_path: os.PathLike | str) -> Path:
182102
"""Return the absolute local path to a bucket asset directory, downloading it
183103
from S3 on first use.
184104
185105
Args:
186-
rel_path: Path of the directory within the bucket, relative to
187-
:data:`S3_ROOT_PREFIX` (e.g. ``"neuromechfly_fullsize_meshes_20260623a"``,
188-
as defined by each fly model's ``*_FULLSIZE_MESH_DIR`` constant).
189-
190-
The directory is cached under :func:`get_cache_root` keyed by ``rel_path``. If
191-
the cached copy already exists it is returned as-is (no network access);
192-
otherwise the whole directory is downloaded into a temporary location and moved
193-
into place atomically, so an interrupted or concurrent download never leaves a
194-
partial cache.
195-
196-
Raises:
197-
FileNotFoundError: If ``rel_path`` does not exist in the bucket.
106+
rel_path: Name of the asset set on the bucket, i.e. the shared stem of
107+
`<rel_path>.tar` and `<rel_path>.checksum` under
108+
:data:`S3_ROOT_PREFIX` (as defined by each fly model's
109+
`*_MESH_DIR` constant).
110+
111+
If the cached copy already exists it is returned as-is (no network access).
112+
Otherwise the tar is downloaded, verified, and extracted inside a temporary
113+
directory that is moved into place atomically, so an interrupted or
114+
concurrent download never leaves a partial cache.
198115
"""
199-
rel_path = Path(rel_path)
200-
cache_dir = get_cache_root() / rel_path
116+
name = Path(rel_path).as_posix()
117+
cache_dir = get_cache_root() / name
201118
if cache_dir.is_dir():
202119
return cache_dir
203120

121+
logger.info(f"Downloading FlyGym asset '{name}' from S3 (one-time download)...")
204122
cache_dir.parent.mkdir(parents=True, exist_ok=True)
205-
staging = Path(tempfile.mkdtemp(dir=cache_dir.parent, suffix=".partial"))
206-
try:
207-
_download_prefix(f"{S3_ROOT_PREFIX}/{rel_path.as_posix()}", staging)
123+
with tempfile.TemporaryDirectory(
124+
dir=cache_dir.parent, suffix=".partial"
125+
) as staging:
126+
staging = Path(staging)
127+
_download_tar(name, staging / "asset.tar")
128+
extracted = staging / "extracted"
129+
with tarfile.open(staging / "asset.tar") as tar:
130+
tar.extractall(extracted, filter="data")
208131
try:
209-
staging.replace(cache_dir)
132+
extracted.replace(cache_dir)
210133
except OSError:
211134
# Another process finished downloading the same asset while we were
212-
# working: os.replace cannot move onto the now-populated directory.
213-
# Their copy is equivalent to ours, so use it instead of failing.
135+
# working, so cache_dir is now populated and cannot be replaced.
136+
# Their copy is equivalent to ours: use it instead of failing.
214137
if not cache_dir.is_dir():
215138
raise
216-
finally:
217-
shutil.rmtree(staging, ignore_errors=True)
139+
logger.info(f"Finished downloading FlyGym asset '{name}'.")
218140
return cache_dir
219141

220142

221-
def prefetch_meshes() -> list[Path]:
222-
"""Eagerly download all remotely hosted meshes into the cache.
143+
def download_all_assets() -> list[Path]:
144+
"""Eagerly download all remotely hosted assets into the cache.
223145
224146
Useful for warming a CI cache or preparing an offline environment. Returns the
225147
list of local directories that now hold the assets.

0 commit comments

Comments
 (0)