From 3c0a4049aa133f1710423a190195b9c79497b1f8 Mon Sep 17 00:00:00 2001 From: Emmanuel GALLOIS Date: Tue, 23 Jun 2026 16:22:41 +0200 Subject: [PATCH 1/6] feat(QTDI-2030): Add health and readiness endpoints to component-server Introduces GET /api/v1/health (liveness probe) and GET /api/v1/readiness (readiness probe) as dedicated Kubernetes-style endpoints, replacing the insufficient /environment endpoint for cluster health monitoring. Health checks: heap memory availability (configurable threshold), component index validity, and Vault connectivity via VaultClient.ping(). Readiness check: component index load state (isStarted()). Both endpoints return {"status":"UP"|"DOWN","cause":"..."} with HTTP 200/503. Closes QTDI-2030 Co-Authored-By: GitHub Copilot --- .../component/server/api/HealthResource.java | 53 +++++++ .../server/api/ReadinessResource.java | 53 +++++++ .../server/front/model/HealthStatus.java | 30 ++++ .../component-server/pom.xml | 6 + .../ComponentServerConfiguration.java | 6 + .../server/front/HealthResourceImpl.java | 40 +++++ .../server/front/ReadinessResourceImpl.java | 40 +++++ .../service/ComponentManagerService.java | 4 + .../server/service/HealthService.java | 117 +++++++++++++++ .../server/front/HealthResourceImplIT.java | 65 ++++++++ .../server/service/HealthServiceTest.java | 141 ++++++++++++++++++ .../components/vault/client/VaultClient.java | 19 +++ 12 files changed, 574 insertions(+) create mode 100644 component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/HealthResource.java create mode 100644 component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/ReadinessResource.java create mode 100644 component-server-parent/component-server-model/src/main/java/org/talend/sdk/component/server/front/model/HealthStatus.java create mode 100644 component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/HealthResourceImpl.java create mode 100644 component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/ReadinessResourceImpl.java create mode 100644 component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/HealthService.java create mode 100644 component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/HealthResourceImplIT.java create mode 100644 component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/HealthServiceTest.java diff --git a/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/HealthResource.java b/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/HealthResource.java new file mode 100644 index 0000000000000..a44c8660b7c1f --- /dev/null +++ b/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/HealthResource.java @@ -0,0 +1,53 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.talend.sdk.component.server.api; + +import static javax.ws.rs.core.MediaType.APPLICATION_JSON; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Response; + +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.talend.sdk.component.server.front.model.HealthStatus; + +@Path("health") +@Tag(name = "Health", description = "Kubernetes liveness probe endpoint.") +public interface HealthResource { + + @GET + @Produces(APPLICATION_JSON) + @Operation(operationId = "getLiveness", + description = "Liveness probe: returns 200 when the server is healthy (heap, component index, Vault). " + + "Returns 503 with a cause when any check fails.") + @APIResponses({ + @APIResponse(responseCode = "200", + description = "Server is healthy.", + content = @Content(mediaType = APPLICATION_JSON, + schema = @Schema(implementation = HealthStatus.class))), + @APIResponse(responseCode = "503", + description = "Server is not healthy.", + content = @Content(mediaType = APPLICATION_JSON, + schema = @Schema(implementation = HealthStatus.class))) + }) + Response getLiveness(); +} diff --git a/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/ReadinessResource.java b/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/ReadinessResource.java new file mode 100644 index 0000000000000..e260756280747 --- /dev/null +++ b/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/ReadinessResource.java @@ -0,0 +1,53 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.talend.sdk.component.server.api; + +import static javax.ws.rs.core.MediaType.APPLICATION_JSON; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Response; + +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.talend.sdk.component.server.front.model.HealthStatus; + +@Path("readiness") +@Tag(name = "Health", description = "Kubernetes readiness probe endpoint.") +public interface ReadinessResource { + + @GET + @Produces(APPLICATION_JSON) + @Operation(operationId = "getReadiness", + description = "Readiness probe: returns 200 when the component index is loaded and the server is ready " + + "to serve traffic. Returns 503 with a cause otherwise.") + @APIResponses({ + @APIResponse(responseCode = "200", + description = "Server is ready.", + content = @Content(mediaType = APPLICATION_JSON, + schema = @Schema(implementation = HealthStatus.class))), + @APIResponse(responseCode = "503", + description = "Server is not ready.", + content = @Content(mediaType = APPLICATION_JSON, + schema = @Schema(implementation = HealthStatus.class))) + }) + Response getReadiness(); +} diff --git a/component-server-parent/component-server-model/src/main/java/org/talend/sdk/component/server/front/model/HealthStatus.java b/component-server-parent/component-server-model/src/main/java/org/talend/sdk/component/server/front/model/HealthStatus.java new file mode 100644 index 0000000000000..ba6e5d4355599 --- /dev/null +++ b/component-server-parent/component-server-model/src/main/java/org/talend/sdk/component/server/front/model/HealthStatus.java @@ -0,0 +1,30 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.talend.sdk.component.server.front.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class HealthStatus { + + private String status; + + private String cause; +} diff --git a/component-server-parent/component-server/pom.xml b/component-server-parent/component-server/pom.xml index abe2f936646b1..91a9aaa228dd0 100644 --- a/component-server-parent/component-server/pom.xml +++ b/component-server-parent/component-server/pom.xml @@ -158,6 +158,12 @@ ${meecrowave.version} test + + org.mockito + mockito-core + ${mockito4.version} + test + org.apache.tomee ziplock diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/configuration/ComponentServerConfiguration.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/configuration/ComponentServerConfiguration.java index 847360ece24c9..953b94cdd33d1 100644 --- a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/configuration/ComponentServerConfiguration.java +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/configuration/ComponentServerConfiguration.java @@ -201,6 +201,12 @@ public class ComponentServerConfiguration { @ConfigProperty(name = "talend.component.server.plugins.reloading.marker") private Optional pluginsReloadFileMarker; + @Inject + @Documentation("Minimum percentage of available JVM heap required for the liveness probe to report UP. " + + "If the available heap drops below this threshold the health endpoint returns 503.") + @ConfigProperty(name = "talend.server.health.memory.threshold", defaultValue = "10") + private Integer healthMemoryThreshold; + @PostConstruct private void init() { if (logRequests != null && logRequests) { diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/HealthResourceImpl.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/HealthResourceImpl.java new file mode 100644 index 0000000000000..6f136fc332bef --- /dev/null +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/HealthResourceImpl.java @@ -0,0 +1,40 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.talend.sdk.component.server.front; + +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import javax.ws.rs.core.Response; + +import org.talend.sdk.component.server.api.HealthResource; +import org.talend.sdk.component.server.front.model.HealthStatus; +import org.talend.sdk.component.server.service.HealthService; + +@ApplicationScoped +public class HealthResourceImpl implements HealthResource { + + @Inject + private HealthService healthService; + + @Override + public Response getLiveness() { + final HealthStatus status = healthService.checkLiveness(); + if ("UP".equals(status.getStatus())) { + return Response.ok(status).build(); + } + return Response.status(Response.Status.SERVICE_UNAVAILABLE).entity(status).build(); + } +} diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/ReadinessResourceImpl.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/ReadinessResourceImpl.java new file mode 100644 index 0000000000000..4b91d12d73c39 --- /dev/null +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/ReadinessResourceImpl.java @@ -0,0 +1,40 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.talend.sdk.component.server.front; + +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import javax.ws.rs.core.Response; + +import org.talend.sdk.component.server.api.ReadinessResource; +import org.talend.sdk.component.server.front.model.HealthStatus; +import org.talend.sdk.component.server.service.HealthService; + +@ApplicationScoped +public class ReadinessResourceImpl implements ReadinessResource { + + @Inject + private HealthService healthService; + + @Override + public Response getReadiness() { + final HealthStatus status = healthService.checkReadiness(); + if ("UP".equals(status.getStatus())) { + return Response.ok(status).build(); + } + return Response.status(Response.Status.SERVICE_UNAVAILABLE).entity(status).build(); + } +} diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/ComponentManagerService.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/ComponentManagerService.java index edd9d0f533459..871c676bb46ff 100644 --- a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/ComponentManagerService.java +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/ComponentManagerService.java @@ -460,4 +460,8 @@ public ComponentManager manager() { return instance; } + public boolean isStarted() { + return started; + } + } diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/HealthService.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/HealthService.java new file mode 100644 index 0000000000000..0db7c05c24331 --- /dev/null +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/HealthService.java @@ -0,0 +1,117 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.talend.sdk.component.server.service; + +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; + +import org.talend.sdk.component.server.configuration.ComponentServerConfiguration; +import org.talend.sdk.component.server.front.model.HealthStatus; +import org.talend.sdk.components.vault.client.VaultClient; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@ApplicationScoped +public class HealthService { + + private static final String STATUS_UP = "UP"; + + private static final String STATUS_DOWN = "DOWN"; + + @Inject + private ComponentManagerService componentManagerService; + + @Inject + private VaultClient vaultClient; + + @Inject + private ComponentServerConfiguration configuration; + + /** + * Performs the liveness check: heap availability, component index, and Vault connectivity. + * + * @return {@link HealthStatus} with {@code status="UP"} if all checks pass, + * or {@code status="DOWN"} with a human-readable cause on failure. + */ + public HealthStatus checkLiveness() { + final HealthStatus memoryStatus = checkMemory(); + if (STATUS_DOWN.equals(memoryStatus.getStatus())) { + return memoryStatus; + } + final HealthStatus indexStatus = checkComponentIndex(); + if (STATUS_DOWN.equals(indexStatus.getStatus())) { + return indexStatus; + } + final HealthStatus vaultStatus = checkVault(); + if (STATUS_DOWN.equals(vaultStatus.getStatus())) { + return vaultStatus; + } + return new HealthStatus(STATUS_UP, null); + } + + /** + * Performs the readiness check: verifies that the component index is loaded. + * + * @return {@link HealthStatus} with {@code status="UP"} if ready, + * or {@code status="DOWN"} with cause otherwise. + */ + public HealthStatus checkReadiness() { + if (!componentManagerService.isStarted()) { + return new HealthStatus(STATUS_DOWN, "Component index not ready"); + } + return new HealthStatus(STATUS_UP, null); + } + + private HealthStatus checkMemory() { + final Runtime runtime = Runtime.getRuntime(); + final long maxMemory = runtime.maxMemory(); + if (maxMemory == Long.MAX_VALUE) { + return new HealthStatus(STATUS_UP, null); + } + final long availableMemory = maxMemory - runtime.totalMemory() + runtime.freeMemory(); + final int availablePercent = (int) (availableMemory * 100L / maxMemory); + final int threshold = configuration.getHealthMemoryThreshold(); + if (availablePercent < threshold) { + final String cause = String + .format("Available heap is %d%% which is below the configured threshold of %d%%", + availablePercent, threshold); + log.warn("Liveness check failed: {}", cause); + return new HealthStatus(STATUS_DOWN, cause); + } + return new HealthStatus(STATUS_UP, null); + } + + private HealthStatus checkComponentIndex() { + try { + componentManagerService.manager().getContainer().findAll(); + return new HealthStatus(STATUS_UP, null); + } catch (final Throwable t) { + final String cause = "Component index check failed: " + t.getMessage(); + log.warn("Liveness check failed: {}", cause); + return new HealthStatus(STATUS_DOWN, cause); + } + } + + private HealthStatus checkVault() { + if (!vaultClient.ping()) { + final String cause = "Vault is not reachable"; + log.warn("Liveness check failed: {}", cause); + return new HealthStatus(STATUS_DOWN, cause); + } + return new HealthStatus(STATUS_UP, null); + } +} diff --git a/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/HealthResourceImplIT.java b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/HealthResourceImplIT.java new file mode 100644 index 0000000000000..4468e784449f7 --- /dev/null +++ b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/HealthResourceImplIT.java @@ -0,0 +1,65 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.talend.sdk.component.server.front; + +import static javax.ws.rs.core.MediaType.APPLICATION_JSON_TYPE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import javax.inject.Inject; +import javax.ws.rs.client.WebTarget; +import javax.ws.rs.core.Response; + +import org.apache.meecrowave.junit5.MonoMeecrowaveConfig; +import org.junit.jupiter.api.Test; +import org.talend.sdk.component.server.front.model.HealthStatus; + +@MonoMeecrowaveConfig +class HealthResourceImplIT { + + @Inject + private WebTarget base; + + @Test + void livenessReturns200WhenHealthy() { + final Response response = base.path("health").request(APPLICATION_JSON_TYPE).get(); + assertEquals(200, response.getStatus()); + final HealthStatus status = response.readEntity(HealthStatus.class); + assertNotNull(status); + assertEquals("UP", status.getStatus()); + } + + @Test + void readinessReturns200WhenReady() { + final Response response = base.path("readiness").request(APPLICATION_JSON_TYPE).get(); + assertEquals(200, response.getStatus()); + final HealthStatus status = response.readEntity(HealthStatus.class); + assertNotNull(status); + assertEquals("UP", status.getStatus()); + } + + @Test + void healthAndReadinessAreIndependentFromEnvironment() { + final Response envResponse = base.path("environment").request(APPLICATION_JSON_TYPE).get(); + assertEquals(200, envResponse.getStatus()); + + final Response healthResponse = base.path("health").request(APPLICATION_JSON_TYPE).get(); + assertEquals(200, healthResponse.getStatus()); + + final Response readinessResponse = base.path("readiness").request(APPLICATION_JSON_TYPE).get(); + assertEquals(200, readinessResponse.getStatus()); + } +} diff --git a/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/HealthServiceTest.java b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/HealthServiceTest.java new file mode 100644 index 0000000000000..c39fec4f0fdf1 --- /dev/null +++ b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/HealthServiceTest.java @@ -0,0 +1,141 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.talend.sdk.component.server.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.talend.sdk.component.container.ContainerManager; +import org.talend.sdk.component.runtime.manager.ComponentManager; +import org.talend.sdk.component.server.configuration.ComponentServerConfiguration; +import org.talend.sdk.component.server.front.model.HealthStatus; +import org.talend.sdk.components.vault.client.VaultClient; + +class HealthServiceTest { + + private AutoCloseable closeable; + + @Mock + private ComponentManagerService componentManagerService; + + @Mock + private VaultClient vaultClient; + + @Mock + private ComponentServerConfiguration configuration; + + @InjectMocks + private HealthService healthService; + + @BeforeEach + void setup() { + closeable = MockitoAnnotations.openMocks(this); + when(configuration.getHealthMemoryThreshold()).thenReturn(10); + } + + @AfterEach + void tearDown() throws Exception { + closeable.close(); + } + + @Test + void livenessReturnUpWhenAllChecksPass() { + final ComponentManager manager = mock(ComponentManager.class); + final ContainerManager containerManager = mock(ContainerManager.class); + when(componentManagerService.manager()).thenReturn(manager); + when(manager.getContainer()).thenReturn(containerManager); + when(vaultClient.ping()).thenReturn(true); + + final HealthStatus status = healthService.checkLiveness(); + + assertEquals("UP", status.getStatus()); + assertNull(status.getCause()); + } + + @Test + void livenessReturnDownWhenHeapBelowThreshold() { + when(configuration.getHealthMemoryThreshold()).thenReturn(101); + + final HealthStatus status = healthService.checkLiveness(); + + assertEquals("DOWN", status.getStatus()); + assertNotNull(status.getCause()); + } + + @Test + void livenessReturnDownWhenComponentIndexThrows() { + final ComponentManager manager = mock(ComponentManager.class); + when(componentManagerService.manager()).thenReturn(manager); + when(manager.getContainer()).thenThrow(new RuntimeException("index failure")); + + final HealthStatus status = healthService.checkLiveness(); + + assertEquals("DOWN", status.getStatus()); + assertNotNull(status.getCause()); + } + + @Test + void livenessReturnDownWhenVaultPingFails() { + final ComponentManager manager = mock(ComponentManager.class); + final ContainerManager containerManager = mock(ContainerManager.class); + when(componentManagerService.manager()).thenReturn(manager); + when(manager.getContainer()).thenReturn(containerManager); + when(vaultClient.ping()).thenReturn(false); + + final HealthStatus status = healthService.checkLiveness(); + + assertEquals("DOWN", status.getStatus()); + assertEquals("Vault is not reachable", status.getCause()); + } + + @Test + void readinessReturnUpWhenStarted() { + when(componentManagerService.isStarted()).thenReturn(true); + + final HealthStatus status = healthService.checkReadiness(); + + assertEquals("UP", status.getStatus()); + assertNull(status.getCause()); + } + + @Test + void readinessReturnDownWhenNotStarted() { + when(componentManagerService.isStarted()).thenReturn(false); + + final HealthStatus status = healthService.checkReadiness(); + + assertEquals("DOWN", status.getStatus()); + assertEquals("Component index not ready", status.getCause()); + } + + @Test + void environmentRemainsIndependentOfHealthChecks() { + when(componentManagerService.isStarted()).thenReturn(false); + + final HealthStatus readiness = healthService.checkReadiness(); + + assertEquals("DOWN", readiness.getStatus()); + } +} diff --git a/vault-client/src/main/java/org/talend/sdk/components/vault/client/VaultClient.java b/vault-client/src/main/java/org/talend/sdk/components/vault/client/VaultClient.java index c6d805ca35225..03d26fca9cfd9 100644 --- a/vault-client/src/main/java/org/talend/sdk/components/vault/client/VaultClient.java +++ b/vault-client/src/main/java/org/talend/sdk/components/vault/client/VaultClient.java @@ -201,6 +201,25 @@ public Thread newThread(final Runnable r) { }); } + /** + * Checks connectivity to Vault. + * + * @return {@code true} if Vault is reachable or if Vault is not configured ({@code no-vault}), + * {@code false} if a transport-level error prevents reaching Vault. + */ + public boolean ping() { + if ("no-vault".equals(setup.getVaultUrl())) { + return true; + } + try { + vault.path("v1/sys/health").request().get().close(); + return true; + } catch (final javax.ws.rs.ProcessingException e) { + log.warn("Vault ping failed: {}", e.getMessage()); + return false; + } + } + @SneakyThrows public Map decrypt(final Map values) { return decrypt(values, null); From 95af6b895e8069349e679e0726cb976ca5efa806 Mon Sep 17 00:00:00 2001 From: Emmanuel GALLOIS Date: Tue, 7 Jul 2026 13:42:23 +0200 Subject: [PATCH 2/6] =?UTF-8?q?fix(QTDI-2030):=20address=20review=20round?= =?UTF-8?q?=201=20=E2=80=94=20architectural=20refactor=20liveness/readines?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename /health → /liveness (LivenessResource + LivenessResourceImpl) The /health endpoint was misleading; /liveness matches Kubernetes naming - Add FatalState singleton to record VirtualMachineError occurrences LivenessResourceImpl now returns DOWN if a fatal JVM error was recorded, instead of performing active synchronous checks that could themselves trigger OOMEs - Add DefaultExceptionHandler VirtualMachineError interception → FatalState Any OOME or other VirtualMachineError during a request marks the JVM as fatal - Add RequestContextFilter + RequestContextHolder to expose request path on the current thread, used by DefaultExceptionHandler for richer error messages - Move Vault check from liveness to ReadinessResourceImpl with 5s cache Synchronous Vault ping on the liveness path risked cascading pod restarts; moved to readiness with a TTL cache and catch(Throwable) + Response.close() - Add VaultClient.ping() timeout (configurable, default 2s) via talend.vault.cache.vault.ping.timeout - Remove HealthService (logic split into LivenessResourceImpl/ReadinessResourceImpl) - New unit tests: FatalStateTest, RequestContextHolderTest, ReadinessResourceImplTest - Rewrite HealthResourceImplIT → LivenessResourceImplIT --- ...lthResource.java => LivenessResource.java} | 12 +- ...rceImpl.java => LivenessResourceImpl.java} | 22 ++- .../server/front/ReadinessResourceImpl.java | 62 +++++++- .../front/error/DefaultExceptionHandler.java | 11 ++ .../front/filter/RequestContextFilter.java | 49 ++++++ .../component/server/service/FatalState.java | 58 +++++++ .../server/service/HealthService.java | 117 --------------- .../server/service/RequestContextHolder.java | 41 +++++ ...mplIT.java => LivenessResourceImplIT.java} | 14 +- .../front/ReadinessResourceImplTest.java | 104 +++++++++++++ .../server/service/FatalStateTest.java | 56 +++++++ .../server/service/HealthServiceTest.java | 141 ------------------ .../service/RequestContextHolderTest.java | 50 +++++++ .../components/vault/client/VaultClient.java | 18 ++- 14 files changed, 470 insertions(+), 285 deletions(-) rename component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/{HealthResource.java => LivenessResource.java} (85%) rename component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/{HealthResourceImpl.java => LivenessResourceImpl.java} (59%) create mode 100644 component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/filter/RequestContextFilter.java create mode 100644 component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/FatalState.java delete mode 100644 component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/HealthService.java create mode 100644 component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/RequestContextHolder.java rename component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/{HealthResourceImplIT.java => LivenessResourceImplIT.java} (80%) create mode 100644 component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/ReadinessResourceImplTest.java create mode 100644 component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/FatalStateTest.java delete mode 100644 component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/HealthServiceTest.java create mode 100644 component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/RequestContextHolderTest.java diff --git a/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/HealthResource.java b/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/LivenessResource.java similarity index 85% rename from component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/HealthResource.java rename to component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/LivenessResource.java index a44c8660b7c1f..d675c1a320c26 100644 --- a/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/HealthResource.java +++ b/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/LivenessResource.java @@ -30,22 +30,22 @@ import org.eclipse.microprofile.openapi.annotations.tags.Tag; import org.talend.sdk.component.server.front.model.HealthStatus; -@Path("health") +@Path("liveness") @Tag(name = "Health", description = "Kubernetes liveness probe endpoint.") -public interface HealthResource { +public interface LivenessResource { @GET @Produces(APPLICATION_JSON) @Operation(operationId = "getLiveness", - description = "Liveness probe: returns 200 when the server is healthy (heap, component index, Vault). " - + "Returns 503 with a cause when any check fails.") + description = "Liveness probe: returns 200 when the JVM is healthy (no fatal error recorded). " + + "Returns 503 with a cause when a VirtualMachineError (e.g. OutOfMemoryError) has been intercepted.") @APIResponses({ @APIResponse(responseCode = "200", - description = "Server is healthy.", + description = "JVM is healthy.", content = @Content(mediaType = APPLICATION_JSON, schema = @Schema(implementation = HealthStatus.class))), @APIResponse(responseCode = "503", - description = "Server is not healthy.", + description = "JVM has encountered a fatal error.", content = @Content(mediaType = APPLICATION_JSON, schema = @Schema(implementation = HealthStatus.class))) }) diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/HealthResourceImpl.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/LivenessResourceImpl.java similarity index 59% rename from component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/HealthResourceImpl.java rename to component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/LivenessResourceImpl.java index 6f136fc332bef..4d7628e34adea 100644 --- a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/HealthResourceImpl.java +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/LivenessResourceImpl.java @@ -19,22 +19,28 @@ import javax.inject.Inject; import javax.ws.rs.core.Response; -import org.talend.sdk.component.server.api.HealthResource; +import org.talend.sdk.component.server.api.LivenessResource; import org.talend.sdk.component.server.front.model.HealthStatus; -import org.talend.sdk.component.server.service.HealthService; +import org.talend.sdk.component.server.service.FatalState; @ApplicationScoped -public class HealthResourceImpl implements HealthResource { +public class LivenessResourceImpl implements LivenessResource { + + private static final String STATUS_UP = "UP"; + + private static final String STATUS_DOWN = "DOWN"; @Inject - private HealthService healthService; + private FatalState fatalState; @Override public Response getLiveness() { - final HealthStatus status = healthService.checkLiveness(); - if ("UP".equals(status.getStatus())) { - return Response.ok(status).build(); + if (fatalState.hasFatalError()) { + return Response + .status(Response.Status.SERVICE_UNAVAILABLE) + .entity(new HealthStatus(STATUS_DOWN, fatalState.getCause())) + .build(); } - return Response.status(Response.Status.SERVICE_UNAVAILABLE).entity(status).build(); + return Response.ok(new HealthStatus(STATUS_UP, null)).build(); } } diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/ReadinessResourceImpl.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/ReadinessResourceImpl.java index 4b91d12d73c39..001a44bac78bb 100644 --- a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/ReadinessResourceImpl.java +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/ReadinessResourceImpl.java @@ -15,26 +15,76 @@ */ package org.talend.sdk.component.server.front; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + import javax.enterprise.context.ApplicationScoped; import javax.inject.Inject; import javax.ws.rs.core.Response; import org.talend.sdk.component.server.api.ReadinessResource; import org.talend.sdk.component.server.front.model.HealthStatus; -import org.talend.sdk.component.server.service.HealthService; +import org.talend.sdk.component.server.service.ComponentManagerService; +import org.talend.sdk.components.vault.client.VaultClient; + +import lombok.extern.slf4j.Slf4j; +@Slf4j @ApplicationScoped public class ReadinessResourceImpl implements ReadinessResource { + private static final String STATUS_UP = "UP"; + + private static final String STATUS_DOWN = "DOWN"; + + private static final long VAULT_CACHE_TTL_MS = 5_000L; + + @Inject + private ComponentManagerService componentManagerService; + @Inject - private HealthService healthService; + private VaultClient vaultClient; + + private final AtomicBoolean cachedVaultResult = new AtomicBoolean(true); + + private final AtomicLong lastVaultCheck = new AtomicLong(0L); @Override public Response getReadiness() { - final HealthStatus status = healthService.checkReadiness(); - if ("UP".equals(status.getStatus())) { - return Response.ok(status).build(); + if (!componentManagerService.isStarted()) { + return Response + .status(Response.Status.SERVICE_UNAVAILABLE) + .entity(new HealthStatus(STATUS_DOWN, "Component index not ready")) + .build(); + } + final HealthStatus vaultStatus = checkVaultCached(); + if (STATUS_DOWN.equals(vaultStatus.getStatus())) { + return Response.status(Response.Status.SERVICE_UNAVAILABLE).entity(vaultStatus).build(); + } + return Response.ok(new HealthStatus(STATUS_UP, null)).build(); + } + + private HealthStatus checkVaultCached() { + final long now = System.currentTimeMillis(); + if (now - lastVaultCheck.get() < VAULT_CACHE_TTL_MS) { + return cachedVaultResult.get() + ? new HealthStatus(STATUS_UP, null) + : new HealthStatus(STATUS_DOWN, "Vault is not reachable"); + } + lastVaultCheck.set(now); + try { + final boolean reachable = vaultClient.ping(); + cachedVaultResult.set(reachable); + if (!reachable) { + log.warn("Readiness check: Vault is not reachable"); + return new HealthStatus(STATUS_DOWN, "Vault is not reachable"); + } + } catch (final Throwable t) { + cachedVaultResult.set(false); + final String cause = "Vault connectivity check failed: " + t.getMessage(); + log.warn("Readiness check failed: {}", cause); + return new HealthStatus(STATUS_DOWN, cause); } - return Response.status(Response.Status.SERVICE_UNAVAILABLE).entity(status).build(); + return new HealthStatus(STATUS_UP, null); } } diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/error/DefaultExceptionHandler.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/error/DefaultExceptionHandler.java index f6cbe9244fe66..0d8318d5077d4 100644 --- a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/error/DefaultExceptionHandler.java +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/error/DefaultExceptionHandler.java @@ -30,6 +30,8 @@ import org.talend.sdk.component.server.configuration.ComponentServerConfiguration; import org.talend.sdk.component.server.front.model.ErrorDictionary; import org.talend.sdk.component.server.front.model.error.ErrorPayload; +import org.talend.sdk.component.server.service.FatalState; +import org.talend.sdk.component.server.service.RequestContextHolder; import lombok.extern.slf4j.Slf4j; @@ -41,6 +43,9 @@ public class DefaultExceptionHandler implements ExceptionMapper { @Inject private ComponentServerConfiguration configuration; + @Inject + private FatalState fatalState; + private boolean replaceException; @PostConstruct @@ -50,6 +55,12 @@ private void init() { @Override public Response toResponse(final Throwable exception) { + if (exception instanceof VirtualMachineError) { + final String requestPath = RequestContextHolder.get(); + final String cause = exception.getClass().getSimpleName() + " during request " + + (requestPath != null ? requestPath : "(unknown)") + ": " + exception.getMessage(); + fatalState.markFatal(cause); + } log.error("[DefaultExceptionHandler#toResponse] Throwable: ", exception); final Response response; if (exception instanceof WebApplicationException) { diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/filter/RequestContextFilter.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/filter/RequestContextFilter.java new file mode 100644 index 0000000000000..730c8a2583f95 --- /dev/null +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/filter/RequestContextFilter.java @@ -0,0 +1,49 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.talend.sdk.component.server.front.filter; + +import java.io.IOException; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.annotation.WebFilter; +import javax.servlet.http.HttpServletRequest; + +import org.talend.sdk.component.server.service.RequestContextHolder; + +/** + * Servlet filter that populates {@link RequestContextHolder} with the current request path + * so that fatal-error recording in {@code DefaultExceptionHandler} can include request context. + */ +@WebFilter(asyncSupported = true, urlPatterns = "/*") +public class RequestContextFilter implements Filter { + + @Override + public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain) + throws IOException, ServletException { + if (request instanceof HttpServletRequest) { + RequestContextHolder.set(((HttpServletRequest) request).getRequestURI()); + } + try { + chain.doFilter(request, response); + } finally { + RequestContextHolder.clear(); + } + } +} diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/FatalState.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/FatalState.java new file mode 100644 index 0000000000000..a7faba22a041d --- /dev/null +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/FatalState.java @@ -0,0 +1,58 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.talend.sdk.component.server.service; + +import java.util.concurrent.atomic.AtomicReference; + +import javax.enterprise.context.ApplicationScoped; + +import lombok.extern.slf4j.Slf4j; + +/** + * Singleton that records whether the JVM has encountered a fatal error (VirtualMachineError) + * during request processing. Once set, the state is never cleared — the pod must be restarted. + */ +@Slf4j +@ApplicationScoped +public class FatalState { + + private final AtomicReference fatalCause = new AtomicReference<>(null); + + /** + * Records a fatal error. Subsequent calls are no-ops; the first cause wins. + * + * @param cause human-readable description of the error + */ + public void markFatal(final String cause) { + if (fatalCause.compareAndSet(null, cause)) { + log.error("Fatal JVM error recorded — liveness probe will now return DOWN: {}", cause); + } + } + + /** + * @return {@code true} if a fatal error has been recorded + */ + public boolean hasFatalError() { + return fatalCause.get() != null; + } + + /** + * @return the cause of the fatal error, or {@code null} if none has been recorded + */ + public String getCause() { + return fatalCause.get(); + } +} diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/HealthService.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/HealthService.java deleted file mode 100644 index 0db7c05c24331..0000000000000 --- a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/HealthService.java +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Copyright (C) 2006-2026 Talend Inc. - www.talend.com - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.talend.sdk.component.server.service; - -import javax.enterprise.context.ApplicationScoped; -import javax.inject.Inject; - -import org.talend.sdk.component.server.configuration.ComponentServerConfiguration; -import org.talend.sdk.component.server.front.model.HealthStatus; -import org.talend.sdk.components.vault.client.VaultClient; - -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@ApplicationScoped -public class HealthService { - - private static final String STATUS_UP = "UP"; - - private static final String STATUS_DOWN = "DOWN"; - - @Inject - private ComponentManagerService componentManagerService; - - @Inject - private VaultClient vaultClient; - - @Inject - private ComponentServerConfiguration configuration; - - /** - * Performs the liveness check: heap availability, component index, and Vault connectivity. - * - * @return {@link HealthStatus} with {@code status="UP"} if all checks pass, - * or {@code status="DOWN"} with a human-readable cause on failure. - */ - public HealthStatus checkLiveness() { - final HealthStatus memoryStatus = checkMemory(); - if (STATUS_DOWN.equals(memoryStatus.getStatus())) { - return memoryStatus; - } - final HealthStatus indexStatus = checkComponentIndex(); - if (STATUS_DOWN.equals(indexStatus.getStatus())) { - return indexStatus; - } - final HealthStatus vaultStatus = checkVault(); - if (STATUS_DOWN.equals(vaultStatus.getStatus())) { - return vaultStatus; - } - return new HealthStatus(STATUS_UP, null); - } - - /** - * Performs the readiness check: verifies that the component index is loaded. - * - * @return {@link HealthStatus} with {@code status="UP"} if ready, - * or {@code status="DOWN"} with cause otherwise. - */ - public HealthStatus checkReadiness() { - if (!componentManagerService.isStarted()) { - return new HealthStatus(STATUS_DOWN, "Component index not ready"); - } - return new HealthStatus(STATUS_UP, null); - } - - private HealthStatus checkMemory() { - final Runtime runtime = Runtime.getRuntime(); - final long maxMemory = runtime.maxMemory(); - if (maxMemory == Long.MAX_VALUE) { - return new HealthStatus(STATUS_UP, null); - } - final long availableMemory = maxMemory - runtime.totalMemory() + runtime.freeMemory(); - final int availablePercent = (int) (availableMemory * 100L / maxMemory); - final int threshold = configuration.getHealthMemoryThreshold(); - if (availablePercent < threshold) { - final String cause = String - .format("Available heap is %d%% which is below the configured threshold of %d%%", - availablePercent, threshold); - log.warn("Liveness check failed: {}", cause); - return new HealthStatus(STATUS_DOWN, cause); - } - return new HealthStatus(STATUS_UP, null); - } - - private HealthStatus checkComponentIndex() { - try { - componentManagerService.manager().getContainer().findAll(); - return new HealthStatus(STATUS_UP, null); - } catch (final Throwable t) { - final String cause = "Component index check failed: " + t.getMessage(); - log.warn("Liveness check failed: {}", cause); - return new HealthStatus(STATUS_DOWN, cause); - } - } - - private HealthStatus checkVault() { - if (!vaultClient.ping()) { - final String cause = "Vault is not reachable"; - log.warn("Liveness check failed: {}", cause); - return new HealthStatus(STATUS_DOWN, cause); - } - return new HealthStatus(STATUS_UP, null); - } -} diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/RequestContextHolder.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/RequestContextHolder.java new file mode 100644 index 0000000000000..0777abd14d871 --- /dev/null +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/RequestContextHolder.java @@ -0,0 +1,41 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.talend.sdk.component.server.service; + +/** + * Holds the HTTP request path for the current thread, set by {@code RequestContextFilter}. + * Provides context information when a fatal error is recorded during request processing. + */ +public class RequestContextHolder { + + private static final ThreadLocal REQUEST_PATH = new ThreadLocal<>(); + + private RequestContextHolder() { + // utility + } + + public static void set(final String path) { + REQUEST_PATH.set(path); + } + + public static String get() { + return REQUEST_PATH.get(); + } + + public static void clear() { + REQUEST_PATH.remove(); + } +} diff --git a/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/HealthResourceImplIT.java b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/LivenessResourceImplIT.java similarity index 80% rename from component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/HealthResourceImplIT.java rename to component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/LivenessResourceImplIT.java index 4468e784449f7..3b6bd4636867a 100644 --- a/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/HealthResourceImplIT.java +++ b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/LivenessResourceImplIT.java @@ -18,6 +18,7 @@ import static javax.ws.rs.core.MediaType.APPLICATION_JSON_TYPE; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import javax.inject.Inject; import javax.ws.rs.client.WebTarget; @@ -28,18 +29,19 @@ import org.talend.sdk.component.server.front.model.HealthStatus; @MonoMeecrowaveConfig -class HealthResourceImplIT { +class LivenessResourceImplIT { @Inject private WebTarget base; @Test - void livenessReturns200WhenHealthy() { - final Response response = base.path("health").request(APPLICATION_JSON_TYPE).get(); + void livenessReturns200WhenNoFatalError() { + final Response response = base.path("liveness").request(APPLICATION_JSON_TYPE).get(); assertEquals(200, response.getStatus()); final HealthStatus status = response.readEntity(HealthStatus.class); assertNotNull(status); assertEquals("UP", status.getStatus()); + assertNull(status.getCause()); } @Test @@ -52,12 +54,12 @@ void readinessReturns200WhenReady() { } @Test - void healthAndReadinessAreIndependentFromEnvironment() { + void livenessAndReadinessAreIndependentFromEnvironment() { final Response envResponse = base.path("environment").request(APPLICATION_JSON_TYPE).get(); assertEquals(200, envResponse.getStatus()); - final Response healthResponse = base.path("health").request(APPLICATION_JSON_TYPE).get(); - assertEquals(200, healthResponse.getStatus()); + final Response livenessResponse = base.path("liveness").request(APPLICATION_JSON_TYPE).get(); + assertEquals(200, livenessResponse.getStatus()); final Response readinessResponse = base.path("readiness").request(APPLICATION_JSON_TYPE).get(); assertEquals(200, readinessResponse.getStatus()); diff --git a/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/ReadinessResourceImplTest.java b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/ReadinessResourceImplTest.java new file mode 100644 index 0000000000000..fef53c032bac2 --- /dev/null +++ b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/ReadinessResourceImplTest.java @@ -0,0 +1,104 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.talend.sdk.component.server.front; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import javax.ws.rs.core.Response; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.talend.sdk.component.server.front.model.HealthStatus; +import org.talend.sdk.component.server.service.ComponentManagerService; +import org.talend.sdk.components.vault.client.VaultClient; + +class ReadinessResourceImplTest { + + private AutoCloseable closeable; + + @Mock + private ComponentManagerService componentManagerService; + + @Mock + private VaultClient vaultClient; + + @InjectMocks + private ReadinessResourceImpl readinessResource; + + @BeforeEach + void setUp() { + closeable = MockitoAnnotations.openMocks(this); + } + + @AfterEach + void tearDown() throws Exception { + closeable.close(); + } + + @Test + void returns503WhenIndexNotReady() { + when(componentManagerService.isStarted()).thenReturn(false); + + final Response response = readinessResource.getReadiness(); + + assertEquals(503, response.getStatus()); + final HealthStatus status = (HealthStatus) response.getEntity(); + assertEquals("DOWN", status.getStatus()); + assertEquals("Component index not ready", status.getCause()); + } + + @Test + void returns200WhenIndexReadyAndVaultReachable() { + when(componentManagerService.isStarted()).thenReturn(true); + when(vaultClient.ping()).thenReturn(true); + + final Response response = readinessResource.getReadiness(); + + assertEquals(200, response.getStatus()); + final HealthStatus status = (HealthStatus) response.getEntity(); + assertEquals("UP", status.getStatus()); + } + + @Test + void returns503WhenVaultNotReachable() { + when(componentManagerService.isStarted()).thenReturn(true); + when(vaultClient.ping()).thenReturn(false); + + final Response response = readinessResource.getReadiness(); + + assertEquals(503, response.getStatus()); + final HealthStatus status = (HealthStatus) response.getEntity(); + assertEquals("DOWN", status.getStatus()); + assertEquals("Vault is not reachable", status.getCause()); + } + + @Test + void returns503WhenVaultThrows() { + when(componentManagerService.isStarted()).thenReturn(true); + when(vaultClient.ping()).thenThrow(new RuntimeException("connection refused")); + + final Response response = readinessResource.getReadiness(); + + assertEquals(503, response.getStatus()); + final HealthStatus status = (HealthStatus) response.getEntity(); + assertEquals("DOWN", status.getStatus()); + } +} diff --git a/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/FatalStateTest.java b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/FatalStateTest.java new file mode 100644 index 0000000000000..e2c7da912dffe --- /dev/null +++ b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/FatalStateTest.java @@ -0,0 +1,56 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.talend.sdk.component.server.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class FatalStateTest { + + private FatalState fatalState; + + @BeforeEach + void setUp() { + fatalState = new FatalState(); + } + + @Test + void initiallyNoFatalError() { + assertFalse(fatalState.hasFatalError()); + assertNull(fatalState.getCause()); + } + + @Test + void markFatalSetsCause() { + fatalState.markFatal("OutOfMemoryError during /api/v1/component/index"); + + assertTrue(fatalState.hasFatalError()); + assertEquals("OutOfMemoryError during /api/v1/component/index", fatalState.getCause()); + } + + @Test + void markFatalIsIdempotentFirstCauseWins() { + fatalState.markFatal("first error"); + fatalState.markFatal("second error"); + + assertEquals("first error", fatalState.getCause()); + } +} diff --git a/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/HealthServiceTest.java b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/HealthServiceTest.java deleted file mode 100644 index c39fec4f0fdf1..0000000000000 --- a/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/HealthServiceTest.java +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Copyright (C) 2006-2026 Talend Inc. - www.talend.com - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.talend.sdk.component.server.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.talend.sdk.component.container.ContainerManager; -import org.talend.sdk.component.runtime.manager.ComponentManager; -import org.talend.sdk.component.server.configuration.ComponentServerConfiguration; -import org.talend.sdk.component.server.front.model.HealthStatus; -import org.talend.sdk.components.vault.client.VaultClient; - -class HealthServiceTest { - - private AutoCloseable closeable; - - @Mock - private ComponentManagerService componentManagerService; - - @Mock - private VaultClient vaultClient; - - @Mock - private ComponentServerConfiguration configuration; - - @InjectMocks - private HealthService healthService; - - @BeforeEach - void setup() { - closeable = MockitoAnnotations.openMocks(this); - when(configuration.getHealthMemoryThreshold()).thenReturn(10); - } - - @AfterEach - void tearDown() throws Exception { - closeable.close(); - } - - @Test - void livenessReturnUpWhenAllChecksPass() { - final ComponentManager manager = mock(ComponentManager.class); - final ContainerManager containerManager = mock(ContainerManager.class); - when(componentManagerService.manager()).thenReturn(manager); - when(manager.getContainer()).thenReturn(containerManager); - when(vaultClient.ping()).thenReturn(true); - - final HealthStatus status = healthService.checkLiveness(); - - assertEquals("UP", status.getStatus()); - assertNull(status.getCause()); - } - - @Test - void livenessReturnDownWhenHeapBelowThreshold() { - when(configuration.getHealthMemoryThreshold()).thenReturn(101); - - final HealthStatus status = healthService.checkLiveness(); - - assertEquals("DOWN", status.getStatus()); - assertNotNull(status.getCause()); - } - - @Test - void livenessReturnDownWhenComponentIndexThrows() { - final ComponentManager manager = mock(ComponentManager.class); - when(componentManagerService.manager()).thenReturn(manager); - when(manager.getContainer()).thenThrow(new RuntimeException("index failure")); - - final HealthStatus status = healthService.checkLiveness(); - - assertEquals("DOWN", status.getStatus()); - assertNotNull(status.getCause()); - } - - @Test - void livenessReturnDownWhenVaultPingFails() { - final ComponentManager manager = mock(ComponentManager.class); - final ContainerManager containerManager = mock(ContainerManager.class); - when(componentManagerService.manager()).thenReturn(manager); - when(manager.getContainer()).thenReturn(containerManager); - when(vaultClient.ping()).thenReturn(false); - - final HealthStatus status = healthService.checkLiveness(); - - assertEquals("DOWN", status.getStatus()); - assertEquals("Vault is not reachable", status.getCause()); - } - - @Test - void readinessReturnUpWhenStarted() { - when(componentManagerService.isStarted()).thenReturn(true); - - final HealthStatus status = healthService.checkReadiness(); - - assertEquals("UP", status.getStatus()); - assertNull(status.getCause()); - } - - @Test - void readinessReturnDownWhenNotStarted() { - when(componentManagerService.isStarted()).thenReturn(false); - - final HealthStatus status = healthService.checkReadiness(); - - assertEquals("DOWN", status.getStatus()); - assertEquals("Component index not ready", status.getCause()); - } - - @Test - void environmentRemainsIndependentOfHealthChecks() { - when(componentManagerService.isStarted()).thenReturn(false); - - final HealthStatus readiness = healthService.checkReadiness(); - - assertEquals("DOWN", readiness.getStatus()); - } -} diff --git a/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/RequestContextHolderTest.java b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/RequestContextHolderTest.java new file mode 100644 index 0000000000000..43816f67b5340 --- /dev/null +++ b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/RequestContextHolderTest.java @@ -0,0 +1,50 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.talend.sdk.component.server.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class RequestContextHolderTest { + + @AfterEach + void cleanup() { + RequestContextHolder.clear(); + } + + @Test + void getReturnsNullByDefault() { + assertNull(RequestContextHolder.get()); + } + + @Test + void setAndGet() { + RequestContextHolder.set("/api/v1/component/index"); + + assertEquals("/api/v1/component/index", RequestContextHolder.get()); + } + + @Test + void clearRemovesValue() { + RequestContextHolder.set("/api/v1/component/index"); + RequestContextHolder.clear(); + + assertNull(RequestContextHolder.get()); + } +} diff --git a/vault-client/src/main/java/org/talend/sdk/components/vault/client/VaultClient.java b/vault-client/src/main/java/org/talend/sdk/components/vault/client/VaultClient.java index 03d26fca9cfd9..bd1684eb42c83 100644 --- a/vault-client/src/main/java/org/talend/sdk/components/vault/client/VaultClient.java +++ b/vault-client/src/main/java/org/talend/sdk/components/vault/client/VaultClient.java @@ -140,6 +140,12 @@ public class VaultClient { @ConfigProperty(name = "talend.vault.cache.service.decipher.skip.regex", defaultValue = "vault\\:v[0-9]+\\:.*") private String passthroughRegex; + @Inject + @Documentation("Timeout in milliseconds for the Vault ping (connectivity check). " + + "Applied as both connect and read timeout on the health check request.") + @ConfigProperty(name = "talend.vault.cache.vault.ping.timeout", defaultValue = "2000") + private Integer pingTimeoutMs; + @Inject private Cache cache; @@ -211,12 +217,22 @@ public boolean ping() { if ("no-vault".equals(setup.getVaultUrl())) { return true; } + Response response = null; try { - vault.path("v1/sys/health").request().get().close(); + response = vault + .path("v1/sys/health") + .request() + .property("javax.ws.rs.client.connectTimeout", pingTimeoutMs) + .property("javax.ws.rs.client.readTimeout", pingTimeoutMs) + .get(); return true; } catch (final javax.ws.rs.ProcessingException e) { log.warn("Vault ping failed: {}", e.getMessage()); return false; + } finally { + if (response != null) { + response.close(); + } } } From 77896cbef1a2ba0898117bfcb00c4af8b1ebbecd Mon Sep 17 00:00:00 2001 From: Emmanuel GALLOIS Date: Tue, 7 Jul 2026 15:10:45 +0200 Subject: [PATCH 3/6] =?UTF-8?q?fix(QTDI-2030):=20address=20review=20round?= =?UTF-8?q?=202=20=E2=80=94=20liveness/readiness=20architectural=20refinem?= =?UTF-8?q?ents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LivenessResource: rename JVM → Application in API response descriptions (U1, U2) - ComponentServerConfiguration: remove unused healthMemoryThreshold field (U3) - ComponentServerConfiguration: add healthVaultEnabled flag (default false) (U4) - ReadinessResourceImpl: gate Vault check behind healthVaultEnabled flag (U4) - ReadinessResourceImpl: fix race — update lastVaultCheck after result (C1) - ReadinessResourceImpl: re-throw VirtualMachineError before catch(Throwable) (C1) - ComponentManagerService: make started field volatile (C5) - DefaultExceptionHandler: replace RequestContextHolder with @Context HttpServletRequest (C6) - VaultClient: use dedicated ClientBuilder for ping with connect/read timeout (C7) - FatalState: add reset() method for test isolation (U5) - LivenessResourceImplIT: add test for FatalState.markFatal() → /liveness 503 (U5) --- .../server/api/LivenessResource.java | 4 ++-- .../ComponentServerConfiguration.java | 8 ++++---- .../server/front/ReadinessResourceImpl.java | 17 ++++++++++++---- .../front/error/DefaultExceptionHandler.java | 10 +++++++--- .../service/ComponentManagerService.java | 2 +- .../component/server/service/FatalState.java | 7 +++++++ .../server/front/LivenessResourceImplIT.java | 20 +++++++++++++++++++ .../components/vault/client/VaultClient.java | 14 ++++++++++--- 8 files changed, 65 insertions(+), 17 deletions(-) diff --git a/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/LivenessResource.java b/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/LivenessResource.java index d675c1a320c26..a266b9ab3e311 100644 --- a/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/LivenessResource.java +++ b/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/LivenessResource.java @@ -41,11 +41,11 @@ public interface LivenessResource { + "Returns 503 with a cause when a VirtualMachineError (e.g. OutOfMemoryError) has been intercepted.") @APIResponses({ @APIResponse(responseCode = "200", - description = "JVM is healthy.", + description = "Application is healthy.", content = @Content(mediaType = APPLICATION_JSON, schema = @Schema(implementation = HealthStatus.class))), @APIResponse(responseCode = "503", - description = "JVM has encountered a fatal error.", + description = "Application has encountered a fatal error.", content = @Content(mediaType = APPLICATION_JSON, schema = @Schema(implementation = HealthStatus.class))) }) diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/configuration/ComponentServerConfiguration.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/configuration/ComponentServerConfiguration.java index 953b94cdd33d1..93d740a794d7b 100644 --- a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/configuration/ComponentServerConfiguration.java +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/configuration/ComponentServerConfiguration.java @@ -202,10 +202,10 @@ public class ComponentServerConfiguration { private Optional pluginsReloadFileMarker; @Inject - @Documentation("Minimum percentage of available JVM heap required for the liveness probe to report UP. " - + "If the available heap drops below this threshold the health endpoint returns 503.") - @ConfigProperty(name = "talend.server.health.memory.threshold", defaultValue = "10") - private Integer healthMemoryThreshold; + @Documentation("Whether the Vault connectivity check is included in the readiness probe. " + + "Set to true only when this server instance uses Vault for credential decryption.") + @ConfigProperty(name = "talend.server.health.vault.enabled", defaultValue = "false") + private Boolean healthVaultEnabled; @PostConstruct private void init() { diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/ReadinessResourceImpl.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/ReadinessResourceImpl.java index 001a44bac78bb..d833bfc76c238 100644 --- a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/ReadinessResourceImpl.java +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/ReadinessResourceImpl.java @@ -23,6 +23,7 @@ import javax.ws.rs.core.Response; import org.talend.sdk.component.server.api.ReadinessResource; +import org.talend.sdk.component.server.configuration.ComponentServerConfiguration; import org.talend.sdk.component.server.front.model.HealthStatus; import org.talend.sdk.component.server.service.ComponentManagerService; import org.talend.sdk.components.vault.client.VaultClient; @@ -42,6 +43,9 @@ public class ReadinessResourceImpl implements ReadinessResource { @Inject private ComponentManagerService componentManagerService; + @Inject + private ComponentServerConfiguration configuration; + @Inject private VaultClient vaultClient; @@ -57,9 +61,11 @@ public Response getReadiness() { .entity(new HealthStatus(STATUS_DOWN, "Component index not ready")) .build(); } - final HealthStatus vaultStatus = checkVaultCached(); - if (STATUS_DOWN.equals(vaultStatus.getStatus())) { - return Response.status(Response.Status.SERVICE_UNAVAILABLE).entity(vaultStatus).build(); + if (configuration.getHealthVaultEnabled()) { + final HealthStatus vaultStatus = checkVaultCached(); + if (STATUS_DOWN.equals(vaultStatus.getStatus())) { + return Response.status(Response.Status.SERVICE_UNAVAILABLE).entity(vaultStatus).build(); + } } return Response.ok(new HealthStatus(STATUS_UP, null)).build(); } @@ -71,16 +77,19 @@ private HealthStatus checkVaultCached() { ? new HealthStatus(STATUS_UP, null) : new HealthStatus(STATUS_DOWN, "Vault is not reachable"); } - lastVaultCheck.set(now); try { final boolean reachable = vaultClient.ping(); cachedVaultResult.set(reachable); + lastVaultCheck.set(now); if (!reachable) { log.warn("Readiness check: Vault is not reachable"); return new HealthStatus(STATUS_DOWN, "Vault is not reachable"); } + } catch (final VirtualMachineError vme) { + throw vme; } catch (final Throwable t) { cachedVaultResult.set(false); + lastVaultCheck.set(now); final String cause = "Vault connectivity check failed: " + t.getMessage(); log.warn("Readiness check failed: {}", cause); return new HealthStatus(STATUS_DOWN, cause); diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/error/DefaultExceptionHandler.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/error/DefaultExceptionHandler.java index 0d8318d5077d4..6b4688d78ac2f 100644 --- a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/error/DefaultExceptionHandler.java +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/error/DefaultExceptionHandler.java @@ -22,7 +22,9 @@ import javax.annotation.PostConstruct; import javax.enterprise.context.Dependent; import javax.inject.Inject; +import javax.servlet.http.HttpServletRequest; import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Context; import javax.ws.rs.core.Response; import javax.ws.rs.ext.ExceptionMapper; import javax.ws.rs.ext.Provider; @@ -31,7 +33,6 @@ import org.talend.sdk.component.server.front.model.ErrorDictionary; import org.talend.sdk.component.server.front.model.error.ErrorPayload; import org.talend.sdk.component.server.service.FatalState; -import org.talend.sdk.component.server.service.RequestContextHolder; import lombok.extern.slf4j.Slf4j; @@ -46,6 +47,9 @@ public class DefaultExceptionHandler implements ExceptionMapper { @Inject private FatalState fatalState; + @Context + private HttpServletRequest request; + private boolean replaceException; @PostConstruct @@ -56,9 +60,9 @@ private void init() { @Override public Response toResponse(final Throwable exception) { if (exception instanceof VirtualMachineError) { - final String requestPath = RequestContextHolder.get(); + final String requestPath = request != null ? request.getRequestURI() : "(unknown)"; final String cause = exception.getClass().getSimpleName() + " during request " - + (requestPath != null ? requestPath : "(unknown)") + ": " + exception.getMessage(); + + requestPath + ": " + exception.getMessage(); fatalState.markFatal(cause); } log.error("[DefaultExceptionHandler#toResponse] Throwable: ", exception); diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/ComponentManagerService.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/ComponentManagerService.java index 871c676bb46ff..799ca4e8fed11 100644 --- a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/ComponentManagerService.java +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/ComponentManagerService.java @@ -121,7 +121,7 @@ public class ComponentManagerService { private Connectors connectors; - private boolean started; + private volatile boolean started; private Path m2; diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/FatalState.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/FatalState.java index a7faba22a041d..ab95a4ec323e9 100644 --- a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/FatalState.java +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/FatalState.java @@ -55,4 +55,11 @@ public boolean hasFatalError() { public String getCause() { return fatalCause.get(); } + + /** + * Resets the fatal state. Intended for use in tests only — never call in production code. + */ + public void reset() { + fatalCause.set(null); + } } diff --git a/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/LivenessResourceImplIT.java b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/LivenessResourceImplIT.java index 3b6bd4636867a..8c149043ff180 100644 --- a/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/LivenessResourceImplIT.java +++ b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/LivenessResourceImplIT.java @@ -27,6 +27,7 @@ import org.apache.meecrowave.junit5.MonoMeecrowaveConfig; import org.junit.jupiter.api.Test; import org.talend.sdk.component.server.front.model.HealthStatus; +import org.talend.sdk.component.server.service.FatalState; @MonoMeecrowaveConfig class LivenessResourceImplIT { @@ -34,6 +35,9 @@ class LivenessResourceImplIT { @Inject private WebTarget base; + @Inject + private FatalState fatalState; + @Test void livenessReturns200WhenNoFatalError() { final Response response = base.path("liveness").request(APPLICATION_JSON_TYPE).get(); @@ -64,4 +68,20 @@ void livenessAndReadinessAreIndependentFromEnvironment() { final Response readinessResponse = base.path("readiness").request(APPLICATION_JSON_TYPE).get(); assertEquals(200, readinessResponse.getStatus()); } + + @Test + void livenessReturns503WhenFatalErrorRecorded() { + fatalState.markFatal("simulated OOM during test"); + try { + final Response response = base.path("liveness").request(APPLICATION_JSON_TYPE).get(); + assertEquals(503, response.getStatus()); + final HealthStatus status = response.readEntity(HealthStatus.class); + assertNotNull(status); + assertEquals("DOWN", status.getStatus()); + assertNotNull(status.getCause()); + } finally { + // reset the singleton state so other tests are not affected + fatalState.reset(); + } + } } diff --git a/vault-client/src/main/java/org/talend/sdk/components/vault/client/VaultClient.java b/vault-client/src/main/java/org/talend/sdk/components/vault/client/VaultClient.java index bd1684eb42c83..e83fbb9c63e86 100644 --- a/vault-client/src/main/java/org/talend/sdk/components/vault/client/VaultClient.java +++ b/vault-client/src/main/java/org/talend/sdk/components/vault/client/VaultClient.java @@ -61,6 +61,8 @@ import javax.json.bind.annotation.JsonbProperty; import javax.servlet.ServletContext; import javax.ws.rs.WebApplicationException; +import javax.ws.rs.client.Client; +import javax.ws.rs.client.ClientBuilder; import javax.ws.rs.client.WebTarget; import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; @@ -154,6 +156,8 @@ public class VaultClient { private final AtomicReference authToken = new AtomicReference<>(); + private Client pingClient; + private ScheduledExecutorService scheduledExecutorService; private Pattern compiledPassthroughRegex; @@ -172,6 +176,10 @@ public class VaultClient { @PostConstruct private void init() { compiledPassthroughRegex = Pattern.compile(passthroughRegex); + pingClient = ClientBuilder.newBuilder() + .connectTimeout(pingTimeoutMs, MILLISECONDS) + .readTimeout(pingTimeoutMs, MILLISECONDS) + .build(); } @PreDestroy @@ -182,6 +190,7 @@ private void destroy() { } catch (final InterruptedException e) { Thread.currentThread().interrupt(); } + pingClient.close(); } // as deprecation was introduced since = "17", can ignore it for now... @@ -219,11 +228,10 @@ public boolean ping() { } Response response = null; try { - response = vault + response = pingClient + .target(setup.getVaultUrl()) .path("v1/sys/health") .request() - .property("javax.ws.rs.client.connectTimeout", pingTimeoutMs) - .property("javax.ws.rs.client.readTimeout", pingTimeoutMs) .get(); return true; } catch (final javax.ws.rs.ProcessingException e) { From 919201c8b81eb9a460895a61d7e1d859736476a7 Mon Sep 17 00:00:00 2001 From: Emmanuel GALLOIS Date: Tue, 7 Jul 2026 16:10:13 +0200 Subject: [PATCH 4/6] fix(QTDI-2030): add missing configuration mock in ReadinessResourceImplTest --- .../component/server/front/ReadinessResourceImplTest.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/ReadinessResourceImplTest.java b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/ReadinessResourceImplTest.java index fef53c032bac2..687e3270fb43f 100644 --- a/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/ReadinessResourceImplTest.java +++ b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/ReadinessResourceImplTest.java @@ -26,6 +26,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.talend.sdk.component.server.configuration.ComponentServerConfiguration; import org.talend.sdk.component.server.front.model.HealthStatus; import org.talend.sdk.component.server.service.ComponentManagerService; import org.talend.sdk.components.vault.client.VaultClient; @@ -37,6 +38,9 @@ class ReadinessResourceImplTest { @Mock private ComponentManagerService componentManagerService; + @Mock + private ComponentServerConfiguration configuration; + @Mock private VaultClient vaultClient; @@ -68,7 +72,7 @@ void returns503WhenIndexNotReady() { @Test void returns200WhenIndexReadyAndVaultReachable() { when(componentManagerService.isStarted()).thenReturn(true); - when(vaultClient.ping()).thenReturn(true); + when(configuration.getHealthVaultEnabled()).thenReturn(false); final Response response = readinessResource.getReadiness(); @@ -80,6 +84,7 @@ void returns200WhenIndexReadyAndVaultReachable() { @Test void returns503WhenVaultNotReachable() { when(componentManagerService.isStarted()).thenReturn(true); + when(configuration.getHealthVaultEnabled()).thenReturn(true); when(vaultClient.ping()).thenReturn(false); final Response response = readinessResource.getReadiness(); @@ -93,6 +98,7 @@ void returns503WhenVaultNotReachable() { @Test void returns503WhenVaultThrows() { when(componentManagerService.isStarted()).thenReturn(true); + when(configuration.getHealthVaultEnabled()).thenReturn(true); when(vaultClient.ping()).thenThrow(new RuntimeException("connection refused")); final Response response = readinessResource.getReadiness(); From 7d686b8335132ae6758de87751a1645fbe1d8409 Mon Sep 17 00:00:00 2001 From: Emmanuel GALLOIS Date: Tue, 7 Jul 2026 16:45:27 +0200 Subject: [PATCH 5/6] fix(QTDI-3020): fix sonar issues --- .../talend/sdk/component/server/api/LivenessResource.java | 5 +---- .../talend/sdk/component/server/api/ReadinessResource.java | 5 +---- .../component/server/front/filter/RequestContextFilter.java | 4 ++-- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/LivenessResource.java b/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/LivenessResource.java index a266b9ab3e311..17c1cfada1230 100644 --- a/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/LivenessResource.java +++ b/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/LivenessResource.java @@ -26,7 +26,6 @@ import org.eclipse.microprofile.openapi.annotations.media.Content; import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; import org.eclipse.microprofile.openapi.annotations.tags.Tag; import org.talend.sdk.component.server.front.model.HealthStatus; @@ -39,15 +38,13 @@ public interface LivenessResource { @Operation(operationId = "getLiveness", description = "Liveness probe: returns 200 when the JVM is healthy (no fatal error recorded). " + "Returns 503 with a cause when a VirtualMachineError (e.g. OutOfMemoryError) has been intercepted.") - @APIResponses({ @APIResponse(responseCode = "200", description = "Application is healthy.", content = @Content(mediaType = APPLICATION_JSON, - schema = @Schema(implementation = HealthStatus.class))), + schema = @Schema(implementation = HealthStatus.class))) @APIResponse(responseCode = "503", description = "Application has encountered a fatal error.", content = @Content(mediaType = APPLICATION_JSON, schema = @Schema(implementation = HealthStatus.class))) - }) Response getLiveness(); } diff --git a/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/ReadinessResource.java b/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/ReadinessResource.java index e260756280747..2b2a40d61eee0 100644 --- a/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/ReadinessResource.java +++ b/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/ReadinessResource.java @@ -26,7 +26,6 @@ import org.eclipse.microprofile.openapi.annotations.media.Content; import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; import org.eclipse.microprofile.openapi.annotations.tags.Tag; import org.talend.sdk.component.server.front.model.HealthStatus; @@ -39,15 +38,13 @@ public interface ReadinessResource { @Operation(operationId = "getReadiness", description = "Readiness probe: returns 200 when the component index is loaded and the server is ready " + "to serve traffic. Returns 503 with a cause otherwise.") - @APIResponses({ @APIResponse(responseCode = "200", description = "Server is ready.", content = @Content(mediaType = APPLICATION_JSON, - schema = @Schema(implementation = HealthStatus.class))), + schema = @Schema(implementation = HealthStatus.class))) @APIResponse(responseCode = "503", description = "Server is not ready.", content = @Content(mediaType = APPLICATION_JSON, schema = @Schema(implementation = HealthStatus.class))) - }) Response getReadiness(); } diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/filter/RequestContextFilter.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/filter/RequestContextFilter.java index 730c8a2583f95..d571f81e9a7df 100644 --- a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/filter/RequestContextFilter.java +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/filter/RequestContextFilter.java @@ -37,8 +37,8 @@ public class RequestContextFilter implements Filter { @Override public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain) throws IOException, ServletException { - if (request instanceof HttpServletRequest) { - RequestContextHolder.set(((HttpServletRequest) request).getRequestURI()); + if (request instanceof HttpServletRequest servletRequest) { + RequestContextHolder.set(servletRequest.getRequestURI()); } try { chain.doFilter(request, response); From 8cb58885591546471fd8824f0ffda721b2de1c63 Mon Sep 17 00:00:00 2001 From: Emmanuel GALLOIS Date: Tue, 7 Jul 2026 16:52:00 +0200 Subject: [PATCH 6/6] fix(QTDI-2030): reuse vault WebTarget for ping, remove dead RequestContextFilter --- .../front/filter/RequestContextFilter.java | 49 ------------------ .../server/service/RequestContextHolder.java | 41 --------------- .../service/RequestContextHolderTest.java | 50 ------------------- .../components/vault/client/VaultClient.java | 18 +------ 4 files changed, 1 insertion(+), 157 deletions(-) delete mode 100644 component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/filter/RequestContextFilter.java delete mode 100644 component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/RequestContextHolder.java delete mode 100644 component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/RequestContextHolderTest.java diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/filter/RequestContextFilter.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/filter/RequestContextFilter.java deleted file mode 100644 index d571f81e9a7df..0000000000000 --- a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/filter/RequestContextFilter.java +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Copyright (C) 2006-2026 Talend Inc. - www.talend.com - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.talend.sdk.component.server.front.filter; - -import java.io.IOException; - -import javax.servlet.Filter; -import javax.servlet.FilterChain; -import javax.servlet.ServletException; -import javax.servlet.ServletRequest; -import javax.servlet.ServletResponse; -import javax.servlet.annotation.WebFilter; -import javax.servlet.http.HttpServletRequest; - -import org.talend.sdk.component.server.service.RequestContextHolder; - -/** - * Servlet filter that populates {@link RequestContextHolder} with the current request path - * so that fatal-error recording in {@code DefaultExceptionHandler} can include request context. - */ -@WebFilter(asyncSupported = true, urlPatterns = "/*") -public class RequestContextFilter implements Filter { - - @Override - public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain) - throws IOException, ServletException { - if (request instanceof HttpServletRequest servletRequest) { - RequestContextHolder.set(servletRequest.getRequestURI()); - } - try { - chain.doFilter(request, response); - } finally { - RequestContextHolder.clear(); - } - } -} diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/RequestContextHolder.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/RequestContextHolder.java deleted file mode 100644 index 0777abd14d871..0000000000000 --- a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/RequestContextHolder.java +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Copyright (C) 2006-2026 Talend Inc. - www.talend.com - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.talend.sdk.component.server.service; - -/** - * Holds the HTTP request path for the current thread, set by {@code RequestContextFilter}. - * Provides context information when a fatal error is recorded during request processing. - */ -public class RequestContextHolder { - - private static final ThreadLocal REQUEST_PATH = new ThreadLocal<>(); - - private RequestContextHolder() { - // utility - } - - public static void set(final String path) { - REQUEST_PATH.set(path); - } - - public static String get() { - return REQUEST_PATH.get(); - } - - public static void clear() { - REQUEST_PATH.remove(); - } -} diff --git a/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/RequestContextHolderTest.java b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/RequestContextHolderTest.java deleted file mode 100644 index 43816f67b5340..0000000000000 --- a/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/RequestContextHolderTest.java +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Copyright (C) 2006-2026 Talend Inc. - www.talend.com - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.talend.sdk.component.server.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; - -class RequestContextHolderTest { - - @AfterEach - void cleanup() { - RequestContextHolder.clear(); - } - - @Test - void getReturnsNullByDefault() { - assertNull(RequestContextHolder.get()); - } - - @Test - void setAndGet() { - RequestContextHolder.set("/api/v1/component/index"); - - assertEquals("/api/v1/component/index", RequestContextHolder.get()); - } - - @Test - void clearRemovesValue() { - RequestContextHolder.set("/api/v1/component/index"); - RequestContextHolder.clear(); - - assertNull(RequestContextHolder.get()); - } -} diff --git a/vault-client/src/main/java/org/talend/sdk/components/vault/client/VaultClient.java b/vault-client/src/main/java/org/talend/sdk/components/vault/client/VaultClient.java index e83fbb9c63e86..bdf1fe60771b0 100644 --- a/vault-client/src/main/java/org/talend/sdk/components/vault/client/VaultClient.java +++ b/vault-client/src/main/java/org/talend/sdk/components/vault/client/VaultClient.java @@ -61,8 +61,6 @@ import javax.json.bind.annotation.JsonbProperty; import javax.servlet.ServletContext; import javax.ws.rs.WebApplicationException; -import javax.ws.rs.client.Client; -import javax.ws.rs.client.ClientBuilder; import javax.ws.rs.client.WebTarget; import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; @@ -142,12 +140,6 @@ public class VaultClient { @ConfigProperty(name = "talend.vault.cache.service.decipher.skip.regex", defaultValue = "vault\\:v[0-9]+\\:.*") private String passthroughRegex; - @Inject - @Documentation("Timeout in milliseconds for the Vault ping (connectivity check). " - + "Applied as both connect and read timeout on the health check request.") - @ConfigProperty(name = "talend.vault.cache.vault.ping.timeout", defaultValue = "2000") - private Integer pingTimeoutMs; - @Inject private Cache cache; @@ -156,8 +148,6 @@ public class VaultClient { private final AtomicReference authToken = new AtomicReference<>(); - private Client pingClient; - private ScheduledExecutorService scheduledExecutorService; private Pattern compiledPassthroughRegex; @@ -176,10 +166,6 @@ public class VaultClient { @PostConstruct private void init() { compiledPassthroughRegex = Pattern.compile(passthroughRegex); - pingClient = ClientBuilder.newBuilder() - .connectTimeout(pingTimeoutMs, MILLISECONDS) - .readTimeout(pingTimeoutMs, MILLISECONDS) - .build(); } @PreDestroy @@ -190,7 +176,6 @@ private void destroy() { } catch (final InterruptedException e) { Thread.currentThread().interrupt(); } - pingClient.close(); } // as deprecation was introduced since = "17", can ignore it for now... @@ -228,8 +213,7 @@ public boolean ping() { } Response response = null; try { - response = pingClient - .target(setup.getVaultUrl()) + response = vault .path("v1/sys/health") .request() .get();