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