fuse4dbricks 0.7.8__tar.gz → 0.7.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/CHANGELOG.md +18 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/PKG-INFO +1 -1
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/fuse4dbricks/fs/data_manager.py +21 -2
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/fuse4dbricks/main.py +9 -1
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/fuse4dbricks/storage/persistence.py +49 -3
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/pyproject.toml +1 -1
- fuse4dbricks-0.7.9/tests/sim_sequential_read.py +879 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/tests/test_data_manager.py +81 -3
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/tests/test_main.py +5 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/tests/test_persistence.py +78 -0
- fuse4dbricks-0.7.9/tests/test_sim_sequential_read.py +74 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/.env.example +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/.github/workflows/test.yml +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/.gitignore +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/README.md +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/fuse4dbricks/__init__.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/fuse4dbricks/api/__init__.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/fuse4dbricks/api/errors.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/fuse4dbricks/api/uc_client.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/fuse4dbricks/auth/__init__.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/fuse4dbricks/auth/provider.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/fuse4dbricks/fs/__init__.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/fuse4dbricks/fs/auth_manager.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/fuse4dbricks/fs/inode_manager.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/fuse4dbricks/fs/metadata_manager.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/fuse4dbricks/fs/operations.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/fuse4dbricks/fs/ram_cache.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/fuse4dbricks/fs/securable_filter.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/fuse4dbricks/fs/utils.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/fuse4dbricks/fs/write_buffer.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/fuse4dbricks/storage/__init__.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/tests/test_auth_manager.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/tests/test_auth_provider.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/tests/test_e2e_mount.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/tests/test_inode_manager.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/tests/test_metadata_manager.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/tests/test_operations.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/tests/test_securable_filter.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/tests/test_uc_client.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/tests/test_uc_client_consistency.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/tests/test_uc_client_live.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/tests/test_utils.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/tests/test_write_buffer.py +0 -0
- {fuse4dbricks-0.7.8 → fuse4dbricks-0.7.9}/uv.lock +0 -0
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
# 0.7.9 (2026-10-31)
|
|
2
|
+
|
|
3
|
+
- Fix prefetch re-queueing chunks that had already been downloaded. A
|
|
4
|
+
finished prefetch was forgotten, so every following read requested it
|
|
5
|
+
again until the reader reached it: up to 64 reads per 8 MiB chunk for
|
|
6
|
+
each chunk in the prefetch window. Each one woke a download worker and
|
|
7
|
+
checked the disk from a thread. Reading a large file sequentially was
|
|
8
|
+
CPU-bound, and raising `--prefetch_mb` made it slower. The disk cache
|
|
9
|
+
now keeps an in-memory index of the chunks it holds, and prefetch skips
|
|
10
|
+
them.
|
|
11
|
+
- Keep prefetched chunks in the RAM cache as well as on disk, so the read
|
|
12
|
+
that reaches a chunk no longer reads 8 MiB back from disk first, and
|
|
13
|
+
concurrent reads of the same chunk no longer each read it back.
|
|
14
|
+
Enabled by default; `--no-prefetch-to-ram` restores disk-only prefetch.
|
|
15
|
+
- Add `tests/sim_sequential_read.py`, a simulator that drives the real
|
|
16
|
+
`DataManager` against a bandwidth-limited fake Databricks and disk
|
|
17
|
+
cache. It reports where time goes in a large sequential read.
|
|
18
|
+
|
|
1
19
|
# 0.7.8 (2026-10-15)
|
|
2
20
|
|
|
3
21
|
- Fix typo in DataManager
|
|
@@ -116,12 +116,15 @@ class DownloadScheduler:
|
|
|
116
116
|
|
|
117
117
|
|
|
118
118
|
class DataManager:
|
|
119
|
-
def __init__(self, uc_client: UnityCatalogClient, persistence: DiskPersistence, ram_cache_mb = 512, num_workers=10, prefetch_mb=80):
|
|
119
|
+
def __init__(self, uc_client: UnityCatalogClient, persistence: DiskPersistence, ram_cache_mb = 512, num_workers=10, prefetch_mb=80, prefetch_to_ram: bool = True):
|
|
120
120
|
"""
|
|
121
121
|
uc_client: client to download chunks from UC
|
|
122
122
|
persistence: where to store downloaded chunks on disk
|
|
123
123
|
ram_cache_mb: how much RAM to use for caching chunks in memory (LRU eviction)
|
|
124
124
|
num_workers: how many concurrent downloads to allow
|
|
125
|
+
prefetch_to_ram: also keep freshly prefetched chunks in the RAM cache,
|
|
126
|
+
so the read that reaches them does not have to read them back from
|
|
127
|
+
disk (and concurrent reads of the same chunk do not each do so)
|
|
125
128
|
"""
|
|
126
129
|
self.uc_client = uc_client
|
|
127
130
|
self.persistence = persistence
|
|
@@ -146,6 +149,7 @@ class DataManager:
|
|
|
146
149
|
if chunks_to_prefetch < 0:
|
|
147
150
|
chunks_to_prefetch = 0
|
|
148
151
|
self._chunks_to_prefetch = chunks_to_prefetch
|
|
152
|
+
self._prefetch_to_ram = prefetch_to_ram
|
|
149
153
|
|
|
150
154
|
def close(self) -> None:
|
|
151
155
|
self._download_scheduler.close()
|
|
@@ -181,7 +185,14 @@ class DataManager:
|
|
|
181
185
|
# not even be read; a plain existence check is enough to
|
|
182
186
|
# decide whether a download is needed.
|
|
183
187
|
if not await self.persistence.chunk_exists(fs_path=chunk_request.fs_path, chunk_index=chunk_request.chunk_id, mtime=chunk_request.mtime, gen=chunk_request.gen):
|
|
184
|
-
await self._fetch_chunk(chunk_request)
|
|
188
|
+
chunk = await self._fetch_chunk(chunk_request)
|
|
189
|
+
# The bytes are already in memory (store_chunk_from_stream
|
|
190
|
+
# returns them), so keeping them costs no extra I/O or copy
|
|
191
|
+
# and saves the reader an 8 MiB read-back from disk. Chunks
|
|
192
|
+
# that were already on disk are NOT loaded: that read-back
|
|
193
|
+
# is exactly what the existence check avoids.
|
|
194
|
+
if self._prefetch_to_ram:
|
|
195
|
+
await self._ram_cache.put(cache_key, chunk)
|
|
185
196
|
finally:
|
|
186
197
|
await self._inflight_coalescer.notify_done(cache_key)
|
|
187
198
|
|
|
@@ -212,6 +223,14 @@ class DataManager:
|
|
|
212
223
|
|
|
213
224
|
async def _request_fetch_ahead_chunks(self, fs_path: str, chunks_to_prefetch: list[Tuple[int, int]], mtime: float, gen: int, ctx: pyfuse3.RequestContext):
|
|
214
225
|
for (chunk_id, chunk_size) in chunks_to_prefetch:
|
|
226
|
+
# Already on disk: nothing to prefetch. Without this check, a chunk
|
|
227
|
+
# that finished downloading (and so left the coalescer) was
|
|
228
|
+
# re-enqueued on every read until the reader reached it: 64 reads
|
|
229
|
+
# per 8 MiB chunk x up to N prefetch chunks, each costing a worker
|
|
230
|
+
# wake-up and a chunk_exists() thread hop. has_chunk() is a
|
|
231
|
+
# synchronous in-RAM lookup.
|
|
232
|
+
if self.persistence.has_chunk(fs_path, chunk_id, mtime, gen):
|
|
233
|
+
continue
|
|
215
234
|
cache_key = (fs_path, chunk_id, mtime, gen)
|
|
216
235
|
(wait_event, leader) = await self._inflight_coalescer.join_or_lead(cache_key)
|
|
217
236
|
if not leader:
|
|
@@ -101,6 +101,14 @@ def parse_args():
|
|
|
101
101
|
default=80,
|
|
102
102
|
help="Amount of data to prefetch in MB for each file",
|
|
103
103
|
)
|
|
104
|
+
parser.add_argument(
|
|
105
|
+
"--prefetch-to-ram",
|
|
106
|
+
action=argparse.BooleanOptionalAction,
|
|
107
|
+
default=True,
|
|
108
|
+
help="Keep prefetched chunks in the RAM cache as well as on disk, so "
|
|
109
|
+
"sequential reads do not read them back from disk "
|
|
110
|
+
"(--no-prefetch-to-ram: disk only)",
|
|
111
|
+
)
|
|
104
112
|
parser.add_argument(
|
|
105
113
|
"--num-download-workers",
|
|
106
114
|
type=int,
|
|
@@ -254,7 +262,7 @@ async def async_main():
|
|
|
254
262
|
persistence = DiskPersistence(data_cache_dir, max_size_gb=args.disk_cache_gb, max_age_days=args.disk_cache_max_days)
|
|
255
263
|
|
|
256
264
|
inode_manager = InodeManager()
|
|
257
|
-
data_manager = DataManager(uc_client, persistence, ram_cache_mb=args.ram_cache_mb, num_workers=args.num_download_workers, prefetch_mb=args.prefetch_mb)
|
|
265
|
+
data_manager = DataManager(uc_client, persistence, ram_cache_mb=args.ram_cache_mb, num_workers=args.num_download_workers, prefetch_mb=args.prefetch_mb, prefetch_to_ram=args.prefetch_to_ram)
|
|
258
266
|
metadata_manager = MetadataManager(uc_client, ttl=args.metadata_cache_ttl_sec, max_entries=args.metadata_cache_max_entries, ttl_catalog=args.metadata_cache_ttl_catalog_sec, ttl_negative=args.metadata_cache_ttl_negative_sec, inode_manager=inode_manager)
|
|
259
267
|
# On a 401, the token is invalidated and re-resolved (possibly to a
|
|
260
268
|
# different principal); drop the cached principal so authz re-derives it.
|
|
@@ -8,6 +8,7 @@ import logging
|
|
|
8
8
|
import os
|
|
9
9
|
import shutil
|
|
10
10
|
import time
|
|
11
|
+
from functools import lru_cache
|
|
11
12
|
from heapq import heappop, heappush
|
|
12
13
|
from typing import AsyncGenerator, Tuple
|
|
13
14
|
|
|
@@ -16,6 +17,20 @@ import trio
|
|
|
16
17
|
logger = logging.getLogger(__name__)
|
|
17
18
|
|
|
18
19
|
|
|
20
|
+
# access_map value marking an indexed chunk whose file turned out to be missing
|
|
21
|
+
# (deleted behind our back). Real access times are always > 0. The entry is
|
|
22
|
+
# kept, not deleted, so that its size is still released from current_size when
|
|
23
|
+
# evict() later pops the matching access_log record and finds the file gone.
|
|
24
|
+
_MISSING = 0.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@lru_cache(maxsize=4096)
|
|
28
|
+
def _path_hash(fs_path: str) -> str:
|
|
29
|
+
"""sha256 of an fs_path. Cached: has_chunk() runs for every prefetch
|
|
30
|
+
candidate of every read, and hashing dominated its cost."""
|
|
31
|
+
return hashlib.sha256(fs_path.encode("utf-8")).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
19
34
|
def clear_cache(cache_dir: str):
|
|
20
35
|
"""Removes *.tmp and *.bin files from cache_dir recursively"""
|
|
21
36
|
logging.info("Clearing cache... %s", cache_dir)
|
|
@@ -36,7 +51,9 @@ class DiskPersistence:
|
|
|
36
51
|
self.current_size = 0
|
|
37
52
|
self.lock = trio.Lock()
|
|
38
53
|
self.access_log: list[Tuple[float, str, int]] = [] # Heap: (now, cache_path, bytes_written)
|
|
39
|
-
|
|
54
|
+
# {cache_path: latest_use_timestamp}. Also the in-RAM index of which
|
|
55
|
+
# chunks are on disk (see has_chunk); _MISSING marks a stale entry.
|
|
56
|
+
self.access_map: dict[str, float] = {}
|
|
40
57
|
|
|
41
58
|
def run_services(self, nursery):
|
|
42
59
|
"""Starts background maintenance and discovery."""
|
|
@@ -54,7 +71,7 @@ class DiskPersistence:
|
|
|
54
71
|
``_ensure_parent_dir``); a read of a missing chunk simply hits
|
|
55
72
|
FileNotFoundError and returns None.
|
|
56
73
|
"""
|
|
57
|
-
sha256_hash =
|
|
74
|
+
sha256_hash = _path_hash(fs_path)
|
|
58
75
|
shard1 = sha256_hash[:2]
|
|
59
76
|
shard2 = f"{(chunk_index // 1000):07d}" # Secondary shard to prevent too many files in one dir
|
|
60
77
|
shard_dir = os.path.join(self.cache_dir, shard1, shard2)
|
|
@@ -65,6 +82,31 @@ class DiskPersistence:
|
|
|
65
82
|
shard_dir, f"{sha256_hash}_{mtime_ms}_g{gen}_{chunk_index:07d}.bin"
|
|
66
83
|
)
|
|
67
84
|
|
|
85
|
+
def has_chunk(self, fs_path: str, chunk_index: int, mtime: float, gen: int = 0) -> bool:
|
|
86
|
+
"""Whether the chunk is on disk, according to the in-RAM index.
|
|
87
|
+
|
|
88
|
+
Synchronous and lock-free (a single dict lookup, no await, no thread
|
|
89
|
+
hop), so the read path can call it for every prefetch candidate. It
|
|
90
|
+
does not refresh the LRU timestamp.
|
|
91
|
+
|
|
92
|
+
The index can briefly disagree with the disk:
|
|
93
|
+
* false negatives while the startup scan (``_graceful_init``) is
|
|
94
|
+
still running, or while ``evict`` removes a file: callers fall back
|
|
95
|
+
to downloading / ``chunk_exists``, which is harmless;
|
|
96
|
+
* false positives if a file is deleted behind our back: the next
|
|
97
|
+
``retrieve_chunk``/``chunk_exists`` on it notices and marks the
|
|
98
|
+
entry ``_MISSING``, after which this returns False.
|
|
99
|
+
"""
|
|
100
|
+
ts = self.access_map.get(self._get_chunk_path(fs_path, chunk_index, mtime, gen))
|
|
101
|
+
return ts is not None and ts != _MISSING
|
|
102
|
+
|
|
103
|
+
async def _mark_missing(self, cache_path: str) -> None:
|
|
104
|
+
"""The index has ``cache_path`` but the file is gone: stop reporting it
|
|
105
|
+
as present. Size accounting is settled later by evict()."""
|
|
106
|
+
async with self.lock:
|
|
107
|
+
if cache_path in self.access_map:
|
|
108
|
+
self.access_map[cache_path] = _MISSING
|
|
109
|
+
|
|
68
110
|
@staticmethod
|
|
69
111
|
def _ensure_parent_dir(path: str) -> None:
|
|
70
112
|
"""Create the parent directory of ``path`` (idempotent). Runs off the
|
|
@@ -130,6 +172,7 @@ class DiskPersistence:
|
|
|
130
172
|
return data
|
|
131
173
|
|
|
132
174
|
except FileNotFoundError:
|
|
175
|
+
await self._mark_missing(cache_path)
|
|
133
176
|
return None
|
|
134
177
|
|
|
135
178
|
def _read_file(self, path: str) -> bytes:
|
|
@@ -149,7 +192,10 @@ class DiskPersistence:
|
|
|
149
192
|
async with self.lock:
|
|
150
193
|
if cache_path in self.access_map:
|
|
151
194
|
self.access_map[cache_path] = time.time()
|
|
152
|
-
|
|
195
|
+
exists = await trio.to_thread.run_sync(os.path.exists, cache_path)
|
|
196
|
+
if not exists:
|
|
197
|
+
await self._mark_missing(cache_path)
|
|
198
|
+
return exists
|
|
153
199
|
|
|
154
200
|
async def store_chunk_from_stream(
|
|
155
201
|
self,
|