Skip to content

Commit 8f3645c

Browse files
authored
Fix issue when isolating libraries when file not ready (#854)
* Fix an issue when isolating libraries when file not mounted Adding namespace import scenarios Ensure changes Add unittests Amend test cases Fix an issue when customer defines a module with collided name Fix test_paths_resolution Fix case * Squash commit * Retry webhost resolution * Reload linux consumption environment * Add more logs in depenency * Refactor prioritize_customers_dependencies * Remove unnecessary if statements * Fix linting * Fix namespace packaging * Revert "Fix namespace packaging" This reverts commit 8cb98ee. * Only fix namespace in Python 3.9 * Fix tab typo * Fix naming * Should fetch the data from variables
1 parent d2005ff commit 8f3645c

File tree

22 files changed

+518
-86
lines changed

22 files changed

+518
-86
lines changed

azure_functions_worker/constants.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
# Platform Environment Variables
2020
AZURE_WEBJOBS_SCRIPT_ROOT = "AzureWebJobsScriptRoot"
21+
CONTAINER_NAME = "CONTAINER_NAME"
2122

2223
# Python Specific Feature Flags and App Settings
2324
PYTHON_ROLLBACK_CWD_PATH = "PYTHON_ROLLBACK_CWD_PATH"
@@ -37,7 +38,7 @@
3738
PYTHON_THREADPOOL_THREAD_COUNT_MIN = 1
3839
PYTHON_THREADPOOL_THREAD_COUNT_MAX = 32
3940
PYTHON_ISOLATE_WORKER_DEPENDENCIES_DEFAULT = False
40-
PYTHON_ISOLATE_WORKER_DEPENDENCIES_DEFAULT_39 = False
41+
PYTHON_ISOLATE_WORKER_DEPENDENCIES_DEFAULT_39 = True
4142
PYTHON_ENABLE_WORKER_EXTENSIONS_DEFAULT = False
4243
PYTHON_ENABLE_WORKER_EXTENSIONS_DEFAULT_39 = True
4344

azure_functions_worker/dispatcher.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,11 @@ async def _handle__worker_init_request(self, req):
271271
constants.SHARED_MEMORY_DATA_TRANSFER: _TRUE,
272272
}
273273

274-
# Can detech worker packages
275-
DependencyManager.prioritize_customer_dependencies()
274+
# Can detech worker packages only when customer's code is present
275+
# This only works in dedicated and premium sku.
276+
# The consumption sku will switch on environment_reload request.
277+
if not DependencyManager.is_in_linux_consumption():
278+
DependencyManager.prioritize_customer_dependencies()
276279

277280
return protos.StreamingMessage(
278281
request_id=self.request_id,
@@ -480,7 +483,7 @@ async def _handle__function_environment_reload_request(self, req):
480483
)
481484

