Skip to content

Commit 88547a7

Browse files
authored
cache the fallbackfs access to remote fs (#24)
* cache the fallbackfs access to remote fs * fix tests and types
1 parent 792fe4a commit 88547a7

8 files changed

Lines changed: 988 additions & 19 deletions

File tree

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the terms described in the LICENSE file in
5+
# the root directory of this source tree.
6+
7+
8+
import logging
9+
import os
10+
from typing import Any
11+
12+
from fsspec import AbstractFileSystem
13+
14+
from are.simulation.apps.utils.remote_fs_cache import get_remote_fs_cache
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
class CachedRemoteFileSystem(AbstractFileSystem):
20+
"""
21+
A filesystem wrapper that caches listings and stats for remote filesystems.
22+
23+
This filesystem wraps any fsspec filesystem and provides global caching
24+
for file listings and stats. It's designed to reduce API calls to remote
25+
filesystems (e.g., S3, HuggingFace) when running multiple scenarios that
26+
share the same remote path.
27+
28+
The cache is global and thread-safe, shared across all instances of this
29+
filesystem with the same remote URI.
30+
31+
Key Features:
32+
* Global caching of file listings (from find() calls)
33+
* Global caching of file stats (size, mode) loaded lazily
34+
* Thread-safe cache shared across all instances
35+
* Transparent pass-through for all other operations
36+
"""
37+
38+
def __init__(
39+
self,
40+
fs: AbstractFileSystem,
41+
remote_uri: str,
42+
**kwargs: Any,
43+
):
44+
"""
45+
Initialize the CachedRemoteFileSystem.
46+
47+
:param fs: The underlying remote filesystem to wrap
48+
:param remote_uri: The URI of the remote filesystem (for cache key)
49+
:param kwargs: Additional arguments to pass to AbstractFileSystem
50+
"""
51+
super().__init__(**kwargs)
52+
self.remote_uri = remote_uri
53+
self.cache = get_remote_fs_cache()
54+
55+
# Extract root path from URI
56+
# For "mock://remote" the root should be "/remote"
57+
if "://" in remote_uri:
58+
root = "/" + remote_uri.split("://", 1)[1]
59+
else:
60+
root = remote_uri if remote_uri.startswith("/") else "/" + remote_uri
61+
62+
# Pre-populate metadata cache on initialization
63+
# Pass the filesystem directly to avoid URL resolution
64+
self.cache.get_or_create_fs_entry(self.remote_uri, fs=fs, root=root)
65+
66+
# Get the shared cached filesystem from the global cache
67+
# This ensures all instances with the same remote_uri share
68+
# the same file cache, avoiding redundant downloads
69+
self.fs = self.cache.get_cached_filesystem(remote_uri)
70+
71+
logger.debug(f"Initialized CachedRemoteFileSystem for {remote_uri}")
72+
73+
def find(
74+
self,
75+
path: str,
76+
maxdepth: int | None = None,
77+
withdirs: bool = False,
78+
detail: bool = False,
79+
**kwargs: Any,
80+
) -> list[str] | list[dict[str, Any]]:
81+
"""
82+
List all files below path using cached data when possible.
83+
84+
:param path: The path to search from
85+
:param maxdepth: Maximum depth to search
86+
:param withdirs: Include directories in results
87+
:param detail: If True, return list of dicts with file info
88+
:param kwargs: Additional arguments
89+
:return: List of file paths or list of dicts if detail=True
90+
"""
91+
# Get cached data (doesn't call find() again if already cached)
92+
if self.remote_uri in self.cache._cache:
93+
entry = self.cache._cache[self.remote_uri]
94+
root = entry["root"]
95+
cached_files = entry["file_list"]
96+
97+
# Convert cached relative paths to absolute paths
98+
result = []
99+
for rel_path in cached_files:
100+
abs_path = os.path.join(root, rel_path.lstrip("/"))
101+
# Filter by path prefix if specified
102+
if abs_path.startswith(path):
103+
result.append(abs_path)
104+
105+
logger.debug(f"find() returned {len(result)} cached files for {path}")
106+
if detail:
107+
# Return detailed info for each file
108+
detailed_result: list[dict[str, Any]] = []
109+
for file_path in result:
110+
try:
111+
info = self.info(file_path)
112+
detailed_result.append(info)
113+
except Exception:
114+
pass
115+
return detailed_result
116+
return result
117+
else:
118+
# Fall back to underlying filesystem if not cached
119+
result_from_fs = self.fs.find(
120+
path, maxdepth=maxdepth, withdirs=withdirs, detail=detail, **kwargs
121+
)
122+
# Type narrowing for return value
123+
if isinstance(result_from_fs, dict):
124+
# If it's a dict, we need to handle it - but find() should return list
125+
return []
126+
return result_from_fs
127+
128+
def ls(
129+
self, path: str, detail: bool = True, **kwargs: Any
130+
) -> list[dict[str, Any]] | list[str]:
131+
"""
132+
List directory contents, with optional caching of stats.
133+
134+
:param path: Path to list
135+
:param detail: If True, return detailed information
136+
:param kwargs: Additional arguments
137+
:return: List of files/directories
138+
"""
139+
# Use the underlying filesystem for ls
140+
results = self.fs.ls(path, detail=detail, **kwargs)
141+
142+
if detail:
143+
# Cache stats for files
144+
for item in results:
145+
if isinstance(item, dict) and item.get("type") == "file":
146+
try:
147+
# Get relative path for caching
148+
_, root, _ = self.cache.get_or_create_fs_entry(self.remote_uri)
149+
rel_path = "/" + os.path.relpath(item["name"], root)
150+
151+
# Cache the stats
152+
self.cache.set_file_stats(
153+
self.remote_uri,
154+
rel_path,
155+
item.get("size", 0),
156+
item.get("mode", 0o644),
157+
)
158+
except Exception as e:
159+
logger.debug(f"Failed to cache stats for {item['name']}: {e}")
160+
161+
return results
162+
163+
def info(self, path: str, **kwargs: Any) -> dict[str, Any]:
164+
"""
165+
Get file info, using cached stats when available.
166+
167+
:param path: Path to get info for
168+
:param kwargs: Additional arguments
169+
:return: File info dictionary
170+
"""
171+
# Try to get cached stats first
172+
try:
173+
_, root, _ = self.cache.get_or_create_fs_entry(self.remote_uri)
174+
rel_path = "/" + os.path.relpath(path, root)
175+
cached_stats = self.cache.get_file_stats(self.remote_uri, rel_path)
176+
177+
if cached_stats:
178+
# We have cached stats, but we still need other metadata
179+
# Get full info from filesystem
180+
info = self.fs.info(path, **kwargs)
181+
182+
# Update with cached stats (avoid re-fetching if expensive)
183+
info["size"] = cached_stats["size"]
184+
info["mode"] = cached_stats["mode"]
185+
186+
logger.debug(f"Used cached stats for {path}")
187+
return info
188+
except Exception as e:
189+
logger.debug(f"Failed to use cached stats for {path}: {e}")
190+
191+
# Fall back to direct filesystem call
192+
info = self.fs.info(path, **kwargs)
193+
194+
# Cache the stats for future use
195+
try:
196+
_, root, _ = self.cache.get_or_create_fs_entry(self.remote_uri)
197+
rel_path = "/" + os.path.relpath(path, root)
198+
self.cache.set_file_stats(
199+
self.remote_uri, rel_path, info.get("size", 0), info.get("mode", 0o644)
200+
)
201+
except Exception as e:
202+
logger.debug(f"Failed to cache stats for {path}: {e}")
203+
204+
return info
205+
206+
# Proxy all other methods to the underlying filesystem
207+
def __getattr__(self, attr: str) -> Any:
208+
"""Proxy all other methods to the underlying filesystem."""
209+
return getattr(self.fs, attr)
210+
211+
def open(
212+
self,
213+
path: str,
214+
mode: str = "rb",
215+
block_size: int | None = None,
216+
cache_options: dict | None = None,
217+
compression: str | None = None,
218+
**kwargs: Any,
219+
) -> Any:
220+
"""
221+
Open a file.
222+
223+
:param path: Path to open
224+
:param mode: Mode to open in
225+
:param block_size: Block size for reading
226+
:param cache_options: Cache options
227+
:param compression: Compression codec
228+
:param kwargs: Additional arguments
229+
:return: File handle
230+
"""
231+
return self.fs.open(
232+
path,
233+
mode=mode,
234+
block_size=block_size,
235+
cache_options=cache_options,
236+
compression=compression,
237+
**kwargs,
238+
)

are/simulation/apps/utils/fallback_file_system.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,14 @@ def _get_fallback_stats_for_item(
117117
fallback_path
118118
) and self.fallback_fs.isfile(fallback_path):
119119
info = self.fallback_fs.info(fallback_path)
120+
120121
# Update the registry with actual stats
121122
self.fallback_stats[rel_path] = LoadedFallbackEntry(
122123
size=info["size"],
123124
mode=info.get("mode", DEFAULT_MODE),
124125
loaded=True,
125126
)
127+
126128
logger.debug(f"Lazy loaded stats for {rel_path}")
127129
else:
128130
# File doesn't exist in fallback, remove from registry
@@ -204,7 +206,22 @@ def set_fallback_root(
204206
would be overwritten by the fallback
205207
"""
206208
# Parse the fallback_root URI to get a filesystem and path
207-
self.fallback_fs, self.fallback_root = url_to_fs(fallback_root)
209+
raw_fs, self.fallback_root = url_to_fs(fallback_root)
210+
211+
# Wrap remote filesystems with CachedRemoteFileSystem for performance
212+
if "://" in fallback_root:
213+
# This is a remote filesystem - wrap it with caching
214+
from are.simulation.apps.utils.cached_remote_filesystem import (
215+
CachedRemoteFileSystem,
216+
)
217+
218+
self.fallback_fs = CachedRemoteFileSystem(raw_fs, fallback_root)
219+
logger.info(
220+
f"Wrapped remote filesystem {fallback_root} with CachedRemoteFileSystem"
221+
)
222+
else:
223+
# Local filesystem - use as-is
224+
self.fallback_fs = raw_fs
208225

209226
assert self.fallback_fs is not None
210227
assert self.fallback_root is not None
@@ -214,15 +231,20 @@ def set_fallback_root(
214231
expected_paths = set()
215232
# If fallback_root exists, scan it to find all potential paths
216233
if self.fallback_root and self.fallback_fs.exists(self.fallback_root):
217-
for path_info in self.fallback_fs.find(
234+
found_paths = self.fallback_fs.find(
218235
self.fallback_root, withdirs=True, detail=False
219-
):
220-
# Get the path relative to fallback_root
221-
rel_path = os.path.relpath(path_info, self.fallback_root)
222-
if rel_path == ".":
223-
continue
224-
rel_path = "/" + rel_path
225-
expected_paths.add(rel_path)
236+
)
237+
# Ensure we're working with a list of strings
238+
if isinstance(found_paths, list):
239+
for path_info in found_paths:
240+
# path_info should be a string when detail=False
241+
if isinstance(path_info, str):
242+
# Get the path relative to fallback_root
243+
rel_path = os.path.relpath(path_info, self.fallback_root)
244+
if rel_path == ".":
245+
continue
246+
rel_path = "/" + rel_path
247+
expected_paths.add(rel_path)
226248

227249
# Check for conflicts - non-empty files in the underlying filesystem
228250
# that would be overwritten by the fallback
@@ -276,7 +298,7 @@ def _create_placeholders_internal(self, expected_paths: set[str]) -> None:
276298
if self.fallback_root is None or self.fallback_fs is None:
277299
return
278300

279-
# Phase 1: Lightweight discovery - use find() to get all existing files in one call
301+
# Phase 1: Lightweight discovery - use find() to get all existing files
280302
try:
281303
all_paths = self.fallback_fs.find(
282304
self.fallback_root, withdirs=True, detail=False

0 commit comments

Comments
 (0)