Skip to content

Commit 991883c

Browse files
authored
Fix pex3 cache prune handling of cached Pips. (#2589)
Previously, performing a `pex3 cache prune` would bump the last access time of all un-pruned cached Pips artificially. If you ran `pex3 cache prune` in a daily or weekly cron job, this would mean Pips would never be pruned.
1 parent 06b8850 commit 991883c

File tree

12 files changed

+147
-100
lines changed

12 files changed

+147
-100
lines changed

CHANGES.md

+10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Release Notes
22

3+
## 2.24.1
4+
5+
This release fixes `pex3 cache prune` handling of cached Pips.
6+
Previously, performing a `pex3 cache prune` would bump the last access
7+
time of all un-pruned cached Pips artificially. If you ran
8+
`pex3 cache prune` in a daily or weekly cron job, this would mean Pips
9+
would never be pruned.
10+
11+
* Fix `pex3 cache prune` handling of cached Pips. (#2589)
12+
313
## 2.24.0
414

515
This release adds `pex3 cache prune` as a likely more useful Pex cache

pex/cache/access.py

+13-11
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,16 @@
66
import fcntl
77
import itertools
88
import os
9-
import time
109
from contextlib import contextmanager
1110

12-
from pex.common import safe_mkdir
11+
from pex.common import safe_mkdir, touch
1312
from pex.typing import TYPE_CHECKING
1413
from pex.variables import ENV
1514

1615
if TYPE_CHECKING:
1716
from typing import Iterator, Optional, Tuple, Union
1817

19-
from pex.cache.dirs import AtomicCacheDir, UnzipDir, VenvDirs # noqa
18+
from pex.cache.dirs import UnzipDir, VenvDir, VenvDirs # noqa
2019

2120

2221
# N.B.: The lock file path is last in the lock state tuple to allow for a simple encoding scheme in
@@ -105,18 +104,21 @@ def await_delete_lock():
105104
_lock(exclusive=True)
106105

107106

107+
LAST_ACCESS_FILE = ".last-access"
108+
109+
110+
def _last_access_file(pex_dir):
111+
# type: (Union[UnzipDir, VenvDir, VenvDirs]) -> str
112+
return os.path.join(pex_dir.path, LAST_ACCESS_FILE)
113+
114+
108115
def record_access(
109-
atomic_cache_dir, # type: AtomicCacheDir
116+
pex_dir, # type: Union[UnzipDir, VenvDir]
110117
last_access=None, # type: Optional[float]
111118
):
112119
# type: (...) -> None
113120

114-
# N.B.: We explicitly set atime and do not rely on the filesystem implicitly setting it when the
115-
# directory is read since filesystems may be mounted noatime, nodiratime or relatime on Linux
116-
# and similar toggles exist, at least in part, for some macOS file systems.
117-
atime = last_access or time.time()
118-
mtime = os.stat(atomic_cache_dir.path).st_mtime
119-
os.utime(atomic_cache_dir.path, (atime, mtime))
121+
touch(_last_access_file(pex_dir), last_access)
120122

121123

122124
def iter_all_cached_pex_dirs():
@@ -128,5 +130,5 @@ def iter_all_cached_pex_dirs():
128130
UnzipDir.iter_all(), VenvDirs.iter_all()
129131
) # type: Iterator[Union[UnzipDir, VenvDirs]]
130132
for pex_dir in pex_dirs:
131-
last_access = os.stat(pex_dir.path).st_atime
133+
last_access = os.stat(_last_access_file(pex_dir)).st_mtime
132134
yield pex_dir, last_access

pex/cache/dirs.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -189,15 +189,15 @@ def iter_transitive_dependents(self):
189189

190190
UNZIPPED_PEXES = Value(
191191
"unzipped_pexes",
192-
version=0,
192+
version=1,
193193
name="Unzipped PEXes",
194194
description="The unzipped PEX files executed on this machine.",
195195
dependencies=[BOOTSTRAPS, USER_CODE, INSTALLED_WHEELS],
196196
)
197197

198198
VENVS = Value(
199199
"venvs",
200-
version=0,
200+
version=1,
201201
name="Virtual Environments",
202202
description="Virtual environments generated at runtime for `--venv` mode PEXes.",
203203
dependencies=[INSTALLED_WHEELS],

pex/cache/prunable.py

+9-16
Original file line numberDiff line numberDiff line change
@@ -41,31 +41,24 @@
4141
from pex.third_party import attr
4242

4343

44-
@attr.s(frozen=True)
45-
class PrunablePipCache(object):
46-
pip = attr.ib() # type: Pip
47-
pex_dir = attr.ib() # type: Union[UnzipDir, VenvDirs]
48-
last_access = attr.ib() # type: float
49-
50-
5144
@attr.s(frozen=True)
5245
class Pips(object):
5346
@classmethod
5447
def scan(cls, pex_dirs_by_hash):
55-
# type: (Mapping[str, Tuple[Union[UnzipDir, VenvDirs], float, bool]]) -> Pips
48+
# type: (Mapping[str, Tuple[Union[UnzipDir, VenvDirs], bool]]) -> Pips
5649

5750
# True to prune the Pip version completely, False to just prune the Pip PEX.
5851
pips_to_prune = OrderedDict() # type: OrderedDict[Pip, bool]
5952

6053
# N.B.: We just need 1 Pip per version (really per paired cache). Whether a Pip has
6154
# extra requirements installed does not affect cache management.
62-
pip_caches_to_prune = OrderedDict() # type: OrderedDict[PipVersionValue, PrunablePipCache]
63-
for pip in iter_all_pips():
64-
pex_dir, last_access, prunable = pex_dirs_by_hash[pip.pex_hash]
55+
pip_caches_to_prune = OrderedDict() # type: OrderedDict[PipVersionValue, Pip]
56+
for pip in iter_all_pips(record_access=False):
57+
pex_dir, prunable = pex_dirs_by_hash[pip.pex_hash]
6558
if prunable:
6659
pips_to_prune[pip] = False
6760
else:
68-
pip_caches_to_prune[pip.version] = PrunablePipCache(pip, pex_dir, last_access)
61+
pip_caches_to_prune[pip.version] = pip
6962
for pip in pips_to_prune:
7063
if pip.version not in pip_caches_to_prune:
7164
pips_to_prune[pip] = True
@@ -74,10 +67,10 @@ def scan(cls, pex_dirs_by_hash):
7467
(pip.pex_dir.base_dir if prune_version else pip.pex_dir.path)
7568
for pip, prune_version in pips_to_prune.items()
7669
)
77-
return cls(paths=pip_paths_to_prune, caches=tuple(pip_caches_to_prune.values()))
70+
return cls(paths=pip_paths_to_prune, pips=tuple(pip_caches_to_prune.values()))
7871

7972
paths = attr.ib() # type: Tuple[str, ...]
80-
caches = attr.ib() # type: Tuple[PrunablePipCache, ...]
73+
pips = attr.ib() # type: Tuple[Pip, ...]
8174

8275

8376
@attr.s(frozen=True)
@@ -107,15 +100,15 @@ def scan(cls, cutoff):
107100
OrderedSet()
108101
) # type: OrderedSet[Union[BootstrapDir, UserCodeDir, InstalledWheelDir]]
109102
unprunable_deps = [] # type: List[Union[BootstrapDir, UserCodeDir, InstalledWheelDir]]
110-
pex_dirs_by_hash = {} # type: Dict[str, Tuple[Union[UnzipDir, VenvDirs], float, bool]]
103+
pex_dirs_by_hash = {} # type: Dict[str, Tuple[Union[UnzipDir, VenvDirs], bool]]
111104
for pex_dir, last_access in access.iter_all_cached_pex_dirs():
112105
prunable = pex_dir in prunable_pex_dirs
113106
if prunable:
114107
pex_dirs.append(pex_dir)
115108
pex_deps.update(pex_dir.iter_deps())
116109
else:
117110
unprunable_deps.extend(pex_dir.iter_deps())
118-
pex_dirs_by_hash[pex_dir.pex_hash] = pex_dir, last_access, prunable
111+
pex_dirs_by_hash[pex_dir.pex_hash] = pex_dir, prunable
119112
pips = Pips.scan(pex_dirs_by_hash)
120113