482485
# Reload azure google namespaces
483-
DependencyManager.reload_azure_google_namespace(
486+
DependencyManager.reload_customer_libraries(
484487
func_env_reload_request.function_app_directory
485488
)
486489

azure_functions_worker/utils/dependency.py

Lines changed: 87 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from azure_functions_worker.utils.common import is_true_like
22
from typing import List, Optional
3+
from types import ModuleType
34
import importlib
45
import inspect
56
import os
@@ -9,6 +10,7 @@
910
from ..logging import logger
1011
from ..constants import (
1112
AZURE_WEBJOBS_SCRIPT_ROOT,
13+
CONTAINER_NAME,
1214
PYTHON_ISOLATE_WORKER_DEPENDENCIES,
1315
PYTHON_ISOLATE_WORKER_DEPENDENCIES_DEFAULT,
1416
PYTHON_ISOLATE_WORKER_DEPENDENCIES_DEFAULT_39
@@ -66,6 +68,10 @@ def initialize(cls):
6668
cls.cx_working_dir = cls._get_cx_working_dir()
6769
cls.worker_deps_path = cls._get_worker_deps_path()
6870

71+
@classmethod
72+
def is_in_linux_consumption(cls):
73+
return CONTAINER_NAME in os.environ
74+
6975
@classmethod
7076
@enable_feature_by(
7177
flag=PYTHON_ISOLATE_WORKER_DEPENDENCIES,
@@ -87,6 +93,11 @@ def use_worker_dependencies(cls):
8793
# The following log line will not show up in core tools but should
8894
# work in kusto since core tools only collects gRPC logs. This function
8995
# is executed even before the gRPC logging channel is ready.
96+
logger.info(f'Applying use_worker_dependencies:'
97+
f' worker_dependencies: {cls.worker_deps_path},'
98+
f' customer_dependencies: {cls.cx_deps_path},'
99+
f' working_directory: {cls.cx_working_dir}')
100+
90101
cls._remove_from_sys_path(cls.cx_deps_path)
91102
cls._remove_from_sys_path(cls.cx_working_dir)
92103
cls._add_to_sys_path(cls.worker_deps_path, True)
@@ -101,7 +112,7 @@ def use_worker_dependencies(cls):
101112
PYTHON_ISOLATE_WORKER_DEPENDENCIES_DEFAULT
102113
)
103114
)
104-
def prioritize_customer_dependencies(cls):
115+
def prioritize_customer_dependencies(cls, cx_working_dir=None):
105116
"""Switch the sys.path and ensure the customer's code import are loaded
106117
from CX's deppendencies.
107118
@@ -112,24 +123,50 @@ def prioritize_customer_dependencies(cls):
112123
As for Linux Consumption, this will only remove worker_deps_path,
113124
but the customer's path will be loaded in function_environment_reload.
114125
115-
The search order of a module name in customer frame is:
126+
The search order of a module name in customer's paths is:
116127
1. cx_deps_path
117-
2. cx_working_dir
128+
2. worker_deps_path
129+
3. cx_working_dir
118130
"""
131+
# Try to get the latest customer's working directory
132+
# cx_working_dir => cls.cx_working_dir => AzureWebJobsScriptRoot
133+
working_directory: str = ''
134+
if cx_working_dir:
135+
working_directory: str = os.path.abspath(cx_working_dir)
136+
if not working_directory:
137+
working_directory = cls.cx_working_dir
138+
if not working_directory:
139+
working_directory = os.getenv(AZURE_WEBJOBS_SCRIPT_ROOT, '')
140+
141+
# Try to get the latest customer's dependency path
142+
cx_deps_path: str = cls._get_cx_deps_path()
143+
if not cx_deps_path:
144+
cx_deps_path = cls.cx_deps_path
145+
146+
logger.info('Applying prioritize_customer_dependencies:'
147+
f' worker_dependencies: {cls.worker_deps_path},'
148+
f' customer_dependencies: {cx_deps_path},'
149+
f' working_directory: {working_directory}')
150+
119151
cls._remove_from_sys_path(cls.worker_deps_path)
120152
cls._add_to_sys_path(cls.cx_deps_path, True)
121-
cls._add_to_sys_path(cls.cx_working_dir, False)
122153

123154
# Deprioritize worker dependencies but don't completely remove it
124155
# Otherwise, it will break some really old function apps, those
125156
# don't have azure-functions module in .python_packages
126157
# https://github.com/Azure/azure-functions-core-tools/pull/1498
127158
cls._add_to_sys_path(cls.worker_deps_path, False)
128159

129-
logger.info(f'Start using customer dependencies {cls.cx_deps_path}')
160+
# The modules defined in customer's working directory should have the
161+
# least priority since we uses the new folder structure.
162+
# Please check the "Message to customer" section in the following PR:
163+
# https://github.com/Azure/azure-functions-python-worker/pull/726
164+
cls._add_to_sys_path(working_directory, False)
165+
166+
logger.info('Finished prioritize_customer_dependencies')
130167

131168
@classmethod
132-
def reload_azure_google_namespace(cls, cx_working_dir: str):
169+
def reload_customer_libraries(cls, cx_working_dir: str):
133170
"""Reload azure and google namespace, this including any modules in
134171
this namespace, such as azure-functions, grpcio, grpcio-tools etc.
135172
@@ -152,7 +189,7 @@ def reload_azure_google_namespace(cls, cx_working_dir: str):
152189
use_new = is_true_like(use_new_env)
153190

154191
if use_new:
155-
cls.reload_all_namespaces_from_customer_deps(cx_working_dir)
192+
cls.prioritize_customer_dependencies(cx_working_dir)
156193
else:
157194
cls.reload_azure_google_namespace_from_worker_deps()
158195

@@ -189,33 +226,6 @@ def reload_azure_google_namespace_from_worker_deps(cls):
189226
logger.info('Unable to reload azure.functions. '
190227
'Using default. Exception:\n{}'.format(ex))
191228

192-
@classmethod
193-
def reload_all_namespaces_from_customer_deps(cls, cx_working_dir: str):
194-
"""This is a new implementation of reloading azure and google
195-
namespace from customer's .python_packages folder. Only intended to be
196-
used in Linux Consumption scenario.
197-
198-
Parameters
199-
----------
200-
cx_working_dir: str
201-
The path which contains customer's project file (e.g. wwwroot).
202-
"""
203-
# Specialized working directory needs to be added
204-
working_directory: str = os.path.abspath(cx_working_dir)
205-
206-
# Switch to customer deps and clear out all module cache in worker deps
207-
cls._remove_from_sys_path(cls.worker_deps_path)
208-
cls._add_to_sys_path(cls.cx_deps_path, True)
209-
cls._add_to_sys_path(working_directory, False)
210-
211-
# Deprioritize worker dependencies but don't completely remove it
212-
# Otherwise, it will break some really old function apps, those
213-
# don't have azure-functions module in .python_packages
214-
# https://github.com/Azure/azure-functions-core-tools/pull/1498
215-
cls._add_to_sys_path(cls.worker_deps_path, False)
216-
217-
logger.info('Reloaded all namespaces from customer dependencies')
218-
219229
@classmethod
220230
def _add_to_sys_path(cls, path: str, add_to_first: bool):
221231
"""This will ensure no duplicated path are added into sys.path and
@@ -325,7 +335,18 @@ def _get_worker_deps_path() -> str:
325335
if worker_deps_paths:
326336
return worker_deps_paths[0]
327337

328-
# 2. If it fails to find one, try to find one from the parent path
338+
# 2. Try to find module spec of azure.functions without actually
339+
# importing it (e.g. lib/site-packages/azure/functions/__init__.py)
340+
try:
341+
azf_spec = importlib.util.find_spec('azure.functions')
342+
if azf_spec and azf_spec.origin:
343+
return os.path.abspath(
344+
os.path.join(os.path.dirname(azf_spec.origin), '..', '..')
345+
)
346+
except ModuleNotFoundError:
347+
logger.warning('Cannot locate built-in azure.functions module')
348+
349+
# 3. If it fails to find one, try to find one from the parent path
329350
# This is used for handling the CI/localdev environment
330351
return os.path.abspath(
331352
os.path.join(os.path.dirname(__file__), '..', '..')
@@ -341,18 +362,34 @@ def _remove_module_cache(path: str):
341362
path: str
342363
The module cache to be removed if it is imported from this path.
343364
"""
344-
all_modules = set(sys.modules.keys()) - set(sys.builtin_module_names)
345-
for module_name in all_modules:
346-
module = sys.modules[module_name]
347-
# Module path can be actual file path or a pure namespace path
348-
# For actual files: use __file__ attribute to retrieve module path
349-
# For namespace: use __path__[0] to retrieve module path
350-
module_path = ''
351-
if getattr(module, '__file__', None):
352-
module_path = os.path.dirname(module.__file__)
353-
elif getattr(module, '__path__', None) and getattr(
354-
module.__path__, '_path', None):
355-
module_path = module.__path__._path[0]
356-
357-
if module_path.startswith(path):
358-
sys.modules.pop(module_name)
365+
if not path:
366+
return
367+
368+
not_builtin = set(sys.modules.keys()) - set(sys.builtin_module_names)
369+
370+
# Don't reload azure_functions_worker
371+
to_be_cleared_from_cache = set([
372+
module_name for module_name in not_builtin
373+
if not module_name.startswith('azure_functions_worker')
374+
])
375+
376+
for module_name in to_be_cleared_from_cache:
377+
module = sys.modules.get(module_name)
378+
if not isinstance(module, ModuleType):
379+
continue
380+
381+
# Module path can be actual file path or a pure namespace path.
382+
# Both of these has the module path placed in __path__ property
383+
# The property .__path__ can be None or does not exist in module
384+
try:
385+
module_paths = set(getattr(module, '__path__', None) or [])
386+
if hasattr(module, '__file__') and module.__file__:
387+
module_paths.add(module.__file__)
388+
389+
if any([p for p in module_paths if p.startswith(path)]):
390+
sys.modules.pop(module_name)
391+
except Exception as e:
392+
logger.warning(
393+
f'Attempt to remove module cache for {module_name} but'
394+
f' failed with {e}. Using the original module cache.'
395+
)

pack/templates/nix_env_gen.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ steps:
1212
inputs:
1313
disableAutoCwd: true
1414
scriptPath: 'pack/scripts/nix_deps.sh'
15+
# The following task is disabled since Python 3.9 to fix azure/ namespace
16+
# https://github.com/Azure/azure-functions-python-worker/pull/854
1517
- task: CopyFiles@2
18+
condition: and(succeeded(), in(variables['pythonVersion'], '3.6', '3.7', '3.8'))
1619
inputs:
1720
contents: |
1821
pack/utils/__init__.py

pack/templates/win_env_gen.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ steps:
1212
- task: PowerShell@2
1313
inputs:
1414
filePath: 'pack\scripts\win_deps.ps1'
15+
# The following task is disabled since Python 3.9 to fix azure/ namespace
16+
# https://github.com/Azure/azure-functions-python-worker/pull/854
1517
- task: CopyFiles@2
18+
condition: and(succeeded(), in(variables['pythonVersion'], '3.6', '3.7', '3.8'))
1619
inputs:
1720
contents: |
1821
pack\utils\__init__.py
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
# This is a dummy protobuf==3.9.0 package used for E2E
5+
# testing in Azure Functions Python Worker
6+
7+
__version__ = '3.9.0'
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
# This is a dummy grpcio==1.35.0 package used for E2E
5+
# testing in Azure Functions Python Worker.
6+
7+
__version__ = '1.35.0'
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import sys
2+
import os
3+
import json
4+
import azure.functions as func
5+
import google.protobuf as proto
6+
import grpc
7+
8+
# Load dependency manager from customer' context
9+
from azure_functions_worker.utils.dependency import DependencyManager as dm
10+
11+
12+
def main(req: func.HttpRequest) -> func.HttpResponse:
13+
"""This function is an HttpTrigger to check if the modules are loaded from
14+
customer's dependencies. We have mock a .python_packages/ folder in
15+
this e2e test function app which contains the following stub package:
16+
17+
azure.functions==1.2.1
18+
protobuf==3.9.0
19+
grpc==1.35.0
20+
21+
If the version we check is the same as the one in local .python_packages/,
22+
that means the isolate worker dependencies are working as expected.
23+
"""
24+
result = {
25+
"sys.path": list(sys.path),
26+
"dependency_manager": {
27+
"cx_deps_path": dm._get_cx_deps_path(),
28+
"cx_working_dir": dm._get_cx_working_dir(),
29+
"worker_deps_path": dm._get_worker_deps_path(),
30+
},
31+
"libraries": {
32+
"func.expected.version": "1.2.1",
33+
"func.version": func.__version__,
34+
"func.file": func.__file__,
35+
"proto.expected.version": "3.9.0",
36+
"proto.version": proto.__version__,
37+
"proto.file": proto.__file__,
38+
"grpc.expected.version": "1.35.0",
39+
"grpc.version": grpc.__version__,
40+
"grpc.file": grpc.__file__,
41+
},
42+
"environments": {
43+
"PYTHON_ISOLATE_WORKER_DEPENDENCIES": (
44+
os.getenv('PYTHON_ISOLATE_WORKER_DEPENDENCIES')
45+
),
46+
"AzureWebJobsScriptRoot": os.getenv('AzureWebJobsScriptRoot'),
47+
"PYTHONPATH": os.getenv('PYTHONPATH'),
48+
"HOST_VERSION": os.getenv('HOST_VERSION')
49+
}
50+
}
51+
return func.HttpResponse(json.dumps(result))
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"scriptFile": "__init__.py",
3+
"bindings": [
4+
{
5+
"authLevel": "function",
6+
"type": "httpTrigger",
7+
"direction": "in",
8+
"name": "req",
9+
"methods": [
10+
"get",
11+
"post"
12+
]
13+
},
14+
{
15+
"type": "http",
16+
"direction": "out",
17+
"name": "$return"
18+
}
19+
]
20+
}

0 commit comments

Comments
 (0)