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.
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/CHANGELOG.md +42 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/PKG-INFO +7 -1
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/README.md +6 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/auth/provider.py +71 -74
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/data_manager.py +65 -31
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/inode_manager.py +13 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/metadata_manager.py +63 -2
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/operations.py +63 -27
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/write_buffer.py +13 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/main.py +2 -1
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/storage/persistence.py +23 -6
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/pyproject.toml +1 -1
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_auth_provider.py +66 -90
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_data_manager.py +123 -8
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_e2e_mount.py +51 -24
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_metadata_manager.py +83 -1
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_operations.py +73 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_persistence.py +49 -2
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_uc_client_live.py +116 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_write_buffer.py +51 -0
- fuse4dbricks-0.7.5/uv.lock +911 -0
- fuse4dbricks-0.7.3/uv.lock +0 -911
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/.env.example +0 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/.github/workflows/test.yml +0 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/.gitignore +0 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/__init__.py +0 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/api/__init__.py +0 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/api/errors.py +0 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/api/uc_client.py +0 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/auth/__init__.py +0 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/__init__.py +0 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/auth_manager.py +0 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/ram_cache.py +0 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/securable_filter.py +0 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/utils.py +0 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/fuse4dbricks/storage/__init__.py +0 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_auth_manager.py +0 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_inode_manager.py +0 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_main.py +0 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_securable_filter.py +0 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_uc_client.py +0 -0
- {fuse4dbricks-0.7.3 → fuse4dbricks-0.7.5}/tests/test_uc_client_consistency.py +0 -0
- {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
|
+
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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
token
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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[
|
|
36
|
-
self._requests_regular: 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[
|
|
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[
|
|
122
|
-
"Inflight coalescer for downloads identified by (
|
|
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[
|
|
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
|
-
|
|
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
|