diff --git a/aws-lambda-java-runtime-interface-client/RELEASE.CHANGELOG.md b/aws-lambda-java-runtime-interface-client/RELEASE.CHANGELOG.md index 97d17703..9ca18e74 100644 --- a/aws-lambda-java-runtime-interface-client/RELEASE.CHANGELOG.md +++ b/aws-lambda-java-runtime-interface-client/RELEASE.CHANGELOG.md @@ -1,3 +1,7 @@ +### September 2, 2026 +`2.12.1` +- Emit a structured `runtime_worker_pool_initializing` DEBUG log event once during INIT in multi-concurrent (Lambda Managed Instances) mode, reporting the worker pool size (`workerCount`) and the maximum concurrency the execution environment supports (`executionEnvironmentMaxConcurrency`). Only visible when the function log level is DEBUG or lower; not emitted for standard on-demand functions. + ### July 17, 2026 `2.12.0` - 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. diff --git a/aws-lambda-java-runtime-interface-client/src/main/java/com/amazonaws/services/lambda/runtime/api/client/AWSLambda.java b/aws-lambda-java-runtime-interface-client/src/main/java/com/amazonaws/services/lambda/runtime/api/client/AWSLambda.java index b9aa0fd1..c81433fd 100644 --- a/aws-lambda-java-runtime-interface-client/src/main/java/com/amazonaws/services/lambda/runtime/api/client/AWSLambda.java +++ b/aws-lambda-java-runtime-interface-client/src/main/java/com/amazonaws/services/lambda/runtime/api/client/AWSLambda.java @@ -35,7 +35,11 @@ import java.io.PrintStream; import java.lang.reflect.Constructor; import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; import java.security.Security; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.Properties; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -251,6 +255,14 @@ protected static void startRuntimeLoops(LambdaRequestHandler lambdaRequestHandle if (concurrencyConfig.isMultiConcurrent()) { lambdaLogger.log(concurrencyConfig.getConcurrencyConfigMessage(), lambdaLogger.getLogFormat() == LogFormat.JSON ? LogLevel.INFO : LogLevel.UNDEFINED); ExecutorService platformThreadExecutor = Executors.newFixedThreadPool(concurrencyConfig.getNumberOfPlatformThreads()); + // Emitted once during INIT. The byte[] log path applies level filtering but no + // formatting, so the hand-built line keeps "message" as a nested JSON object. + if (lambdaLogger.getLogFormat() == LogFormat.JSON) { + WorkerPoolInitializedEvent event = new WorkerPoolInitializedEvent( + concurrencyConfig.getNumberOfPlatformThreads(), + concurrencyConfig.getNumberOfPlatformThreads()); + lambdaLogger.log(event.toJsonLogLine().getBytes(StandardCharsets.UTF_8), LogLevel.DEBUG); + } try { for (int i = 0; i < concurrencyConfig.getNumberOfPlatformThreads(); i++) { startRuntimeLoopWithExecutor(lambdaRequestHandler, lambdaLogger, platformThreadExecutor, runtimeClient); @@ -373,4 +385,30 @@ private static void logExceptionCloudWatch(LambdaContextLogger lambdaLogger, Exc protected static URLClassLoader getCustomerClassLoader() { return customerClassLoader; } + + static class WorkerPoolInitializedEvent { + static final String EVENT_NAME = "runtime_worker_pool_initializing"; + private static final DateTimeFormatter TIMESTAMP_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").withZone(ZoneId.of("UTC")); + + final int workerCount; + final int executionEnvironmentMaxConcurrency; + + WorkerPoolInitializedEvent(int workerCount, int executionEnvironmentMaxConcurrency) { + this.workerCount = workerCount; + this.executionEnvironmentMaxConcurrency = executionEnvironmentMaxConcurrency; + } + + /** + * Complete JSON log line with "message" as a nested object (queryable in CloudWatch + * Logs Insights). Hand-built safely: all values are constants or ints, no escaping needed. + */ + String toJsonLogLine() { + return "{\"timestamp\":\"" + TIMESTAMP_FORMAT.format(Instant.now()) + + "\",\"message\":{\"event\":\"" + EVENT_NAME + + "\",\"workerCount\":" + workerCount + + ",\"executionEnvironmentMaxConcurrency\":" + executionEnvironmentMaxConcurrency + + "},\"level\":\"DEBUG\"}\n"; + } + } } diff --git a/aws-lambda-java-runtime-interface-client/src/test/java/com/amazonaws/services/lambda/runtime/api/client/AWSLambdaTest.java b/aws-lambda-java-runtime-interface-client/src/test/java/com/amazonaws/services/lambda/runtime/api/client/AWSLambdaTest.java index 10046553..d747d21f 100644 --- a/aws-lambda-java-runtime-interface-client/src/test/java/com/amazonaws/services/lambda/runtime/api/client/AWSLambdaTest.java +++ b/aws-lambda-java-runtime-interface-client/src/test/java/com/amazonaws/services/lambda/runtime/api/client/AWSLambdaTest.java @@ -576,6 +576,82 @@ void testSequentialWithVirtualMachineErrorStopsLoop() throws Throwable { assertEquals(2 * SampleHandler.nOfIterations, SampleHandler.globalCounter.get()); } + @Test + @Timeout(value = 1, unit = TimeUnit.MINUTES) + void testWorkerPoolInitializedEventEmittedOnceInMultiConcurrentMode() throws Throwable { + when(lambdaLogger.getLogFormat()).thenReturn(LogFormat.JSON); + when(concurrencyConfig.isMultiConcurrent()).thenReturn(true); + when(concurrencyConfig.getNumberOfPlatformThreads()).thenReturn(4); + + when(runtimeClient.nextInvocationWithExponentialBackoff(lambdaLogger)) + .thenThrow(fakelambdaRuntimeClientMaxRetriesExceededException); + + AWSLambda.startRuntimeLoops(lambdaRequestHandler, lambdaLogger, concurrencyConfig, runtimeClient); + + org.mockito.ArgumentCaptor lineCaptor = org.mockito.ArgumentCaptor.forClass(byte[].class); + verify(lambdaLogger, times(1)).log(lineCaptor.capture(), eq(LogLevel.DEBUG)); + + com.amazonaws.lambda.thirdparty.org.json.JSONObject parsed = + new com.amazonaws.lambda.thirdparty.org.json.JSONObject( + new String(lineCaptor.getValue(), java.nio.charset.StandardCharsets.UTF_8)); + com.amazonaws.lambda.thirdparty.org.json.JSONObject message = parsed.getJSONObject("message"); + assertEquals("runtime_worker_pool_initializing", message.getString("event")); + assertEquals(4, message.getInt("workerCount")); + assertEquals(4, message.getInt("executionEnvironmentMaxConcurrency")); + } + + @Test + @Timeout(value = 1, unit = TimeUnit.MINUTES) + void testWorkerPoolInitializedEventNotEmittedInNonJsonLogFormat() throws Throwable { + // Guard: no emission when the log format is not JSON, even in multi-concurrent mode + when(lambdaLogger.getLogFormat()).thenReturn(LogFormat.TEXT); + when(concurrencyConfig.isMultiConcurrent()).thenReturn(true); + when(concurrencyConfig.getNumberOfPlatformThreads()).thenReturn(2); + + when(runtimeClient.nextInvocationWithExponentialBackoff(lambdaLogger)) + .thenThrow(fakelambdaRuntimeClientMaxRetriesExceededException); + + AWSLambda.startRuntimeLoops(lambdaRequestHandler, lambdaLogger, concurrencyConfig, runtimeClient); + + verify(lambdaLogger, never()).log(any(byte[].class), any(LogLevel.class)); + } + + @Test + @Timeout(value = 1, unit = TimeUnit.MINUTES) + void testWorkerPoolInitializedEventNotEmittedInSequentialMode() throws Throwable { + when(concurrencyConfig.isMultiConcurrent()).thenReturn(false); + + InvocationRequest fatalRequest = mock(InvocationRequest.class); + when(fatalRequest.getId()).thenThrow(UserFault.makeUserFault(new IOError(new Throwable()), true)).thenReturn("fatal"); + when(runtimeClient.nextInvocation()).thenReturn(fatalRequest); + + AWSLambda.startRuntimeLoops(lambdaRequestHandler, lambdaLogger, concurrencyConfig, runtimeClient); + + verify(lambdaLogger, never()).log(any(byte[].class), any(LogLevel.class)); + } + + /* + * Pins the exact wire format of the hand-built log line: "message" must be a nested + * JSON object with exactly the documented schema, inside a valid envelope. + */ + @Test + void testWorkerPoolInitializedEventJsonWireFormat() { + String line = new AWSLambda.WorkerPoolInitializedEvent(16, 16).toJsonLogLine(); + org.junit.jupiter.api.Assertions.assertTrue(line.endsWith("\n")); + + com.amazonaws.lambda.thirdparty.org.json.JSONObject parsed = + new com.amazonaws.lambda.thirdparty.org.json.JSONObject(line); + assertEquals("DEBUG", parsed.getString("level")); + org.junit.jupiter.api.Assertions.assertNotNull(parsed.getString("timestamp")); + assertEquals(3, parsed.length()); + + com.amazonaws.lambda.thirdparty.org.json.JSONObject message = parsed.getJSONObject("message"); + assertEquals("runtime_worker_pool_initializing", message.getString("event")); + assertEquals(16, message.getInt("workerCount")); + assertEquals(16, message.getInt("executionEnvironmentMaxConcurrency")); + assertEquals(3, message.length()); + } + @Test @Timeout(value = 1, unit = TimeUnit.MINUTES) void testInvocationIdIsPassedToReportSuccess() throws Throwable {