1717import hashlib
1818import os
1919import tempfile
20+ import threading
2021import time
2122from pathlib import Path
2223from typing import Iterable
@@ -401,6 +402,19 @@ def __init__(
401402 # crashed writers. Age-based so concurrent in-flight writes from
402403 # other processes are preserved.
403404 self ._sweep_stale_tmp_files ()
405+ # Incremental size tracker. Without it every ``__setitem__`` would
406+ # walk ``entries/`` + ``tmp/`` to compute the total -- O(n) per
407+ # write. With it: writes update the tracker by the net delta in O(1)
408+ # and only walk on eviction (which already needs the scan to sort
409+ # entries by atime). The tracker is seeded by one full scan at open
410+ # time and refreshed on every eviction pass; cross-process drift
411+ # (other writers/deleters) self-corrects the next time eviction
412+ # fires. The lock guards mutations so multi-threaded writers in
413+ # the same process don't interleave the read-modify-write on the
414+ # int. Skipped entirely when ``max_size_bytes is None`` -- without
415+ # a cap the tracker is dead weight.
416+ self ._size_lock = threading .Lock ()
417+ self ._tracked_size_bytes = self ._compute_total_size () if max_size_bytes is not None else 0
404418
405419 # -- key-to-path helpers -------------------------------------------------
406420
@@ -445,6 +459,18 @@ def __setitem__(self, key: object, value: bytes | bytearray | memoryview | Objec
445459 # FileNotFoundError even though we could trivially recover.
446460 self ._tmp .mkdir (parents = True , exist_ok = True )
447461
462+ # Stat the existing entry (if any) BEFORE the replace so we can
463+ # update the tracker by the net delta. A racing writer that lands
464+ # an ``os.replace`` between this stat and our own makes ``old_size``
465+ # slightly off; the next ``_enforce_size_cap`` reconciles by
466+ # re-scanning. Skipped when ``max_size_bytes is None`` (no tracker).
467+ old_size = 0
468+ if self ._max_size_bytes is not None :
469+ try :
470+ old_size = target .stat ().st_size
471+ except FileNotFoundError :
472+ old_size = 0
473+
448474 fd , tmp_name = tempfile .mkstemp (prefix = "entry-" , dir = self ._tmp )
449475 tmp_path = Path (tmp_name )
450476 try :
@@ -465,14 +491,39 @@ def __setitem__(self, key: object, value: bytes | bytearray | memoryview | Objec
465491 with contextlib .suppress (FileNotFoundError ):
466492 tmp_path .unlink ()
467493 raise
468- self ._enforce_size_cap ()
494+
495+ if self ._max_size_bytes is None :
496+ return
497+
498+ # O(1) tracker update. Only run the scan-heavy ``_enforce_size_cap``
499+ # when this write actually pushes the running total above the cap.
500+ new_size = len (data )
501+ with self ._size_lock :
502+ self ._tracked_size_bytes += new_size - old_size
503+ over_cap = self ._tracked_size_bytes > self ._max_size_bytes
504+ if over_cap :
505+ self ._enforce_size_cap ()
469506
470507 def __delitem__ (self , key : object ) -> None :
471508 path = self ._path_for_key (key )
509+ # Stat before unlink so we can decrement the tracker by the actual
510+ # on-disk size. Best-effort: if the file vanishes between stat and
511+ # unlink (concurrent eviction), we treat the delete as a miss --
512+ # matching the behaviour callers expect (KeyError) and leaving the
513+ # tracker untouched (the racing eviction already accounted for it).
514+ size = 0
515+ if self ._max_size_bytes is not None :
516+ try :
517+ size = path .stat ().st_size
518+ except FileNotFoundError :
519+ raise KeyError (key ) from None
472520 try :
473521 _unlink_with_sharing_retry (path )
474522 except FileNotFoundError :
475523 raise KeyError (key ) from None
524+ if self ._max_size_bytes is not None :
525+ with self ._size_lock :
526+ self ._tracked_size_bytes -= size
476527
477528 def __len__ (self ) -> int :
478529 """Return the number of files currently in ``entries/``.
@@ -513,18 +564,71 @@ def clear(self) -> None:
513564 if sub .is_dir ():
514565 with contextlib .suppress (OSError ):
515566 sub .rmdir ()
567+ # The directory is now (almost) empty -- but a concurrent writer may
568+ # have landed a fresh entry between the snapshot and the unlink, and
569+ # young temp files were intentionally preserved. Re-derive the
570+ # tracker from the post-clear state instead of zeroing blindly.
571+ if self ._max_size_bytes is not None :
572+ actual = self ._compute_total_size ()
573+ with self ._size_lock :
574+ self ._tracked_size_bytes = actual
516575
517576 # -- internals -----------------------------------------------------------
518577
519578 def _iter_entry_paths (self ) -> Iterable [Path ]:
520- if not self ._entries .exists ():
579+ # ``os.scandir`` returns ``DirEntry`` objects whose ``is_dir`` /
580+ # ``is_file`` methods consult the cached dirent type from the
581+ # ``readdir`` result on filesystems that report it (ext4, NTFS, ...),
582+ # avoiding a per-entry ``stat`` syscall. ``Path.iterdir`` also wraps
583+ # ``scandir`` but discards the cached type, forcing a separate
584+ # ``stat`` for every ``Path.is_dir`` / ``Path.is_file`` -- a real
585+ # cost on caches with thousands of entries. ``follow_symlinks=False``
586+ # matches the prior behaviour: ``pathlib`` defaults to following,
587+ # but our cache layout never creates symlinks, so the choice is
588+ # only visible if a user manually points one inside ``entries/``;
589+ # treating it as the file/dir it points to is the surprising
590+ # outcome, so we don't.
591+ try :
592+ outer = os .scandir (self ._entries )
593+ except FileNotFoundError :
521594 return
522- for sub in self ._entries .iterdir ():
523- if not sub .is_dir ():
595+ with outer :
596+ for sub in outer :
597+ if not sub .is_dir (follow_symlinks = False ):
598+ continue
599+ try :
600+ inner = os .scandir (sub .path )
601+ except FileNotFoundError :
602+ continue
603+ with inner :
604+ for entry in inner :
605+ if entry .is_file (follow_symlinks = False ):
606+ yield Path (entry .path )
607+
608+ def _compute_total_size (self ) -> int :
609+ """Walk ``entries/`` + ``tmp/`` and return the on-disk byte total.
610+
611+ Used to seed the tracker at open time and to refresh it after every
612+ eviction pass. Best-effort: files that vanish under us during the
613+ walk (concurrent eviction by this or another process) are skipped.
614+ Tracked total may briefly differ from this scan's result under
615+ cross-process contention; the next eviction will reconcile.
616+ """
617+ total = 0
618+ for path in self ._iter_entry_paths ():
619+ try :
620+ total += path .stat ().st_size
621+ except FileNotFoundError :
524622 continue
525- for entry in sub .iterdir ():
526- if entry .is_file ():
527- yield entry
623+ if self ._tmp .exists ():
624+ for tmp in self ._tmp .iterdir ():
625+ if not tmp .is_file ():
626+ continue
627+ try :
628+ total += tmp .stat ().st_size
629+ except FileNotFoundError :
630+ continue
631+ return total
528632
529633 def _sweep_stale_tmp_files (self ) -> None :
530634 """Remove temp files left behind by crashed writers.
@@ -587,11 +691,18 @@ def _enforce_size_cap(self) -> None:
587691 except FileNotFoundError :
588692 continue
589693 if total <= self ._max_size_bytes :
694+ # Re-seed the tracker from the scan: catches drift from
695+ # cross-process writers/deleters that the per-write delta
696+ # accounting wouldn't have observed. Reaching here means the
697+ # tracker was over-cap but the disk truth is under-cap, so
698+ # this assignment is the cheapest reconciliation point we get.
699+ with self ._size_lock :
700+ self ._tracked_size_bytes = total
590701 return
591702 entries .sort (key = lambda e : e [0 ]) # oldest atime first
592703 for _atime , size , path , st_before in entries :
593704 if total <= self ._max_size_bytes :
594- return
705+ break
595706 # _prune_if_stat_unchanged refuses if a writer replaced the file
596707 # between snapshot and now, so eviction can't silently delete a
597708 # freshly-committed entry from another process.
@@ -620,3 +731,8 @@ def _enforce_size_cap(self) -> None:
620731 except PermissionError as exc :
621732 if not _is_windows_sharing_violation (exc ):
622733 raise
734+ # Reconcile: after the eviction pass, ``total`` reflects what we
735+ # believe the disk now holds. Re-seed the tracker so the next write
736+ # accumulates from a fresh baseline.
737+ with self ._size_lock :
738+ self ._tracked_size_bytes = total
0 commit comments