8000 fix: Lazy-load functions post worker fork · jrmfg/functions-framework-python@45f3ae8 · GitHub
[go: up one dir, main page]

Skip to content

Commit 45f3ae8

Browse files
committed
fix: Lazy-load functions post worker fork
1 parent 455affd commit 45f3ae8

File tree

5 files changed

+72
-34
lines changed

5 files changed

+72
-34
lines changed

src/functions_framework/__init__.py

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
import functools
1615
import importlib.util
1716
import io
1817
import json
@@ -250,27 +249,46 @@ def handle_none(rv):
250249
sys.stderr = _LoggingHandler("ERROR", sys.stderr)
251250
setup_logging()
252251

253-
# 7. Execute the module, within the application context
254-
with app.app_context():
255-
spec.loader.exec_module(source_module)
256-
257-
# Extract the target function from the source file
258-
if not hasattr(source_module, target):
259-
raise MissingTargetException(
260-
"File {source} is expected to contain a function named {target}".format(
261-
source=source, target=target
262-
)
263-
)
264-
function = getattr(source_module, target)
265-
266-
# Check that it is a function
267-
if not isinstance(function, types.FunctionType):
268-
raise InvalidTargetTypeException(
269-
"The function defined in file {source} as {target} needs to be of "
270-
"type function. Got: invalid type {target_type}".format(
271-
source=source, target=target, target_type=type(function)
272-
)
273-
)
252+
# Define a lazy function that can be loaded after the app has been initialized
253+
class LazyFunction:
254+
def __init__(self):
255+
self.function = None
256+
257+
def _load_function(self):
258+
if self.function is None:
259+
# Execute the module, within the application context
260+
with app.app_context():
261+
spec.loader.exec_module(source_module)
262+
263+
# Extract the target function from the source file
264+
if not hasattr(source_module, target):
265+
raise MissingTargetException(
266+
"File {source} is expected to contain a function named {target}".format(
267+
source=source, target=target
268+
)
269+
)
270+
function = getattr(source_module, target)
271+
272+
# Check that it is a function
273+
if not isinstance(function, types.FunctionType):
274+
raise Inval EDBE idTargetTypeException(
275+
"The function defined in file {source} as {target} needs to be of "
276+
"type function. Got: invalid type {target_type}".format(
277+
source=source, target=target, target_type=type(function)
278+
)
279+
)
280+
281+
self.function = function
282+
283+
def __call__(self, *args, **kwargs):
284+
self._load_function()
285+
return self.function(*args, **kwargs)
286+
287+
# Initialize the lazy function, to use as a placeholder for the actual function
288+
function = LazyFunction()
289+
290+
# Provide our application with a hook to force the function to load
291+
app.load_function = function._load_function
274292

275293
# Mount the function at the root. Support GCF's default path behavior
276294
# Modify the url_map and view_functions directly here instead of using

src/functions_framework/_http/flask.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,8 @@ def __init__(self, app, host, port, debug, **options):
2121
self.debug = debug
2222
self.options = options
2323

24+
# Go ahead and load the user's function as soon as possible
25+
self.app.load_function()
26+
2427
def run(self):
2528
self.app.run(self.host, self.port, debug=self.debug, **self.options)

src/functions_framework/_http/gunicorn.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,16 @@ def __init__(self, app, host, port, debug, **options):
2323
"threads": 8,
2424
"timeout": 0,
2525
"loglevel": "error",
26+
"post_fork": self.post_fork,
2627
}
2728
self.options.update(options)
2829
self.app = app
2930
super().__init__()
3031

32+
def post_fork(self, server, worker):
33+
# Load the function only once the worker process has forked
34+
self.app.load_function()
35+
3136
def load_config(self):
3237
for key, value in self.options.items():
3338
self.cfg.set(key, value)

tests/test_functions.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,8 @@
1414

1515

1616
import json
17-
import os
1817
import pathlib
1918
import re
20-
import sys
2119
import time
2220

2321
import pretend
@@ -33,7 +31,7 @@
3331
# Python 3.5: ModuleNotFoundError does not exist
3432
try:
3533
_ModuleNotFoundError = ModuleNotFoundError
36-
except:
34+
except NameError:
3735
_ModuleNotFoundError = ImportError
3836

3937

@@ -195,6 +193,7 @@ def test_http_function_execution_time():
195193

196194
assert resp.status_code == 200
197195
assert resp.data == b"OK"
196+
assert execution_time_sec > 1
198197

199198

