Skip to content

Commit 0d72265

Browse files
authored
Only stage required wheel packages from requirements cache, not entire cache directory (#37360)
* Fix cached wheels used in future runs * address review comments * run post tests * add .github/trigger_files/beam_PostCommit_Python_Examples_Dataflow.json
1 parent f947803 commit 0d72265

4 files changed

Lines changed: 141 additions & 17 deletions

File tree

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"comment": "Modify this file in a trivial way to cause this test suite to run.",
3-
"pr": "36271",
4-
"modification": 37
3+
"pr": "37360",
4+
"modification": 38
55
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"comment": "Modify this file in a trivial way to cause this test suite to run.",
3+
"pr": "37360",
4+
"modification": 1
5+
}

sdks/python/apache_beam/runners/portability/stager.py

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -213,13 +213,15 @@ def create_job_resources(
213213
# if we know we are using a dependency pre-installed sdk container image.
214214
if not skip_prestaged_dependencies:
215215
requirements_cache_path = (
216-
os.path.join(tempfile.gettempdir(), 'dataflow-requirements-cache') if
216+
os.path.join(tempfile.gettempdir(), 'beam-requirements-cache') if
217217
(setup_options.requirements_cache
218218
is None) else setup_options.requirements_cache)
219219
if (setup_options.requirements_cache != SKIP_REQUIREMENTS_CACHE and
220220
not os.path.exists(requirements_cache_path)):
221221
os.makedirs(requirements_cache_path, exist_ok=True)
222222

223+
# Track packages to stage for this specific run.
224+
packages_to_stage = set()
223225
# Stage a requirements file if present.
224226
if setup_options.requirements_file is not None:
225227
if not os.path.isfile(setup_options.requirements_file):
@@ -245,12 +247,16 @@ def create_job_resources(
245247
'such as --requirements_file. ')
246248

247249
if setup_options.requirements_cache != SKIP_REQUIREMENTS_CACHE:
248-
(
250+
populate_cache_callable = (
249251
populate_requirements_cache if populate_requirements_cache else
250-
Stager._populate_requirements_cache)(
251-
setup_options.requirements_file,
252-
requirements_cache_path,
253-
setup_options.requirements_cache_only_sources)
252+
Stager._populate_requirements_cache)
253+
254+
downloaded_packages = populate_cache_callable(
255+
setup_options.requirements_file,
256+
requirements_cache_path,
257+
setup_options.requirements_cache_only_sources)
258+
if downloaded_packages:
259+
packages_to_stage.update(downloaded_packages)
254260

255261
if pypi_requirements:
256262
tf = tempfile.NamedTemporaryFile(mode='w', delete=False)
@@ -260,18 +266,23 @@ def create_job_resources(
260266
# Populate cache with packages from PyPI requirements and stage
261267
# the files in the cache.
262268
if setup_options.requirements_cache != SKIP_REQUIREMENTS_CACHE:
263-
(
269+
populate_cache_callable = (
264270
populate_requirements_cache if populate_requirements_cache else
265-
Stager._populate_requirements_cache)(
266-
tf.name,
267-
requirements_cache_path,
268-
setup_options.requirements_cache_only_sources)
271+
Stager._populate_requirements_cache)
272+
downloaded_packages = populate_cache_callable(
273+
tf.name,
274+
requirements_cache_path,
275+
setup_options.requirements_cache_only_sources)
276+
if downloaded_packages:
277+
packages_to_stage.update(downloaded_packages)
269278

270279
if (setup_options.requirements_cache != SKIP_REQUIREMENTS_CACHE) and (
271280
setup_options.requirements_file is not None or pypi_requirements):
272-
for pkg in glob.glob(os.path.join(requirements_cache_path, '*')):
273-
resources.append(
274-
Stager._create_file_stage_to_artifact(pkg, os.path.basename(pkg)))
281+
for pkg in packages_to_stage:
282+
pkg_path = os.path.join(requirements_cache_path, pkg)
283+
if os.path.exists(pkg_path):
284+
resources.append(
285+
Stager._create_file_stage_to_artifact(pkg_path, pkg))
275286

276287
# Handle a setup file if present.
277288
# We will build the setup package locally and then copy it to the staging
@@ -741,19 +752,26 @@ def _populate_requirements_cache(
741752
# the requirements file and will download package dependencies.
742753

743754
# The apache-beam dependency is excluded from requirements cache population
744-
# because we stage the SDK separately.
755+
# because we stage the SDK separately.
745756
with tempfile.TemporaryDirectory() as temp_directory:
746757
tmp_requirements_filepath = Stager._remove_dependency_from_requirements(
747758
requirements_file=requirements_file,
748759
dependency_to_remove='apache-beam',
749760
temp_directory_path=temp_directory)
750761

762+
# Download to a temporary directory first, then copy to cache.
763+
# This allows us to track exactly which packages are needed for this
764+
# requirements file.
765+
download_dir = tempfile.mkdtemp(dir=temp_directory)
766+
751767
cmd_args = [
752768
Stager._get_python_executable(),
753769
'-m',
754770
'pip',
755771
'download',
756772
'--dest',
773+
download_dir,
774+
'--find-links',
757775
cache_dir,
758776
'-r',
759777
tmp_requirements_filepath,
@@ -781,6 +799,18 @@ def _populate_requirements_cache(
781799
_LOGGER.info('Executing command: %s', cmd_args)
782800
processes.check_output(cmd_args, stderr=processes.STDOUT)
783801

802+
# Get list of downloaded packages and copy them to the cache
803+
downloaded_packages = set()
804+
for pkg_file in os.listdir(download_dir):
805+
downloaded_packages.add(pkg_file)
806+
src_path = os.path.join(download_dir, pkg_file)
807+
dest_path = os.path.join(cache_dir, pkg_file)
808+
# Only copy if not already in cache
809+
if not os.path.exists(dest_path):
810+
shutil.copy2(src_path, dest_path)
811+
812+
return downloaded_packages
813+
784814
@staticmethod
785815
def _build_setup_package(
786816
setup_file: str,

sdks/python/apache_beam/runners/portability/stager_test.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ def populate_requirements_cache(
100100
_ = requirements_file
101101
self.create_temp_file(os.path.join(cache_dir, 'abc.txt'), 'nothing')
102102
self.create_temp_file(os.path.join(cache_dir, 'def.txt'), 'nothing')
103+
# Return the list of packages that were "downloaded" for this requirements
104+
return {'abc.txt', 'def.txt'}
103105

104106
@mock.patch('apache_beam.runners.portability.stager.open')
105107
@mock.patch('apache_beam.runners.portability.stager.get_new_http')
@@ -810,10 +812,14 @@ def test_remove_dependency_from_requirements(self):
810812

811813
def _populate_requitements_cache_fake(
812814
self, requirements_file, temp_dir, populate_cache_with_sdists):
815+
packages = set()
813816
if not populate_cache_with_sdists:
814817
self.create_temp_file(os.path.join(temp_dir, 'nothing.whl'), 'Fake whl')
818+
packages.add('nothing.whl')
815819
self.create_temp_file(
816820
os.path.join(temp_dir, 'nothing.tar.gz'), 'Fake tarball')
821+
packages.add('nothing.tar.gz')
822+
return packages
817823

818824
# requirements cache will popultated with bdist/whl if present
819825
# else source would be downloaded.
@@ -913,6 +919,89 @@ def test_populate_requirements_cache_with_local_files(self):
913919
self.assertNotIn('fake_pypi', extra_packages_contents)
914920
self.assertIn('local_package', extra_packages_contents)
915921

922+
def test_only_required_packages_staged_from_cache(self):
923+
"""Test that only packages needed for current requirements are staged.
924+
925+
This test verifies the fix for the issue where the entire cache directory
926+
was being staged, even when some packages weren't needed for the current
927+
workflow.
928+
"""
929+
staging_dir = self.make_temp_dir()
930+
requirements_cache_dir = self.make_temp_dir()
931+
source_dir = self.make_temp_dir()
932+
933+
# Pre-populate cache with packages from a "previous run"
934+
self.create_temp_file(
935+
os.path.join(requirements_cache_dir, 'old_package.whl'), 'old package')
936+
self.create_temp_file(
937+
os.path.join(requirements_cache_dir, 'another_old.tar.gz'), 'another')
938+
939+
options = PipelineOptions()
940+
self.update_options(options)
941+
options.view_as(SetupOptions).requirements_cache = requirements_cache_dir
942+
options.view_as(SetupOptions).requirements_file = os.path.join(
943+
source_dir, stager.REQUIREMENTS_FILE)
944+
self.create_temp_file(
945+
os.path.join(source_dir, stager.REQUIREMENTS_FILE), 'new_package')
946+
947+
def populate_cache_for_new_requirements(
948+
requirements_file, cache_dir, populate_cache_with_sdists=False):
949+
# Simulate downloading only the packages needed for new requirements
950+
self.create_temp_file(
951+
os.path.join(cache_dir, 'new_package.whl'), 'new package content')
952+
return {'new_package.whl'}
953+
954+
resources = self.stager.create_and_stage_job_resources(
955+
options,
956+
populate_requirements_cache=populate_cache_for_new_requirements,
957+
staging_location=staging_dir)[1]
958+
959+
# Verify only new_package.whl is staged, not old packages
960+
self.assertIn('new_package.whl', resources)
961+
self.assertNotIn('old_package.whl', resources)
962+
self.assertNotIn('another_old.tar.gz', resources)
963+
964+
# Verify the file exists in staging
965+
self.assertTrue(
966+
os.path.isfile(os.path.join(staging_dir, 'new_package.whl')))
967+
# Verify old packages are NOT in staging
968+
self.assertFalse(
969+
os.path.isfile(os.path.join(staging_dir, 'old_package.whl')))
970+
self.assertFalse(
971+
os.path.isfile(os.path.join(staging_dir, 'another_old.tar.gz')))
972+
973+
def test_populate_requirements_cache_uses_find_links(self):
974+
"""Test that _populate_requirements_cache uses --find-links to reuse cache.
975+
976+
This test verifies that pip download is called with --find-links pointing
977+
to the cache directory, so packages already in cache are reused instead
978+
of being re-downloaded from PyPI.
979+
"""
980+
requirements_cache_dir = self.make_temp_dir()
981+
source_dir = self.make_temp_dir()
982+
983+
# Create a requirements file
984+
requirements_file = os.path.join(source_dir, 'requirements.txt')
985+
self.create_temp_file(requirements_file, 'some_package==1.0.0')
986+
987+
captured_cmd_args = []
988+
989+
def mock_check_output(cmd_args, **kwargs):
990+
captured_cmd_args.extend(cmd_args)
991+
return b''
992+
993+
with mock.patch(
994+
'apache_beam.runners.portability.stager.processes.check_output',
995+
side_effect=mock_check_output):
996+
stager.Stager._populate_requirements_cache(
997+
requirements_file, requirements_cache_dir)
998+
999+
# Verify --find-links is in the command with the cache directory
1000+
self.assertIn('--find-links', captured_cmd_args)
1001+
find_links_index = captured_cmd_args.index('--find-links')
1002+
self.assertEqual(
1003+
requirements_cache_dir, captured_cmd_args[find_links_index + 1])
1004+
9161005

9171006
class TestStager(stager.Stager):
9181007
def stage_artifact(self, local_path_to_artifact, artifact_name, sha256):

0 commit comments

Comments
 (0)