121114
return cls(

pex/cli/commands/cache/command.py

+16-18
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
InstalledWheelDir,
2222
VenvDirs,
2323
)
24-
from pex.cache.prunable import Prunable, PrunablePipCache
24+
from pex.cache.prunable import Prunable
2525
from pex.cli.command import BuildTimeCommand
2626
from pex.cli.commands.cache.bytes import ByteAmount, ByteUnits
2727
from pex.cli.commands.cache.du import DiskUsage
@@ -33,6 +33,7 @@
3333
from pex.orderedset import OrderedSet
3434
from pex.pep_440 import Version
3535
from pex.pep_503 import ProjectName
36+
from pex.pip.tool import Pip
3637
from pex.result import Error, Ok, Result
3738
from pex.typing import TYPE_CHECKING
3839
from pex.variables import ENV
@@ -629,21 +630,21 @@ def prune_pip_caches():
629630
if not prunable_wheels:
630631
return
631632

632-
def spawn_list(prunable_pip_cache):
633-
# type: (PrunablePipCache) -> SpawnedJob[Tuple[ProjectNameAndVersion, ...]]
633+
def spawn_list(pip):
634+
# type: (Pip) -> SpawnedJob[Tuple[ProjectNameAndVersion, ...]]
634635
return SpawnedJob.stdout(
635-
job=prunable_pip_cache.pip.spawn_cache_list(),
636+
job=pip.spawn_cache_list(),
636637
result_func=lambda stdout: tuple(
637638
ProjectNameAndVersion.from_filename(wheel_file)
638639
for wheel_file in stdout.decode("utf-8").splitlines()
639640
if wheel_file
640641
),
641642
)
642643