200199
def test_background_function_executes(background_event_client, background_json):
@@ -274,7 +273,8 @@ def test_invalid_function_definition_multiple_entry_points():
274273
target = "function"
275274

276275
with pytest.raises(exceptions.MissingTargetException) as excinfo:
277-
create_app(target, source, "event")
276+
app = create_app(target, source, "event")
277+
app.load_function()
278278

279279
assert re.match(
280280
"File .* is expected to contain a function named function", str(excinfo.value)
@@ -286,7 +286,8 @@ def test_invalid_function_definition_multiple_entry_points_invalid_function():
286286
target = "invalidFunction"
287287

288288
with pytest.raises(exceptions.MissingTargetException) as excinfo:
289-
create_app(target, source, "event")
289+
app = create_app(target, source, "event")
290+
app.load_function()
290291

291292
assert re.match(
292293
"File .* is expected to contain a function named invalidFunction",
@@ -299,7 +300,8 @@ def test_invalid_function_definition_multiple_entry_points_not_a_function():
299300
target = "notAFunction"
300301

301302
with pytest.raises(exceptions.InvalidTargetTypeException) as excinfo:
302-
create_app(target, source, "event")
303+
app = create_app(target, source, "event")
304+
app.load_function()
303305

304306
assert re.match(
305307
"The function defined in file .* as notAFunction needs to be of type "
@@ -313,7 +315,8 @@ def test_invalid_function_definition_function_syntax_error():
313315
target = "function"
314316

315317
with pytest.raises(SyntaxError) as excinfo:
316-
create_app(target, source, "event")
318+
app = create_app(target, source, "event")
319+
app.load_function()
317320

318321
assert any(
319322
(
@@ -328,7 +331,8 @@ def test_invalid_function_definition_missing_dependency():
328331
target = "function"
329332

330333
with pytest.raises(_ModuleNotFoundError) as excinfo:
331-
create_app(target, source, "event")
334+
app = create_app(target, source, "event")
335+
app.load_function()
332336

333337
assert "No module named 'nonexistentpackage'" in str(excinfo.value)
334338

@@ -347,7 +351,7 @@ def test_invalid_signature_type():
347351
source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py"
348352
target = "function"
349353

350-
with pytest.raises(exceptions.FunctionsFrameworkException) as excinfo:
354+
with pytest.raises(exceptions.FunctionsFrameworkException):
351355
create_app(target, source, "invalid_signature_type")
352356

353357

@@ -432,7 +436,7 @@ def test_lazy_wsgi_app(monkeypatch, target, source, signature_type):
432436
# Test that it's lazy
433437
lazy_app = LazyWSGIApp(target, source, signature_type)
434438

435-
assert lazy_app.app == None
439+
assert lazy_app.app is None
436440

437441
args = [pretend.stub(), pretend.stub()]
438442
kwargs = {"a": pretend.stub(), "b": pretend.stub()}

tests/test_http.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def test_httpserver(monkeypatch, debug, gunicorn_missing, expected):
8282
@pytest.mark.skipif("platform.system() == 'Windows'")
8383
@pytest.mark.parametrize("debug", [True, False])
8484
def test_gunicorn_application(debug):
85-
app = pretend.stub()
85+
app = pretend.stub(load_function=pretend.call_recorder(lambda: None))
8686
host = "1.2.3.4"
8787
port = "1234"
8888
options = {}
@@ -100,6 +100,7 @@ def test_gunicorn_application(debug):
100100
"threads": 8,
101101
"timeout": 0,
102102
"loglevel": "error",
103+
"post_fork": gunicorn_app.post_fork,
103104
}
104105

105106
assert gunicorn_app.cfg.bind == ["1.2.3.4:1234"]
@@ -108,10 +109,17 @@ def test_gunicorn_application(debug):
108109
assert gunicorn_app.cfg.timeout == 0
109110
assert gunicorn_app.load() == app
110111

112+
gunicorn_app.post_fork(pretend.stub(), pretend.stub())
113+
114+
assert app.load_function.calls == [pretend.call()]
115+
111116

112117
@pytest.mark.parametrize("debug", [True, False])
113118
def test_flask_application(debug):
114-
app = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None))
119+
app = pretend.stub(
120+
run=pretend.call_recorder(lambda *a, **kw: None),
121+
load_function=pretend.call_recorder(lambda: None),
122+
)
115123
host = pretend.stub()
116124
port = pretend.stub()
117125
options = {"a": pretend.stub(), "b": pretend.stub()}

0 commit comments

Comments
 (0)
0