@@ -101,6 +101,11 @@ class ChunkedFileDoesNotExist(Exception):
101101
102102CHUNK_SUFFIX = ".chunks"
103103
104+ # Marker file used to detect when a chunk directory has been deleted and
105+ # recreated, on filesystems that do not report a usable inode (see
106+ # ChunkedFile._chunk_dir_incarnation).
107+ INCARNATION_MARKER = ".incarnation"
108+
104109
105110class TransferFileBase (BufferedIOBase , ABC ):
106111 """Abstract base class for file transfer destination objects."""
@@ -182,8 +187,17 @@ def _do_file_eviction(self, chunked_file_stats, file_size):
182187 if file_size <= evicted_file_size :
183188 break
184189 file_stats = chunked_file_stats [chunked_file_dir ]
190+ try :
191+ shutil .rmtree (chunked_file_dir )
192+ except OSError as e :
193+ # The directory may be in use - for example a diskcache handle
194+ # held open while a file is being streamed, which blocks removal
195+ # on Windows. Skip it rather than aborting the whole eviction.
196+ logger .warning (
197+ "Could not evict chunked file {}: {}" .format (chunked_file_dir , e )
198+ )
199+ continue
185200 evicted_file_size += file_stats ["size" ]
186- shutil .rmtree (chunked_file_dir )
187201 return evicted_file_size
188202
189203 def evict_files (self , file_size ):
@@ -213,9 +227,24 @@ class ChunkedFile(TransferFileBase):
213227 # Set chunk size to 128KB
214228 chunk_size = 128 * 1024
215229
216- def __init__ (self , filepath , raise_if_empty = False , raise_if_exists = False ):
230+ def __init__ (
231+ self , filepath , raise_if_empty = False , raise_if_exists = False , cache = None
232+ ):
217233 self .filepath = filepath
218234 self .chunk_dir = filepath + CHUNK_SUFFIX
235+ # The diskcache handle is opened lazily and then reused for the lifetime
236+ # of this object, rather than being reopened for every cache access.
237+ # Opening a diskcache constructs a SQLite connection (with pragmas and
238+ # fsyncs), which is cheap on fast storage but very expensive on slow
239+ # storage like SD cards, where reopening it per read block made serving
240+ # proxied remote files pathologically slow. A caller may inject an
241+ # existing handle (see ``cache``) so that related objects for the same
242+ # file (e.g. a RemoteFile and the FileDownload streaming into it) share
243+ # one handle; an injected handle is not owned and so is not closed here.
244+ # These are assigned before the early returns below so that close() on a
245+ # partially constructed instance does not raise.
246+ self ._cache = cache
247+ self ._owns_cache = cache is None
219248 if raise_if_empty and not os .path .exists (self .chunk_dir ):
220249 raise FileNotFoundError ("Chunked file does not exist" )
221250 if raise_if_exists and os .path .exists (self .filepath ):
@@ -224,19 +253,96 @@ def __init__(self, filepath, raise_if_empty=False, raise_if_exists=False):
224253 self .position = 0
225254 self ._file_size = None
226255
256+ def _chunk_dir_incarnation (self ):
257+ """
258+ Return a token identifying the current on-disk incarnation of the chunk
259+ directory, so that a deletion + recreation (e.g. streamed cache
260+ eviction) can be detected and a stale diskcache handle dropped. Prefers
261+ the directory inode (a cheap stat), and falls back to a marker file on
262+ filesystems that do not report a usable inode (e.g. FAT/exFAT, some
263+ Android storage), where st_ino is 0.
264+ """
265+ try :
266+ inode = os .stat (self .chunk_dir ).st_ino
267+ if inode :
268+ # The inode changes when the directory is deleted and recreated.
269+ # We deliberately do not pair it with mtime/ctime: those change
270+ # whenever a chunk file is written into the directory, which
271+ # would churn the token during normal streaming. The residual
272+ # risk is a recreated directory being assigned the exact same
273+ # inode number (POSIX inode recycling) while a stale handle is
274+ # still held - vanishingly unlikely, and only reachable in the
275+ # already-narrow eviction-mid-stream race.
276+ return inode
277+ marker = os .path .join (self .chunk_dir , INCARNATION_MARKER )
278+ try :
279+ with open (marker ) as f :
280+ return f .read ()
281+ except FileNotFoundError :
282+ token = os .urandom (8 ).hex ()
283+ try :
284+ # Atomic create so concurrent openers agree on one token.
285+ with open (marker , "x" ) as f :
286+ f .write (token )
287+ return token
288+ except FileExistsError :
289+ with open (marker ) as f :
290+ return f .read ()
291+ except OSError :
292+ # The directory was removed between the _check_for_chunk_dir guard
293+ # and here (eviction racing a stream). Normalize to the exception
294+ # callers already handle, rather than letting a bare OSError escape
295+ # and fail the download hard.
296+ raise ChunkedFileDoesNotExist ("Chunked file does not exist" )
297+
298+ @contextmanager
227299 def _open_cache (self ):
228300 self ._check_for_chunk_dir ()
229- return Cache (self .cache_dir )
301+ incarnation = self ._chunk_dir_incarnation ()
302+ if self ._cache is not None and (
303+ getattr (self ._cache , "_kolibri_chunk_incarnation" , None ) != incarnation
304+ ):
305+ # The chunk directory was deleted and recreated (e.g. by streamed
306+ # cache eviction) since this handle was opened, so it points at the
307+ # old, now-deleted diskcache. Drop it and reopen against the live
308+ # directory, matching the pre-memoization behaviour of always using
309+ # the current cache. This matters for correctness, not just leaks:
310+ # chunk locks live as rows in the on-disk SQLite DB, so a stale
311+ # handle would silently fail to coordinate with other readers.
312+ self ._close_cache ()
313+ if self ._cache is None :
314+ cache = Cache (self .cache_dir )
315+ # Stamp the handle with the incarnation it was opened against so a
316+ # later deletion/recreation can be detected.
317+ cache ._kolibri_chunk_incarnation = incarnation
318+ self ._cache = cache
319+ # We opened this handle, so this instance owns and must close it.
320+ self ._owns_cache = True
321+ yield self ._cache
322+
323+ def get_cache (self ):
324+ """
325+ Return the reused diskcache handle for this file, opening it if needed.
326+ Used to share a single handle with a FileDownload for the same file.
327+ """
328+ with self ._open_cache () as cache :
329+ return cache
230330
231331 def _initialize (self ):
232332 os .makedirs (self .chunk_dir , exist_ok = True )
233333 self .cache_dir = os .path .join (self .chunk_dir , ".cache" )
234- self .position = 0
334+ # NB: do not reset self.position here. _initialize is also invoked by
335+ # ensure_writable() to recreate a directory that was cleaned up mid-read,
336+ # and resetting the read cursor there would silently rewind an
337+ # in-progress read. __init__ sets the initial position separately.
235338
236339 def ensure_writable (self ):
237340 try :
238341 self ._check_for_chunk_dir ()
239342 except ChunkedFileDoesNotExist :
343+ # The chunk directory was removed by another process; recreate it.
344+ # Any cache handle we still hold is detected as stale and reopened on
345+ # next use (see _open_cache), so we don't need to touch it here.
240346 self ._initialize ()
241347
242348 @property
@@ -472,10 +578,20 @@ def md5_checksum(self):
472578 return md5 .hexdigest ()
473579
474580 def delete (self ):
581+ # Release our diskcache handle before removing the directory: on Windows
582+ # the open SQLite file would otherwise block removal (WinError 32).
583+ self ._close_cache ()
475584 shutil .rmtree (self .chunk_dir )
476585
586+ def _close_cache (self ):
587+ # Only close a handle we opened ourselves; an injected handle is owned
588+ # (and closed) by whoever provided it.
589+ if self ._owns_cache and self ._cache is not None :
590+ self ._cache .close ()
591+ self ._cache = None
592+
477593 def close (self ):
478- pass
594+ self . _close_cache ()
479595
480596 def __enter__ (self ):
481597 return self
@@ -772,13 +888,21 @@ def __init__(
772888 timeout = Transfer .DEFAULT_TIMEOUT ,
773889 retry_wait = 30 ,
774890 full_ranges = True ,
891+ cache = None ,
775892 ):
776893
777894 # Allow an existing requests.Session to be passed in, so it can be
778895 # reused for speed. The default blocks cross-host redirects so a
779896 # caller-supplied baseurl can't pivot us onto another host.
780897 self .session = session or SameHostSession ()
781898
899+ # Allow an existing diskcache handle to be passed in, so the chunked
900+ # destination file reuses it rather than opening its own. This lets a
901+ # RemoteFile streaming a proxied file share one handle across itself and
902+ # every FileDownload it spawns, instead of reopening the diskcache once
903+ # per downloaded chunk.
904+ self ._cache = cache
905+
782906 # A flag to allow the download to remain in the chunked file directory
783907 # for easier clean up when it is just a temporary download.
784908 self ._finalize_download = finalize_download
@@ -815,6 +939,7 @@ def _initialize_dest_file(self):
815939 self .dest ,
816940 raise_if_empty = self .full_ranges ,
817941 raise_if_exists = self .full_ranges ,
942+ cache = self ._cache ,
818943 )
819944 except FileNotFoundError :
820945 # No chunked file exists, use TransferFile for direct download
@@ -1133,13 +1258,21 @@ def _run_transfer(self):
11331258
11341259 def _start_transfer (self , start = None , end = None ):
11351260 if not self .is_complete (start = start , end = end ):
1261+ # Recreate the chunk directory (and drop any stale cache handle) if
1262+ # it was cleaned up by another process since we opened it, so that
1263+ # sharing our cache handle below opens against a directory that
1264+ # exists rather than raising.
1265+ self .ensure_writable ()
11361266 self .transfer = FileDownload (
11371267 self .remote_url ,
11381268 self .filepath ,
11391269 start_range = start ,
11401270 end_range = end ,
11411271 finalize_download = False ,
11421272 full_ranges = False ,
1273+ # Share this file's diskcache handle with the download, so
1274+ # streaming a file doesn't reopen the diskcache once per chunk.
1275+ cache = self .get_cache (),
11431276 )
11441277 with self ._open_cache () as cache :
11451278 header_info = cache .get (self .remote_url )
@@ -1171,3 +1304,5 @@ def close(self):
11711304 self .transfer .close ()
11721305 if self ._dest_file_handle :
11731306 self ._dest_file_handle .close ()
1307+ # Close the reused diskcache handle shared with any FileDownload.
1308+ super ().close ()
0 commit comments