fuse4dbricks 0.7.3__tar.gz → 0.7.4__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 (43) hide show
  1. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/CHANGELOG.md +31 -0
  2. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/PKG-INFO +1 -1
  3. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/fuse4dbricks/fs/data_manager.py +53 -23
  4. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/fuse4dbricks/fs/metadata_manager.py +62 -1
  5. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/fuse4dbricks/fs/operations.py +61 -21
  6. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/fuse4dbricks/fs/write_buffer.py +13 -0
  7. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/fuse4dbricks/main.py +1 -1
  8. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/fuse4dbricks/storage/persistence.py +8 -6
  9. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/pyproject.toml +1 -1
  10. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/tests/test_data_manager.py +42 -7
  11. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/tests/test_e2e_mount.py +51 -24
  12. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/tests/test_metadata_manager.py +83 -1
  13. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/tests/test_operations.py +73 -0
  14. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/tests/test_persistence.py +22 -2
  15. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/tests/test_uc_client_live.py +116 -0
  16. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/tests/test_write_buffer.py +51 -0
  17. fuse4dbricks-0.7.4/uv.lock +911 -0
  18. fuse4dbricks-0.7.3/uv.lock +0 -911
  19. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/.env.example +0 -0
  20. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/.github/workflows/test.yml +0 -0
  21. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/.gitignore +0 -0
  22. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/README.md +0 -0
  23. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/fuse4dbricks/__init__.py +0 -0
  24. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/fuse4dbricks/api/__init__.py +0 -0
  25. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/fuse4dbricks/api/errors.py +0 -0
  26. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/fuse4dbricks/api/uc_client.py +0 -0
  27. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/fuse4dbricks/auth/__init__.py +0 -0
  28. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/fuse4dbricks/auth/provider.py +0 -0
  29. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/fuse4dbricks/fs/__init__.py +0 -0
  30. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/fuse4dbricks/fs/auth_manager.py +0 -0
  31. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/fuse4dbricks/fs/inode_manager.py +0 -0
  32. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/fuse4dbricks/fs/ram_cache.py +0 -0
  33. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/fuse4dbricks/fs/securable_filter.py +0 -0
  34. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/fuse4dbricks/fs/utils.py +0 -0
  35. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/fuse4dbricks/storage/__init__.py +0 -0
  36. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/tests/test_auth_manager.py +0 -0
  37. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/tests/test_auth_provider.py +0 -0
  38. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/tests/test_inode_manager.py +0 -0
  39. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/tests/test_main.py +0 -0
  40. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/tests/test_securable_filter.py +0 -0
  41. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/tests/test_uc_client.py +0 -0
  42. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/tests/test_uc_client_consistency.py +0 -0
  43. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.4}/tests/test_utils.py +0 -0
