This is an automated email from the ASF dual-hosted git repository.
jerryshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new c619ba08f6 [#13066]improvement(server): Report unhealthy status after
observed out-of-memory errors (#13067)
c619ba08f6 is described below
commit c619ba08f63c7e7eb527ae067318deefea1175b2
Author: Qi Yu <[email protected]>
AuthorDate: Thu Sep 10 22:31:34 2026 +0800
[#13066]improvement(server): Report unhealthy status after observed
out-of-memory errors (#13067)
### What changes were proposed in this pull request?
Record observed `OutOfMemoryError`s and make the Gravitino, Iceberg
REST, and Lance REST health endpoints and root aliases return HTTP 503
with a `jvm` failure until process restart. Healthy checks and response
formats remain unchanged.
Detect direct and wrapped OOM through a servlet filter installed before
other filters, authentication error handling, Jersey listeners and
mappers, request helpers including the built-in IdP, and health-probe
tasks, including failures after timeout. The Jetty worker
uncaught-exception handler remains an additional fallback. The marker
retains no throwable and allocates no visited-cause collection.
### Why are the changes needed?
After heap or Metaspace OOM, already-loaded endpoints and entity-store
lookups can still succeed while catalog operations fail. Health probes
must stop advertising the degraded process as healthy.
Fix: #13066
### Does this PR introduce _any_ user-facing change?
Yes. Once OOM is recorded, health endpoints return 503 with a `jvm`
check and `OutOfMemoryError; restart required`. Successful requests do
not clear the state, and subsequent health checks skip the entity store.
No new configuration. JSON status values remain lowercase for
Gravitino/Lance and uppercase for Iceberg REST.
Embedded services in the same JVM share the health marker through the
default auxiliary classloaders; separate JVMs record OOM independently.
Each service port still needs its own probes for HTTP availability and
initialization. Detection follows the throwable and its cause chain, not
suppressed exceptions or errors swallowed entirely by
connectors/background executors. Severe memory exhaustion may prevent
any HTTP response.
### How was this patch tested?
- Unit tests cover direct/wrapped OOM, cyclic causes, ordinary errors,
request helpers including IdP error conversion, Jersey error handling,
and health-probe failures before/after timeout.
- HTTP regression tests exercise the production Jetty filter
installation with failures in downstream filters and non-Jersey
servlets, as well as Jersey resource failures. All seven main-server
health paths return 503 after OOM while a warm endpoint still succeeds.
- Authentication HTTP tests cover direct/wrapped OOM and both exception
handlers, checking that OOM is recorded before an overridden
error-response method runs. Ordinary failures retain their responses
without marking the server unhealthy.
- Iceberg and Lance tests cover their real error mappers, health checks,
and actual JSON status serialization.
- Errors are simulated; the test JVM is not exhausted.
- `spotlessApply` passed. All 1,459 tests passed across server-common,
server, Iceberg REST, Lance REST, and idp-basic; Docker and integration
tests were excluded.
```bash
SKIP_DOCKER_TESTS=true ./gradlew :server-common:test :server:test \
:iceberg:iceberg-rest-server:test :lance:lance-rest-server:test \
:plugins:idp-basic:test -PskipITs -PskipDockerTests=true -PskipWeb=true
```
---
docs/gravitino-server-config.md | 26 ++-
docs/health-and-readiness.md | 89 ++++++--
docs/iceberg-rest-service.md | 12 +-
docs/lance-rest-service.md | 11 +
.../org/apache/gravitino/iceberg/RESTService.java | 2 +
.../iceberg/service/IcebergExceptionMapper.java | 2 +
.../service/rest/IcebergHealthOperations.java | 35 ++-
.../service/TestIcebergExceptionMapper.java | 24 +-
.../service/rest/TestIcebergHealthOperations.java | 110 ++++++++-
.../apache/gravitino/lance/LanceRESTService.java | 2 +
.../lance/service/LanceExceptionMapper.java | 2 +
.../lance/service/rest/LanceHealthOperations.java | 35 ++-
.../service/rest/TestLanceHealthOperations.java | 110 ++++++++-
.../org/apache/gravitino/idp/web/IdpRESTUtils.java | 3 +
.../apache/gravitino/idp/web/TestIdpRESTUtils.java | 56 +++++
.../authentication/AuthenticationFilter.java | 5 +
.../apache/gravitino/server/web/JettyServer.java | 4 +
.../server/web/OutOfMemoryErrorFilter.java | 55 +++++
.../server/web/OutOfMemoryErrorListener.java | 62 ++++++
.../apache/gravitino/server/web/ServerHealth.java | 82 +++++++
.../org/apache/gravitino/server/web/Utils.java | 27 ++-
.../TestAuthenticationOutOfMemoryHttp.java | 118 ++++++++++
.../gravitino/server/web/TestJettyServer.java | 19 ++
.../server/web/TestOutOfMemoryErrorListener.java | 46 ++++
.../gravitino/server/web/TestServerHealth.java | 67 ++++++
.../gravitino/server/web/TestUtilsOutOfMemory.java | 90 ++++++++
.../apache/gravitino/server/GravitinoServer.java | 2 +
.../server/web/mapper/ErrorExceptionMapper.java | 18 ++
.../server/web/rest/HealthOperations.java | 43 +++-
.../server/TestGravitinoServerOutOfMemoryHttp.java | 210 ++++++++++++++++++
.../web/mapper/TestErrorExceptionMapper.java | 5 +-
.../server/web/rest/TestHealthOperations.java | 77 ++++++-
.../server/web/rest/TestOutOfMemoryHealthHttp.java | 247 +++++++++++++++++++++
33 files changed, 1639 insertions(+), 57 deletions(-)
diff --git a/docs/gravitino-server-config.md b/docs/gravitino-server-config.md
index 8b0725382f..7da4f85cfe 100644
--- a/docs/gravitino-server-config.md
+++ b/docs/gravitino-server-config.md
@@ -208,27 +208,28 @@ Gravitino exposes three health endpoints following
of them are exempt from authentication, so Kubernetes probes, load balancers,
and traffic managers
reach them without credentials.
-| Endpoint | Root Alias | Description
| HTTP Status |
-|-------------------------|---------------------|---------------------------------------------------------------------------------------------------------------------------------------------|-------------|
-| `GET /api/health/live` | `GET /health/live` | Liveness. Returns 200 as
long as an HTTP server thread can respond. Use it to decide whether to restart
a pod. | 200 |
-| `GET /api/health/ready` | `GET /health/ready` | Readiness. Returns 200 when
the entity store answers within the probe timeout, 503 when it is unavailable
or slow. Use it to route traffic. | 200 or 503 |
-| `GET /api/health` | `GET /health` | Aggregate. Returns 200 when
both of the above pass. Also aliased as `GET /health.html`.
| 200 or 503 |
+| Endpoint | Root Alias | Description
| HTTP Status |
+|-------------------------|---------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|-------------|
+| `GET /api/health/live` | `GET /health/live` | Liveness. Returns 200 if an
HTTP thread can respond and no OOM has been observed; otherwise 503. Use it to
decide whether to restart a pod. | 200 or 503 |
+| `GET /api/health/ready` | `GET /health/ready` | Readiness. Returns 200 when
no OOM has been observed and the entity store answers within the probe timeout;
otherwise 503. Use it to route traffic. | 200 or 503 |
+| `GET /api/health` | `GET /health` | Aggregate. Returns 200 when
both of the above pass. Also aliased as `GET /health.html`.
| 200 or 503 |
| Configuration Item | Description
| Default Value |
|------------------------------------------------------|---------------------------------------------------------------------|---------------|
| `gravitino.server.health.entityStore.probeTimeoutMs` | Timeout in
milliseconds for the entity store probe behind `/ready`. | `2000` |
Every endpoint returns the same JSON shape, but not the same checks. `code` is
always `0`,
-`status` is `UP` or `DOWN`, and `checks` carries one entry per component
probed. `/live` reports
-`httpServer` alone, `/ready` reports `entityStore` alone, and the aggregate
endpoint reports both:
+`status` is `up` or `down`, and `checks` carries one entry per component
probed. `/live` reports
+`httpServer` alone, `/ready` reports `entityStore` alone, and the aggregate
endpoint reports both
+while no OOM has been observed:
```json
{
"code": 0,
- "status": "DOWN",
+ "status": "down",
"checks": [
- { "name": "httpServer", "status": "UP", "details": {} },
- { "name": "entityStore", "status": "DOWN", "details": { "reason":
"timeout" } }
+ { "name": "httpServer", "status": "up", "details": {} },
+ { "name": "entityStore", "status": "down", "details": { "reason":
"timeout" } }
]
}
```
@@ -236,6 +237,11 @@ Every endpoint returns the same JSON shape, but not the
same checks. `code` is a
A failing `entityStore` check reports `timeout`, `interrupted`,
`probe-rejected`,
`entity store not initialized`, or the simple class name of an unexpected
exception.
+After an observed `OutOfMemoryError` (including Metaspace OOM), all three
endpoints and their root
+aliases return 503 with a single `jvm: down` check and the reason
`OutOfMemoryError; restart required`.
+This state persists until process restart; successful requests do not reset
it. See
+[Out-of-memory failures](./health-and-readiness.md#out-of-memory-failures) for
detection scope.
+
#### JVM Memory
`GRAVITINO_MEM` sets the heap and metaspace flags. The launch scripts append
it to `JAVA_OPTS`, and
diff --git a/docs/health-and-readiness.md b/docs/health-and-readiness.md
index afe265c5a2..877d8c59df 100644
--- a/docs/health-and-readiness.md
+++ b/docs/health-and-readiness.md
@@ -10,15 +10,17 @@ license: "This software is licensed under the Apache
License version 2."
---
Gravitino exposes separate liveness and readiness endpoints so that a caller
can tell "restart this
-process" apart from "send traffic somewhere else." Liveness answers whether
the server can respond
-at all. Readiness answers whether it can reach the entity store and therefore
do useful work.
+process" apart from "send traffic somewhere else." Liveness checks whether the
server can respond
+and has not observed an out-of-memory error. Readiness also checks whether it
can reach the entity
+store.
The endpoints follow MicroProfile Health semantics. A healthy check returns
200 and an unhealthy one
returns 503, both with a JSON body naming the individual checks that ran.
## Quick Start
-**1. Check liveness.** This returns 200 whenever an HTTP thread is able to
answer.
+**1. Check liveness.** This returns 200 when an HTTP thread can answer and no
out-of-memory error
+has been observed.
```shell
GRAVITINO_URL=http://localhost:8090
@@ -26,7 +28,8 @@ GRAVITINO_URL=http://localhost:8090
curl -i "${GRAVITINO_URL}/api/health/live"
```
-**2. Check readiness.** This returns 200 only when the entity store responds.
+**2. Check readiness.** This returns 200 only when the entity store responds
and no out-of-memory
+error has been observed.
```shell
curl -i "${GRAVITINO_URL}/api/health/ready"
@@ -41,11 +44,11 @@ curl -i "${GRAVITINO_URL}/api/health"
## Endpoints
-| Path | Checks | Returns 503 when
|
-|---------------------|------------------------------|-----------------------------------|
-| `/api/health/live` | HTTP server | Never, if the request
is answered |
-| `/api/health/ready` | Entity store | The entity store check
fails |
-| `/api/health` | HTTP server and entity store | Either check fails
|
+| Path | Checks | Returns 503
when |
+|---------------------|-----------------------------------------|---------------------------------------------------------------------|
+| `/api/health/live` | HTTP server and OOM state | An
out-of-memory error was observed |
+| `/api/health/ready` | Entity store and OOM state | An
out-of-memory error was observed or the entity store check fails |
+| `/api/health` | HTTP server, entity store and OOM state | Any check
fails |
Each path is also served at the root of the server, without the `/api` prefix,
for load balancers
and traffic managers that require probes at well-known locations. The root
aliases are `/health`,
@@ -53,8 +56,63 @@ and traffic managers that require probes at well-known
locations. The root alias
endpoint rather than to a check of its own.
The response body carries an overall status and a list of individual checks.
Each check has a name,
-a status of UP or DOWN, and a details map that explains a failure. On the
Gravitino server the two
-check names are `httpServer` and `entityStore`.
+a status of `up` or `down`, and a details map that explains a failure. On the
Gravitino server the two
+normal check names are `httpServer` and `entityStore`. After an observed
out-of-memory error, all
+three endpoints instead report the `jvm` failure described below.
+
+## Out-of-memory Failures
+
+A Metaspace or heap `OutOfMemoryError` can leave already-loaded endpoints
responding successfully
+while other operations fail. A successful HTTP response or entity-store lookup
therefore does not
+prove recovery after OOM.
+
+The Gravitino, Iceberg REST, and Lance REST servers record OOM observed by
their Jersey exception
+listeners, error mappers, and a servlet filter installed before other filters
and servlets.
+Authentication error handling and request execution/error-response helpers
(including the built-in
+IdP helpers) also record errors they consume. The main server also records
failures in health-probe
+tasks. The Jetty worker uncaught-exception handler is an additional fallback,
not the request
+exception boundary.
+Wrapped causes are checked too. Once recorded, the affected service’s health
endpoints and root
+aliases return HTTP 503. Gravitino and Lance REST serialize status values as
`up`/`down`;
+Iceberg REST uses `UP`/`DOWN`. The following body shows the Gravitino and
Lance REST format
+(the main server uses the `/api/health` prefix); Iceberg REST uses `"DOWN"`
for both status fields:
+
+```json
+{
+ "code": 0,
+ "status": "down",
+ "checks": [
+ {
+ "name": "jvm",
+ "status": "down",
+ "details": { "reason": "OutOfMemoryError; restart required" }
+ }
+ ]
+}
+```
+
+This state lasts until process restart, even if subsequent ordinary API
requests succeed. Health
+checks skip the entity-store probe once OOM is recorded. A database outage,
ordinary HTTP 500,
+`StackOverflowError`, or missing connector class alone does not set this state.
+
+This policy also applies to an OOM caused by a single request, such as an
oversized list response
+or `Requested array size exceeds VM limit`. The server does not distinguish
recoverable allocation
+failures from persistent memory exhaustion: even if memory becomes available
again, the health
+state remains unhealthy until restart. If liveness probes trigger automatic
restarts, repeatedly
+retrying the same oversized request against different replicas can cause those
replicas to restart
+in succession. Account for this behavior when configuring request limits and
retry policies.
+
+Detection covers errors reaching these server boundaries; it cannot detect an
OOM swallowed
+entirely by a connector or unrelated background executor. This is not a
JVM-wide OOM trap. If the
+JVM cannot allocate enough memory to answer a probe, the probe may fail
without a JSON response.
+Only the throwable itself and its cause chain are inspected. An OOM present
only in suppressed
+exceptions (for example, from resource cleanup) is not detected, avoiding
defensive array copies
+while examining failures.
+
+When Iceberg REST and Lance REST run embedded in the main server, the default
auxiliary
+classloaders share the same `ServerHealth` marker. An OOM recorded by any of
these services makes
+all of their health endpoints report unhealthy. Services running in separate
JVM processes track
+OOM independently.
## What Readiness Actually Tests
@@ -69,9 +127,12 @@ and queues at most twenty probes before rejecting further
ones.
## Iceberg REST and Lance REST Endpoints
The Iceberg REST service and the Lance REST service each run their own HTTP
server on their own
-port, including when they run inside the Gravitino server process, so the
Gravitino server's
-endpoints do not report on them. A deployment that runs either service needs
probes against its
-port as well.
+port, including when they run inside the Gravitino server process. Embedded
services share the
+OOM marker, but HTTP availability and initialization checks remain specific to
each service.
+A deployment that runs either service therefore needs probes against its port
as well.
+
+Both services return 503 from all health endpoints and root aliases after
observing OOM, with the
+`jvm` failure described above, until restart. Before OOM, their existing
initialization checks apply.
| Server | Default Port | Health Path Prefix | Readiness Check
|
|----------------------|--------------|--------------------|-------------------------|
diff --git a/docs/iceberg-rest-service.md b/docs/iceberg-rest-service.md
index f3c55cdafd..8eeffbcd8d 100644
--- a/docs/iceberg-rest-service.md
+++ b/docs/iceberg-rest-service.md
@@ -722,11 +722,11 @@ Gravitino provides the built-in
`org.apache.gravitino.iceberg.service.cache.Loca
The Iceberg REST server exposes three health check endpoints following the
same [MicroProfile
Health](https://microprofile.io/project/eclipse/microprofile-health) semantics
as the main Gravitino server. All endpoints are exempt from authentication. The
readiness probe checks whether the `IcebergCatalogWrapperManager` has been
initialized. It performs no I/O and has no configurable timeout.
-| Endpoint | Description
|
HTTP status |
-|-----------------------------|----------------------------------------------------------------------------------------------------------------------------|-------------|
-| `GET /iceberg/health/live` | Liveness probe. Returns 200 as long as the
HTTP server thread can respond.
| 200 |
-| `GET /iceberg/health/ready` | Readiness probe. Returns 200 when the catalog
wrapper manager is initialized; 503 when initialization is not yet complete. |
200 / 503 |
-| `GET /iceberg/health` | Aggregate check. Returns 200 when both
liveness and readiness pass; 503 when any check fails.
| 200 / 503 |
+| Endpoint | Description
|
HTTP status |
+|-----------------------------|---------------------------------------------------------------------------------------------------------------------------|-------------|
+| `GET /iceberg/health/live` | Liveness probe. Returns 200 when the HTTP
thread can respond and no OOM has been observed; 503 after an observed OOM.
| 200 / 503 |
+| `GET /iceberg/health/ready` | Readiness probe. Returns 200 when the catalog
wrapper manager is initialized and no OOM has been observed; 503 otherwise. |
200 / 503 |
+| `GET /iceberg/health` | Aggregate check. Returns 200 when both
liveness and readiness pass; 503 when any check fails.
| 200 / 503 |
Root-level aliases are also available for global traffic managers that require
probes at well-known root paths:
@@ -739,6 +739,8 @@ Root-level aliases are also available for global traffic
managers that require p
**Response format:**
+After an observed `OutOfMemoryError`, all health endpoints and root aliases
return 503 with a `jvm` failure until restart. See [out-of-memory
failures](health-and-readiness.md#out-of-memory-failures) for detection scope.
+
All endpoints return a JSON body with the same shape as the main Gravitino
server. The `code` field is always `0`. `status` is `UP` or `DOWN`. Liveness
reports `httpServer` and readiness reports `catalogWrapperManager`.
Healthy response (HTTP 200):
diff --git a/docs/lance-rest-service.md b/docs/lance-rest-service.md
index 0b464c5451..7186243bb2 100644
--- a/docs/lance-rest-service.md
+++ b/docs/lance-rest-service.md
@@ -218,6 +218,17 @@ Access the service at `http://localhost:9101`.
- **Optional:** Other variables can use default values unless you have
specific requirements
:::
+## Health Checks
+
+The Lance REST service exposes `/lance/health/live`, `/lance/health/ready`, and
+`/lance/health` on its own port. Root aliases `/health/live`, `/health/ready`,
`/health`,
+and `/health.html` are also available. Readiness checks whether the namespace
wrapper
+is initialized.
+
+After an observed `OutOfMemoryError`, all these endpoints return HTTP 503 with
a
+`jvm` failure until restart, even if the wrapper remains initialized. See
+[health and readiness](health-and-readiness.md) for response details and
detection scope.
+
## Usage Guidelines
When using Lance REST service with Gravitino backend, keep the following
considerations in mind:
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
index 78a58f71b7..3710708c57 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
@@ -61,6 +61,7 @@ import org.apache.gravitino.server.web.HttpAuditFilter;
import org.apache.gravitino.server.web.HttpServerMetricsSource;
import org.apache.gravitino.server.web.JettyServer;
import org.apache.gravitino.server.web.JettyServerConfig;
+import org.apache.gravitino.server.web.OutOfMemoryErrorListener;
import org.apache.gravitino.server.web.RequestContextFilter;
import
org.apache.gravitino.server.web.filter.IcebergRESTAuthInterceptionService;
import org.glassfish.hk2.api.InterceptionService;
@@ -105,6 +106,7 @@ public class RESTService implements
GravitinoAuxiliaryService {
config.register(IcebergObjectMapperProvider.class).register(JacksonFeature.class);
config.register(IcebergExceptionMapper.class);
+ config.register(new OutOfMemoryErrorListener());
HttpServerMetricsSource httpServerMetricsSource =
new
HttpServerMetricsSource(MetricsSource.ICEBERG_REST_SERVER_METRIC_NAME, config,
server);
metricsSystem.register(httpServerMetricsSource);
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergExceptionMapper.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergExceptionMapper.java
index c69a9d6dfb..940bf785e7 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergExceptionMapper.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/IcebergExceptionMapper.java
@@ -29,6 +29,7 @@ import
org.apache.gravitino.exceptions.IllegalNameIdentifierException;
import org.apache.gravitino.exceptions.NoSuchCatalogException;
import org.apache.gravitino.exceptions.TokenExpiredException;
import org.apache.gravitino.exceptions.UnauthorizedException;
+import org.apache.gravitino.server.web.ServerHealth;
import org.apache.iceberg.exceptions.AlreadyExistsException;
import org.apache.iceberg.exceptions.BadRequestException;
import org.apache.iceberg.exceptions.CommitFailedException;
@@ -133,6 +134,7 @@ public class IcebergExceptionMapper implements
ExceptionMapper<Throwable> {
}
public static Response toRESTResponse(Throwable ex) {
+ ServerHealth.getInstance().recordFailure(ex);
int status =
EXCEPTION_ERROR_CODES.getOrDefault(
ex.getClass(), Status.INTERNAL_SERVER_ERROR.getStatusCode());
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergHealthOperations.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergHealthOperations.java
index e91fe5e0a8..27304041b6 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergHealthOperations.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergHealthOperations.java
@@ -34,6 +34,7 @@ import org.apache.gravitino.dto.HealthCheckDTO;
import org.apache.gravitino.dto.responses.HealthResponse;
import org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
import org.apache.gravitino.metrics.MetricNames;
+import org.apache.gravitino.server.web.ServerHealth;
import org.apache.gravitino.server.web.Utils;
/**
@@ -41,13 +42,14 @@ import org.apache.gravitino.server.web.Utils;
* semantics as the main Gravitino server.
*
* <ul>
- * <li>{@code GET /iceberg/health/live} — liveness, 200 as long as the HTTP
thread can respond
+ * <li>{@code GET /iceberg/health/live} — liveness, 200 when the HTTP thread
can respond and no
+ * OOM has been observed
* <li>{@code GET /iceberg/health/ready} — readiness, 200 when the catalog
wrapper manager is
* initialized
* <li>{@code GET /iceberg/health} — aggregate, 200 when both pass
* </ul>
*
- * All endpoints return 503 with a JSON body describing the failed check(s)
when unhealthy.
+ * All endpoints return 503 after an observed OOM until process restart, or
when their checks fail.
*/
@Path("/health")
@Produces(MediaType.APPLICATION_JSON)
@@ -56,21 +58,32 @@ public class IcebergHealthOperations {
private static final String CHECK_HTTP_SERVER = "httpServer";
private static final String CHECK_CATALOG_WRAPPER_MANAGER =
"catalogWrapperManager";
+ private final ServerHealth serverHealth;
+
@Inject private IcebergCatalogWrapperManager catalogWrapperManager;
/** Default constructor for Jersey auto-discovery. */
- public IcebergHealthOperations() {}
+ public IcebergHealthOperations() {
+ this(ServerHealth.getInstance());
+ }
+
+ IcebergHealthOperations(ServerHealth serverHealth) {
+ this.serverHealth = serverHealth;
+ }
/**
- * Liveness probe. Returns 200 as long as the HTTP thread can respond.
+ * Liveness probe. Returns 200 when the HTTP thread can respond and no OOM
has been observed.
*
- * @return 200 OK with an UP {@link HealthResponse}
+ * @return 200 OK when live, or 503 with a JVM failure after an observed OOM
*/
@GET
@Path("/live")
@Timed(name = "iceberg.health.live." + MetricNames.HTTP_PROCESS_DURATION,
absolute = true)
@ResponseMetered(name = "iceberg.health.live", absolute = true)
public Response live() {
+ if (serverHealth.hasOutOfMemoryError()) {
+ return Utils.outOfMemoryResponse();
+ }
HealthCheckDTO check = up(CHECK_HTTP_SERVER, Collections.emptyMap());
return Utils.ok(new HealthResponse(HealthCheckDTO.Status.UP,
Collections.singletonList(check)));
}
@@ -86,7 +99,13 @@ public class IcebergHealthOperations {
@Timed(name = "iceberg.health.ready." + MetricNames.HTTP_PROCESS_DURATION,
absolute = true)
@ResponseMetered(name = "iceberg.health.ready", absolute = true)
public Response ready() {
+ if (serverHealth.hasOutOfMemoryError()) {
+ return Utils.outOfMemoryResponse();
+ }
HealthCheckDTO managerCheck = checkCatalogWrapperManager();
+ if (serverHealth.hasOutOfMemoryError()) {
+ return Utils.outOfMemoryResponse();
+ }
HealthCheckDTO.Status overall = managerCheck.getStatus();
HealthResponse body = new HealthResponse(overall,
Collections.singletonList(managerCheck));
return overall == HealthCheckDTO.Status.UP ? Utils.ok(body) :
Utils.serviceUnavailable(body);
@@ -101,9 +120,15 @@ public class IcebergHealthOperations {
@Timed(name = "iceberg.health." + MetricNames.HTTP_PROCESS_DURATION,
absolute = true)
@ResponseMetered(name = "iceberg.health", absolute = true)
public Response health() {
+ if (serverHealth.hasOutOfMemoryError()) {
+ return Utils.outOfMemoryResponse();
+ }
List<HealthCheckDTO> checks = new ArrayList<>(2);
checks.add(up(CHECK_HTTP_SERVER, Collections.emptyMap()));
checks.add(checkCatalogWrapperManager());
+ if (serverHealth.hasOutOfMemoryError()) {
+ return Utils.outOfMemoryResponse();
+ }
HealthCheckDTO.Status overall =
checks.stream().anyMatch(c -> c.getStatus() ==
HealthCheckDTO.Status.DOWN)
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergExceptionMapper.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergExceptionMapper.java
index 354f4d26bd..4226dfdf5e 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergExceptionMapper.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestIcebergExceptionMapper.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.iceberg.service;
import javax.ws.rs.NotFoundException;
import javax.ws.rs.core.Response;
import org.apache.gravitino.exceptions.TokenExpiredException;
+import org.apache.gravitino.server.web.ServerHealth;
import org.apache.iceberg.exceptions.AlreadyExistsException;
import org.apache.iceberg.exceptions.CommitFailedException;
import org.apache.iceberg.exceptions.CommitStateUnknownException;
@@ -36,6 +37,8 @@ import org.apache.iceberg.exceptions.ValidationException;
import org.apache.iceberg.rest.responses.ErrorResponse;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
public class TestIcebergExceptionMapper {
private final IcebergExceptionMapper icebergExceptionMapper = new
IcebergExceptionMapper();
@@ -71,14 +74,19 @@ public class TestIcebergExceptionMapper {
new NoClassDefFoundError("catalog class"), new
AssertionError("assertion")
}) {
error.initCause(new IllegalStateException("root cause"));
- try (Response response = icebergExceptionMapper.toResponse(error)) {
- Assertions.assertEquals(500, response.getStatus());
- ErrorResponse entity = (ErrorResponse) response.getEntity();
- Assertions.assertEquals(error.getClass().getSimpleName(),
entity.type());
- Assertions.assertEquals(error.getMessage(), entity.message());
- Assertions.assertTrue(
- String.join("\n", entity.stack())
- .contains("Caused by: java.lang.IllegalStateException: root
cause"));
+ try (MockedStatic<ServerHealth> shared =
Mockito.mockStatic(ServerHealth.class)) {
+ ServerHealth health = new ServerHealth();
+ shared.when(ServerHealth::getInstance).thenReturn(health);
+ try (Response response = icebergExceptionMapper.toResponse(error)) {
+ Assertions.assertEquals(500, response.getStatus());
+ ErrorResponse entity = (ErrorResponse) response.getEntity();
+ Assertions.assertEquals(error.getClass().getSimpleName(),
entity.type());
+ Assertions.assertEquals(error.getMessage(), entity.message());
+ Assertions.assertTrue(
+ String.join("\n", entity.stack())
+ .contains("Caused by: java.lang.IllegalStateException: root
cause"));
+ }
+ Assertions.assertEquals(error instanceof OutOfMemoryError,
health.hasOutOfMemoryError());
}
}
}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergHealthOperations.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergHealthOperations.java
index 376479140c..3610b85da6 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergHealthOperations.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergHealthOperations.java
@@ -20,18 +20,48 @@ package org.apache.gravitino.iceberg.service.rest;
import static org.mockito.Mockito.mock;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.List;
+import java.util.function.Supplier;
import javax.ws.rs.core.Response;
import org.apache.gravitino.dto.HealthCheckDTO;
import org.apache.gravitino.dto.responses.HealthResponse;
import org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
+import org.apache.gravitino.iceberg.service.IcebergExceptionMapper;
+import org.apache.gravitino.iceberg.service.IcebergObjectMapper;
+import org.apache.gravitino.server.web.ServerHealth;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
public class TestIcebergHealthOperations {
+ /** Verifies the documented status casing with the service's actual JSON
mapper. */
+ @Test
+ public void testSerializedHealthStatus() throws Exception {
+ ServerHealth health = new ServerHealth();
+ IcebergHealthOperations operations = new IcebergHealthOperations(health);
+ ObjectMapper mapper = IcebergObjectMapper.getInstance();
+ try (Response response = operations.live()) {
+ JsonNode json =
mapper.readTree(mapper.writeValueAsString(response.getEntity()));
+ Assertions.assertEquals("UP", json.path("status").asText());
+ Assertions.assertEquals("UP",
json.path("checks").get(0).path("status").asText());
+ }
+ health.recordFailure(new OutOfMemoryError("Metaspace"));
+ try (Response response = operations.live()) {
+ JsonNode json =
mapper.readTree(mapper.writeValueAsString(response.getEntity()));
+ Assertions.assertEquals(503, response.getStatus());
+ Assertions.assertEquals("DOWN", json.path("status").asText());
+ Assertions.assertEquals("DOWN",
json.path("checks").get(0).path("status").asText());
+ Assertions.assertEquals("jvm",
json.path("checks").get(0).path("name").asText());
+ }
+ }
+
private static IcebergHealthOperations operationsWithManager(
IcebergCatalogWrapperManager manager) {
- return new IcebergHealthOperations() {
+ return new IcebergHealthOperations(new ServerHealth()) {
@Override
IcebergCatalogWrapperManager getCatalogWrapperManager() {
return manager;
@@ -91,4 +121,82 @@ public class TestIcebergHealthOperations {
body.getChecks().stream().anyMatch(c ->
"catalogWrapperManager".equals(c.getName()));
Assertions.assertTrue(hasCatalogCheck);
}
+
+ /** Verifies mapped direct and wrapped OOM disable all health probes. */
+ @Test
+ public void testMappedOutOfMemoryMakesAllProbesUnhealthy() {
+ for (Throwable failure :
+ new Throwable[] {
+ new OutOfMemoryError("Metaspace"),
+ new IllegalStateException(new OutOfMemoryError("Java heap space"))
+ }) {
+ ServerHealth health = new ServerHealth();
+ IcebergHealthOperations ops =
+ new IcebergHealthOperations(health) {
+ @Override
+ IcebergCatalogWrapperManager getCatalogWrapperManager() {
+ Assertions.fail("Readiness must skip initialization checks after
OOM");
+ return null;
+ }
+ };
+ try (MockedStatic<ServerHealth> shared =
Mockito.mockStatic(ServerHealth.class)) {
+ shared.when(ServerHealth::getInstance).thenReturn(health);
+ try (Response response =
IcebergExceptionMapper.toRESTResponse(failure)) {
+ Assertions.assertEquals(500, response.getStatus());
+ }
+ }
+ for (Supplier<Response> probe :
+ List.<Supplier<Response>>of(ops::live, ops::ready, ops::health)) {
+ try (Response response = probe.get()) {
+ Assertions.assertEquals(503, response.getStatus());
+ HealthResponse body = (HealthResponse) response.getEntity();
+ Assertions.assertEquals(HealthCheckDTO.Status.DOWN,
body.getStatus());
+ Assertions.assertEquals(1, body.getChecks().size());
+ Assertions.assertEquals("jvm", body.getChecks().get(0).getName());
+ Assertions.assertEquals(
+ "OutOfMemoryError; restart required",
+ body.getChecks().get(0).getDetails().get("reason"));
+ }
+ }
+ Assertions.assertTrue(health.hasOutOfMemoryError());
+ }
+ }
+
+ /** Verifies an ordinary mapped failure leaves liveness healthy. */
+ @Test
+ public void testOrdinaryMappedFailureDoesNotPoisonLiveness() {
+ ServerHealth health = new ServerHealth();
+ Throwable failure = new IllegalStateException("ordinary failure");
+ try (MockedStatic<ServerHealth> shared =
Mockito.mockStatic(ServerHealth.class)) {
+ shared.when(ServerHealth::getInstance).thenReturn(health);
+ try (Response response = IcebergExceptionMapper.toRESTResponse(failure))
{
+ Assertions.assertEquals(500, response.getStatus());
+ }
+ }
+ try (Response response = new IcebergHealthOperations(health).live()) {
+ Assertions.assertEquals(200, response.getStatus());
+ }
+ }
+
+ /** Verifies OOM recorded during initialization checks overrides their
successful result. */
+ @Test
+ public void testOutOfMemoryObservedDuringReadinessOverridesSuccess() {
+ for (boolean aggregate : new boolean[] {false, true}) {
+ ServerHealth health = new ServerHealth();
+ IcebergCatalogWrapperManager dependency =
mock(IcebergCatalogWrapperManager.class);
+ IcebergHealthOperations ops =
+ new IcebergHealthOperations(health) {
+ @Override
+ IcebergCatalogWrapperManager getCatalogWrapperManager() {
+ health.recordFailure(new OutOfMemoryError("Metaspace"));
+ return dependency;
+ }
+ };
+ try (Response response = aggregate ? ops.health() : ops.ready()) {
+ Assertions.assertEquals(503, response.getStatus());
+ HealthResponse body = (HealthResponse) response.getEntity();
+ Assertions.assertEquals("jvm", body.getChecks().get(0).getName());
+ }
+ }
+ }
}
diff --git
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java
index 6d0777a477..bfb27e04d6 100644
---
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java
+++
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/LanceRESTService.java
@@ -48,6 +48,7 @@ import org.apache.gravitino.server.web.HttpAuditFilter;
import org.apache.gravitino.server.web.HttpServerMetricsSource;
import org.apache.gravitino.server.web.JettyServer;
import org.apache.gravitino.server.web.JettyServerConfig;
+import org.apache.gravitino.server.web.OutOfMemoryErrorListener;
import org.apache.gravitino.server.web.RequestContextFilter;
import org.glassfish.hk2.api.InterceptionService;
import org.glassfish.hk2.utilities.binding.AbstractBinder;
@@ -110,6 +111,7 @@ public class LanceRESTService implements
GravitinoAuxiliaryService {
resourceConfig.register(JacksonFeature.class);
resourceConfig.packages(LANCE_REST_SPEC_PACKAGE);
resourceConfig.register(LanceExceptionMapper.class);
+ resourceConfig.register(new OutOfMemoryErrorListener());
resourceConfig.register(
new AbstractBinder() {
@Override
diff --git
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceExceptionMapper.java
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceExceptionMapper.java
index 47d0b7521a..a3de0c46d6 100644
---
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceExceptionMapper.java
+++
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceExceptionMapper.java
@@ -25,6 +25,7 @@ import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
import org.apache.gravitino.exceptions.NoSuchTableException;
import org.apache.gravitino.exceptions.NotFoundException;
+import org.apache.gravitino.server.web.ServerHealth;
import org.lance.namespace.errors.ConcurrentModificationException;
import org.lance.namespace.errors.InternalException;
import org.lance.namespace.errors.InvalidInputException;
@@ -47,6 +48,7 @@ public class LanceExceptionMapper implements
ExceptionMapper<Throwable> {
private static final Logger LOG =
LoggerFactory.getLogger(LanceExceptionMapper.class);
public static Response toRESTResponse(String instance, Throwable ex) {
+ ServerHealth.getInstance().recordFailure(ex);
LanceNamespaceException lanceException =
ex instanceof LanceNamespaceException
? (LanceNamespaceException) ex
diff --git
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceHealthOperations.java
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceHealthOperations.java
index 4c280d9f8b..e98b1d04de 100644
---
a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceHealthOperations.java
+++
b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/rest/LanceHealthOperations.java
@@ -34,6 +34,7 @@ import org.apache.gravitino.dto.HealthCheckDTO;
import org.apache.gravitino.dto.responses.HealthResponse;
import org.apache.gravitino.lance.common.ops.NamespaceWrapper;
import org.apache.gravitino.metrics.MetricNames;
+import org.apache.gravitino.server.web.ServerHealth;
import org.apache.gravitino.server.web.Utils;
/**
@@ -41,12 +42,13 @@ import org.apache.gravitino.server.web.Utils;
* as the main Gravitino server.
*
* <ul>
- * <li>{@code GET /lance/health/live} — liveness, 200 as long as the HTTP
thread can respond
+ * <li>{@code GET /lance/health/live} — liveness, 200 when the HTTP thread
can respond and no OOM
+ * has been observed
* <li>{@code GET /lance/health/ready} — readiness, 200 when the namespace
wrapper is initialized
* <li>{@code GET /lance/health} — aggregate, 200 when both pass
* </ul>
*
- * All endpoints return 503 with a JSON body describing the failed check(s)
when unhealthy.
+ * All endpoints return 503 after an observed OOM until process restart, or
when their checks fail.
*/
@Path("/health")
@Produces(MediaType.APPLICATION_JSON)
@@ -55,21 +57,32 @@ public class LanceHealthOperations {
private static final String CHECK_HTTP_SERVER = "httpServer";
private static final String CHECK_NAMESPACE_WRAPPER = "namespaceWrapper";
+ private final ServerHealth serverHealth;
+
@Inject private NamespaceWrapper namespaceWrapper;
/** Default constructor for Jersey auto-discovery. */
- public LanceHealthOperations() {}
+ public LanceHealthOperations() {
+ this(ServerHealth.getInstance());
+ }
+
+ LanceHealthOperations(ServerHealth serverHealth) {
+ this.serverHealth = serverHealth;
+ }
/**
- * Liveness probe. Returns 200 as long as the HTTP thread can respond.
+ * Liveness probe. Returns 200 when the HTTP thread can respond and no OOM
has been observed.
*
- * @return 200 OK with an UP {@link HealthResponse}
+ * @return 200 OK when live, or 503 with a JVM failure after an observed OOM
*/
@GET
@Path("/live")
@Timed(name = "lance.health.live." + MetricNames.HTTP_PROCESS_DURATION,
absolute = true)
@ResponseMetered(name = "lance.health.live", absolute = true)
public Response live() {
+ if (serverHealth.hasOutOfMemoryError()) {
+ return Utils.outOfMemoryResponse();
+ }
HealthCheckDTO check = up(CHECK_HTTP_SERVER, Collections.emptyMap());
HealthResponse healthResponse =
new HealthResponse(HealthCheckDTO.Status.UP,
Collections.singletonList(check));
@@ -86,7 +99,13 @@ public class LanceHealthOperations {
@Timed(name = "lance.health.ready." + MetricNames.HTTP_PROCESS_DURATION,
absolute = true)
@ResponseMetered(name = "lance.health.ready", absolute = true)
public Response ready() {
+ if (serverHealth.hasOutOfMemoryError()) {
+ return Utils.outOfMemoryResponse();
+ }
HealthCheckDTO namespaceCheck = checkNamespaceWrapper();
+ if (serverHealth.hasOutOfMemoryError()) {
+ return Utils.outOfMemoryResponse();
+ }
HealthCheckDTO.Status overall = namespaceCheck.getStatus();
HealthResponse body = new HealthResponse(overall,
Collections.singletonList(namespaceCheck));
return overall == HealthCheckDTO.Status.UP ? Utils.ok(body) :
Utils.serviceUnavailable(body);
@@ -101,9 +120,15 @@ public class LanceHealthOperations {
@Timed(name = "lance.health." + MetricNames.HTTP_PROCESS_DURATION, absolute
= true)
@ResponseMetered(name = "lance.health", absolute = true)
public Response health() {
+ if (serverHealth.hasOutOfMemoryError()) {
+ return Utils.outOfMemoryResponse();
+ }
List<HealthCheckDTO> checks = new ArrayList<>(2);
checks.add(up(CHECK_HTTP_SERVER, Collections.emptyMap()));
checks.add(checkNamespaceWrapper());
+ if (serverHealth.hasOutOfMemoryError()) {
+ return Utils.outOfMemoryResponse();
+ }
HealthCheckDTO.Status overall =
checks.stream().anyMatch(c -> c.getStatus() ==
HealthCheckDTO.Status.DOWN)
diff --git
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/rest/TestLanceHealthOperations.java
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/rest/TestLanceHealthOperations.java
index 24256fd67d..7c4883e6dc 100644
---
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/rest/TestLanceHealthOperations.java
+++
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/rest/TestLanceHealthOperations.java
@@ -21,17 +21,46 @@ package org.apache.gravitino.lance.service.rest;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.List;
+import java.util.function.Supplier;
import javax.ws.rs.core.Response;
import org.apache.gravitino.dto.HealthCheckDTO;
import org.apache.gravitino.dto.responses.HealthResponse;
import org.apache.gravitino.lance.common.ops.NamespaceWrapper;
+import org.apache.gravitino.lance.service.LanceExceptionMapper;
+import org.apache.gravitino.server.web.ServerHealth;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
public class TestLanceHealthOperations {
+ /** Verifies the documented status casing with the service's actual JSON
mapper. */
+ @Test
+ public void testSerializedHealthStatus() throws Exception {
+ ServerHealth health = new ServerHealth();
+ LanceHealthOperations operations = new LanceHealthOperations(health);
+ ObjectMapper mapper = new
JsonNullableMapperProvider().getContext(HealthResponse.class);
+ try (Response response = operations.live()) {
+ JsonNode json =
mapper.readTree(mapper.writeValueAsString(response.getEntity()));
+ Assertions.assertEquals("up", json.path("status").asText());
+ Assertions.assertEquals("up",
json.path("checks").get(0).path("status").asText());
+ }
+ health.recordFailure(new OutOfMemoryError("Metaspace"));
+ try (Response response = operations.live()) {
+ JsonNode json =
mapper.readTree(mapper.writeValueAsString(response.getEntity()));
+ Assertions.assertEquals(503, response.getStatus());
+ Assertions.assertEquals("down", json.path("status").asText());
+ Assertions.assertEquals("down",
json.path("checks").get(0).path("status").asText());
+ Assertions.assertEquals("jvm",
json.path("checks").get(0).path("name").asText());
+ }
+ }
+
private static LanceHealthOperations operationsWithWrapper(NamespaceWrapper
wrapper) {
- return new LanceHealthOperations() {
+ return new LanceHealthOperations(new ServerHealth()) {
@Override
NamespaceWrapper getNamespaceWrapper() {
return wrapper;
@@ -93,4 +122,83 @@ public class TestLanceHealthOperations {
body.getChecks().stream().anyMatch(c ->
"namespaceWrapper".equals(c.getName()));
Assertions.assertTrue(hasNamespaceCheck);
}
+
+ /** Verifies mapped direct and wrapped OOM disable all health probes. */
+ @Test
+ public void testMappedOutOfMemoryMakesAllProbesUnhealthy() {
+ for (Throwable failure :
+ new Throwable[] {
+ new OutOfMemoryError("Metaspace"),
+ new IllegalStateException(new OutOfMemoryError("Java heap space"))
+ }) {
+ ServerHealth health = new ServerHealth();
+ LanceHealthOperations ops =
+ new LanceHealthOperations(health) {
+ @Override
+ NamespaceWrapper getNamespaceWrapper() {
+ Assertions.fail("Readiness must skip initialization checks after
OOM");
+ return null;
+ }
+ };
+ try (MockedStatic<ServerHealth> shared =
Mockito.mockStatic(ServerHealth.class)) {
+ shared.when(ServerHealth::getInstance).thenReturn(health);
+ try (Response response = LanceExceptionMapper.toRESTResponse("test",
failure)) {
+ Assertions.assertEquals(500, response.getStatus());
+ }
+ }
+ for (Supplier<Response> probe :
+ List.<Supplier<Response>>of(ops::live, ops::ready, ops::health)) {
+ try (Response response = probe.get()) {
+ Assertions.assertEquals(503, response.getStatus());
+ HealthResponse body = (HealthResponse) response.getEntity();
+ Assertions.assertEquals(HealthCheckDTO.Status.DOWN,
body.getStatus());
+ Assertions.assertEquals(1, body.getChecks().size());
+ Assertions.assertEquals("jvm", body.getChecks().get(0).getName());
+ Assertions.assertEquals(
+ "OutOfMemoryError; restart required",
+ body.getChecks().get(0).getDetails().get("reason"));
+ }
+ }
+ Assertions.assertTrue(health.hasOutOfMemoryError());
+ }
+ }
+
+ /** Verifies an ordinary mapped failure leaves liveness healthy. */
+ @Test
+ public void testOrdinaryMappedFailureDoesNotPoisonLiveness() {
+ ServerHealth health = new ServerHealth();
+ Throwable failure = new IllegalStateException("ordinary failure");
+ try (MockedStatic<ServerHealth> shared =
Mockito.mockStatic(ServerHealth.class)) {
+ shared.when(ServerHealth::getInstance).thenReturn(health);
+ try (Response response = LanceExceptionMapper.toRESTResponse("test",
failure)) {
+ Assertions.assertEquals(500, response.getStatus());
+ }
+ }
+ try (Response response = new LanceHealthOperations(health).live()) {
+ Assertions.assertEquals(200, response.getStatus());
+ }
+ }
+
+ /** Verifies OOM recorded during initialization checks overrides their
successful result. */
+ @Test
+ public void testOutOfMemoryObservedDuringReadinessOverridesSuccess() {
+ for (boolean aggregate : new boolean[] {false, true}) {
+ ServerHealth health = new ServerHealth();
+ NamespaceWrapper dependency = mock(NamespaceWrapper.class);
+ when(dependency.isInitialized()).thenReturn(true);
+ LanceHealthOperations ops =
+ new LanceHealthOperations(health) {
+ @Override
+ NamespaceWrapper getNamespaceWrapper() {
+ health.recordFailure(new OutOfMemoryError("Metaspace"));
+ return dependency;
+ }
+ };
+ try (Response response = aggregate ? ops.health() : ops.ready()) {
+ Assertions.assertEquals(503, response.getStatus());
+ HealthResponse body = (HealthResponse) response.getEntity();
+ Assertions.assertEquals("jvm", body.getChecks().get(0).getName());
+ }
+ }
+ }
}
diff --git
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/IdpRESTUtils.java
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/IdpRESTUtils.java
index b843e5670f..35a2831b5a 100644
---
a/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/IdpRESTUtils.java
+++
b/plugins/idp-basic/src/main/java/org/apache/gravitino/idp/web/IdpRESTUtils.java
@@ -30,6 +30,7 @@ import org.apache.gravitino.exceptions.AlreadyExistsException;
import org.apache.gravitino.exceptions.ForbiddenException;
import org.apache.gravitino.exceptions.NonEmptyEntityException;
import org.apache.gravitino.exceptions.NotFoundException;
+import org.apache.gravitino.server.web.ServerHealth;
import org.apache.gravitino.utils.PrincipalUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -71,6 +72,7 @@ public final class IdpRESTUtils {
public static Response handleException(
String resourceType, IdpOperationType op, String name, Exception e) {
+ ServerHealth.getInstance().recordFailure(e);
String errorMsg =
String.format(
"Failed to operate built-in IdP %s [%s] operation [%s], reason
[%s]",
@@ -109,6 +111,7 @@ public final class IdpRESTUtils {
}
public static Response internalError(String message, Throwable throwable) {
+ ServerHealth.getInstance().recordFailure(throwable);
return json(
Response.Status.INTERNAL_SERVER_ERROR,
ErrorResponse.internalError(message, throwable));
}
diff --git
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/TestIdpRESTUtils.java
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/TestIdpRESTUtils.java
index 3b8840fd14..9d1ed61f11 100644
---
a/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/TestIdpRESTUtils.java
+++
b/plugins/idp-basic/src/test/java/org/apache/gravitino/idp/web/TestIdpRESTUtils.java
@@ -19,15 +19,71 @@
package org.apache.gravitino.idp.web;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.core.Response;
import org.apache.gravitino.dto.responses.ErrorConstants;
import org.apache.gravitino.dto.responses.ErrorResponse;
import org.apache.gravitino.exceptions.NonEmptyEntityException;
+import org.apache.gravitino.server.web.ServerHealth;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.MockedStatic;
class TestIdpRESTUtils {
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void testRecordsWrappedOomInRequestHelpers(boolean oom) {
+ for (boolean viaDoAs : new boolean[] {true, false}) {
+ ServerHealth health = new ServerHealth();
+ // A mapped 400 must still record an OOM; internalError alone is
insufficient.
+ IllegalArgumentException failure =
+ new IllegalArgumentException(
+ "invalid request",
+ oom ? new OutOfMemoryError("Metaspace") : new
IllegalStateException("ordinary"));
+ try (MockedStatic<ServerHealth> state = mockStatic(ServerHealth.class)) {
+ state.when(ServerHealth::getInstance).thenReturn(health);
+ try (Response response =
+ viaDoAs
+ ? IdpRESTUtils.doAs(
+ mock(HttpServletRequest.class),
+ () -> {
+ throw failure;
+ },
+ "group",
+ IdpOperationType.GET,
+ "group")
+ : IdpRESTUtils.handleException("group", IdpOperationType.GET,
"group", failure)) {
+ assertEquals(400, response.getStatus());
+ assertEquals(oom, health.hasOutOfMemoryError());
+ }
+ }
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"direct", "wrapped", "ordinary"})
+ void testRecordsOomInInternalError(String kind) {
+ ServerHealth health = new ServerHealth();
+ Throwable failure =
+ kind.equals("direct")
+ ? new OutOfMemoryError("Metaspace")
+ : kind.equals("wrapped")
+ ? new RuntimeException(new OutOfMemoryError("Metaspace"))
+ : new IllegalStateException("ordinary");
+ try (MockedStatic<ServerHealth> state = mockStatic(ServerHealth.class)) {
+ state.when(ServerHealth::getInstance).thenReturn(health);
+ try (Response response = IdpRESTUtils.internalError("failure", failure))
{
+ assertEquals(500, response.getStatus());
+ assertEquals(!kind.equals("ordinary"), health.hasOutOfMemoryError());
+ }
+ }
+ }
+
@Test
void testUnsupportedOperationReturnsNotImplemented() {
Response response =
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authentication/AuthenticationFilter.java
b/server-common/src/main/java/org/apache/gravitino/server/authentication/AuthenticationFilter.java
index 6f75837212..28024d6075 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authentication/AuthenticationFilter.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authentication/AuthenticationFilter.java
@@ -42,6 +42,7 @@ import org.apache.gravitino.exceptions.ForbiddenException;
import org.apache.gravitino.exceptions.UnauthorizedException;
import org.apache.gravitino.server.web.HealthCheckPathMatcher;
import org.apache.gravitino.server.web.ObjectMapperProvider;
+import org.apache.gravitino.server.web.ServerHealth;
import org.apache.gravitino.utils.PrincipalUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -50,6 +51,8 @@ public class AuthenticationFilter implements Filter {
private static final Logger LOG =
LoggerFactory.getLogger(AuthenticationFilter.class);
+ private final ServerHealth health = ServerHealth.getInstance();
+
private final List<Authenticator> filterAuthenticators;
/**
@@ -131,6 +134,7 @@ public class AuthenticationFilter implements Filter {
return null;
});
} catch (UnauthorizedException ue) {
+ health.recordFailure(ue);
HttpServletResponse resp = (HttpServletResponse) response;
if (!ue.getChallenges().isEmpty()) {
// For some authentication, HTTP response can provide some challenge
information
@@ -144,6 +148,7 @@ public class AuthenticationFilter implements Filter {
}
sendAuthErrorResponse(resp, ue);
} catch (Exception e) {
+ health.recordFailure(e);
HttpServletResponse resp = (HttpServletResponse) response;
sendAuthErrorResponse(resp, e);
}
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/web/JettyServer.java
b/server-common/src/main/java/org/apache/gravitino/server/web/JettyServer.java
index 2385b49a1f..7a507eb74c 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/web/JettyServer.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/web/JettyServer.java
@@ -179,6 +179,9 @@ public class JettyServer {
webUiEnabled = false;
}
+ // Install before authentication, custom filters, and servlet mappings on
every service.
+ addFilter(new OutOfMemoryErrorFilter(), "/*");
+
MetricsSystem metricsSystem = GravitinoEnv.getInstance().metricsSystem();
// Metrics System could be null in UT.
if (metricsSystem != null) {
@@ -484,6 +487,7 @@ public class JettyServer {
thread.setName(getName() + "-" + thread.getId());
thread.setUncaughtExceptionHandler(
(t, throwable) -> {
+ ServerHealth.getInstance().recordFailure(throwable);
LOG.error("{} uncaught exception:", t.getName(),
throwable);
});
// JettyServer maybe used by Gravitino server and Iceberg
REST server with
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/web/OutOfMemoryErrorFilter.java
b/server-common/src/main/java/org/apache/gravitino/server/web/OutOfMemoryErrorFilter.java
new file mode 100644
index 0000000000..06272ba336
--- /dev/null
+++
b/server-common/src/main/java/org/apache/gravitino/server/web/OutOfMemoryErrorFilter.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.gravitino.server.web;
+
+import java.io.IOException;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+
+/** Records OOM escaping downstream filters and servlets before Jetty consumes
the failure. */
+public final class OutOfMemoryErrorFilter implements Filter {
+ private final ServerHealth health = ServerHealth.getInstance();
+
+ /** Creates a filter using the shared server health state. */
+ public OutOfMemoryErrorFilter() {}
+
+ /** {@inheritDoc} */
+ @Override
+ public void init(FilterConfig filterConfig) {}
+
+ /** {@inheritDoc} */
+ @Override
+ public void destroy() {}
+
+ /** {@inheritDoc} */
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain)
+ throws IOException, ServletException {
+ try {
+ chain.doFilter(request, response);
+ } catch (Throwable failure) {
+ health.recordFailure(failure);
+ throw failure;
+ }
+ }
+}
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/web/OutOfMemoryErrorListener.java
b/server-common/src/main/java/org/apache/gravitino/server/web/OutOfMemoryErrorListener.java
new file mode 100644
index 0000000000..d4538ed48b
--- /dev/null
+++
b/server-common/src/main/java/org/apache/gravitino/server/web/OutOfMemoryErrorListener.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.gravitino.server.web;
+
+import org.glassfish.jersey.server.monitoring.ApplicationEvent;
+import org.glassfish.jersey.server.monitoring.ApplicationEventListener;
+import org.glassfish.jersey.server.monitoring.RequestEvent;
+import org.glassfish.jersey.server.monitoring.RequestEventListener;
+
+/** Records out-of-memory failures before Jersey maps them to an HTTP
response. */
+public final class OutOfMemoryErrorListener
+ implements ApplicationEventListener, RequestEventListener {
+ private final ServerHealth health;
+
+ /** Creates a listener using the shared server health state. */
+ public OutOfMemoryErrorListener() {
+ this(ServerHealth.getInstance());
+ }
+
+ /**
+ * Creates a listener using the supplied health state.
+ *
+ * @param health the state to update on an out-of-memory failure
+ */
+ public OutOfMemoryErrorListener(ServerHealth health) {
+ this.health = health;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void onEvent(ApplicationEvent event) {}
+
+ /** {@inheritDoc} */
+ @Override
+ public RequestEventListener onRequest(RequestEvent event) {
+ return this;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void onEvent(RequestEvent event) {
+ if (event.getType() == RequestEvent.Type.ON_EXCEPTION) {
+ health.recordFailure(event.getException());
+ }
+ }
+}
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/web/ServerHealth.java
b/server-common/src/main/java/org/apache/gravitino/server/web/ServerHealth.java
new file mode 100644
index 0000000000..7e5ff1662b
--- /dev/null
+++
b/server-common/src/main/java/org/apache/gravitino/server/web/ServerHealth.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.gravitino.server.web;
+
+import javax.annotation.Nullable;
+
+/**
+ * Records out-of-memory failures observed by the server. Once recorded, the
failure remains until
+ * the process restarts; a successful request does not establish that the JVM
has recovered.
+ *
+ * <p>The failure path only sets a flag. It does not retain the error, format
its stack trace, or
+ * allocate a collection while walking its causes.
+ */
+public final class ServerHealth {
+ private static final ServerHealth INSTANCE = new ServerHealth();
+
+ private volatile boolean outOfMemory;
+
+ /** Creates an independent health state, initially healthy. */
+ public ServerHealth() {}
+
+ /**
+ * Returns the shared state used by the server's request and health-check
paths.
+ *
+ * @return the shared health state
+ */
+ public static ServerHealth getInstance() {
+ return INSTANCE;
+ }
+
+ /**
+ * Records an out-of-memory error, including one wrapped in another
throwable. Suppressed
+ * exceptions are not inspected, avoiding the array copies made by {@link
+ * Throwable#getSuppressed()}.
+ *
+ * @param failure the observed failure, or null
+ */
+ public void recordFailure(@Nullable Throwable failure) {
+ Throwable slow = failure;
+ boolean advanceSlow = false;
+ while (failure != null) {
+ if (failure instanceof OutOfMemoryError) {
+ outOfMemory = true;
+ return;
+ }
+ failure = failure.getCause();
+ if (advanceSlow) {
+ slow = slow.getCause();
+ }
+ advanceSlow = !advanceSlow;
+ // Throwable cause chains can be cyclic. Detect cycles without
allocating a visited set.
+ if (failure == slow) {
+ return;
+ }
+ }
+ }
+
+ /**
+ * Returns whether this server has observed an out-of-memory error.
+ *
+ * @return true after an out-of-memory error has been recorded
+ */
+ public boolean hasOutOfMemoryError() {
+ return outOfMemory;
+ }
+}
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/web/Utils.java
b/server-common/src/main/java/org/apache/gravitino/server/web/Utils.java
index e2165637bb..0f5196c9ae 100644
--- a/server-common/src/main/java/org/apache/gravitino/server/web/Utils.java
+++ b/server-common/src/main/java/org/apache/gravitino/server/web/Utils.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.server.web;
import com.google.common.collect.Maps;
import java.lang.reflect.Parameter;
import java.security.PrivilegedExceptionAction;
+import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
@@ -37,7 +38,9 @@ import org.apache.gravitino.audit.FilesetDataOperation;
import org.apache.gravitino.audit.InternalClientType;
import org.apache.gravitino.auth.AuthConstants;
import org.apache.gravitino.credential.CredentialConstants;
+import org.apache.gravitino.dto.HealthCheckDTO;
import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.apache.gravitino.dto.responses.HealthResponse;
import org.apache.gravitino.utils.PrincipalUtils;
public class Utils {
@@ -100,6 +103,7 @@ public class Utils {
}
public static Response internalError(String message, Throwable throwable) {
+ ServerHealth.getInstance().recordFailure(throwable);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(ErrorResponse.internalError(message, throwable))
.type(MediaType.APPLICATION_JSON)
@@ -260,6 +264,21 @@ public class Utils {
.build();
}
+ /**
+ * Returns the health response used after an observed out-of-memory failure.
+ *
+ * @return HTTP 503 with a JVM failure requiring process restart
+ */
+ public static Response outOfMemoryResponse() {
+ HealthCheckDTO check =
+ new HealthCheckDTO(
+ "jvm",
+ HealthCheckDTO.Status.DOWN,
+ Collections.singletonMap("reason", "OutOfMemoryError; restart
required"));
+ return serviceUnavailable(
+ new HealthResponse(HealthCheckDTO.Status.DOWN,
Collections.singletonList(check)));
+ }
+
public static Response doAs(
HttpServletRequest httpRequest, PrivilegedExceptionAction<Response>
action) throws Exception {
UserPrincipal principal =
@@ -268,7 +287,13 @@ public class Utils {
if (principal == null) {
principal = new UserPrincipal(AuthConstants.ANONYMOUS_USER);
}
- return PrincipalUtils.doAs(principal, action);
+ try {
+ return PrincipalUtils.doAs(principal, action);
+ } catch (Exception | Error failure) {
+ // Record before a resource converts a wrapped failure into an ordinary
error response.
+ ServerHealth.getInstance().recordFailure(failure);
+ throw failure;
+ }
}
public static Map<String, String>
filterFilesetAuditHeaders(HttpServletRequest httpRequest) {
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/authentication/TestAuthenticationOutOfMemoryHttp.java
b/server-common/src/test/java/org/apache/gravitino/server/authentication/TestAuthenticationOutOfMemoryHttp.java
new file mode 100644
index 0000000000..af300d029f
--- /dev/null
+++
b/server-common/src/test/java/org/apache/gravitino/server/authentication/TestAuthenticationOutOfMemoryHttp.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.gravitino.server.authentication;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.Collections;
+import java.util.concurrent.atomic.AtomicBoolean;
+import javax.servlet.Filter;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.exceptions.UnauthorizedException;
+import org.apache.gravitino.rest.RESTUtils;
+import org.apache.gravitino.server.web.JettyServer;
+import org.apache.gravitino.server.web.JettyServerConfig;
+import org.apache.gravitino.server.web.ServerHealth;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.MockedStatic;
+
+/** Verifies authentication failures are recorded across the real HTTP filter
chain. */
+class TestAuthenticationOutOfMemoryHttp {
+ @ParameterizedTest
+ @ValueSource(strings = {"direct", "wrapped", "unauthorized", "ordinary",
"ordinary-unauthorized"})
+ void recordsOomBeforeConvertingAuthenticationErrors(String kind) throws
Exception {
+ ServerHealth health = new ServerHealth();
+ AtomicBoolean recordedBeforeConversion = new AtomicBoolean();
+ Authenticator authenticator = mock(Authenticator.class);
+ when(authenticator.supportsToken(any())).thenReturn(true);
+ when(authenticator.isDataFromToken()).thenReturn(true);
+ Throwable failure;
+ switch (kind) {
+ case "direct":
+ failure = new OutOfMemoryError("Metaspace");
+ break;
+ case "wrapped":
+ failure = new IllegalStateException(new OutOfMemoryError("Java heap
space"));
+ break;
+ case "unauthorized":
+ failure =
+ new UnauthorizedException(new OutOfMemoryError("Metaspace"),
"authentication failed");
+ break;
+ case "ordinary-unauthorized":
+ failure = new UnauthorizedException("invalid credentials");
+ break;
+ default:
+ failure = new IllegalStateException("ordinary failure");
+ }
+ when(authenticator.authenticateToken(any())).thenThrow(failure);
+ int port = RESTUtils.findAvailablePort(0, 0);
+ Config config = new Config(false) {};
+ config.set(JettyServerConfig.WEBSERVER_HTTP_PORT, port);
+ JettyServer server =
+ new JettyServer() {
+ /** {@inheritDoc} */
+ @Override
+ protected Filter createAuthenticationFilter() {
+ return new
AuthenticationFilter(Collections.singletonList(authenticator)) {
+ /** {@inheritDoc} */
+ @Override
+ protected void sendAuthErrorResponse(
+ HttpServletResponse response, Exception exception) throws
IOException {
+ recordedBeforeConversion.set(health.hasOutOfMemoryError());
+ super.sendAuthErrorResponse(response, exception);
+ }
+ };
+ }
+ };
+ try {
+ try (MockedStatic<ServerHealth> state = mockStatic(ServerHealth.class)) {
+ state.when(ServerHealth::getInstance).thenReturn(health);
+ server.initialize(JettyServerConfig.fromConfig(config),
"authentication-oom-test", false);
+ server.addSystemFilters("/*");
+ }
+ server.start();
+ HttpResponse<String> response =
+ HttpClient.newHttpClient()
+ .send(
+ HttpRequest.newBuilder(URI.create("http://localhost:" + port
+ "/test"))
+ .header("Authorization", "test")
+ .GET()
+ .build(),
+ HttpResponse.BodyHandlers.ofString());
+ assertEquals(kind.contains("unauthorized") ? 401 : 500,
response.statusCode());
+ assertEquals(!kind.startsWith("ordinary"), health.hasOutOfMemoryError());
+ if (!kind.equals("direct")) {
+ assertEquals(!kind.startsWith("ordinary"),
recordedBeforeConversion.get());
+ }
+ } finally {
+ server.stop();
+ }
+ }
+}
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/web/TestJettyServer.java
b/server-common/src/test/java/org/apache/gravitino/server/web/TestJettyServer.java
index 3ff3cfc2cc..21b0bfc58d 100644
---
a/server-common/src/test/java/org/apache/gravitino/server/web/TestJettyServer.java
+++
b/server-common/src/test/java/org/apache/gravitino/server/web/TestJettyServer.java
@@ -20,16 +20,20 @@ package org.apache.gravitino.server.web;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
import java.io.IOException;
import javax.servlet.Filter;
import javax.servlet.Servlet;
import org.apache.gravitino.Config;
import org.apache.gravitino.rest.RESTUtils;
+import org.eclipse.jetty.util.thread.QueuedThreadPool;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
public class TestJettyServer {
@@ -95,4 +99,19 @@ public class TestJettyServer {
public void testStartWithoutInitialise() throws InterruptedException {
assertThrows(RuntimeException.class, () -> jettyServer.start());
}
+ /** Jetty worker failures update health before logging the uncaught error. */
+ @Test
+ public void testUncaughtOutOfMemoryUpdatesHealth() throws IOException {
+ ServerHealth health = new ServerHealth();
+ Config config = new Config(false) {};
+ jettyServer.initialize(JettyServerConfig.fromConfig(config), "test",
false);
+ Thread worker = ((QueuedThreadPool)
jettyServer.getThreadPool()).newThread(() -> {});
+ try (MockedStatic<ServerHealth> state = mockStatic(ServerHealth.class)) {
+ state.when(ServerHealth::getInstance).thenReturn(health);
+ worker
+ .getUncaughtExceptionHandler()
+ .uncaughtException(worker, new OutOfMemoryError("Metaspace"));
+ assertTrue(health.hasOutOfMemoryError());
+ }
+ }
}
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/web/TestOutOfMemoryErrorListener.java
b/server-common/src/test/java/org/apache/gravitino/server/web/TestOutOfMemoryErrorListener.java
new file mode 100644
index 0000000000..933f8d05d7
--- /dev/null
+++
b/server-common/src/test/java/org/apache/gravitino/server/web/TestOutOfMemoryErrorListener.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.gravitino.server.web;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.glassfish.jersey.server.monitoring.RequestEvent;
+import org.junit.jupiter.api.Test;
+
+/** Tests detection of errors that Jersey may wrap or map to ordinary
responses. */
+class TestOutOfMemoryErrorListener {
+ @Test
+ void recordsWrappedErrorsOnlyOnExceptionEvents() {
+ ServerHealth health = new ServerHealth();
+ OutOfMemoryErrorListener listener = new OutOfMemoryErrorListener(health);
+ RequestEvent event = mock(RequestEvent.class);
+ when(event.getException()).thenReturn(new RuntimeException(new
OutOfMemoryError("Metaspace")));
+ when(event.getType()).thenReturn(RequestEvent.Type.START);
+ assertSame(listener, listener.onRequest(event));
+ listener.onEvent(event);
+ assertFalse(health.hasOutOfMemoryError());
+ when(event.getType()).thenReturn(RequestEvent.Type.ON_EXCEPTION);
+ listener.onEvent(event);
+ assertTrue(health.hasOutOfMemoryError());
+ }
+}
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/web/TestServerHealth.java
b/server-common/src/test/java/org/apache/gravitino/server/web/TestServerHealth.java
new file mode 100644
index 0000000000..f49d630c3d
--- /dev/null
+++
b/server-common/src/test/java/org/apache/gravitino/server/web/TestServerHealth.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.gravitino.server.web;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+/** Tests the sticky out-of-memory state and cause-chain handling. */
+class TestServerHealth {
+ @Test
+ void ordinaryFailuresDoNotPoisonHealth() {
+ ServerHealth health = new ServerHealth();
+ health.recordFailure(null);
+ health.recordFailure(new IllegalStateException("database unavailable"));
+ health.recordFailure(new StackOverflowError());
+ health.recordFailure(new NoClassDefFoundError("missing connector"));
+ assertFalse(health.hasOutOfMemoryError());
+ }
+
+ @Test
+ void heapAndMetaspaceFailuresRemainRecorded() {
+ for (String message :
+ new String[] {"Java heap space", "Metaspace", "unable to create native
thread"}) {
+ ServerHealth health = new ServerHealth();
+ health.recordFailure(new RuntimeException(new
OutOfMemoryError(message)));
+ health.recordFailure(null);
+ health.recordFailure(new IllegalArgumentException());
+ assertTrue(health.hasOutOfMemoryError());
+ }
+ }
+
+ @Test
+ void cyclicCausesTerminateAndStillFindOutOfMemory() {
+ Throwable first = new RuntimeException();
+ Throwable second = new RuntimeException(first);
+ first.initCause(second);
+ ServerHealth health = new ServerHealth();
+ health.recordFailure(first);
+ assertFalse(health.hasOutOfMemoryError());
+
+ Throwable root = new RuntimeException();
+ Throwable oom = new OutOfMemoryError("Metaspace");
+ Throwable middle = new RuntimeException(oom);
+ root.initCause(middle);
+ oom.initCause(root);
+ health.recordFailure(root);
+ assertTrue(health.hasOutOfMemoryError());
+ }
+}
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/web/TestUtilsOutOfMemory.java
b/server-common/src/test/java/org/apache/gravitino/server/web/TestUtilsOutOfMemory.java
new file mode 100644
index 0000000000..c2d50285c0
--- /dev/null
+++
b/server-common/src/test/java/org/apache/gravitino/server/web/TestUtilsOutOfMemory.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.gravitino.server.web;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.core.Response;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+/** Tests recording errors before resource-level exception handling hides
them. */
+class TestUtilsOutOfMemory {
+ @Test
+ void doAsRecordsAndRethrowsTheOriginalError() {
+ ServerHealth health = new ServerHealth();
+ OutOfMemoryError failure = new OutOfMemoryError("Metaspace");
+ try (MockedStatic<ServerHealth> state = mockStatic(ServerHealth.class)) {
+ state.when(ServerHealth::getInstance).thenReturn(health);
+ assertSame(
+ failure,
+ assertThrows(
+ OutOfMemoryError.class,
+ () ->
+ Utils.doAs(
+ mock(HttpServletRequest.class),
+ () -> {
+ throw failure;
+ })));
+ assertTrue(health.hasOutOfMemoryError());
+ }
+ }
+
+ @Test
+ void doAsRecordsWrappedFailuresBeforeTheyAreMappedToResponses() {
+ ServerHealth health = new ServerHealth();
+ RuntimeException failure = new RuntimeException(new OutOfMemoryError("Java
heap space"));
+ try (MockedStatic<ServerHealth> state = mockStatic(ServerHealth.class)) {
+ state.when(ServerHealth::getInstance).thenReturn(health);
+ assertThrows(
+ Exception.class,
+ () ->
+ Utils.doAs(
+ mock(HttpServletRequest.class),
+ () -> {
+ throw failure;
+ }));
+ assertTrue(health.hasOutOfMemoryError());
+ }
+ }
+
+ @Test
+ void internalErrorRecordsWrappedOomButNotOrdinaryServerErrors() {
+ ServerHealth health = new ServerHealth();
+ try (MockedStatic<ServerHealth> state = mockStatic(ServerHealth.class)) {
+ state.when(ServerHealth::getInstance).thenReturn(health);
+ try (Response response = Utils.internalError("unavailable", new
IllegalStateException())) {
+ assertEquals(500, response.getStatus());
+ assertFalse(health.hasOutOfMemoryError());
+ }
+ try (Response response =
+ Utils.internalError("failed", new RuntimeException(new
OutOfMemoryError()))) {
+ assertEquals(500, response.getStatus());
+ assertTrue(health.hasOutOfMemoryError());
+ }
+ }
+ }
+}
diff --git
a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
index 04ba74a209..0fb4b007b6 100644
--- a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
+++ b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
@@ -60,6 +60,7 @@ import
org.apache.gravitino.server.web.HttpServerMetricsSource;
import org.apache.gravitino.server.web.JettyServer;
import org.apache.gravitino.server.web.JettyServerConfig;
import org.apache.gravitino.server.web.ObjectMapperProvider;
+import org.apache.gravitino.server.web.OutOfMemoryErrorListener;
import org.apache.gravitino.server.web.RequestContextFilter;
import org.apache.gravitino.server.web.VersioningFilter;
import org.apache.gravitino.server.web.filter.AccessControlNotAllowedFilter;
@@ -188,6 +189,7 @@ public class GravitinoServer extends ResourceConfig {
}
});
register(JsonProcessingExceptionMapper.class);
+ register(new OutOfMemoryErrorListener());
register(ErrorExceptionMapper.class);
register(JsonParseExceptionMapper.class);
register(JsonMappingExceptionMapper.class);
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/mapper/ErrorExceptionMapper.java
b/server/src/main/java/org/apache/gravitino/server/web/mapper/ErrorExceptionMapper.java
index 892cc8f699..41ec7c63df 100644
---
a/server/src/main/java/org/apache/gravitino/server/web/mapper/ErrorExceptionMapper.java
+++
b/server/src/main/java/org/apache/gravitino/server/web/mapper/ErrorExceptionMapper.java
@@ -22,6 +22,7 @@ import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.apache.gravitino.server.web.ServerHealth;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -29,6 +30,22 @@ import org.slf4j.LoggerFactory;
public class ErrorExceptionMapper implements ExceptionMapper<Error> {
private static final Logger LOG =
LoggerFactory.getLogger(ErrorExceptionMapper.class);
+ private final ServerHealth health;
+
+ /** Creates a mapper using the shared server health state. */
+ public ErrorExceptionMapper() {
+ this(ServerHealth.getInstance());
+ }
+
+ /**
+ * Creates a mapper using the supplied health state.
+ *
+ * @param health the state to update before constructing an error response
+ */
+ public ErrorExceptionMapper(ServerHealth health) {
+ this.health = health;
+ }
+
/**
* Returns a server error response retaining the original error type and
complete stack trace.
*
@@ -37,6 +54,7 @@ public class ErrorExceptionMapper implements
ExceptionMapper<Error> {
*/
@Override
public Response toResponse(Error error) {
+ health.recordFailure(error);
String message = "Server error while processing request: " + error;
LOG.error(message, error);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/rest/HealthOperations.java
b/server/src/main/java/org/apache/gravitino/server/web/rest/HealthOperations.java
index ea2aa6ffc0..1a505be127 100644
---
a/server/src/main/java/org/apache/gravitino/server/web/rest/HealthOperations.java
+++
b/server/src/main/java/org/apache/gravitino/server/web/rest/HealthOperations.java
@@ -47,6 +47,7 @@ import org.apache.gravitino.dto.HealthCheckDTO;
import org.apache.gravitino.dto.responses.HealthResponse;
import org.apache.gravitino.metrics.MetricNames;
import org.apache.gravitino.server.ServerConfig;
+import org.apache.gravitino.server.web.ServerHealth;
import org.apache.gravitino.server.web.Utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -57,8 +58,10 @@ import org.slf4j.LoggerFactory;
* managers can distinguish "restart this pod" from "route traffic elsewhere."
*
* <ul>
- * <li>{@code GET /api/health/live} — liveness, 200 as long as the HTTP
thread can respond
- * <li>{@code GET /api/health/ready} — readiness, 200 when entity store is
reachable
+ * <li>{@code GET /api/health/live} — liveness, 200 when the HTTP thread can
respond and no OOM
+ * has been observed
+ * <li>{@code GET /api/health/ready} — readiness, 200 when entity store is
reachable and no OOM
+ * has been observed
* <li>{@code GET /api/health} — aggregate, 200 when both pass
* </ul>
*
@@ -96,12 +99,20 @@ public class HealthOperations {
private static final String CHECK_HTTP_SERVER = "httpServer";
private static final String CHECK_ENTITY_STORE = "entityStore";
+ private final ServerHealth serverHealth;
+
/**
* Default constructor for Jersey auto-discovery. The entity store is
resolved lazily at request
* time via {@link #getEntityStore()} so that probes issued before {@link
GravitinoEnv} has
* finished initializing report DOWN rather than throwing
NullPointerException.
*/
- public HealthOperations() {}
+ public HealthOperations() {
+ this(ServerHealth.getInstance());
+ }
+
+ HealthOperations(ServerHealth serverHealth) {
+ this.serverHealth = serverHealth;
+ }
@GET
@Path("/live")
@@ -109,6 +120,9 @@ public class HealthOperations {
@Timed(name = "health.live." + MetricNames.HTTP_PROCESS_DURATION, absolute =
true)
@ResponseMetered(name = "health.live", absolute = true)
public Response live() {
+ if (serverHealth.hasOutOfMemoryError()) {
+ return Utils.outOfMemoryResponse();
+ }
HealthCheckDTO check = up(CHECK_HTTP_SERVER, Collections.emptyMap());
return Utils.ok(new HealthResponse(HealthCheckDTO.Status.UP,
Collections.singletonList(check)));
}
@@ -119,7 +133,13 @@ public class HealthOperations {
@Timed(name = "health.ready." + MetricNames.HTTP_PROCESS_DURATION, absolute
= true)
@ResponseMetered(name = "health.ready", absolute = true)
public Response ready() {
+ if (serverHealth.hasOutOfMemoryError()) {
+ return Utils.outOfMemoryResponse();
+ }
HealthCheckDTO entityStoreCheck = checkEntityStore();
+ if (serverHealth.hasOutOfMemoryError()) {
+ return Utils.outOfMemoryResponse();
+ }
HealthCheckDTO.Status overall = entityStoreCheck.getStatus();
HealthResponse body = new HealthResponse(overall,
Collections.singletonList(entityStoreCheck));
return overall == HealthCheckDTO.Status.UP ? Utils.ok(body) :
Utils.serviceUnavailable(body);
@@ -130,9 +150,15 @@ public class HealthOperations {
@Timed(name = "health." + MetricNames.HTTP_PROCESS_DURATION, absolute = true)
@ResponseMetered(name = "health", absolute = true)
public Response health() {
+ if (serverHealth.hasOutOfMemoryError()) {
+ return Utils.outOfMemoryResponse();
+ }
List<HealthCheckDTO> checks = new ArrayList<>(2);
checks.add(up(CHECK_HTTP_SERVER, Collections.emptyMap()));
checks.add(checkEntityStore());
+ if (serverHealth.hasOutOfMemoryError()) {
+ return Utils.outOfMemoryResponse();
+ }
HealthCheckDTO.Status overall =
checks.stream().anyMatch(c -> c.getStatus() ==
HealthCheckDTO.Status.DOWN)
@@ -158,8 +184,14 @@ public class HealthOperations {
try {
return entityStore.exists(
NameIdentifier.of(HEALTH_PROBE_SENTINEL),
EntityType.METALAKE);
- } catch (IOException e) {
- throw new RuntimeException(e);
+ } catch (IOException failure) {
+ serverHealth.recordFailure(failure);
+ throw new RuntimeException(failure);
+ } catch (RuntimeException | Error failure) {
+ // CompletableFuture captures Errors too. Record here even
if the caller has
+ // already timed out and will never inspect the future's
exception.
+ serverHealth.recordFailure(failure);
+ throw failure;
}
},
HEALTH_PROBE_EXECUTOR);
@@ -184,6 +216,7 @@ public class HealthOperations {
return down(CHECK_ENTITY_STORE, "reason", "interrupted");
} catch (ExecutionException e) {
+ serverHealth.recordFailure(e);
Throwable cause = e.getCause() != null ? e.getCause() : e;
// Unwrap RuntimeException wrappers introduced by supplyAsync tunneling
checked exceptions.
if (cause instanceof RuntimeException && cause.getCause() != null) {
diff --git
a/server/src/test/java/org/apache/gravitino/server/TestGravitinoServerOutOfMemoryHttp.java
b/server/src/test/java/org/apache/gravitino/server/TestGravitinoServerOutOfMemoryHttp.java
new file mode 100644
index 0000000000..3a81d3d993
--- /dev/null
+++
b/server/src/test/java/org/apache/gravitino/server/TestGravitinoServerOutOfMemoryHttp.java
@@ -0,0 +1,210 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.gravitino.server;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.Map;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Response;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.auxiliary.AuxiliaryServiceManager;
+import org.apache.gravitino.rest.RESTUtils;
+import org.apache.gravitino.server.web.JettyServerConfig;
+import org.apache.gravitino.server.web.ObjectMapperProvider;
+import org.apache.gravitino.server.web.ServerHealth;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.api.parallel.Isolated;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/** Exercises OOM reporting through the production server's filters and Jersey
providers. */
+@Isolated(
+ "Uses the production server environment and restores its shared OOM marker
after shutdown")
+class TestGravitinoServerOutOfMemoryHttp {
+ private static final String[] HEALTH_PATHS = {
+ "/api/health",
+ "/api/health/live",
+ "/api/health/ready",
+ "/health",
+ "/health/live",
+ "/health/ready",
+ "/health.html"
+ };
+
+ @TempDir File temporaryDirectory;
+
+ /** Adds only failure injection; all health resources and providers come
from production. */
+ @Path("/oom-wiring/{kind}")
+ @Produces("application/vnd.gravitino.v1+json")
+ public static class FailingResource {
+ /**
+ * Simulates a failed allocation without exhausting the test JVM.
+ *
+ * @param kind the failure to simulate
+ * @return a successful response when no failure was requested
+ */
+ @GET
+ public Response get(@PathParam("kind") String kind) {
+ fail(kind);
+ return Response.ok().build();
+ }
+ }
+
+ /** Injects failures downstream of the production request-context and audit
filters. */
+ public static class FailingFilter implements Filter {
+ /** {@inheritDoc} */
+ @Override
+ public void init(FilterConfig config) {}
+
+ /** {@inheritDoc} */
+ @Override
+ public void destroy() {}
+
+ /** {@inheritDoc} */
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain)
+ throws IOException, ServletException {
+ String path = ((HttpServletRequest) request).getRequestURI();
+ String prefix = "/api/oom-wiring/filter-";
+ if (path.startsWith(prefix)) {
+ fail(path.substring(prefix.length()));
+ }
+ chain.doFilter(request, response);
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"oom", "wrapped", "filter-oom", "filter-wrapped"})
+ void testProductionWiringRecordsOutOfMemory(String kind) throws Exception {
+ ServerHealth health = ServerHealth.getInstance();
+ assertFalse(health.hasOutOfMemoryError());
+ // Real Jersey resources may be constructed on HTTP worker threads. A
thread-local static
+ // mock would not isolate those callers, so restore the real marker only
after Jetty stops.
+ Field marker = ServerHealth.class.getDeclaredField("outOfMemory");
+ marker.setAccessible(true);
+ int port = RESTUtils.findAvailablePort(0, 0);
+ ServerConfig config = new ServerConfig();
+ config.loadFromMap(
+ Map.of(
+ GravitinoServer.WEBSERVER_CONF_PREFIX +
JettyServerConfig.WEBSERVER_HTTP_PORT.getKey(),
+ String.valueOf(port),
+ GravitinoServer.WEBSERVER_CONF_PREFIX +
JettyServerConfig.CUSTOM_FILTERS.getKey(),
+ FailingFilter.class.getName(),
+ Configs.ENTITY_RELATIONAL_JDBC_BACKEND_PATH.getKey(),
+ temporaryDirectory.toPath().resolve("jdbc").toString(),
+ AuxiliaryServiceManager.GRAVITINO_AUX_SERVICE_PREFIX
+ + AuxiliaryServiceManager.AUX_SERVICE_NAMES,
+ ""),
+ entry -> true);
+ GravitinoServer server = new GravitinoServer(config,
GravitinoEnv.getInstance());
+ try {
+ server.initialize();
+ server.register(FailingResource.class);
+ server.start();
+ HttpClient client =
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
+ assertHealth(client, port, 200);
+ for (String ordinary : new String[] {"ordinary", "filter-ordinary"}) {
+ assertEquals(500, get(client, port, "/api/oom-wiring/" +
ordinary).statusCode());
+ assertHealth(client, port, 200);
+ assertFalse(health.hasOutOfMemoryError());
+ }
+ HttpResponse<String> failure = get(client, port, "/api/oom-wiring/" +
kind);
+ assertEquals(500, failure.statusCode(), failure.body());
+ if (kind.equals("oom")) {
+ // Prove that Jersey instantiated the production class-registered
ErrorExceptionMapper.
+ JsonNode body =
ObjectMapperProvider.objectMapper().readTree(failure.body());
+ assertEquals("OutOfMemoryError", body.path("type").asText());
+ assertEquals(
+ "Server error while processing request:
java.lang.OutOfMemoryError: Requested array size exceeds VM limit",
+ body.path("message").asText());
+ }
+ assertTrue(health.hasOutOfMemoryError());
+ assertHealth(client, port, 503);
+ assertEquals(200, get(client, port,
"/api/oom-wiring/warm").statusCode());
+ assertHealth(client, port, 503);
+ } finally {
+ try {
+ server.stop();
+ } finally {
+ marker.setBoolean(health, false);
+ }
+ }
+ }
+
+ private static void fail(String kind) {
+ switch (kind) {
+ case "oom":
+ throw new OutOfMemoryError("Requested array size exceeds VM limit");
+ case "wrapped":
+ throw new IllegalStateException(new OutOfMemoryError("Java heap
space"));
+ case "ordinary":
+ throw new IllegalStateException("ordinary failure");
+ default:
+ break;
+ }
+ }
+
+ private static void assertHealth(HttpClient client, int port, int status)
throws Exception {
+ for (String path : HEALTH_PATHS) {
+ HttpResponse<String> response = get(client, port, path);
+ assertEquals(status, response.statusCode(), path + ": " +
response.body());
+ JsonNode body =
ObjectMapperProvider.objectMapper().readTree(response.body());
+ assertEquals(status == 200 ? "up" : "down",
body.path("status").asText(), path);
+ if (status == 503) {
+ assertEquals(1, body.path("checks").size(), path);
+ assertEquals("jvm", body.path("checks").get(0).path("name").asText(),
path);
+ assertEquals("down",
body.path("checks").get(0).path("status").asText(), path);
+ }
+ }
+ }
+
+ private static HttpResponse<String> get(HttpClient client, int port, String
path)
+ throws Exception {
+ return client.send(
+ HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path))
+ .timeout(Duration.ofSeconds(10))
+ .header("Accept", "application/vnd.gravitino.v1+json")
+ .GET()
+ .build(),
+ HttpResponse.BodyHandlers.ofString());
+ }
+}
diff --git
a/server/src/test/java/org/apache/gravitino/server/web/mapper/TestErrorExceptionMapper.java
b/server/src/test/java/org/apache/gravitino/server/web/mapper/TestErrorExceptionMapper.java
index 78d264db46..ca0960a343 100644
---
a/server/src/test/java/org/apache/gravitino/server/web/mapper/TestErrorExceptionMapper.java
+++
b/server/src/test/java/org/apache/gravitino/server/web/mapper/TestErrorExceptionMapper.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.server.web.mapper;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.apache.gravitino.server.web.ServerHealth;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -35,7 +36,9 @@ public class TestErrorExceptionMapper {
new NoClassDefFoundError("catalog class"), new
AssertionError("assertion")
}) {
error.initCause(new IllegalStateException("root cause"));
- try (Response response = new ErrorExceptionMapper().toResponse(error)) {
+ ServerHealth health = new ServerHealth();
+ try (Response response = new
ErrorExceptionMapper(health).toResponse(error)) {
+ Assertions.assertEquals(error instanceof OutOfMemoryError,
health.hasOutOfMemoryError());
Assertions.assertEquals(500, response.getStatus());
Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE,
response.getMediaType());
ErrorResponse entity = (ErrorResponse) response.getEntity();
diff --git
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestHealthOperations.java
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestHealthOperations.java
index 9f5262a2fb..996d99a439 100644
---
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestHealthOperations.java
+++
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestHealthOperations.java
@@ -29,6 +29,7 @@ import javax.ws.rs.core.Response;
import org.apache.gravitino.EntityStore;
import org.apache.gravitino.dto.HealthCheckDTO;
import org.apache.gravitino.dto.responses.HealthResponse;
+import org.apache.gravitino.server.web.ServerHealth;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
@@ -39,7 +40,11 @@ public class TestHealthOperations {
}
private HealthOperations newOps(EntityStore store, long probeTimeoutMs) {
- return new HealthOperations() {
+ return newOps(store, probeTimeoutMs, new ServerHealth());
+ }
+
+ private HealthOperations newOps(EntityStore store, long probeTimeoutMs,
ServerHealth health) {
+ return new HealthOperations(health) {
@Override
EntityStore getEntityStore() {
return store;
@@ -146,4 +151,74 @@ public class TestHealthOperations {
HealthResponse response = new HealthResponse();
assertThrows(IllegalArgumentException.class, response::validate);
}
+ /** All probes stay down after OOM, even if the entity store would answer
successfully. */
+ @Test
+ public void testAllEndpointsStayDownAfterOutOfMemory() {
+ ServerHealth health = new ServerHealth();
+ EntityStore store = Mockito.mock(EntityStore.class);
+ HealthOperations ops = newOps(store, 2000L, health);
+ health.recordFailure(new OutOfMemoryError("Metaspace"));
+ for (Response response : new Response[] {ops.live(), ops.ready(),
ops.health()}) {
+ try (Response ignored = response) {
+ assertEquals(503, response.getStatus());
+ HealthResponse body = (HealthResponse) response.getEntity();
+ assertEquals(HealthCheckDTO.Status.DOWN, body.getStatus());
+ assertEquals("jvm", body.getChecks().get(0).getName());
+ assertEquals(
+ "OutOfMemoryError; restart required",
+ body.getChecks().get(0).getDetails().get("reason"));
+ }
+ }
+ Mockito.verifyNoInteractions(store);
+ }
+
+ /** A captured executor OOM must poison liveness too, and must not recover
on the next probe. */
+ @Test
+ public void testProbeOutOfMemoryIsSticky() throws IOException {
+ ServerHealth health = new ServerHealth();
+ EntityStore store = Mockito.mock(EntityStore.class);
+ Mockito.when(store.exists(Mockito.any(), Mockito.any()))
+ .thenThrow(new OutOfMemoryError("Java heap space"))
+ .thenReturn(false);
+ HealthOperations ops = newOps(store, 2000L, health);
+ try (Response first = ops.ready();
+ Response live = ops.live();
+ Response next = ops.ready()) {
+ assertEquals(503, first.getStatus());
+ assertEquals(503, live.getStatus());
+ assertEquals(503, next.getStatus());
+ }
+ Mockito.verify(store).exists(Mockito.any(), Mockito.any());
+ }
+
+ /** OOM from a timed-out probe must still be recorded when the abandoned
task finishes. */
+ @Test
+ public void testOutOfMemoryAfterProbeTimeout() throws IOException {
+ ServerHealth health = new ServerHealth();
+ EntityStore store = Mockito.mock(EntityStore.class);
+ CountDownLatch release = new CountDownLatch(1);
+ Mockito.when(store.exists(Mockito.any(), Mockito.any()))
+ .thenAnswer(
+ invocation -> {
+ release.await();
+ throw new OutOfMemoryError("Metaspace");
+ });
+ try {
+ try (Response response = newOps(store, 100L, health).ready()) {
+ assertEquals(503, response.getStatus());
+ HealthResponse body = (HealthResponse) response.getEntity();
+ assertEquals("timeout",
body.getChecks().get(0).getDetails().get("reason"));
+ }
+ } finally {
+ release.countDown();
+ }
+ // If the first worker has not recorded the OOM yet, this probe queues
behind it.
+ HealthOperations ops = newOps(store, 2000L, health);
+ try (Response ready = ops.ready();
+ Response live = ops.live()) {
+ assertEquals(503, ready.getStatus());
+ assertEquals(503, live.getStatus());
+ assertTrue(health.hasOutOfMemoryError());
+ }
+ }
}
diff --git
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestOutOfMemoryHealthHttp.java
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestOutOfMemoryHealthHttp.java
new file mode 100644
index 0000000000..46779eb92d
--- /dev/null
+++
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestOutOfMemoryHealthHttp.java
@@ -0,0 +1,247 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.apache.gravitino.server.web.rest;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.ext.ExceptionMapper;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.EntityStore;
+import org.apache.gravitino.rest.RESTUtils;
+import org.apache.gravitino.server.web.HealthAliasServlet;
+import org.apache.gravitino.server.web.JettyServer;
+import org.apache.gravitino.server.web.JettyServerConfig;
+import org.apache.gravitino.server.web.ObjectMapperProvider;
+import org.apache.gravitino.server.web.OutOfMemoryErrorListener;
+import org.apache.gravitino.server.web.ServerHealth;
+import org.apache.gravitino.server.web.mapper.ErrorExceptionMapper;
+import org.glassfish.jersey.jackson.JacksonFeature;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.servlet.ServletContainer;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.MockedStatic;
+
+/** Exercises an OOM on a separate HTTP request before checking every health
alias. */
+class TestOutOfMemoryHealthHttp {
+ private static final String[] HEALTH_PATHS = {
+ "/api/health",
+ "/api/health/live",
+ "/api/health/ready",
+ "/health",
+ "/health/live",
+ "/health/ready",
+ "/health.html"
+ };
+
+ /** A warm endpoint and failing requests, independent of the health
resources. */
+ @Path("/test/{kind}")
+ public static class FailingResource {
+ /**
+ * Simulates errors without exhausting the test worker's memory.
+ *
+ * @param kind the outcome to simulate
+ * @return the successful or ordinary server error response
+ */
+ @GET
+ public Response get(@PathParam("kind") String kind) {
+ switch (kind) {
+ case "oom":
+ throw new OutOfMemoryError("Metaspace");
+ case "wrapped":
+ throw new IllegalStateException(new OutOfMemoryError("Java heap
space"));
+ case "ordinary":
+ return Response.serverError().build();
+ default:
+ return Response.ok().build();
+ }
+ }
+ }
+
+ /** Simulates a resource-specific mapper that consumes wrapped errors. */
+ public static class RuntimeMapper implements
ExceptionMapper<IllegalStateException> {
+ /** {@inheritDoc} */
+ @Override
+ public Response toResponse(IllegalStateException error) {
+ return Response.serverError().build();
+ }
+ }
+
+ /** Supplies a reachable store so an unrelated readiness failure cannot mask
the result. */
+ public static class TestHealthResource extends HealthOperations {
+ private final EntityStore store;
+
+ TestHealthResource(ServerHealth health, EntityStore store) {
+ super(health);
+ this.store = store;
+ }
+
+ @Override
+ EntityStore getEntityStore() {
+ return store;
+ }
+
+ @Override
+ long getProbeTimeoutMs() {
+ return 2000L;
+ }
+ }
+
+ @Test
+ void directOomPoisonsAllHealthPathsWhileWarmEndpointsStillRespond() throws
Exception {
+ assertHealthAfterFailure("oom");
+ }
+
+ @Test
+ void mappedWrappedOomAlsoPoisonsAllHealthPaths() throws Exception {
+ assertHealthAfterFailure("wrapped");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"filter-oom", "filter-wrapped", "servlet-oom",
"servlet-wrapped"})
+ void errorsOutsideJerseyPoisonAllHealthPaths(String kind) throws Exception {
+ assertHealthAfterFailure(kind);
+ }
+
+ private void assertHealthAfterFailure(String failureKind) throws Exception {
+ ServerHealth health = new ServerHealth();
+ EntityStore store = mock(EntityStore.class);
+ when(store.exists(any(), any())).thenReturn(false);
+ ResourceConfig config =
+ new ResourceConfig()
+ .register(new TestHealthResource(health, store))
+ .register(FailingResource.class)
+ .register(new OutOfMemoryErrorListener(health))
+ .register(new ErrorExceptionMapper(health))
+ .register(RuntimeMapper.class)
+ .register(ObjectMapperProvider.class)
+ .register(JacksonFeature.class);
+ int port = RESTUtils.findAvailablePort(0, 0);
+ Config serverConfig = new Config(false) {};
+ serverConfig.set(JettyServerConfig.WEBSERVER_HTTP_PORT, port);
+ JettyServer server = new JettyServer();
+ // Capture an independent health state when the production filter is
constructed.
+ try (MockedStatic<ServerHealth> state = mockStatic(ServerHealth.class)) {
+ state.when(ServerHealth::getInstance).thenReturn(health);
+ server.initialize(JettyServerConfig.fromConfig(serverConfig),
"oom-test", false);
+ }
+ server.addServlet(new ServletContainer(config), "/api/*");
+ server.addServlet(new HealthAliasServlet(), "/health/*");
+ server.addServlet(new HealthAliasServlet(), "/health.html");
+ server.addFilter(
+ new Filter() {
+ /** {@inheritDoc} */
+ @Override
+ public void init(FilterConfig config) {}
+
+ /** {@inheritDoc} */
+ @Override
+ public void destroy() {}
+
+ /** {@inheritDoc} */
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse
response, FilterChain chain)
+ throws IOException, ServletException {
+ String path = ((HttpServletRequest) request).getRequestURI();
+ if (path.endsWith("filter-oom")) {
+ throw new OutOfMemoryError("Metaspace");
+ }
+ if (path.endsWith("filter-wrapped")) {
+ throw new IllegalStateException(new OutOfMemoryError("Java heap
space"));
+ }
+ if (path.endsWith("filter-ordinary")) {
+ throw new IllegalStateException("ordinary filter failure");
+ }
+ chain.doFilter(request, response);
+ }
+ },
+ "/*");
+ server.addServlet(
+ new HttpServlet() {
+ /** {@inheritDoc} */
+ @Override
+ protected void doGet(HttpServletRequest request, HttpServletResponse
response)
+ throws IOException {
+ if (request.getRequestURI().endsWith("servlet-oom")) {
+ throw new OutOfMemoryError("Metaspace");
+ }
+ if (request.getRequestURI().endsWith("servlet-wrapped")) {
+ throw new IllegalStateException(new OutOfMemoryError("Java heap
space"));
+ }
+ throw new IOException("ordinary servlet failure");
+ }
+ },
+ "/outside/*");
+ try {
+ server.start();
+ HttpClient client = HttpClient.newHttpClient();
+ for (String path : HEALTH_PATHS) {
+ assertEquals(200, get(client, port, path).statusCode(), path);
+ }
+ assertEquals(500, get(client, port, "/api/test/ordinary").statusCode());
+ assertEquals(500, get(client, port,
"/api/test/filter-ordinary").statusCode());
+ assertEquals(500, get(client, port, "/outside/ordinary").statusCode());
+ assertEquals(200, get(client, port, "/api/health").statusCode());
+ String failurePath = failureKind.startsWith("servlet-") ? "/outside/" :
"/api/test/";
+ assertEquals(500, get(client, port, failurePath +
failureKind).statusCode());
+ // This is the production failure mode: a successful warm endpoint is
not proof of recovery.
+ assertEquals(200, get(client, port, "/api/test/warm").statusCode());
+ for (String path : HEALTH_PATHS) {
+ HttpResponse<String> response = get(client, port, path);
+ assertEquals(503, response.statusCode(), path);
+ JsonNode body = new ObjectMapper().readTree(response.body());
+ assertEquals("down", body.path("status").asText());
+ assertEquals("jvm", body.path("checks").get(0).path("name").asText());
+ }
+ } finally {
+ server.stop();
+ }
+ }
+
+ private HttpResponse<String> get(HttpClient client, int port, String path)
throws Exception {
+ return client.send(
+ HttpRequest.newBuilder(URI.create("http://localhost:" + port +
path)).GET().build(),
+ HttpResponse.BodyHandlers.ofString());
+ }
+}