fuse4dbricks 0.7.7__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.
Files changed (44) hide show
  1. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/CHANGELOG.md +22 -0
  2. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/PKG-INFO +1 -1
  3. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/fuse4dbricks/fs/data_manager.py +22 -3
  4. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/fuse4dbricks/main.py +9 -1
  5. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/fuse4dbricks/storage/persistence.py +49 -3
  6. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/pyproject.toml +1 -1
  7. fuse4dbricks-0.7.9/tests/sim_sequential_read.py +879 -0
  8. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/tests/test_data_manager.py +81 -3
  9. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/tests/test_main.py +5 -0
  10. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/tests/test_persistence.py +78 -0
  11. fuse4dbricks-0.7.9/tests/test_sim_sequential_read.py +74 -0
  12. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/.env.example +0 -0
  13. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/.github/workflows/test.yml +0 -0
  14. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/.gitignore +0 -0
  15. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/README.md +0 -0
  16. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/fuse4dbricks/__init__.py +0 -0
  17. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/fuse4dbricks/api/__init__.py +0 -0
  18. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/fuse4dbricks/api/errors.py +0 -0
  19. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/fuse4dbricks/api/uc_client.py +0 -0
  20. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/fuse4dbricks/auth/__init__.py +0 -0
  21. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/fuse4dbricks/auth/provider.py +0 -0
  22. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/fuse4dbricks/fs/__init__.py +0 -0
  23. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/fuse4dbricks/fs/auth_manager.py +0 -0
  24. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/fuse4dbricks/fs/inode_manager.py +0 -0
  25. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/fuse4dbricks/fs/metadata_manager.py +0 -0
  26. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/fuse4dbricks/fs/operations.py +0 -0
  27. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/fuse4dbricks/fs/ram_cache.py +0 -0
  28. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/fuse4dbricks/fs/securable_filter.py +0 -0
  29. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/fuse4dbricks/fs/utils.py +0 -0
  30. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/fuse4dbricks/fs/write_buffer.py +0 -0
  31. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/fuse4dbricks/storage/__init__.py +0 -0
  32. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/tests/test_auth_manager.py +0 -0
  33. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/tests/test_auth_provider.py +0 -0
  34. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/tests/test_e2e_mount.py +0 -0
  35. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/tests/test_inode_manager.py +0 -0
  36. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/tests/test_metadata_manager.py +0 -0
  37. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/tests/test_operations.py +0 -0
  38. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/tests/test_securable_filter.py +0 -0
  39. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/tests/test_uc_client.py +0 -0
  40. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/tests/test_uc_client_consistency.py +0 -0
  41. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/tests/test_uc_client_live.py +0 -0
  42. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/tests/test_utils.py +0 -0
  43. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/tests/test_write_buffer.py +0 -0
  44. {fuse4dbricks-0.7.7 → fuse4dbricks-0.7.9}/uv.lock +0 -0
@@ -1,3 +1,25 @@
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
+
19
+ # 0.7.8 (2026-10-15)
20
+
21
+ - Fix typo in DataManager
22
+
1
23
  # 0.7.7 (2026-10-01)
2
24
 
3
25
  - Add --num-download-workers and --prefetch-mb to control prefetching
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fuse4dbricks
3
- Version: 0.7.7
3
+ Version: 0.7.9
4
4
  Summary: FUSE driver for Databricks Unity Catalog Volumes.
5
5
  Project-URL: Homepage, https://github.com/zeehio/fuse4dbricks
6
6
  Author-email: Sergio Oller <sergioller@gmail.com>
@@ -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, prefecth_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
@@ -141,11 +144,12 @@ class DataManager:
141
144
  # max number of chunks to cache in memory
142
145
  max_ram_chunks = int(ram_cache_mb*1024*1024 / self.chunk_size)
143
146
  self._ram_cache: RamCache[_CacheKey] = RamCache(max_entries = max_ram_chunks)
144
- prefetch_bytes = prefecth_mb * 1024 * 1024
147
+ prefetch_bytes = prefetch_mb * 1024 * 1024
145
148
  chunks_to_prefetch = int(prefetch_bytes / self.chunk_size)
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
- self.access_map: dict[str, float] = {} # {cache_path: latest_use_timestamp}
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 = hashlib.sha256(fs_path.encode("utf-8")).hexdigest()
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
- return await trio.to_thread.run_sync(os.path.exists, cache_path)
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,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fuse4dbricks"
7
- version = "0.7.7"
7
+ version = "0.7.9"
8
8
  description = "FUSE driver for Databricks Unity Catalog Volumes."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"