@@ -1,3 +1,34 @@
1
+ # 0.7.4 (2026-06-20)
2
+
3
+ - Make writes durable and immediately visible on `close()`. The upload ran only
4
+ in `release()`, which the kernel does not wait for and whose errors it
5
+ discards, so `close()` returned before the data was uploaded — a file written
6
+ through the mount was invisible (and write errors were silent) for the
7
+ duration of the asynchronous upload. The upload now runs in `flush()`, which
8
+ handles the `close()` syscall: the kernel blocks on it, the upload completes
9
+ before `close()` returns, and a failed upload surfaces as a `close()` error.
10
+ `release()` keeps the upload as a fallback for the rare close-without-flush.
11
+ - Fix a stale read after overwriting a file within the same second. Cached
12
+ chunks are keyed by the server `mtime`, but the Files API `Last-Modified`
13
+ header has only 1-second resolution (verified against the live API: no
14
+ sub-second timestamp and no `ETag`), so two writes in the same second shared a
15
+ key and a read could return the previous content. The chunk cache key now
16
+ carries a per-path generation counter (RAM key and on-disk chunk filename)
17
+ that `DataManager.invalidate_path()` bumps O(1) on every local write (wired
18
+ into write/flush, rename, truncate and unlink); old chunks become unreachable
19
+ and age out via the existing LRUs. Exposed by the durable-`close()` fix above,
20
+ which made same-second overwrites reachable.
21
+ - Fix cache invalidation not reaching the kernel. Mutating ops route through
22
+ `invalidate()`, which evicted the in-process attribute/dir/negative caches but
23
+ never told the FUSE kernel module, so the kernel kept serving stale `stat`
24
+ results from its own attribute and dentry caches for up to its
25
+ `attr_timeout` / `entry_timeout` (the pyfuse3 default, 300s) — a deleted file
26
+ kept stat-ing OK and a recreated one stayed hidden. `invalidate()` now also
27
+ resolves the path through the inode manager and calls
28
+ `pyfuse3.invalidate_entry_async()` (drop the parent dentry; the async variant
29
+ avoids deadlocking against the unlink/rename/create request in flight) and
30
+ `pyfuse3.invalidate_inode()` (drop the inode's cached attributes and data).
31
+
1
32
  # 0.7.3 (2026-06-19)
2
33
 
3
34
  - Fix `mv` of a freshly written file failing with an I/O error. `rename()`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fuse4dbricks
3
- Version: 0.7.3
3
+ Version: 0.7.4
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>
@@ -24,16 +24,24 @@ class _ChunkRequest:
24
24
  "piece to download"
25
25
  mtime: float
26
26
  "last modified, to ensure chunks are not altered between reads"
27
+ gen: int
28
+ "cache generation: bumped on a local write so a same-second overwrite (mtime has 1s resolution) is not served stale"
27
29
  chunk_size: int
28
30
  "size of this chunk. It may be smaller than the typical chunk_size on the last chunk of o a file"
29
31
  ctx: pyfuse3.RequestContext
30
32
  "request context, used for auth when downloading"
31
33
 
34
+
35
+ # Chunk cache key: (fs_path, chunk_id, mtime, gen). mtime guards against the
36
+ # remote file changing under us; gen is a local epoch bumped on every write so a
37
+ # stale chunk is unreachable even when the server mtime (1s resolution) collides.
38
+ _CacheKey = Tuple[str, int, float, int]
39
+
32
40
  class DownloadScheduler:
33
41
  def __init__(self, process_request, num_workers=10):
34
42
  self._requests_lock = trio.Lock()
35
- self._requests_priority: OrderedDict[Tuple[str, int, float], _ChunkRequest] = OrderedDict()
36
- self._requests_regular: OrderedDict[Tuple[str, int, float], _ChunkRequest] = OrderedDict()
43
+ self._requests_priority: OrderedDict[_CacheKey, _ChunkRequest] = OrderedDict()
44
+ self._requests_regular: OrderedDict[_CacheKey, _ChunkRequest] = OrderedDict()
37
45
  self._requests_num_workers = num_workers
38
46
  self._process_request = process_request
39
47
 
@@ -43,7 +51,7 @@ class DownloadScheduler:
43
51
  for i in range(self._requests_num_workers):
44
52
  nursery.start_soon(self._downloader)
45
53
 
46
- async def _dequeue_one(self) -> tuple[Tuple[str, int, float] | None, _ChunkRequest | None, str]:
54
+ async def _dequeue_one(self) -> tuple[_CacheKey | None, _ChunkRequest | None, str]:
47
55
  async with self._requests_lock:
48
56
  try:
49
57
  cache_key, chunk_request = self._requests_priority.popitem(last=False)
@@ -92,7 +100,7 @@ class DownloadScheduler:
92
100
  pass
93
101
 
94
102
  async def enqueue_request(self, chunk_request: _ChunkRequest, high_priority: bool):
95
- cache_key = (chunk_request.fs_path, chunk_request.chunk_id, chunk_request.mtime)
103
+ cache_key = (chunk_request.fs_path, chunk_request.chunk_id, chunk_request.mtime, chunk_request.gen)
96
104
  async with self._requests_lock:
97
105
  if high_priority:
98
106
  # If request for that chunk was present as a regular request, promote it:
@@ -118,24 +126,42 @@ class DataManager:
118
126
  self.uc_client = uc_client
119
127
  self.persistence = persistence
120
128
  self.chunk_size = 8 * 1024 * 1024
121
- self._inflight_coalescer: InflightCoalescer[Tuple[str, int, float]] = InflightCoalescer()
122
- "Inflight coalescer for downloads identified by (file_id, chunk_id, mtime)"
129
+ self._inflight_coalescer: InflightCoalescer[_CacheKey] = InflightCoalescer()
130
+ "Inflight coalescer for downloads identified by (fs_path, chunk_id, mtime, gen)"
123
131
 
124
132
  self._download_scheduler = DownloadScheduler(process_request=self._process_request, num_workers=num_workers)
125
133
 
134
+ # Per-path cache generation. Bumped on a local write so previously cached
135
+ # chunks become unreachable without scanning. Absent path => generation 0.
136
+ self._write_generation: dict[str, int] = {}
137
+
126
138
  # max number of chunks to cache in memory
127
139
  max_ram_chunks = int(ram_cache_mb*1024*1024 / self.chunk_size)
128
- self._ram_cache: RamCache[Tuple[str, int, float]] = RamCache(max_entries = max_ram_chunks)
140
+ self._ram_cache: RamCache[_CacheKey] = RamCache(max_entries = max_ram_chunks)
129
141
 
130
142
  def close(self) -> None:
131
143
  self._download_scheduler.close()
132
144
 
145
+ def _generation(self, fs_path: str) -> int:
146
+ return self._write_generation.get(fs_path, 0)
147
+
148
+ async def invalidate_path(self, fs_path: str) -> None:
149
+ """Drop the cached content of ``fs_path`` after a local write.
150
+
151
+ O(1): bumps the path's cache generation rather than scanning RAM or
152
+ globbing disk. Subsequent reads compute a new key, so the old chunks are
153
+ unreachable and age out via the RAM/disk LRUs. Necessary because chunks
154
+ are keyed by the server ``mtime``, which has only 1-second resolution and
155
+ so cannot distinguish two writes made within the same second.
156
+ """
157
+ self._write_generation[fs_path] = self._generation(fs_path) + 1
158
+
133
159
  async def _process_request(self, chunk_request: _ChunkRequest, priority: str):
134
160
  try:
135
- cache_key = (chunk_request.fs_path, chunk_request.chunk_id, chunk_request.mtime)
161
+ cache_key = (chunk_request.fs_path, chunk_request.chunk_id, chunk_request.mtime, chunk_request.gen)
136
162
  # We have a chunk_request to download
137
163
  # check if we already downloaded it:
138
- chunk = await self.persistence.retrieve_chunk(fs_path=chunk_request.fs_path, chunk_index=chunk_request.chunk_id, mtime=chunk_request.mtime)
164
+ chunk = await self.persistence.retrieve_chunk(fs_path=chunk_request.fs_path, chunk_index=chunk_request.chunk_id, mtime=chunk_request.mtime, gen=chunk_request.gen)
139
165
  if chunk is None:
140
166
  # download it.
141
167
  chunk = await self._fetch_chunk(chunk_request)
@@ -164,39 +190,40 @@ class DataManager:
164
190
  fs_path=chunk_request.fs_path,
165
191
  chunk_index=chunk_request.chunk_id,
166
192
  mtime=chunk_request.mtime,
193
+ gen=chunk_request.gen,
167
194
  stream=stream,
168
195
  )
