fuse4dbricks 0.7.4__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.4 → fuse4dbricks-0.7.5}/CHANGELOG.md +11 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/PKG-INFO +7 -1
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/README.md +6 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/auth/provider.py +71 -74
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/data_manager.py +13 -9
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/inode_manager.py +13 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/metadata_manager.py +1 -1
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/operations.py +2 -6
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/main.py +1 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/storage/persistence.py +15 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/pyproject.toml +1 -1
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_auth_provider.py +66 -90
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_data_manager.py +81 -1
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_persistence.py +27 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/.env.example +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/.github/workflows/test.yml +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/.gitignore +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/__init__.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/api/__init__.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/api/errors.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/api/uc_client.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/auth/__init__.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/__init__.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/auth_manager.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/ram_cache.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/securable_filter.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/utils.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/write_buffer.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/storage/__init__.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_auth_manager.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_e2e_mount.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_inode_manager.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_main.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_metadata_manager.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_operations.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_securable_filter.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_uc_client.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_uc_client_consistency.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_uc_client_live.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_utils.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_write_buffer.py +0 -0
- {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/uv.lock +0 -0
|
@@ -1,3 +1,14 @@
|
|
|
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
|
+
|
|
1
12
|
# 0.7.4 (2026-06-20)
|
|
2
13
|
|
|
3
14
|
- Make writes durable and immediately visible on `close()`. The upload ran only
|
|
@@ -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,
|
|
@@ -159,14 +159,21 @@ class DataManager:
|
|
|
159
159
|
async def _process_request(self, chunk_request: _ChunkRequest, priority: str):
|
|
160
160
|
try:
|
|
161
161
|
cache_key = (chunk_request.fs_path, chunk_request.chunk_id, chunk_request.mtime, chunk_request.gen)
|
|
162
|
-
# We have a chunk_request to download
|
|
163
|
-
# check if we already downloaded it:
|
|
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)
|
|
165
|
-
if chunk is None:
|
|
166
|
-
# download it.
|
|
167
|
-
chunk = await self._fetch_chunk(chunk_request)
|
|
168
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)
|
|
169
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)
|
|
170
177
|
finally:
|
|
171
178
|
await self._inflight_coalescer.notify_done(cache_key)
|
|
172
179
|
|
|
@@ -317,6 +324,3 @@ class DataManager:
|
|
|
317
324
|
)
|
|
318
325
|
result.extend(chunk_data[chunk_start:chunk_end])
|
|
319
326
|
return bytes(result)
|
|
320
|
-
|
|
321
|
-
async def write(self, fs_path: str, offset: int, buffer: bytes, ctx: pyfuse3.RequestContext) -> int:
|
|
322
|
-
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
|
):
|
|
@@ -504,7 +504,7 @@ class MetadataManager:
|
|
|
504
504
|
if parent_path in self._dir_cache:
|
|
505
505
|
del self._dir_cache[parent_path]
|
|
506
506
|
except Exception:
|
|
507
|
-
|
|
507
|
+
logger.warning("Cache invalidation failed for %s", fs_path, exc_info=True)
|
|
508
508
|
|
|
509
509
|
# Evicting our own caches is not enough: the kernel keeps its own
|
|
510
510
|
# attribute and dentry caches (for up to attr_timeout / entry_timeout),
|
|
@@ -704,9 +704,7 @@ class UnityCatalogFS(pyfuse3.Operations):
|
|
|
704
704
|
except Exception as e:
|
|
705
705
|
self._raise_fuse_error(e, fs_path=child_fs_path, op="unlink")
|
|
706
706
|
|
|
707
|
-
|
|
708
|
-
if child_inode is not None:
|
|
709
|
-
self.inodes._prune_subtree(child_inode)
|
|
707
|
+
self.inodes.detach_path(child_fs_path)
|
|
710
708
|
self.metadata_manager.invalidate(child_fs_path, is_dir=False)
|
|
711
709
|
# Drop any cached content so a later same-named file is not served stale.
|
|
712
710
|
await self.data_manager.invalidate_path(child_fs_path)
|
|
@@ -734,9 +732,7 @@ class UnityCatalogFS(pyfuse3.Operations):
|
|
|
734
732
|
except Exception as e:
|
|
735
733
|
self._raise_fuse_error(e, fs_path=child_fs_path, op="rmdir")
|
|
736
734
|
|
|
737
|
-
|
|
738
|
-
if child_inode is not None:
|
|
739
|
-
self.inodes._prune_subtree(child_inode)
|
|
735
|
+
self.inodes.detach_path(child_fs_path)
|
|
740
736
|
self.metadata_manager.invalidate(child_fs_path, is_dir=True)
|
|
741
737
|
|
|
742
738
|
async def rename(self, parent_inode_old, name_old, parent_inode_new, name_new, flags, ctx):
|
|
@@ -283,6 +283,7 @@ async def async_main():
|
|
|
283
283
|
logger.error("Top-level group message: %s", eg)
|
|
284
284
|
for i, exc in enumerate(eg.exceptions, 1):
|
|
285
285
|
logger.error("[%d] type=%s -> %s\n%s", i, type(exc).__name__, exc, traceback.format_exc())
|
|
286
|
+
raise
|
|
286
287
|
finally:
|
|
287
288
|
data_manager.close()
|
|
288
289
|
await uc_client.close()
|
|
@@ -136,6 +136,21 @@ class DiskPersistence:
|
|
|
136
136
|
with open(path, "rb") as f:
|
|
137
137
|
return f.read()
|
|
138
138
|
|
|
139
|
+
async def chunk_exists(
|
|
140
|
+
self, fs_path: str, chunk_index: int, mtime: float, gen: int = 0
|
|
141
|
+
) -> bool:
|
|
142
|
+
"""Whether a chunk is already cached on disk, without reading its
|
|
143
|
+
content. For a prefetch, the caller only needs to know whether a
|
|
144
|
+
download is required; reading the full chunk back (as
|
|
145
|
+
``retrieve_chunk`` does) would cost as much I/O as the read it is
|
|
146
|
+
trying to save.
|
|
147
|
+
"""
|
|
148
|
+
cache_path = self._get_chunk_path(fs_path, chunk_index, mtime, gen)
|
|
149
|
+
async with self.lock:
|
|
150
|
+
if cache_path in self.access_map:
|
|
151
|
+
self.access_map[cache_path] = time.time()
|
|
152
|
+
return await trio.to_thread.run_sync(os.path.exists, cache_path)
|
|
153
|
+
|
|
139
154
|
async def store_chunk_from_stream(
|
|
140
155
|
self,
|
|
141
156
|
fs_path: str,
|
|
@@ -4,8 +4,7 @@ Tests for fuse4dbricks.auth.provider.
|
|
|
4
4
|
Covers the multi-user trust boundary:
|
|
5
5
|
- _subuid_owner / _home_for_uid (uid -> home, including subuid ranges)
|
|
6
6
|
- _get_env_for_pid (binary /proc/<pid>/environ parsing)
|
|
7
|
-
-
|
|
8
|
-
- _get_token_from_config (config parsing)
|
|
7
|
+
- _read_token_from_path (atomic open+fstat ownership check, then parse)
|
|
9
8
|
- DatabricksUnifiedAuthProvider.get_access_token (end-to-end resolution)
|
|
10
9
|
- AuthProvider (local-token precedence, invalidation, EACCES)
|
|
11
10
|
"""
|
|
@@ -115,121 +114,77 @@ def test_get_env_for_pid_parses_and_skips_non_utf8(unified, monkeypatch):
|
|
|
115
114
|
|
|
116
115
|
|
|
117
116
|
# ---------------------------------------------------------------------------
|
|
118
|
-
#
|
|
117
|
+
# _read_token_from_path (atomic open+fstat ownership check, then parse)
|
|
119
118
|
# ---------------------------------------------------------------------------
|
|
120
119
|
|
|
121
120
|
|
|
122
|
-
def
|
|
121
|
+
def test_read_token_from_path_owned_by_uid(unified, tmp_path):
|
|
123
122
|
cfg = tmp_path / "cfg"
|
|
124
|
-
cfg.write_text("[
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
123
|
+
cfg.write_text("[PROD]\ntoken = dapi-prod\n")
|
|
124
|
+
found, token = unified._read_token_from_path(str(cfg), os.getuid(), "PROD")
|
|
125
|
+
assert (found, token) == (True, "dapi-prod")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_read_token_from_path_not_owned_is_rejected(unified, tmp_path):
|
|
129
|
+
# Trust-boundary regression: under root + allow_other, a config file (or
|
|
130
|
+
# a symlink swapped in to point at another user's file) not owned by the
|
|
131
|
+
# requesting uid must be rejected — root must not cache the victim's
|
|
132
|
+
# token under the attacker's uid. Ownership is checked with fstat() on
|
|
133
|
+
# the same fd that is then read, so there is no separate check-then-open
|
|
134
|
+
# step for a swapped path to land in between.
|
|
133
135
|
cfg = tmp_path / "cfg"
|
|
134
|
-
cfg.write_text("[DEFAULT]\ntoken=
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
monkeypatch.setattr(unified, "_home_for_uid", lambda uid: str(tmp_path / "emptyhome"))
|
|
138
|
-
env = {"DATABRICKS_CONFIG_FILE": str(cfg)}
|
|
139
|
-
profile, config_file = unified._get_profile_and_config_file_name(env, os.getuid() + 424242)
|
|
140
|
-
assert (profile, config_file) == (None, None)
|
|
136
|
+
cfg.write_text("[DEFAULT]\ntoken = victim-secret\n")
|
|
137
|
+
found, token = unified._read_token_from_path(str(cfg), os.getuid() + 424242, "DEFAULT")
|
|
138
|
+
assert (found, token) == (False, None)
|
|
141
139
|
|
|
142
140
|
|
|
143
|
-
def
|
|
141
|
+
def test_read_token_from_path_not_regular_is_rejected(unified, tmp_path):
|
|
144
142
|
d = tmp_path / "adir"
|
|
145
143
|
d.mkdir()
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
profile, config_file = unified._get_profile_and_config_file_name(env, os.getuid())
|
|
149
|
-
assert (profile, config_file) == (None, None)
|
|
144
|
+
found, token = unified._read_token_from_path(str(d), os.getuid(), "DEFAULT")
|
|
145
|
+
assert (found, token) == (False, None)
|
|
150
146
|
|
|
151
147
|
|
|
152
|
-
def
|
|
153
|
-
|
|
154
|
-
home.mkdir()
|
|
155
|
-
(home / ".databrickscfg").write_text("[DEFAULT]\ntoken=x\n")
|
|
156
|
-
monkeypatch.setattr(unified, "_home_for_uid", lambda uid: str(home))
|
|
157
|
-
# The default file must also pass the ownership check, so the resolved uid
|
|
158
|
-
# has to match the file owner (the test user that just created it).
|
|
159
|
-
profile, config_file = unified._get_profile_and_config_file_name(env=None, uid=os.getuid())
|
|
160
|
-
assert profile == "DEFAULT"
|
|
161
|
-
assert config_file == str(home / ".databrickscfg")
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def test_profile_config_default_home_file_not_owned_is_rejected(unified, tmp_path, monkeypatch):
|
|
165
|
-
# Trust-boundary regression: under root + allow_other, the default
|
|
166
|
-
# ~/.databrickscfg is resolved from another user's home and could be a
|
|
167
|
-
# symlink the user planted to a victim's config. os.stat follows the link,
|
|
168
|
-
# so the file appears owned by the victim (not the requesting uid) and must
|
|
169
|
-
# be rejected — root must not cache the victim's token under the attacker.
|
|
170
|
-
home = tmp_path / "home"
|
|
171
|
-
home.mkdir()
|
|
172
|
-
(home / ".databrickscfg").write_text("[DEFAULT]\ntoken=victim-secret\n")
|
|
173
|
-
monkeypatch.setattr(unified, "_home_for_uid", lambda uid: str(home))
|
|
174
|
-
# A uid that does not own the file -> rejected, nothing resolved.
|
|
175
|
-
profile, config_file = unified._get_profile_and_config_file_name(
|
|
176
|
-
env=None, uid=os.getuid() + 424242
|
|
177
|
-
)
|
|
178
|
-
assert (profile, config_file) == (None, None)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def test_profile_config_nothing_found(unified, tmp_path, monkeypatch):
|
|
182
|
-
monkeypatch.setattr(unified, "_home_for_uid", lambda uid: str(tmp_path / "empty"))
|
|
183
|
-
profile, config_file = unified._get_profile_and_config_file_name(env=None, uid=os.getuid())
|
|
184
|
-
assert (profile, config_file) == (None, None)
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def test_missing_default_config_is_not_logged_as_error(unified, tmp_path, monkeypatch, caplog):
|
|
188
|
-
"""A missing ~/.databrickscfg is the normal state for a uid without a
|
|
148
|
+
def test_read_token_from_path_missing_file_is_not_logged_as_error(unified, tmp_path, caplog):
|
|
149
|
+
"""A missing config file is the normal state for a uid without a
|
|
189
150
|
Databricks setup (e.g. root probing the mount). It must not be logged at
|
|
190
151
|
error level, or a repeatedly-probing uid floods the journal with one line
|
|
191
152
|
per request."""
|
|
192
|
-
monkeypatch.setattr(unified, "_home_for_uid", lambda uid: str(tmp_path / "empty"))
|
|
193
153
|
with caplog.at_level("DEBUG", logger="fuse4dbricks.auth.provider"):
|
|
194
|
-
|
|
195
|
-
assert (
|
|
196
|
-
# The benign "does not exist" path logs at debug, never at error/warning.
|
|
154
|
+
found, token = unified._read_token_from_path(str(tmp_path / "nope"), os.getuid(), "DEFAULT")
|
|
155
|
+
assert (found, token) == (False, None)
|
|
197
156
|
assert not [r for r in caplog.records if r.levelname in ("ERROR", "WARNING")]
|
|
198
157
|
assert any(r.levelname == "DEBUG" for r in caplog.records)
|
|
199
158
|
|
|
200
159
|
|
|
201
|
-
def
|
|
202
|
-
"""
|
|
203
|
-
unexpected and stays visible at warning level."""
|
|
204
|
-
def _boom(path):
|
|
160
|
+
def test_read_token_from_path_open_failure_is_logged_as_warning(unified, tmp_path, monkeypatch, caplog):
|
|
161
|
+
"""An open() failure that is NOT 'file missing' (e.g. permission denied)
|
|
162
|
+
is unexpected and stays visible at warning level."""
|
|
163
|
+
def _boom(path, flags):
|
|
205
164
|
raise PermissionError("denied")
|
|
206
|
-
monkeypatch.setattr(provider_mod.os, "
|
|
165
|
+
monkeypatch.setattr(provider_mod.os, "open", _boom)
|
|
207
166
|
with caplog.at_level("DEBUG", logger="fuse4dbricks.auth.provider"):
|
|
208
|
-
|
|
167
|
+
found, token = unified._read_token_from_path(str(tmp_path / "cfg"), os.getuid(), "DEFAULT")
|
|
168
|
+
assert (found, token) == (False, None)
|
|
209
169
|
assert any(r.levelname == "WARNING" for r in caplog.records)
|
|
210
170
|
|
|
211
171
|
|
|
212
|
-
|
|
213
|
-
# _get_token_from_config
|
|
214
|
-
# ---------------------------------------------------------------------------
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def test_get_token_from_config_reads_token(unified, tmp_path):
|
|
172
|
+
def test_read_token_from_path_reads_token(unified, tmp_path):
|
|
218
173
|
cfg = tmp_path / "cfg"
|
|
219
174
|
cfg.write_text("[DEFAULT]\ntoken = dapi-default\n\n[PROD]\ntoken = dapi-prod\n")
|
|
220
|
-
assert unified.
|
|
175
|
+
assert unified._read_token_from_path(str(cfg), os.getuid(), "PROD") == (True, "dapi-prod")
|
|
221
176
|
|
|
222
177
|
|
|
223
|
-
def
|
|
178
|
+
def test_read_token_from_path_missing_profile(unified, tmp_path):
|
|
224
179
|
cfg = tmp_path / "cfg"
|
|
225
180
|
cfg.write_text("[DEFAULT]\ntoken = x\n")
|
|
226
|
-
assert unified.
|
|
181
|
+
assert unified._read_token_from_path(str(cfg), os.getuid(), "NOPE") == (True, None)
|
|
227
182
|
|
|
228
183
|
|
|
229
|
-
def
|
|
184
|
+
def test_read_token_from_path_missing_token_key(unified, tmp_path):
|
|
230
185
|
cfg = tmp_path / "cfg"
|
|
231
186
|
cfg.write_text("[DEFAULT]\nhost = https://x\n")
|
|
232
|
-
assert unified.
|
|
187
|
+
assert unified._read_token_from_path(str(cfg), os.getuid(), "DEFAULT") == (True, None)
|
|
233
188
|
|
|
234
189
|
|
|
235
190
|
# ---------------------------------------------------------------------------
|
|
@@ -244,21 +199,42 @@ async def test_unified_get_token_prefers_env_token(unified, monkeypatch):
|
|
|
244
199
|
|
|
245
200
|
|
|
246
201
|
@pytest.mark.trio
|
|
247
|
-
async def
|
|
202
|
+
async def test_unified_get_token_uses_env_config_file(unified, tmp_path, monkeypatch):
|
|
248
203
|
cfg = tmp_path / "cfg"
|
|
249
204
|
cfg.write_text("[DEFAULT]\ntoken = dapi-cfg\n")
|
|
250
|
-
monkeypatch.setattr(unified, "_get_env_for_pid", lambda pid: {})
|
|
205
|
+
monkeypatch.setattr(unified, "_get_env_for_pid", lambda pid: {"DATABRICKS_CONFIG_FILE": str(cfg)})
|
|
206
|
+
assert await unified.get_access_token(_ctx(uid=os.getuid())) == "dapi-cfg"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@pytest.mark.trio
|
|
210
|
+
async def test_unified_get_token_missing_env_file_falls_back_to_default(unified, tmp_path, monkeypatch):
|
|
211
|
+
home = tmp_path / "home"
|
|
212
|
+
home.mkdir()
|
|
213
|
+
(home / ".databrickscfg").write_text("[DEFAULT]\ntoken = dapi-home\n")
|
|
251
214
|
monkeypatch.setattr(
|
|
252
|
-
unified, "
|
|
215
|
+
unified, "_get_env_for_pid",
|
|
216
|
+
lambda pid: {"DATABRICKS_CONFIG_FILE": str(tmp_path / "does-not-exist")},
|
|
253
217
|
)
|
|
254
|
-
|
|
218
|
+
monkeypatch.setattr(unified, "_home_for_uid", lambda uid: str(home))
|
|
219
|
+
assert await unified.get_access_token(_ctx(uid=os.getuid())) == "dapi-home"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@pytest.mark.trio
|
|
223
|
+
async def test_unified_get_token_env_file_not_owned_is_ignored(unified, tmp_path, monkeypatch):
|
|
224
|
+
cfg = tmp_path / "cfg"
|
|
225
|
+
cfg.write_text("[DEFAULT]\ntoken = x\n")
|
|
226
|
+
monkeypatch.setattr(unified, "_get_env_for_pid", lambda pid: {"DATABRICKS_CONFIG_FILE": str(cfg)})
|
|
227
|
+
monkeypatch.setattr(unified, "_home_for_uid", lambda uid: str(tmp_path / "emptyhome"))
|
|
228
|
+
# A uid that does not own cfg -> the env config file is rejected; the
|
|
229
|
+
# default home has no config file either -> nothing resolved.
|
|
230
|
+
assert await unified.get_access_token(_ctx(uid=os.getuid() + 424242)) is None
|
|
255
231
|
|
|
256
232
|
|
|
257
233
|
@pytest.mark.trio
|
|
258
|
-
async def test_unified_get_token_none_when_unresolvable(unified, monkeypatch):
|
|
234
|
+
async def test_unified_get_token_none_when_unresolvable(unified, tmp_path, monkeypatch):
|
|
259
235
|
monkeypatch.setattr(unified, "_get_env_for_pid", lambda pid: None)
|
|
260
|
-
monkeypatch.setattr(unified, "
|
|
261
|
-
assert await unified.get_access_token(_ctx()) is None
|
|
236
|
+
monkeypatch.setattr(unified, "_home_for_uid", lambda uid: str(tmp_path / "empty"))
|
|
237
|
+
assert await unified.get_access_token(_ctx(uid=os.getuid())) is None
|
|
262
238
|
|
|
263
239
|
|
|
264
240
|
# ---------------------------------------------------------------------------
|
|
@@ -14,7 +14,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
14
14
|
import pytest
|
|
15
15
|
import pyfuse3
|
|
16
16
|
|
|
17
|
-
from fuse4dbricks.fs.data_manager import DataManager
|
|
17
|
+
from fuse4dbricks.fs.data_manager import DataManager, _ChunkRequest
|
|
18
|
+
from fuse4dbricks.fs.ram_cache import RamCache
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
# ---------------------------------------------------------------------------
|
|
@@ -285,6 +286,85 @@ async def test_read_prefetch_stops_at_eof(manager, ctx):
|
|
|
285
286
|
assert 3 not in prefetched_chunk_ids # chunk 3 doesn't exist
|
|
286
287
|
|
|
287
288
|
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
# Tests: _process_request (on-demand reads vs. prefetch downloads)
|
|
291
|
+
#
|
|
292
|
+
# On-demand ("high" priority) reads need the actual bytes now, so they read
|
|
293
|
+
# the chunk (from disk, falling back to network) and cache it in RAM.
|
|
294
|
+
# Prefetch ("regular" priority) only needs the chunk to end up on disk --
|
|
295
|
+
# reading it back would cost as much disk I/O as the read it's trying to
|
|
296
|
+
# save, for a chunk that may never be read, so it uses a cheap existence
|
|
297
|
+
# check and never populates the RAM cache.
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _chunk_request(manager, chunk_id=0, mtime=100.0, gen=0):
|
|
302
|
+
return _ChunkRequest(
|
|
303
|
+
fs_path="/c/s/v/f", chunk_id=chunk_id, mtime=mtime, gen=gen,
|
|
304
|
+
chunk_size=manager.chunk_size, ctx=SimpleNamespace(uid=1000, pid=1, gid=1000),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@pytest.mark.trio
|
|
309
|
+
async def test_process_request_high_priority_disk_hit_caches_in_ram(manager):
|
|
310
|
+
# The `manager` fixture's ram_cache_mb=1 rounds down to 0-entry capacity
|
|
311
|
+
# (1 MB < one 8 MB chunk), which would no-op every put/get below.
|
|
312
|
+
manager._ram_cache = RamCache(max_entries=8)
|
|
313
|
+
manager.persistence.retrieve_chunk = AsyncMock(return_value=b"cached-bytes")
|
|
314
|
+
request = _chunk_request(manager)
|
|
315
|
+
|
|
316
|
+
await manager._process_request(request, priority="high")
|
|
317
|
+
|
|
318
|
+
manager.persistence.store_chunk_from_stream.assert_not_awaited()
|
|
319
|
+
cached = await manager._ram_cache.get(("/c/s/v/f", 0, 100.0, 0))
|
|
320
|
+
assert cached == b"cached-bytes"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@pytest.mark.trio
|
|
324
|
+
async def test_process_request_high_priority_miss_downloads_and_caches_in_ram(manager):
|
|
325
|
+
manager._ram_cache = RamCache(max_entries=8)
|
|
326
|
+
manager.persistence.retrieve_chunk = AsyncMock(return_value=None)
|
|
327
|
+
manager.persistence.store_chunk_from_stream = AsyncMock(return_value=b"downloaded")
|
|
328
|
+
request = _chunk_request(manager)
|
|
329
|
+
|
|
330
|
+
await manager._process_request(request, priority="high")
|
|
331
|
+
|
|
332
|
+
manager.persistence.store_chunk_from_stream.assert_awaited_once()
|
|
333
|
+
cached = await manager._ram_cache.get(("/c/s/v/f", 0, 100.0, 0))
|
|
334
|
+
assert cached == b"downloaded"
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@pytest.mark.trio
|
|
338
|
+
async def test_process_request_prefetch_skips_download_when_already_on_disk(manager):
|
|
339
|
+
manager.persistence.chunk_exists = AsyncMock(return_value=True)
|
|
340
|
+
request = _chunk_request(manager, chunk_id=5)
|
|
341
|
+
|
|
342
|
+
await manager._process_request(request, priority="regular")
|
|
343
|
+
|
|
344
|
+
manager.persistence.chunk_exists.assert_awaited_once_with(
|
|
345
|
+
fs_path="/c/s/v/f", chunk_index=5, mtime=100.0, gen=0
|
|
346
|
+
)
|
|
347
|
+
# No download, and -- crucially -- no read of the chunk's content either
|
|
348
|
+
# (that would cost the same disk I/O the existence check is avoiding).
|
|
349
|
+
manager.persistence.retrieve_chunk.assert_not_awaited()
|
|
350
|
+
manager.persistence.store_chunk_from_stream.assert_not_awaited()
|
|
351
|
+
assert await manager._ram_cache.get(("/c/s/v/f", 5, 100.0, 0)) is None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@pytest.mark.trio
|
|
355
|
+
async def test_process_request_prefetch_downloads_when_missing_but_not_cached_in_ram(manager):
|
|
356
|
+
manager.persistence.chunk_exists = AsyncMock(return_value=False)
|
|
357
|
+
manager.persistence.store_chunk_from_stream = AsyncMock(return_value=b"downloaded")
|
|
358
|
+
request = _chunk_request(manager, chunk_id=5)
|
|
359
|
+
|
|
360
|
+
await manager._process_request(request, priority="regular")
|
|
361
|
+
|
|
362
|
+
manager.persistence.retrieve_chunk.assert_not_awaited()
|
|
363
|
+
manager.persistence.store_chunk_from_stream.assert_awaited_once()
|
|
364
|
+
# Prefetched content is deliberately not promoted into the RAM cache.
|
|
365
|
+
assert await manager._ram_cache.get(("/c/s/v/f", 5, 100.0, 0)) is None
|
|
366
|
+
|
|
367
|
+
|
|
288
368
|
# ---------------------------------------------------------------------------
|
|
289
369
|
# Tests: cache generation (invalidate_path)
|
|
290
370
|
# ---------------------------------------------------------------------------
|
|
@@ -110,6 +110,33 @@ async def test_retrieve_missing_chunk(persistence):
|
|
|
110
110
|
assert result is None
|
|
111
111
|
|
|
112
112
|
|
|
113
|
+
@pytest.mark.trio
|
|
114
|
+
async def test_chunk_exists_true_after_store(persistence):
|
|
115
|
+
"""chunk_exists reports a stored chunk as present, without needing to
|
|
116
|
+
read its content."""
|
|
117
|
+
await persistence.store_chunk_from_stream("f", 0, 5.0, async_byte_generator(b"data"))
|
|
118
|
+
assert await persistence.chunk_exists("f", 0, 5.0) is True
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@pytest.mark.trio
|
|
122
|
+
async def test_chunk_exists_false_for_missing_chunk(persistence):
|
|
123
|
+
assert await persistence.chunk_exists("ghost_file", 99, 0.0) is False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@pytest.mark.trio
|
|
127
|
+
async def test_chunk_exists_does_not_read_file_content(persistence, monkeypatch):
|
|
128
|
+
"""chunk_exists must not pay the I/O cost of reading the chunk back --
|
|
129
|
+
that's the whole point of using it instead of retrieve_chunk for a
|
|
130
|
+
prefetch's "is a download needed" check."""
|
|
131
|
+
await persistence.store_chunk_from_stream("f", 0, 5.0, async_byte_generator(b"data"))
|
|
132
|
+
|
|
133
|
+
def _boom(path):
|
|
134
|
+
raise AssertionError("chunk_exists must not read the file's content")
|
|
135
|
+
|
|
136
|
+
monkeypatch.setattr(persistence, "_read_file", _boom)
|
|
137
|
+
assert await persistence.chunk_exists("f", 0, 5.0) is True
|
|
138
|
+
|
|
139
|
+
|
|
113
140
|
@pytest.mark.trio
|
|
114
141
|
async def test_get_chunk_path_is_pure_no_dir_created(persistence):
|
|
115
142
|
"""_get_chunk_path must not touch the filesystem: it runs on every read,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|