643-
pip_removes = [] # type: List[Tuple[PrunablePipCache, str]]
644-
for prunable_pip_cache, project_name_and_versions in zip(
645-
prunable.pips.caches,
646-
execute_parallel(inputs=prunable.pips.caches, spawn_func=spawn_list),
644+
pip_removes = [] # type: List[Tuple[Pip, str]]
645+
for pip, project_name_and_versions in zip(
646+
prunable.pips.pips,
647+
execute_parallel(inputs=prunable.pips.pips, spawn_func=spawn_list),
647648
):
648649
for pnav in project_name_and_versions:
649650
if (
@@ -652,7 +653,7 @@ def spawn_list(prunable_pip_cache):
652653
) in prunable_wheels:
653654
pip_removes.append(
654655
(
655-
prunable_pip_cache,
656+
pip,
656657
"{project_name}-{version}*".format(
657658
project_name=pnav.project_name, version=pnav.version
658659
),
@@ -673,22 +674,19 @@ def parse_remove(stdout):
673674
return 0
674675

675676
def spawn_remove(args):
676-
# type: (Tuple[PrunablePipCache, str]) -> SpawnedJob[int]
677-
prunable_pip_cache, wheel_name_glob = args
677+
# type: (Tuple[Pip, str]) -> SpawnedJob[int]
678+
pip, wheel_name_glob = args
678679
return SpawnedJob.stdout(
679-
job=prunable_pip_cache.pip.spawn_cache_remove(wheel_name_glob),
680+
job=pip.spawn_cache_remove(wheel_name_glob),
680681
result_func=parse_remove,
681682
)
682683

683684
removes_by_pip = Counter() # type: typing.Counter[str]
684-
for prunable_pip_cache, remove_count in zip(
685-
[prunable_pip_cache for prunable_pip_cache, _ in pip_removes],
685+
for pip, remove_count in zip(
686+
[pip for pip, _ in pip_removes],
686687
execute_parallel(inputs=pip_removes, spawn_func=spawn_remove),
687688
):
688-
removes_by_pip[prunable_pip_cache.pip.version.value] += remove_count
689-
cache_access.record_access(
690-
prunable_pip_cache.pex_dir, last_access=prunable_pip_cache.last_access
691-
)
689+
removes_by_pip[pip.version.value] += remove_count
692690
if removes_by_pip:
693691
total = sum(removes_by_pip.values())
694692
print(

pex/common.py

+12-4
Original file line numberDiff line numberDiff line change
@@ -532,11 +532,19 @@ def can_write_dir(path):
532532
return os.path.isdir(path) and os.access(path, os.R_OK | os.W_OK | os.X_OK)
533533

534534

535-
def touch(file):
536-
# type: (_Text) -> _Text
537-
"""Equivalent of unix `touch path`."""
535+
def touch(
536+
file, # type: _Text
537+
times=None, # type: Optional[Union[int, float, Tuple[int, int], Tuple[float, float]]]
538+
):
539+
# type: (...) -> _Text
540+
"""Equivalent of unix `touch path`.
541+
542+
If no times is passed, the current time is used to set atime and mtime. If a single int or float
543+
is passed for times, it is used for both atime and mtime. If a 2-tuple of ints or floats is
544+
passed, the 1st slot is the atime and the 2nd the mtime, just as for `os.utime`.
545+
"""
538546
with safe_open(file, "a"):
539-
os.utime(file, None)
547+
os.utime(file, (times, times) if isinstance(times, (int, float)) else times)
540548
return file
541549

542550

pex/layout.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -320,8 +320,6 @@ def _ensure_installed(
320320
if not os.path.exists(install_to):
321321
with ENV.patch(PEX_ROOT=pex_root):
322322
cache_access.read_write()
323-
else:
324-
cache_access.record_access(install_to)
325323
with atomic_directory(install_to) as chroot:
326324
if not chroot.is_finalized():
327325
with ENV.patch(PEX_ROOT=pex_root), TRACER.timed(
@@ -374,6 +372,7 @@ def _ensure_installed(
374372
layout.extract_pex_info(chroot.work_dir)
375373
layout.extract_main(chroot.work_dir)
376374
layout.record(chroot.work_dir)
375+
cache_access.record_access(install_to)
377376
return install_to
378377

379378

pex/pex_bootstrapper.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,7 @@ def ensure_venv(
507507
pex, # type: PEX
508508
collisions_ok=True, # type: bool
509509
copy_mode=None, # type: Optional[CopyMode.Value]
510+
record_access=True, # type: bool
510511
):
511512
# type: (...) -> VenvPex
512513
pex_info = pex.pex_info()
@@ -524,8 +525,6 @@ def ensure_venv(
524525
if not os.path.exists(venv_dir):
525526
with ENV.patch(PEX_ROOT=pex_info.pex_root):
526527
cache_access.read_write()
527-
else:
528-
cache_access.record_access(venv_dir)
529528
with atomic_directory(venv_dir) as venv:
530529
if not venv.is_finalized():
531530
from pex.venv.virtualenv import Virtualenv
@@ -626,7 +625,8 @@ def ensure_venv(
626625
)
627626

628627
break
629-
628+
if record_access:
629+
cache_access.record_access(venv_dir)
630630
return VenvPex(venv_dir, hermetic_scripts=pex_info.venv_hermetic_scripts)
631631

632632

pex/pip/installation.py

+9-2
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,15 @@ def _create_pip(
4545
pip_pex, # type: PipPexDir
4646
interpreter=None, # type: Optional[PythonInterpreter]
4747
use_system_time=False, # type: bool
48+
record_access=True, # type: bool
4849
):
4950
# type: (...) -> Pip
5051

5152
production_assert(os.path.exists(pip_pex.path))
5253

5354
pip_interpreter = interpreter or PythonInterpreter.get()
5455
pex = PEX(pip_pex.path, interpreter=pip_interpreter)
55-
venv_pex = ensure_venv(pex, copy_mode=CopyMode.SYMLINK)
56+
venv_pex = ensure_venv(pex, copy_mode=CopyMode.SYMLINK, record_access=record_access)
5657
pex_hash = pex.pex_info().pex_hash
5758
production_assert(pex_hash is not None)
5859
pip_venv = PipVenv(
@@ -512,8 +513,14 @@ def iter_all(
512513
interpreter=None, # type: Optional[PythonInterpreter]
513514
use_system_time=False, # type: bool
514515
pex_root=ENV, # type: Union[str, Variables]
516+
record_access=True, # type: bool
515517
):
516518
# type: (...) -> Iterator[Pip]
517519

518520
for pip_pex in PipPexDir.iter_all(pex_root=pex_root):
519-
yield _create_pip(pip_pex, interpreter=interpreter, use_system_time=use_system_time)
521+
yield _create_pip(
522+
pip_pex,
523+
interpreter=interpreter,
524+
use_system_time=use_system_time,
525+
record_access=record_access,
526+
)

pex/venv/installer.py

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from textwrap import dedent
1111

1212
from pex import layout, pex_warnings, repl
13+
from pex.cache import access as cache_access
1314
from pex.common import CopyMode, chmod_plus_x, iter_copytree, pluralize
1415
from pex.compatibility import is_valid_python_identifier
1516
from pex.dist_metadata import Distribution
@@ -534,6 +535,7 @@ def mount(cls, pex):
534535
"__main__.py",
535536
"__pex__",
536537
"__pycache__",
538+
cache_access.LAST_ACCESS_FILE,
537539
layout.BOOTSTRAP_DIR,
538540
layout.DEPS_DIR,
539541
layout.PEX_INFO_PATH,

pex/version.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# Copyright 2015 Pex project contributors.
22
# Licensed under the Apache License, Version 2.0 (see LICENSE).
33

4-
__version__ = "2.24.0"
4+
__version__ = "2.24.1"

0 commit comments

Comments
 (0)