169
196
  return chunk
170
197
 
171
- async def _request_fetch_ahead_chunks(self, fs_path: str, chunks_to_prefetch: list[Tuple[int, int]], mtime: float, ctx: pyfuse3.RequestContext):
198
+ async def _request_fetch_ahead_chunks(self, fs_path: str, chunks_to_prefetch: list[Tuple[int, int]], mtime: float, gen: int, ctx: pyfuse3.RequestContext):
172
199
  for (chunk_id, chunk_size) in chunks_to_prefetch:
173
- cache_key = (fs_path, chunk_id, mtime)
200
+ cache_key = (fs_path, chunk_id, mtime, gen)
174
201
  (wait_event, leader) = await self._inflight_coalescer.join_or_lead(cache_key)
175
202
  if not leader:
176
203
  continue # already being fetched, we don't care about it
177
- chunk_request = _ChunkRequest(fs_path=fs_path, chunk_id=chunk_id, mtime=mtime, chunk_size=chunk_size, ctx=ctx)
204
+ chunk_request = _ChunkRequest(fs_path=fs_path, chunk_id=chunk_id, mtime=mtime, gen=gen, chunk_size=chunk_size, ctx=ctx)
178
205
  await self._download_scheduler.enqueue_request(chunk_request, high_priority=False)
179
206
  return None
180
207
 
181
- async def _get_chunk_from_cache_or_disk(self, fs_path: str, chunk_id: int, mtime: float) -> bytes | None:
182
- cache_key = (fs_path, chunk_id, mtime)
208
+ async def _get_chunk_from_cache_or_disk(self, fs_path: str, chunk_id: int, mtime: float, gen: int) -> bytes | None:
209
+ cache_key = (fs_path, chunk_id, mtime, gen)
183
210
  # get chunk from ram
184
211
  cached = await self._ram_cache.get(cache_key)
185
212
  if cached is not None:
186
213
  return cached
187
214
  # get chunk from disk
188
- chunk = await self.persistence.retrieve_chunk(fs_path, chunk_id, mtime)
215
+ chunk = await self.persistence.retrieve_chunk(fs_path, chunk_id, mtime, gen)
189
216
  if chunk is not None:
190
217
  await self._ram_cache.put(cache_key, chunk)
191
218
  return chunk
192
219
 
193
220
 
194
221
  async def _read_chunk(
195
- self, fs_path: str, chunk_id: int, mtime: float, chunk_size: int,
222
+ self, fs_path: str, chunk_id: int, mtime: float, gen: int, chunk_size: int,
196
223
  ctx: pyfuse3.RequestContext, out_dict: dict[int, bytes|None]
197
224
  ):
198
- cache_key = (fs_path, chunk_id, mtime)
199
- chunk = await self._get_chunk_from_cache_or_disk(fs_path=fs_path, chunk_id=chunk_id, mtime=mtime)
225
+ cache_key = (fs_path, chunk_id, mtime, gen)
226
+ chunk = await self._get_chunk_from_cache_or_disk(fs_path=fs_path, chunk_id=chunk_id, mtime=mtime, gen=gen)
200
227
  if chunk is not None:
201
228
  out_dict[chunk_id] = chunk
202
229
  return
@@ -206,12 +233,12 @@ class DataManager:
206
233
 
207
234
  if leader:
208
235
  # get chunk from network
209
- chunk_request = _ChunkRequest(fs_path=fs_path, chunk_id=chunk_id, mtime=mtime, chunk_size=chunk_size, ctx=ctx)
236
+ chunk_request = _ChunkRequest(fs_path=fs_path, chunk_id=chunk_id, mtime=mtime, gen=gen, chunk_size=chunk_size, ctx=ctx)
210
237
  await self._download_scheduler.enqueue_request(chunk_request, high_priority=True)
