Skip to content

Commit fb69aa6

Browse files
authored
feat: Emit worker pool size as a DEBUG log event during init on Lambda Managed Instances (#219)
* feat: Emit worker pool size as a DEBUG log event during init on Lambda Managed Instances * fix: Stringify non-serializable JSON log values and close init log sink Address PR review feedback: - Add default=str to the JSON log encoder so dict messages (and extra attributes) containing non-serializable values are stringified instead of raising and dropping the log record. - Close the log sink opened by init_logging deterministically after the parent's handler is removed, instead of relying on GC. * fix: Remove redundant output redirect from parent process RAPID wires the runtime main process's stdout/stderr to the log egress at spawn, so the parent does not need to dial the telemetry FD provider socket — that socket exists for the forked workers, which continue to redirect in run_single. Addresses PR review feedback.
1 parent 3810eed commit fb69aa6

8 files changed

Lines changed: 195 additions & 5 deletions

RELEASE.CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
### September 2, 2026
2+
`4.0.3`
3+
- Emit a structured `runtime_worker_pool_initializing` DEBUG log event once per execution environment during INIT in multi-concurrent (Lambda Managed Instances) mode, reporting `workerCount` and `executionEnvironmentMaxConcurrency` for worker pool observability. Only visible when the function log level is DEBUG or lower; no impact on the standard on-demand path.
4+
15
### July 15, 2026
26
`4.0.2`
37
- Add `Lambda-Runtime-Invocation-Id` header support for cross-wiring protection. The RIC now echoes the invocation ID received from RAPID on `/next` back on `/response` and `/error`, enabling RAPID to detect and reject stale responses from timed-out invocations.

awslambdaric/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
33
"""
44

5-
__version__ = "4.0.2"
5+
__version__ = "4.0.3"

awslambdaric/bootstrap.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,16 @@ def _log_preview_runtime_warning():
493493
logging.warning(get_lambda_preview_runtime_warning_message())
494494

495495

496+
def init_logging():
497+
"""Setup logging for the parent process before forking (LMI only)."""
498+
sys.stdout = Unbuffered(sys.stdout)
499+
sys.stderr = Unbuffered(sys.stderr)
500+
log_sink = create_log_sink()
501+
log_sink.__enter__()
502+
_setup_logging(_AWS_LAMBDA_LOG_FORMAT, _AWS_LAMBDA_LOG_LEVEL, log_sink)
503+
return log_sink
504+
505+
496506
def run(handler, lambda_runtime_client):
497507
sys.stdout = Unbuffered(sys.stdout)
498508
sys.stderr = Unbuffered(sys.stderr)

awslambdaric/lambda_multi_concurrent_utils.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
33
"""
44

5+
import logging
56
import os
67
import sys
78
import socket
@@ -10,6 +11,8 @@
1011
from . import bootstrap
1112
from .lambda_runtime_client import LambdaMultiConcurrentRuntimeClient
1213

14+
WORKER_POOL_INITIALIZING_EVENT = "runtime_worker_pool_initializing"
15+
1316

1417
class MultiConcurrentRunner:
1518
@staticmethod
@@ -32,6 +35,27 @@ def run_single(
3235
client = LambdaMultiConcurrentRuntimeClient(api_addr, use_thread)
3336
bootstrap.run(handler, client)
3437

38+
@classmethod
39+
def _emit_worker_pool_event(cls, max_concurrency: int):
40+
"""Emit worker pool DEBUG event once from the parent before forking.
41+
42+
No output redirection here: RAPID wires the runtime main process's
43+
stdout/stderr to the log egress at spawn. The FD provider socket is
44+
only for the forked workers, which redirect in run_single.
45+
"""
46+
log_sink = bootstrap.init_logging()
47+
logging.getLogger().debug(
48+
{
49+
"event": WORKER_POOL_INITIALIZING_EVENT,
50+
"workerCount": max_concurrency,
51+
"executionEnvironmentMaxConcurrency": max_concurrency,
52+
}
53+
)
54+
logging.getLogger().handlers.clear()
55+
# Close the sink deterministically now that its handler is gone
56+
# (no-op for StandardLogSink; releases the fd for framed sinks).
57+
log_sink.__exit__(None, None, None)
58+
3559
@classmethod
3660
def run_concurrent(
3761
cls,
@@ -41,6 +65,8 @@ def run_concurrent(
4165
socket_path: str,
4266
max_concurrency: int,
4367
):
68+
cls._emit_worker_pool_event(max_concurrency)
69+
4470
processes = []
4571
for _ in range(max_concurrency):
4672
p = multiprocessing.Process(

awslambdaric/lambda_runtime_log_utils.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,10 @@ def _get_log_level_from_env_var(log_level):
6868
}
6969
_DEFAULT_FRAME_TYPE = _TEXT_FRAME_TYPES[logging.NOTSET]
7070

71-
_json_encoder = json.JSONEncoder(ensure_ascii=False)
71+
# default=str keeps formatting resilient: non-JSON-serializable values in
72+
# dict messages or `extra` attributes are stringified instead of raising and
73+
# dropping the whole log record.
74+
_json_encoder = json.JSONEncoder(ensure_ascii=False, default=str)
7275
_encode_json = _json_encoder.encode
7376

7477

@@ -117,7 +120,11 @@ def format(self, record: logging.LogRecord) -> str:
117120
result = {
118121
"timestamp": self.formatTime(record, self.datefmt),
119122
"level": record.levelname,
120-
"message": record.getMessage(),
123+
"message": (
124+
record.msg
125+
if isinstance(record.msg, dict) and not record.args
126+
else record.getMessage()
127+
),
121128
"logger": record.name,
122129
"stackTrace": self.__format_stacktrace(record.exc_info),
123130
"errorType": self.__format_exception_name(record.exc_info),

tests/test_bootstrap.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1540,6 +1540,106 @@ def test_set_log_level_with_dictConfig(self, mock_stderr, mock_stdout):
15401540
self.assertEqual(mock_stdout.getvalue(), "")
15411541

15421542

1543+
class TestWorkerPoolInitializedLog(unittest.TestCase):
1544+
def setUp(self):
1545+
logging.getLogger().handlers.clear()
1546+
logging.getLogger().level = logging.NOTSET
1547+
1548+
def tearDown(self):
1549+
logging.getLogger().handlers.clear()
1550+
logging.getLogger().level = logging.NOTSET
1551+
1552+
def _setup_json_logging(self, log_level):
1553+
bootstrap._setup_logging(
1554+
LogFormat.from_str("JSON"), log_level, bootstrap.StandardLogSink()
1555+
)
1556+
1557+
@patch("sys.stdout", new_callable=StringIO)
1558+
def test_dict_message_serialized_as_nested_json_at_debug(self, mock_stdout):
1559+
self._setup_json_logging("DEBUG")
1560+
1561+
logging.getLogger().debug(
1562+
{
1563+
"event": "runtime_worker_pool_initializing",
1564+
"workerCount": 17,
1565+
"executionEnvironmentMaxConcurrency": 34,
1566+
}
1567+
)
1568+
1569+
data = json.loads(mock_stdout.getvalue().strip())
1570+
self.assertEqual(data["level"], "DEBUG")
1571+
self.assertEqual(
1572+
data["message"],
1573+
{
1574+
"event": "runtime_worker_pool_initializing",
1575+
"workerCount": 17,
1576+
"executionEnvironmentMaxConcurrency": 34,
1577+
},
1578+
)
1579+
1580+
@patch("sys.stdout", new_callable=StringIO)
1581+
def test_not_emitted_at_higher_log_levels(self, mock_stdout):
1582+
for log_level in ("INFO", "WARN", "ERROR", "FATAL"):
1583+
with self.subTest(log_level):
1584+
logging.getLogger().handlers.clear()
1585+
logging.getLogger().level = logging.NOTSET
1586+
self._setup_json_logging(_get_log_level_from_env_var(log_level))
1587+
1588+
logging.getLogger().debug({"event": "test"})
1589+
1590+
self.assertEqual(mock_stdout.getvalue(), "")
1591+
1592+
@patch("sys.stdout", new_callable=StringIO)
1593+
def test_init_logging_enables_parent_emission(self, mock_stdout):
1594+
with patch.dict(
1595+
os.environ,
1596+
{"AWS_LAMBDA_LOG_FORMAT": "JSON", "AWS_LAMBDA_LOG_LEVEL": "DEBUG"},
1597+
clear=True,
1598+
):
1599+
importlib.reload(bootstrap)
1600+
bootstrap.init_logging()
1601+
1602+
logging.getLogger().debug(
1603+
{
1604+
"event": "runtime_worker_pool_initializing",
1605+
"workerCount": 4,
1606+
"executionEnvironmentMaxConcurrency": 4,
1607+
}
1608+
)
1609+
1610+
importlib.reload(bootstrap)
1611+
1612+
data = json.loads(mock_stdout.getvalue())
1613+
self.assertEqual(data["level"], "DEBUG")
1614+
self.assertEqual(data["message"]["event"], "runtime_worker_pool_initializing")
1615+
1616+
@patch("sys.stdout", new_callable=StringIO)
1617+
def test_dict_message_with_non_serializable_values_is_not_dropped(
1618+
self, mock_stdout
1619+
):
1620+
import datetime
1621+
import decimal
1622+
1623+
self._setup_json_logging("DEBUG")
1624+
1625+
logging.getLogger().debug(
1626+
{
1627+
"event": "custom_event",
1628+
"when": datetime.datetime(2026, 9, 3, 12, 0, 0),
1629+
"amount": decimal.Decimal("1.5"),
1630+
"blob": b"bytes",
1631+
}
1632+
)
1633+
1634+
# The record must not be dropped: it serializes with values
1635+
# stringified via the encoder's default=str fallback.
1636+
data = json.loads(mock_stdout.getvalue())
1637+
self.assertEqual(data["message"]["event"], "custom_event")
1638+
self.assertEqual(data["message"]["when"], "2026-09-03 12:00:00")
1639+
self.assertEqual(data["message"]["amount"], "1.5")
1640+
self.assertEqual(data["message"]["blob"], "b'bytes'")
1641+
1642+
15431643
class TestBootstrapModule(unittest.TestCase):
15441644
def test_run(self):
15451645
expected_handler = "app.my_test_handler"

tests/test_concurrency.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ def fake_bootstrap_run(handler, lambda_runtime_client):
3838

3939
with patch(
4040
"awslambdaric.lambda_multi_concurrent_utils.MultiConcurrentRunner._redirect_output"
41+
), patch(
42+
"awslambdaric.lambda_multi_concurrent_utils.MultiConcurrentRunner._emit_worker_pool_event"
4143
), patch(
4244
"awslambdaric.lambda_multi_concurrent_utils.bootstrap.run",
4345
side_effect=fake_bootstrap_run,

tests/test_multi_concurrent_runner.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import sys
66
import unittest
7-
from unittest.mock import patch, MagicMock
7+
from unittest.mock import patch, MagicMock, call
88

99
from awslambdaric.lambda_multi_concurrent_utils import MultiConcurrentRunner
1010

@@ -58,8 +58,9 @@ def test_run_single_creates_client_and_calls_bootstrap(
5858
mock_client_cls.assert_called_once_with("addr", True)
5959
mock_bootstrap.run.assert_called_once_with("h.fn", mock_client)
6060

61+
@patch.object(MultiConcurrentRunner, "_emit_worker_pool_event")
6162
@patch("multiprocessing.Process")
62-
def test_run_concurrent_spawns_and_joins(self, mock_process):
63+
def test_run_concurrent_spawns_and_joins(self, mock_process, mock_emit):
6364
fake_proc = MagicMock()
6465
mock_process.return_value = fake_proc
6566

@@ -77,6 +78,46 @@ def test_run_concurrent_spawns_and_joins(self, mock_process):
7778
self.assertEqual(target, MultiConcurrentRunner.run_single)
7879
self.assertEqual(args, ("h", "a", False, "/sock"))
7980

81+
@patch("multiprocessing.Process")
82+
def test_run_concurrent_emits_worker_pool_event_once_before_spawning(
83+
self, mock_process
84+
):
85+
mock_process.return_value = MagicMock()
86+
order_tracker = MagicMock()
87+
order_tracker.attach_mock(mock_process, "process")
88+
89+
with patch.object(
90+
MultiConcurrentRunner, "_emit_worker_pool_event"
91+
) as mock_emit:
92+
order_tracker.attach_mock(mock_emit, "emit")
93+
MultiConcurrentRunner.run_concurrent(
94+
"h", "a", False, "/sock", max_concurrency=3
95+
)
96+
97+
mock_emit.assert_called_once_with(3)
98+
self.assertEqual(order_tracker.mock_calls[0], call.emit(3))
99+
100+
@patch("awslambdaric.lambda_multi_concurrent_utils.logging")
101+
@patch("awslambdaric.lambda_multi_concurrent_utils.bootstrap")
102+
def test_emit_worker_pool_event_sets_up_parent_logging_and_emits(
103+
self, mock_bootstrap, mock_logging
104+
):
105+
with patch.object(MultiConcurrentRunner, "_redirect_output") as mock_redirect:
106+
MultiConcurrentRunner._emit_worker_pool_event(16)
107+
108+
# Parent never redirects: RAPID wires its stdout at spawn.
109+
mock_redirect.assert_not_called()
110+
mock_bootstrap.init_logging.assert_called_once_with()
111+
mock_logging.getLogger.return_value.debug.assert_called_once()
112+
event = mock_logging.getLogger.return_value.debug.call_args[0][0]
113+
self.assertEqual(event["workerCount"], 16)
114+
self.assertEqual(event["executionEnvironmentMaxConcurrency"], 16)
115+
mock_logging.getLogger.return_value.handlers.clear.assert_called_once_with()
116+
# Sink is closed deterministically after the handler is removed.
117+
mock_bootstrap.init_logging.return_value.__exit__.assert_called_once_with(
118+
None, None, None
119+
)
120+
80121
@patch(
81122
"awslambdaric.lambda_multi_concurrent_utils.LambdaMultiConcurrentRuntimeClient"
82123
)

0 commit comments

Comments
 (0)