fuse4dbricks 0.7.3__tar.gz → 0.7.5__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.5}/CHANGELOG.md +42 -0
  2. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/PKG-INFO +7 -1
  3. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/README.md +6 -0
  4. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/auth/provider.py +71 -74
  5. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/data_manager.py +65 -31
  6. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/inode_manager.py +13 -0
  7. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/metadata_manager.py +63 -2
  8. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/operations.py +63 -27
  9. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/write_buffer.py +13 -0
  10. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/main.py +2 -1
  11. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/storage/persistence.py +23 -6
  12. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/pyproject.toml +1 -1
  13. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_auth_provider.py +66 -90
  14. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_data_manager.py +123 -8
  15. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_e2e_mount.py +51 -24
  16. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_metadata_manager.py +83 -1
  17. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_operations.py +73 -0
  18. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_persistence.py +49 -2
  19. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_uc_client_live.py +116 -0
  20. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_write_buffer.py +51 -0
  21. fuse4dbricks-0.7.5/uv.lock +911 -0
  22. fuse4dbricks-0.7.3/uv.lock +0 -911
  23. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/.env.example +0 -0
  24. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/.github/workflows/test.yml +0 -0
  25. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/.gitignore +0 -0
  26. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/__init__.py +0 -0
  27. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/api/__init__.py +0 -0
  28. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/api/errors.py +0 -0
  29. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/api/uc_client.py +0 -0
  30. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/auth/__init__.py +0 -0
  31. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/__init__.py +0 -0
  32. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/auth_manager.py +0 -0
  33. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/ram_cache.py +0 -0
  34. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/securable_filter.py +0 -0
  35. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/utils.py +0 -0
  36. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/storage/__init__.py +0 -0
  37. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_auth_manager.py +0 -0
  38. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_inode_manager.py +0 -0
  39. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_main.py +0 -0
  40. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_securable_filter.py +0 -0
  41. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_uc_client.py +0 -0
  42. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_uc_client_consistency.py +0 -0
  43. {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_utils.py +0 -0
@@ -1,3 +1,45 @@
1
+ # 0.7.5 (2026-08-06)
2
+
3
+ - Fix fatal startup/runtime errors exiting with status 0 instead of 1.
4
+ - Fix a race in Databricks config file validation where ownership was
5
+ checked on a path and the file was read from that same path separately.
6
+ Ownership is now checked on the open file descriptor that is read.
7
+ - Fix prefetch reading already-downloaded chunks back from disk on every
8
+ read, instead of just checking they exist. Reading a large file
9
+ sequentially could pull roughly an order of magnitude more bytes off
10
+ local disk than the file actually contains.
11
+
12
+ # 0.7.4 (2026-06-20)
13
+
14
+ - Make writes durable and immediately visible on `close()`. The upload ran only
15
+ in `release()`, which the kernel does not wait for and whose errors it
16
+ discards, so `close()` returned before the data was uploaded — a file written
17
+ through the mount was invisible (and write errors were silent) for the
18
+ duration of the asynchronous upload. The upload now runs in `flush()`, which
19
+ handles the `close()` syscall: the kernel blocks on it, the upload completes
20
+ before `close()` returns, and a failed upload surfaces as a `close()` error.
21
+ `release()` keeps the upload as a fallback for the rare close-without-flush.
22
+ - Fix a stale read after overwriting a file within the same second. Cached
23
+ chunks are keyed by the server `mtime`, but the Files API `Last-Modified`
24
+ header has only 1-second resolution (verified against the live API: no
25
+ sub-second timestamp and no `ETag`), so two writes in the same second shared a
26
+ key and a read could return the previous content. The chunk cache key now
27
+ carries a per-path generation counter (RAM key and on-disk chunk filename)
28
+ that `DataManager.invalidate_path()` bumps O(1) on every local write (wired
29
+ into write/flush, rename, truncate and unlink); old chunks become unreachable
30
+ and age out via the existing LRUs. Exposed by the durable-`close()` fix above,
31
+ which made same-second overwrites reachable.
32
+ - Fix cache invalidation not reaching the kernel. Mutating ops route through
33
+ `invalidate()`, which evicted the in-process attribute/dir/negative caches but
34
+ never told the FUSE kernel module, so the kernel kept serving stale `stat`
35
+ results from its own attribute and dentry caches for up to its
36
+ `attr_timeout` / `entry_timeout` (the pyfuse3 default, 300s) — a deleted file
37
+ kept stat-ing OK and a recreated one stayed hidden. `invalidate()` now also
38
+ resolves the path through the inode manager and calls
39
+ `pyfuse3.invalidate_entry_async()` (drop the parent dentry; the async variant
40
+ avoids deadlocking against the unlink/rename/create request in flight) and
41
+ `pyfuse3.invalidate_inode()` (drop the inode's cached attributes and data).
42
+
1
43
  # 0.7.3 (2026-06-19)
2
44
 
3
45
  - 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.5
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>
@@ -322,6 +322,12 @@ discussing them, so feel free to open an issue if any of them is a problem for y
322
322
  appear until this short TTL expires. Unlike file metadata, these negative results are *not*
323
323
  shared between users.
324
324
 
325
+ - **Revoked permissions can stay effective for a short time.** Catalog and
326
+ schema access permissions are cached using `--metadata-cache-ttl-catalog-sec`,
327
+ set to 10 minutes (600 seconds) by default. Volume access permissions are
328
+ cached using `--metadata-cache-ttl-sec`, set to 30 seconds by default. This
329
+ reduces the request load on the Unity Catalog API, improving performance.
330
+
325
331
  - **Writes are object-store semantics, not POSIX semantics.** The Databricks Files API is a
326
332
  full-replace store: a file is not visible to other processes until the writing process closes
327
333
  it (`release`). Concurrent writers follow last-write-wins — there is no locking. Deleting a
@@ -299,6 +299,12 @@ discussing them, so feel free to open an issue if any of them is a problem for y
299
299
  appear until this short TTL expires. Unlike file metadata, these negative results are *not*
300
300
  shared between users.
301
301
 
302
+ - **Revoked permissions can stay effective for a short time.** Catalog and
303
+ schema access permissions are cached using `--metadata-cache-ttl-catalog-sec`,
304
+ set to 10 minutes (600 seconds) by default. Volume access permissions are
305
+ cached using `--metadata-cache-ttl-sec`, set to 30 seconds by default. This
306
+ reduces the request load on the Unity Catalog API, improving performance.
307
+
302
308
  - **Writes are object-store semantics, not POSIX semantics.** The Databricks Files API is a
303
309
  full-replace store: a file is not visible to other processes until the writing process closes
304
310
  it (`release`). Concurrent writers follow last-write-wins — there is no locking. Deleting a
@@ -156,114 +156,111 @@ class DatabricksUnifiedAuthProvider:
156
156
  logger.error("Failed to read environ file %s for pid %s. Exception: %s", environ_file, pid, exc)
157
157
  return None
158
158
 
159
- def _is_safe_config_file(self, config_file: str, uid: int) -> bool:
160
- """Return True only if ``config_file`` is safe to read on behalf of ``uid``.
161
-
162
- When fuse4dbricks runs as root (the allow_other deployment), it reads
163
- config files out of arbitrary users' home directories. A user who
164
- controls their own ``~/.databrickscfg`` could symlink it at another
165
- user's config file; root would follow the link and cache the *victim's*
166
- token under the *attacker's* uid. ``os.stat`` follows symlinks, so the
167
- ``st_uid == uid`` check is what defeats that: a symlinked-to victim file
168
- is owned by the victim, not the requesting uid, and is rejected. This is
169
- applied to *every* config file path, env-supplied or default, never just
170
- one branch.
159
+ def _read_token_from_path(
160
+ self, config_file: str, uid: int, profile: str
161
+ ) -> tuple[bool, Optional[str]]:
162
+ """Reads ``profile``'s token from ``config_file`` on behalf of ``uid``.
163
+
164
+ Returns ``(True, token)`` if ``config_file`` exists and passes the
165
+ ownership/type checks below (``token`` is ``None`` if the profile or
166
+ its token key is missing -- a content error, not a trust failure, so
167
+ it is not retried against another location). Returns ``(False,
168
+ None)`` if the path does not exist or fails validation, so the
169
+ caller knows to fall back to the next candidate location.
170
+
171
+ fuse4dbricks may run as root and read config files out of arbitrary
172
+ users' home directories (the allow_other deployment), so a path is
173
+ trusted only if it is owned by ``uid``. The file is opened once;
174
+ ownership is checked with ``fstat`` on that descriptor, and the token
175
+ is read from the same descriptor -- never a second, independent
176
+ lookup of ``config_file`` by name -- so the ownership check and the
177
+ read always refer to the same file.
171
178
  """
172
179
  try:
173
- st = os.stat(config_file)
180
+ fd = os.open(config_file, os.O_RDONLY)
174
181
  except FileNotFoundError:
175
182
  # A missing config file is the normal state for any uid without a
176
183
  # Databricks setup (e.g. root or a system daemon probing the mount).
177
184
  # Logging it at error level floods the journal with one line per
178
185
  # request, so keep it at debug.
179
186
  logger.debug("Config file %s does not exist, ignoring", config_file)
180
- return False
187
+ return False, None
181
188
  except OSError as exc:
182
- # Other stat failures (permission denied, etc.) are unexpected and
189
+ # Other open failures (permission denied, etc.) are unexpected and
183
190
  # worth surfacing, but are not fatal to the mount.
191
+ logger.warning("Cannot open config file %s: %s", config_file, exc)
192
+ return False, None
193
+
194
+ try:
195
+ st = os.fstat(fd)
196
+ except OSError as exc:
184
197
  logger.warning("Cannot stat config file %s: %s", config_file, exc)
185
- return False
198
+ os.close(fd)
199
+ return False, None
186
200
  if not stat.S_ISREG(st.st_mode):
187
201
  logger.error("Config file %s is not a regular file, ignoring", config_file)
188
- return False
202
+ os.close(fd)
203
+ return False, None
189
204
  if st.st_uid != uid or not (st.st_mode & stat.S_IRUSR):
190
205
  logger.error(
191
206
  "Config file %s is not owned and readable by uid %s, ignoring",
192
207
  config_file, uid,
193
208
  )
194
- return False
195
- return True
209
+ os.close(fd)
210
+ return False, None
196
211
 
197
- def _get_profile_and_config_file_name(self, env: dict[str, str]|None, uid: int) -> tuple[str, str] | tuple[None, None]:
198
- """ Gets the databricks profile and config file name.
199
- 1. Check if env defines DATABRICKS_CONFIG_PROFILE and DATABRICKS_CONFIG_FILE
200
- 2. If not, default to DATABRICKS_CONFIG_PROFILE=DEFAULT and DATABRICKS_CONFIG_FILE=~/.databrickscfg (in the user's home directory)
201
-
202
- Whichever path is selected must be owned by and readable for ``uid``
203
- (see ``_is_safe_config_file``); an env-supplied path that fails the
204
- check falls through to the default location.
205
- """
206
- if env is not None:
207
- profile = env.get("DATABRICKS_CONFIG_PROFILE", "DEFAULT")
208
- config_file = env.get("DATABRICKS_CONFIG_FILE")
209
- else:
210
- profile = "DEFAULT"
211
- config_file = None
212
-
213
- # An env-supplied config file that fails validation is ignored, falling
214
- # through to the default location below.
215
- if config_file is not None and not self._is_safe_config_file(config_file, uid):
216
- config_file = None
217
-
218
- if config_file is None:
219
- # Default to ~/.databrickscfg in the user's home directory. This path
220
- # is validated identically: under root it may live in another user's
221
- # home and must still be owned by the requesting uid.
222
- home_dir = self._home_for_uid(uid)
223
- default_config = os.path.join(home_dir, ".databrickscfg")
224
- if self._is_safe_config_file(default_config, uid):
225
- config_file = default_config
226
-
227
- if config_file is None:
228
- # Expected for any uid without a Databricks config; debug-level so a
229
- # repeatedly-probing uid (e.g. root) does not flood the journal.
230
- logger.debug("No databricks config file found for uid %s. Checked environment variable DATABRICKS_CONFIG_FILE and default location", uid)
231
- return None, None
232
- return profile, config_file
233
-
234
- def _get_token_from_config(self, profile: str, config_file: str) -> Optional[str]:
235
- """ Reads the databricks token from the config file for the given profile. """
236
212
  config = configparser.ConfigParser()
237
213
  try:
238
- config.read(config_file)
214
+ with os.fdopen(fd, "r") as f:
215
+ config.read_file(f)
239
216
  except Exception as exc:
240
217
  logger.error("Failed to read databricks config file at %s for profile %s. Exception: %s\n%s", config_file, profile, exc, traceback.format_exc())
241
- return None
218
+ return True, None
219
+
242
220
  if profile not in config:
243
221
  logger.error("Profile '%s' not found in %s", profile, config_file)
244
- return None
222
+ return True, None
245
223
  try:
246
- token = config[profile]["token"]
247
- return token
224
+ return True, config[profile]["token"]
248
225
  except KeyError:
249
226
  logger.error(f"'token' not found in profile '{profile}' of {config_file}")
250
- return None
227
+ return True, None
251
228
 
252
229
  async def get_access_token(self, ctx: pyfuse3.RequestContext) -> str|None:
253
- """ Gets the databricks token for the given request context by checking the environment variables of the requesting process and then the config file. """
230
+ """ Gets the databricks token for the given request context by checking the environment variables of the requesting process and then the config file.
231
+ 1. Check if the process defines DATABRICKS_TOKEN in its environment.
232
+ 2. Otherwise, look up a config file: DATABRICKS_CONFIG_PROFILE / DATABRICKS_CONFIG_FILE
233
+ from the process environment if set, falling back to profile DEFAULT and
234
+ ~/.databrickscfg in the uid's home directory. An env-supplied config file that
235
+ fails validation (see ``_read_token_from_path``) falls through to the default.
236
+ """
254
237
  env = self._get_env_for_pid(ctx.pid)
255
238
  # The process defines an access token:
256
239
  if env is not None and "DATABRICKS_TOKEN" in env:
257
240
  return env["DATABRICKS_TOKEN"]
258
- # The process does not define a token, but defines a profile or config file:
259
- profile, config_file = self._get_profile_and_config_file_name(env, ctx.uid)
260
- if profile is None or config_file is None:
261
- # Routine when a uid has neither a token in its environment nor a
262
- # config file; the caller turns this into EACCES. Debug-level to
263
- # avoid one error line per request from a probing uid.
264
- logger.debug("No databricks token found in environment variables for pid %s, and no profile or config file found for uid %s", ctx.pid, ctx.uid)
265
- return None
266
- return self._get_token_from_config(profile, config_file)
241
+
242
+ if env is not None:
243
+ profile = env.get("DATABRICKS_CONFIG_PROFILE", "DEFAULT")
244
+ config_file = env.get("DATABRICKS_CONFIG_FILE")
245
+ else:
246
+ profile = "DEFAULT"
247
+ config_file = None
248
+
249
+ if config_file is not None:
250
+ found, token = self._read_token_from_path(config_file, ctx.uid, profile)
251
+ if found:
252
+ return token
253
+
254
+ home_dir = self._home_for_uid(ctx.uid)
255
+ default_config = os.path.join(home_dir, ".databrickscfg")
256
+ found, token = self._read_token_from_path(default_config, ctx.uid, profile)
257
+ if found:
258
+ return token
259
+
260
+ # Expected for any uid without a Databricks config; debug-level so a
261
+ # repeatedly-probing uid (e.g. root) does not flood the journal.
262
+ logger.debug("No databricks token found in environment variables for pid %s, and no profile or config file found for uid %s", ctx.pid, ctx.uid)
263
+ return None
267
264
 
268
265
  class AuthProvider:
269
266
  # Key for the shared token in single-principal mode. -1 is never a valid uid,
@@ -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,29 +126,54 @@ 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)
136
- # We have a chunk_request to download
137
- # 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)
139
- if chunk is None:
140
- # download it.
141
- chunk = await self._fetch_chunk(chunk_request)
161
+ cache_key = (chunk_request.fs_path, chunk_request.chunk_id, chunk_request.mtime, chunk_request.gen)
142
162
  if priority == "high":
163
+ # A real read needs the bytes now, so fetch them (from disk,
164
+ # falling back to network) and cache in RAM.
165
+ 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)
166
+ if chunk is None:
167
+ chunk = await self._fetch_chunk(chunk_request)
143
168
  await self._ram_cache.put(cache_key, chunk)
169
+ else:
170
+ # A prefetch only needs the chunk to end up on disk. Reading
171
+ # it back (as retrieve_chunk does) would cost as much disk
172
+ # I/O as the read it's trying to save, for a chunk that may
173
+ # not even be read; a plain existence check is enough to
174
+ # decide whether a download is needed.
175
+ 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):
176
+ await self._fetch_chunk(chunk_request)
144
177
  finally:
145
178
  await self._inflight_coalescer.notify_done(cache_key)
146
179
 
@@ -164,39 +197,40 @@ class DataManager:
164
197
  fs_path=chunk_request.fs_path,
165
198
  chunk_index=chunk_request.chunk_id,
166
199
  mtime=chunk_request.mtime,
200
+ gen=chunk_request.gen,
167
201
  stream=stream,
168
202
  )
169
203
  return chunk
170
204
 
171
- async def _request_fetch_ahead_chunks(self, fs_path: str, chunks_to_prefetch: list[Tuple[int, int]], mtime: float, ctx: pyfuse3.RequestContext):
205
+ 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
206
  for (chunk_id, chunk_size) in chunks_to_prefetch:
173
- cache_key = (fs_path, chunk_id, mtime)
207
+ cache_key = (fs_path, chunk_id, mtime, gen)
174
208
  (wait_event, leader) = await self._inflight_coalescer.join_or_lead(cache_key)
175
209
  if not leader:
176
210
  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)
211
+ chunk_request = _ChunkRequest(fs_path=fs_path, chunk_id=chunk_id, mtime=mtime, gen=gen, chunk_size=chunk_size, ctx=ctx)
178
212
  await self._download_scheduler.enqueue_request(chunk_request, high_priority=False)
179
213
  return None
180
214
 
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)
215
+ async def _get_chunk_from_cache_or_disk(self, fs_path: str, chunk_id: int, mtime: float, gen: int) -> bytes | None:
216
+ cache_key = (fs_path, chunk_id, mtime, gen)
183
217
  # get chunk from ram
184
218
  cached = await self._ram_cache.get(cache_key)
185
219
  if cached is not None:
186
220
  return cached
187
221
  # get chunk from disk
188
- chunk = await self.persistence.retrieve_chunk(fs_path, chunk_id, mtime)
222
+ chunk = await self.persistence.retrieve_chunk(fs_path, chunk_id, mtime, gen)
189
223
  if chunk is not None:
190
224
  await self._ram_cache.put(cache_key, chunk)
191
225
  return chunk
192
226
 
193
227
 
194
228
  async def _read_chunk(
195
- self, fs_path: str, chunk_id: int, mtime: float, chunk_size: int,
229
+ self, fs_path: str, chunk_id: int, mtime: float, gen: int, chunk_size: int,
196
230
  ctx: pyfuse3.RequestContext, out_dict: dict[int, bytes|None]
197
231
  ):
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)
232
+ cache_key = (fs_path, chunk_id, mtime, gen)
233
+ chunk = await self._get_chunk_from_cache_or_disk(fs_path=fs_path, chunk_id=chunk_id, mtime=mtime, gen=gen)
200
234
  if chunk is not None:
201
235
  out_dict[chunk_id] = chunk
202
236
  return
@@ -206,12 +240,12 @@ class DataManager:
206
240
 
207
241
  if leader:
208
242
  # get chunk from network
209
- chunk_request = _ChunkRequest(fs_path=fs_path, chunk_id=chunk_id, mtime=mtime, chunk_size=chunk_size, ctx=ctx)
243
+ chunk_request = _ChunkRequest(fs_path=fs_path, chunk_id=chunk_id, mtime=mtime, gen=gen, chunk_size=chunk_size, ctx=ctx)
210
244
  await self._download_scheduler.enqueue_request(chunk_request, high_priority=True)
211
245
 
212
246
  # Now we wait for the download to complete and fetch it from RAM or Disk
213
247
  await wait_event.wait()
214
- chunk = await self._get_chunk_from_cache_or_disk(fs_path=fs_path, chunk_id=chunk_id, mtime=mtime)
248
+ chunk = await self._get_chunk_from_cache_or_disk(fs_path=fs_path, chunk_id=chunk_id, mtime=mtime, gen=gen)
215
249
  # here if chunk is none, we will return None and let the caller decide what to do (probably return EIO)
216
250
  out_dict[chunk_id] = chunk
217
251
  return
@@ -224,6 +258,9 @@ class DataManager:
224
258
  Reads file contents
225
259
  Raises on error raise pyfuse3.FUSEError(errno.EIO)
226
260
  """
261
+ # Resolve the cache generation once for the whole read so every chunk
262
+ # (and the prefetch below) shares a consistent key.
263
+ gen = self._generation(fs_path)
227
264
  # Determine path and chunks that need to be read
228
265
  if offset >= file_size:
229
266
  return bytes()
@@ -246,7 +283,7 @@ class DataManager:
246
283
  chunk_size = last_chunk_size
247
284
  else:
248
285
  chunk_size = self.chunk_size
249
- await self._read_chunk(fs_path, chunk_id, mtime, chunk_size, ctx, chunks)
286
+ await self._read_chunk(fs_path, chunk_id, mtime, gen, chunk_size, ctx, chunks)
250
287
  else:
251
288
  # dictionary: chunk_id -> bytes
252
289
  async with trio.open_nursery() as nursery:
@@ -256,7 +293,7 @@ class DataManager:
256
293
  else:
257
294
  chunk_size = self.chunk_size
258
295
  nursery.start_soon(
259
- self._read_chunk, fs_path, chunk_id, mtime, chunk_size, ctx, chunks
296
+ self._read_chunk, fs_path, chunk_id, mtime, gen, chunk_size, ctx, chunks
260
297
  )
261
298
  # fetch ahead next 10 chunks once we are beyond chunk 0 (avoid prefetching too much on `head *` command)
262
299
  num_chunks_to_prefetch = 10 if end_chunk > 0 else 1
@@ -273,7 +310,7 @@ class DataManager:
273
310
  chunk_size = self.chunk_size
274
311
  # Add to list to fetch:
275
312
  chunks_to_prefetch.append((chunk_to_prefetch, chunk_size))
276
- await self._request_fetch_ahead_chunks(fs_path, chunks_to_prefetch, mtime, ctx)
313
+ await self._request_fetch_ahead_chunks(fs_path, chunks_to_prefetch, mtime, gen, ctx)
277
314
  # Assemble chunks
278
315
  result = bytearray()
279
316
  for chunk_id in chunks_to_read:
@@ -287,6 +324,3 @@ class DataManager:
287
324
  )
288
325
  result.extend(chunk_data[chunk_start:chunk_end])
289
326
  return bytes(result)
290
-
291
- async def write(self, fs_path: str, offset: int, buffer: bytes, ctx: pyfuse3.RequestContext) -> int:
292
- raise pyfuse3.FUSEError(errno.EACCES)
@@ -134,6 +134,19 @@ class InodeManager:
134
134
  if entry.ref_count <= 0:
135
135
  self._delete_inode_internal(inode)
136
136
 
137
+ def detach_path(self, fs_path: str) -> None:
138
+ """Detach ``fs_path`` -- and, if it is a directory, everything
139
+ beneath it -- from the tree after it has been deleted remotely.
140
+
141
+ A no-op if ``fs_path`` has no known inode. This only touches the
142
+ in-process tree; it does not tell the kernel to drop its own cached
143
+ attributes/dentries for the path (see
144
+ ``MetadataManager._invalidate_kernel_cache`` for that).
145
+ """
146
+ inode = self.get_inode_by_path(fs_path)
147
+ if inode is not None:
148
+ self._prune_subtree(inode)
149
+
137
150
  def add_entry(
138
151
  self, parent_inode: int, name: str, attr: InodeEntryAttr
139
152
  ):
@@ -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
@@ -496,7 +504,60 @@ class MetadataManager:
496
504
  if parent_path in self._dir_cache:
497
505
  del self._dir_cache[parent_path]
498
506
  except Exception:
499
- pass
507
+ logger.warning("Cache invalidation failed for %s", fs_path, exc_info=True)
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
+ )
500
561
 
501
562
  # =========================================================================
502
563
  # Internal Logic & Helpers