211
238
 
212
239
  # Now we wait for the download to complete and fetch it from RAM or Disk
213
240
  await wait_event.wait()
214
- chunk = await self._get_chunk_from_cache_or_disk(fs_path=fs_path, chunk_id=chunk_id, mtime=mtime)
241
+ chunk = await self._get_chunk_from_cache_or_disk(fs_path=fs_path, chunk_id=chunk_id, mtime=mtime, gen=gen)
215
242
  # here if chunk is none, we will return None and let the caller decide what to do (probably return EIO)
216
243
  out_dict[chunk_id] = chunk
217
244
  return
@@ -224,6 +251,9 @@ class DataManager:
224
251
  Reads file contents
225
252
  Raises on error raise pyfuse3.FUSEError(errno.EIO)
226
253
  """
254
+ # Resolve the cache generation once for the whole read so every chunk
255
+ # (and the prefetch below) shares a consistent key.
256
+ gen = self._generation(fs_path)
227
257
  # Determine path and chunks that need to be read
228
258
  if offset >= file_size:
229
259
  return bytes()
@@ -246,7 +276,7 @@ class DataManager:
246
276
  chunk_size = last_chunk_size
247
277
  else:
248
278
  chunk_size = self.chunk_size
249
- await self._read_chunk(fs_path, chunk_id, mtime, chunk_size, ctx, chunks)
279
+ await self._read_chunk(fs_path, chunk_id, mtime, gen, chunk_size, ctx, chunks)
250
280
  else:
251
281
  # dictionary: chunk_id -> bytes
252
282
  async with trio.open_nursery() as nursery:
@@ -256,7 +286,7 @@ class DataManager:
256
286
  else:
257
287
  chunk_size = self.chunk_size
258
288
  nursery.start_soon(
259
- self._read_chunk, fs_path, chunk_id, mtime, chunk_size, ctx, chunks
289
+ self._read_chunk, fs_path, chunk_id, mtime, gen, chunk_size, ctx, chunks
260
290
  )
261
291
  # fetch ahead next 10 chunks once we are beyond chunk 0 (avoid prefetching too much on `head *` command)
262
292
  num_chunks_to_prefetch = 10 if end_chunk > 0 else 1
@@ -273,7 +303,7 @@ class DataManager:
273
303
  chunk_size = self.chunk_size
274
304
  # Add to list to fetch:
275
305
  chunks_to_prefetch.append((chunk_to_prefetch, chunk_size))
276
- await self._request_fetch_ahead_chunks(fs_path, chunks_to_prefetch, mtime, ctx)
306
+ await self._request_fetch_ahead_chunks(fs_path, chunks_to_prefetch, mtime, gen, ctx)
277
307
  # Assemble chunks
278
308
  result = bytearray()
279
309
  for chunk_id in chunks_to_read:
@@ -15,7 +15,7 @@ import pyfuse3
15
15
 
16
16
  from fuse4dbricks.api.uc_client import (UcNodeType, UnityCatalogClient,
17
17
  UnityCatalogEntry)
18
- from fuse4dbricks.fs.inode_manager import InodeEntry, InodeEntryAttr
18
+ from fuse4dbricks.fs.inode_manager import InodeEntry, InodeEntryAttr, InodeManager
19
19
  from fuse4dbricks.fs.utils import (fs_to_uc_path, fs_to_securable,
20
20
  InflightCoalescer,
21
21
  uc_to_fs_path)
@@ -54,6 +54,7 @@ class MetadataManager:
54
54
  max_entries=20000,
55
55
  ttl_catalog=600.0,
56
56
  ttl_negative: float=5.0,
57
+ inode_manager: InodeManager | None = None,
57
58
  ):
58
59
  """
59
60
  :param uc_client: The Unity Catalog API client.
@@ -61,8 +62,15 @@ class MetadataManager:
61
62
  :param max_entries: Maximum number of metadata entries to keep in RAM.
62
63
  :param ttl_negative: Time-to-live in seconds for negative (not-found)
63
64
  cache entries. Kept short because it hides newly-created paths.
65
+ :param inode_manager: Used by ``invalidate`` to resolve an fs_path to an
66
+ inode so the kernel's own attribute/dentry cache can be dropped too.
67
+ Optional: when ``None`` only the in-process caches are evicted (the
68
+ kernel still expires its entries on its own ``attr_timeout`` /
69
+ ``entry_timeout``). Always wired in production; left unset in unit
70
+ tests that exercise the in-process caches in isolation.
64
71
  """
65
72
  self.uc_client = uc_client
73
+ self._inode_manager = inode_manager
66
74
  self._ttl: float = ttl
67
75
  self._ttl_catalog = ttl_catalog
68
76
  self._ttl_negative = ttl_negative
@@ -498,6 +506,59 @@ class MetadataManager:
498
506
  except Exception:
499
507
  pass
500
508
 
509
+ # Evicting our own caches is not enough: the kernel keeps its own
510
+ # attribute and dentry caches (for up to attr_timeout / entry_timeout),
511
+ # so without this a deleted file would keep stat-ing OK and a fresh one
512
+ # would stay hidden until the kernel's timeout expired. Tell the kernel
513
+ # to forget this path too.
514
+ self._invalidate_kernel_cache(fs_path)
515
+
516
+ def _invalidate_kernel_cache(self, fs_path: str):
517
+ """Drop the kernel's cached attributes, data and dentry for ``fs_path``.
518
+
519
+ Resolves the path through the inode manager and notifies the FUSE
520
+ kernel module. A no-op when no inode manager is wired (unit tests) or
521
+ when neither the path nor its parent is currently known to the kernel.
522
+ """
523
+ if self._inode_manager is None:
524
+ return
525
+ norm = fs_path.rstrip("/") or "/"
526
+ if norm == "/":
527
+ return
528
+ parent_path, _, name = norm.rpartition("/")
529
+ parent_path = parent_path or "/"
530
+
531
+ # Forget the directory entry in the parent so a deleted name stops
532
+ # resolving and a recreated one is re-looked-up. Must use the *_async
533
+ # variant: invalidate() runs inside the very unlink/rename/create
534
+ # request that touched this entry, and the synchronous invalidate_entry
535
+ # would deadlock against it.
536
+ parent_inode = self._inode_manager.get_inode_by_path(parent_path)
537
+ if parent_inode is not None:
538
+ try:
539
+ pyfuse3.invalidate_entry_async(
540
+ parent_inode, name.encode("utf-8"), ignore_enoent=True
541
+ )
542
+ except Exception:
543
+ logger.debug(
544
+ "invalidate_entry_async failed for %s", fs_path, exc_info=True
545
+ )
546
+
547
+ # Forget cached attributes and page data for the inode itself, so a
548
+ # changed size/mtime or stale contents are re-fetched on next access.
549
+ inode = self._inode_manager.get_inode_by_path(norm)
550
+ if inode is not None:
551
+ try:
552
+ pyfuse3.invalidate_inode(inode)
553
+ except OSError:
554
+ # ENOSYS (kernel lacks support) or a transient failure: the
555
+ # kernel's own attr_timeout still bounds how long it stays stale.
556
+ pass
557
+ except Exception:
558
+ logger.debug(
559
+ "invalidate_inode failed for %s", fs_path, exc_info=True
560
+ )
561
+
501
562
  # =========================================================================
502
563
  # Internal Logic & Helpers
503
564
  # =========================================================================
@@ -473,6 +473,55 @@ class UnityCatalogFS(pyfuse3.Operations):
473
473
  }
474
474
  return (pyfuse3.FileInfo(fh=fh), self._entry_to_fuse_attr(entry))
475
475
 
476
+ async def _upload_if_dirty(self, state: dict) -> None:
477
+ """Upload the write buffer to Unity Catalog if it has unsaved writes.
478
+
479
+ Shared by ``flush`` and ``release``. Uploads, then marks the handle
480
+ clean and busts the caches so the new content is immediately visible.
481
+ Raises ``FUSEError(EIO)`` on upload failure (the caller decides whether
482
+ that error reaches userspace). A no-op when the buffer is clean, so a
483
+ second ``flush`` — or the ``release`` fallback after a successful
484
+ ``flush`` — does not re-upload.
485
+ """
486
+ write_buffer: WriteBuffer | None = state.get("write_buffer")
487
+ if write_buffer is None or not state.get("dirty", False):
488
+ return
489
+
490
+ entry = self.inodes.get_entry(state["inode"])
491
+ if entry is None:
492
+ raise pyfuse3.FUSEError(errno.ENOENT)
493
+
494
+ uc_path = fs_to_uc_path(entry.fs_path)
495
+ try:
496
+ # Push buffered writes to the OS so the upload's separate reader sees
497
+ # the complete file (writes smaller than ~8 KB would otherwise read
498
+ # back as zero bytes). Keep the handle open: flush may be delivered
499
+ # again with more writes before release.
500
+ write_buffer.flush_to_disk()
501
+ await self.uc_client.upload_file(uc_path, write_buffer.path, ctx=state["ctx"])
502
+ except Exception as e:
503
+ logger.error("Upload failed for %s: %s", entry.fs_path, e)
504
+ # Remote file is unchanged: do NOT invalidate the cache.
505
+ raise pyfuse3.FUSEError(errno.EIO) from e
506
+ # Clean before invalidating so a concurrent reader never sees "dirty".
507
+ state["dirty"] = False
508
+ # Bust caches so subsequent reads see the new file: metadata (size/mtime)
509
+ # and the chunk content cache (whose mtime key cannot tell apart two
510
+ # writes in the same second).
511
+ self.metadata_manager.invalidate(entry.fs_path, is_dir=False)
512
+ await self.data_manager.invalidate_path(entry.fs_path)
513
+
514
+ async def flush(self, fh: pyfuse3.FileHandleT) -> None:
515
+ # flush handles the close() syscall: the kernel waits for it and returns
516
+ # its error to userspace. Uploading here (rather than only in release(),
517
+ # which runs asynchronously and whose errors the kernel discards) makes a
518
+ # write durable and immediately visible by the time close() returns, and
519
+ # lets a failed upload surface as a close() error instead of being lost.
520
+ state = self._open_state.get(fh)
521
+ if state is None:
522
+ return
523
+ await self._upload_if_dirty(state)
524
+
476
525
  async def release(self, fh: pyfuse3.FileHandleT) -> None:
477
526
  if fh not in self._open_state:
478
527
  # This should not happen, but we want to be resilient to it. Just ignore.
@@ -482,7 +531,6 @@ class UnityCatalogFS(pyfuse3.Operations):
482
531
  inode = state["inode"]
483
532
  ctx = state["ctx"]
484
533
  write_buffer: WriteBuffer | None = state.get("write_buffer")
485
- dirty: bool = state.get("dirty", False)
486
534
 
487
535
  entry = self.inodes.get_entry(inode)
488
536
  if entry is None:
@@ -492,26 +540,11 @@ class UnityCatalogFS(pyfuse3.Operations):
492
540
  raise pyfuse3.FUSEError(errno.ENOENT)
493
541
 
494
542
  try:
495
- if write_buffer is not None and dirty:
496
- uc_path = fs_to_uc_path(entry.fs_path)
497
- try:
498
- # Flush + close the write handle so the upload reads the
499
- # complete file from disk (buffered writes smaller than
500
- # ~8 KB would otherwise upload as zero bytes).
501
- write_buffer.finalize()
502
- await self.uc_client.upload_file(
503
- uc_path, write_buffer.path, ctx=ctx
504
- )
505
- except Exception as e:
506
- logger.error("Upload failed for %s: %s", entry.fs_path, e)
507
- # Remote file is unchanged: do NOT invalidate the cache.
508
- raise pyfuse3.FUSEError(errno.EIO) from e
509
- else:
510
- # Bust caches so subsequent reads see the new file.
511
- self.metadata_manager.invalidate(entry.fs_path, is_dir=False)
512
- elif write_buffer is not None:
513
- # Opened writable but never written — discard buffer silently.
514
- pass
543
+ if write_buffer is not None:
544
+ # Normally flush() already uploaded and cleared the dirty flag,
545
+ # making this a no-op; it stays as a fallback for the rare path
546
+ # where release arrives without a preceding flush.
547
+ await self._upload_if_dirty(state)
515
548
  elif self._dispatch(entry.fs_path) == "auth":
516
549
  try:
517
550
  await self.auth_manager.release(entry.fs_path, ctx=ctx)
@@ -675,6 +708,8 @@ class UnityCatalogFS(pyfuse3.Operations):
675
708
  if child_inode is not None:
676
709
  self.inodes._prune_subtree(child_inode)
677
710
  self.metadata_manager.invalidate(child_fs_path, is_dir=False)
711
+ # Drop any cached content so a later same-named file is not served stale.
712
+ await self.data_manager.invalidate_path(child_fs_path)
678
713
 
679
714
  async def rmdir(self, parent_inode, name, ctx):
680
715
  if self._read_only:
@@ -829,6 +864,10 @@ class UnityCatalogFS(pyfuse3.Operations):
829
864
  self.inodes.move_inode(src_inode, parent_inode_new, new_name)
830
865
  self.metadata_manager.invalidate(old_fs_path, is_dir=False)
831
866
  self.metadata_manager.invalidate(new_fs_path, is_dir=False)
867
+ # The source is gone and the destination has new content; drop both from
868
+ # the chunk content cache.
869
+ await self.data_manager.invalidate_path(old_fs_path)
870
+ await self.data_manager.invalidate_path(new_fs_path)
832
871
 
833
872
  async def setattr(self, inode, attr, fields, fh, ctx):
834
873
  entry = self.inodes.get_entry(inode)
@@ -940,6 +979,7 @@ class UnityCatalogFS(pyfuse3.Operations):
940
979
  write_buffer.close()
941
980
  entry.attr.st_size = new_size
942
981
  self.metadata_manager.invalidate(entry.fs_path, is_dir=False)
982
+ await self.data_manager.invalidate_path(entry.fs_path)
943
983
 
944
984
  async def forget(self, inode_list):
945
985
  for inode, nlookup in inode_list:
@@ -48,6 +48,19 @@ class WriteBuffer:
48
48
  self._tmp.truncate(size)
49
49
  self._size = size
50
50
 
51
+ def flush_to_disk(self) -> None:
52
+ """Push buffered writes to the OS so a separate reader sees them.
53
+
54
+ Like ``finalize()`` this makes the full content visible to the upload's
55
+ own ``open(self.path)``, but it keeps the write handle open so ``read()``
56
+ and ``write()`` may continue afterwards. Use this when the upload happens
57
+ on ``flush`` (the ``close()`` syscall), which the kernel may deliver more
58
+ than once for a single open file (e.g. a duplicated fd) with further
59
+ writes in between. Idempotent; a no-op once the handle is closed.
60
+ """
61
+ if not self._tmp.closed:
62
+ self._tmp.flush()
63
+
51
64
  def finalize(self) -> None:
52
65
  """Flush and close the write handle, keeping the file on disk.
53
66
 
@@ -243,7 +243,7 @@ async def async_main():
243
243
 
244
244
  inode_manager = InodeManager()
245
245
  data_manager = DataManager(uc_client, persistence, ram_cache_mb=args.ram_cache_mb)
246
- 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)
246
+ 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)
247
247
  # On a 401, the token is invalidated and re-resolved (possibly to a
248
248
  # different principal); drop the cached principal so authz re-derives it.
249
249
  auth_provider.set_token_invalidation_callback(metadata_manager.forget_principal)
@@ -43,7 +43,7 @@ class DiskPersistence:
43
43
  nursery.start_soon(self._graceful_init)
44
44
  nursery.start_soon(self._background_maintenance)
45
45
 
46
- def _get_chunk_path(self, fs_path: str, chunk_index: int, mtime: float) -> str:
46
+ def _get_chunk_path(self, fs_path: str, chunk_index: int, mtime: float, gen: int = 0) -> str:
47
47
  """Compute the cache path for a chunk (256-shard distribution).
48
48
 
49
49
  Pure: does NOT touch the filesystem. ``retrieve_chunk`` runs on every
@@ -59,9 +59,10 @@ class DiskPersistence:
59
59
  shard2 = f"{(chunk_index // 1000):07d}" # Secondary shard to prevent too many files in one dir
60
60
  shard_dir = os.path.join(self.cache_dir, shard1, shard2)
61
61
  mtime_ms = int(mtime * 1000)
62
- # the cache_path
62
+ # gen is a local cache epoch (see DataManager.invalidate_path); it keeps
63
+ # a same-second overwrite from reusing a stale chunk file on disk.
63
64
  return os.path.join(
64
- shard_dir, f"{sha256_hash}_{mtime_ms}_{chunk_index:07d}.bin"
65
+ shard_dir, f"{sha256_hash}_{mtime_ms}_g{gen}_{chunk_index:07d}.bin"
65
66
  )
66
67
 
67
68
  @staticmethod
@@ -96,13 +97,13 @@ class DiskPersistence:
96
97
  continue
97
98
 
98
99
  async def retrieve_chunk(
99
- self, fs_path: str, chunk_index: int, mtime: float
100
+ self, fs_path: str, chunk_index: int, mtime: float, gen: int = 0
100
101
  ) -> bytes | None:
101
102
  """
102
103
  Retrieves a chunk from disk.
103
104
  Updates LRU access time BEFORE reading to prevent concurrent eviction.
104
105
  """
105
- cache_path = self._get_chunk_path(fs_path, chunk_index, mtime)
106
+ cache_path = self._get_chunk_path(fs_path, chunk_index, mtime, gen)
106
107
 
107
108
  # 1. OPTIMISTIC PROMOTION (Pinning)
108
109
  # Mark as accessed so GC doesn't delete it while we read.
@@ -141,9 +142,10 @@ class DiskPersistence:
141
142
  chunk_index: int,
142
143
  mtime: float,
143
144
  stream: AsyncGenerator[bytes, None],
145
+ gen: int = 0,
144
146
  ) -> bytes:
145
147
  """Consumes a stream and writes it to disk. Returns the bytes written."""
146
- cache_path = self._get_chunk_path(fs_path, chunk_index, mtime)
148
+ cache_path = self._get_chunk_path(fs_path, chunk_index, mtime, gen)
147
149
  temp_path = f"{cache_path}.{os.getpid()}.tmp"
148
150
  result = bytearray()
149
151
  bytes_written = 0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fuse4dbricks"
7
- version = "0.7.3"
7
+ version = "0.7.4"
8
8
  description = "FUSE driver for Databricks Unity Catalog Volumes."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -48,7 +48,7 @@ def manager():
48
48
 
49
49
  def _make_fake_read_chunk(chunk_size: int, fill_byte: bytes = b"x"):
50
50
  """Returns a coroutine that writes `chunk_size` bytes of fill_byte to out_dict[chunk_id]."""
51
- async def _fake(fs_path, chunk_id, mtime, cs, ctx, out_dict):
51
+ async def _fake(fs_path, chunk_id, mtime, gen, cs, ctx, out_dict):
52
52
  out_dict[chunk_id] = fill_byte * cs
53
53
 
54
54
  return _fake
@@ -56,7 +56,7 @@ def _make_fake_read_chunk(chunk_size: int, fill_byte: bytes = b"x"):
56
56
 
57
57
  def _make_fake_read_chunk_none():
58
58
  """Returns a coroutine that sets out_dict[chunk_id] = None (simulates download failure)."""
59
- async def _fake(fs_path, chunk_id, mtime, chunk_size, ctx, out_dict):
59
+ async def _fake(fs_path, chunk_id, mtime, gen, chunk_size, ctx, out_dict):
60
60
  out_dict[chunk_id] = None
61
61
 
62
62
  return _fake
@@ -159,7 +159,7 @@ async def test_read_last_chunk_smaller_than_chunk_size(manager, ctx):
159
159
 
160
160
  call_log: list[int] = []
161
161
 
162
- async def fake_read_chunk(fs_path, chunk_id, mtime, cs, ctx, out_dict):
162
+ async def fake_read_chunk(fs_path, chunk_id, mtime, gen, cs, ctx, out_dict):
163
163
  call_log.append((chunk_id, cs))
164
164
  out_dict[chunk_id] = b"x" * cs
165
165
 
@@ -184,7 +184,7 @@ async def test_read_file_size_exact_multiple_of_chunk_size(manager, ctx):
184
184
 
185
185
  call_log: list[tuple[int, int]] = []
186
186
 
187
- async def fake_read_chunk(fs_path, chunk_id, mtime, cs, ctx, out_dict):
187
+ async def fake_read_chunk(fs_path, chunk_id, mtime, gen, cs, ctx, out_dict):
188
188
  call_log.append((chunk_id, cs))
189
189
  out_dict[chunk_id] = b"x" * cs
190
190
 
@@ -230,7 +230,7 @@ async def test_read_prefetch_not_triggered_at_start_of_first_chunk(manager, ctx)
230
230
  file_size = 12 * CHUNK
231
231
  prefetch_calls: list = []
232
232
 
233
- async def fake_prefetch(fs_path, chunks, mtime, ctx):
233
+ async def fake_prefetch(fs_path, chunks, mtime, gen, ctx):
234
234
  prefetch_calls.append(chunks)
235
235
 
236
236
  with patch.object(manager, "_read_chunk", side_effect=_make_fake_read_chunk(CHUNK)):
@@ -250,7 +250,7 @@ async def test_read_prefetch_triggered_after_first_chunk(manager, ctx):
250
250
  file_size = 20 * CHUNK
251
251
  prefetch_calls: list = []
252
252
 
253
- async def fake_prefetch(fs_path, chunks, mtime, ctx):
253
+ async def fake_prefetch(fs_path, chunks, mtime, gen, ctx):
254
254
  prefetch_calls.append(chunks)
255
255
 
256
256
  with patch.object(manager, "_read_chunk", side_effect=_make_fake_read_chunk(CHUNK)):
@@ -269,7 +269,7 @@ async def test_read_prefetch_stops_at_eof(manager, ctx):
269
269
  file_size = 3 * CHUNK # 3 chunks total (0, 1, 2)
270
270
  prefetch_calls: list = []
271
271
 
272
- async def fake_prefetch(fs_path, chunks, mtime, ctx):
272
+ async def fake_prefetch(fs_path, chunks, mtime, gen, ctx):
273
273
  prefetch_calls.append(chunks)
274
274
 
275
275
  with patch.object(manager, "_read_chunk", side_effect=_make_fake_read_chunk(CHUNK)):
@@ -283,3 +283,38 @@ async def test_read_prefetch_stops_at_eof(manager, ctx):
283
283
  prefetched_chunk_ids = [cid for cid, _ in prefetch_calls[0]]
284
284
  assert all(cid < 3 for cid in prefetched_chunk_ids)
285
285
  assert 3 not in prefetched_chunk_ids # chunk 3 doesn't exist
286
+
287
+
288
+ # ---------------------------------------------------------------------------
289
+ # Tests: cache generation (invalidate_path)
290
+ # ---------------------------------------------------------------------------
291
+
292
+
293
+ @pytest.mark.trio
294
+ async def test_invalidate_path_bumps_generation(manager):
295
+ fs = "/cat/sch/vol/f.txt"
296
+ assert manager._generation(fs) == 0
297
+ await manager.invalidate_path(fs)
298
+ assert manager._generation(fs) == 1
299
+ await manager.invalidate_path(fs)
300
+ assert manager._generation(fs) == 2
301
+ # Other paths are unaffected.
302
+ assert manager._generation("/cat/sch/vol/other.txt") == 0
303
+
304
+
305
+ @pytest.mark.trio
306
+ async def test_invalidate_path_changes_the_chunk_cache_key(manager):
307
+ """After a write bumps the generation, the next lookup keys the chunk under
308
+ the new generation -- so a stale RAM/disk chunk from before the write (same
309
+ fs_path, chunk and mtime, but old gen) is bypassed. This is what stops a
310
+ same-second overwrite (mtime has 1s resolution) from serving stale bytes."""
311
+ fs = "/cat/sch/vol/f.txt"
312
+ mtime = 1000.0
313
+
314
+ await manager._get_chunk_from_cache_or_disk(fs, 0, mtime, manager._generation(fs))
315
+ manager.persistence.retrieve_chunk.assert_awaited_with(fs, 0, mtime, 0)
316
+
317
+ await manager.invalidate_path(fs)
318
+
319
+ await manager._get_chunk_from_cache_or_disk(fs, 0, mtime, manager._generation(fs))
320
+ manager.persistence.retrieve_chunk.assert_awaited_with(fs, 0, mtime, 1)