/*
* Copyright 2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package ratpack.health;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSortedMap;
import com.google.common.reflect.TypeToken;
import ratpack.exec.Fulfiller;
import ratpack.exec.Promise;
import ratpack.handling.Context;
import ratpack.handling.Handler;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Handler that runs and renders health checks executing in non-blocking mode
* <p>
* This handler queries {@code context} for either all or {@link ratpack.health.HealthCheck} with given token.
* Then handler gets {@link Promise<HealthCheck.Result>} for every health check to execute.
* Promises are executed in parallel and independently.
* <p>
* This handler should be bound to an application path, and most likely only for the GET method.
* <pre class="java-chain-dsl">
* import ratpack.health.HealthCheckHandler;
*
* chain instanceof ratpack.handling.Chain;
* chain.get("health-checks/:name?", new HealthCheckHandler());
* </pre>
* <p>
* The handler can render the result of all of the health checks or an individual health check, depending on the presence of a path token.
* The path token provides the name of the health check to render.
* If the path token is not present, all health checks will be rendered.
* The token name to use can be provided as the construction argument to this handler.
* The default token name is {@value #DEFAULT_NAME_TOKEN} and is used if the no-arg constructor is used.
* <p>
* If the token is present, the health check whose name is the value of the token will be rendered.
* If no health check exists by that name, the client will receive a 404.
* <p>
* When a single health check is selected (by presence of the path token)
* the {@link ratpack.health.HealthCheckResults} with one {@link ratpack.health.HealthCheck.Result}
* is {@link Context#render(Object) rendered}.
* When rendering all health checks a {@link ratpack.health.HealthCheckResults} is {@link Context#render(Object) rendered}.
* <p>
* The default {@link ratpack.health.HealthCheckResultsRenderer} is added to base registry. It renders in plain text.
* If you wish to change the output, to JSON for example, you can register your own renderer for {@link ratpack.health.HealthCheckResults}.
* <pre class="java">{@code
* import ratpack.exec.ExecControl;
* import ratpack.exec.Promise;
* import ratpack.guice.Guice;
* import ratpack.health.HealthCheck;
* import ratpack.health.HealthCheckHandler;
* import ratpack.health.HealthCheckResultsRenderer;
* import ratpack.test.embed.EmbeddedApp;
* import static org.junit.Assert.*;
* public class Example {
* public static class FooHealthCheck implements HealthCheck {
* public String getName() { return "foo"; }
* public Promise<HealthCheck.Result> check(ExecControl execControl) throws Exception {
* return execControl.promise(f -> {
* f.success(HealthCheck.Result.healthy());
* });
* }
* }
*
* public static void main(String... args) throws Exception {
* EmbeddedApp.of(s -> s
* .registryOf(r -> r
* .add(new HealthCheckResultsRenderer())
* )
* .registry(Guice.registry(b -> b
* .bind(FooHealthCheck.class)
* ))
* .handler(HealthCheckHandler.class)
* ).test(httpClient -> {
* assertEquals("foo : HEALTHY", httpClient.getText());
* });
*
* EmbeddedApp.of(s -> s
* .registryOf(r -> r
* .add(new HealthCheckResultsRenderer())
* )
* .registry(Guice.registry(b -> b
* .bind(FooHealthCheck.class)
* .bindInstance(HealthCheck.class, HealthCheck.of("bar", execControl -> {
* return execControl.promise(f -> {
* f.success(HealthCheck.Result.unhealthy("FAILED"));
* });
* }))
* ))
* .handler(r -> {
* return new HealthCheckHandler("bar");
* })
* ).test(httpClient -> {
* assertEquals("bar : FAILED", httpClient.getText());
* });
* }
* }
* }</pre>
*
* @see ratpack.health.HealthCheck
* @see ratpack.health.HealthCheckResults
* @see ratpack.health.HealthCheckResultsRenderer
*/
public class HealthCheckHandler implements Handler {
public static final String DEFAULT_NAME_TOKEN = "DEFAULT";
public static final int DEFAULT_CONCURRENCY_LEVEL = 0;
/**
* If defined, run only health check with the given name
*/
private final String name;
/**
* Define number of health checks (as promises) to be run in parallel.
* 0 - infinite potential parallelism - up to the event loop number of threads
* 1 - serial, promises run one by one in sequence
* 2 - max 2 at a time promises run in parallel
* 3 - max 3 at a time promises run in parallel
* ...
*/
private final int concurrencyLevel;
/**
* Default constructor with {@code concurrencyLevel} set to 0 (infinite potential parallelism) and
* undefined health check name.
*/
public HealthCheckHandler() {
this(DEFAULT_NAME_TOKEN, DEFAULT_CONCURRENCY_LEVEL);
}
/**
* Health check of the given name will be executed. If not registered HTTP 404 error is reported.
* @param healthCheckName health check name
*/
public HealthCheckHandler(String healthCheckName) {
this(healthCheckName, DEFAULT_CONCURRENCY_LEVEL);
}
/**
* Execute all registered health check with the given {@link ratpack.health.HealthCheckHandler#concurrencyLevel}.
*
* @param concurrencyLevel
*/
public HealthCheckHandler(int concurrencyLevel) {
this(DEFAULT_NAME_TOKEN, concurrencyLevel);
}
/**
* Execute health check of the given name (if different than {@link ratpack.health.HealthCheckHandler#DEFAULT_NAME_TOKEN} and
* given {@code concurrencyLevel}.
*
* @param healthCheckName
* @param concurrencyLevel
*/
protected HealthCheckHandler(String healthCheckName, int concurrencyLevel) {
this.name = healthCheckName;
this.concurrencyLevel = concurrencyLevel;
}
/**
* Run either individual health check by name or all health checks.
* The {@link ratpack.health.HealthCheckHandler#concurrencyLevel} determines parallelism in health check execution.
*
* @param context request context and exec control as well
* @throws Exception
*/
@Override
public void handle(Context context) throws Exception {
if (!name.equals(DEFAULT_NAME_TOKEN)) {
handleByName(context, name);
}
else {
handleAll(context);
}
}
/**
* Run health check with the given name. If it is not registered HTTP 404 is send to the client
* All exceptions from health check are wrapped in unhealthy result with error=Exception.
* @param context request context
* @param name health check name
* @throws Exception
*/
private void handleByName(Context context, String name) throws Exception {
if (name == null || "".equals(name) || DEFAULT_NAME_TOKEN.equals(name)) {
context.clientError(404);
return;
}
SortedMap<String, HealthCheck.Result> hcheckResults = new ConcurrentSkipListMap<>();
Optional<HealthCheck> hcheck = context.first(TypeToken.of(HealthCheck.class), hc -> hc.getName().equals(name));
if (!hcheck.isPresent()) {
context.clientError(404);
return;
}
try {
Promise<HealthCheck.Result> promise = hcheck.get().check(context.getExecution().getControl());
promise.onError(throwable -> {
hcheckResults.put(hcheck.get().getName(), HealthCheck.Result.unhealthy(throwable));
context.render(new HealthCheckResults(ImmutableSortedMap.<String, HealthCheck.Result>copyOfSorted(hcheckResults)));
}).then(r -> {
hcheckResults.put(hcheck.get().getName(), r);
context.render(new HealthCheckResults(ImmutableSortedMap.<String, HealthCheck.Result>copyOfSorted(hcheckResults)));
});
}
catch (Exception ex) {
hcheckResults.put(hcheck.get().getName(), HealthCheck.Result.unhealthy(ex));
context.render(new HealthCheckResults(ImmutableSortedMap.<String, HealthCheck.Result>copyOfSorted(hcheckResults)));
}
}
/**
* Run all health checks. The concurrencyLevel determines how many health checks could be run in parallel.
* If concurrencyLevel>1 and there is more health checks than concurrencyLevel they are grouped in chanks of size
* equal to concurrencyLevel.
* concurrencyLevel equal to 0 runs all health checks in parallel.
* IMPORTANT: the real concurrency level depends on the event loop size. Event loop sets the higher limit of concurrency.
* If event loop size is 8 ({@link ratpack.exec.internal.DefaultExecController#DefaultExecController}), the 9th health check will wait for the first.
*
* @param context request context and exec control as well
* @throws Exception
*/
private void handleAll(Context context) throws Exception {
SortedMap<String, HealthCheck.Result> hcheckResults = new ConcurrentSkipListMap<>();
SortedMap<String, Promise<HealthCheck.Result>> promises = new ConcurrentSkipListMap<>();
context.getAll(HealthCheck.class).forEach(hcheck -> {
try {
Promise<HealthCheck.Result> promise = hcheck.check(context.getExecution().getControl());
promises.put(hcheck.getName(), promise);
}
catch (Exception ex) {
hcheckResults.put(hcheck.getName(), HealthCheck.Result.unhealthy(ex));
}
});
if (promises.size() == 0) {
context.render(new HealthCheckResults(ImmutableSortedMap.<String, HealthCheck.Result>copyOfSorted(hcheckResults)));
return;
}
context.promise(f -> {
// execute promises in parallel based on the concurrencyLevel
// count finished health checks. If all are done, render results
AtomicInteger executedPromisesCountDown = new AtomicInteger(promises.size());
// count health checks waiting for execution.
AtomicInteger toExecPromisesCountDown = new AtomicInteger(promises.size());
// collect health checks to execute in the next run. Used when concurrencyLevel > 1
Map<String, Promise<HealthCheck.Result>> toExecPromises = new ConcurrentHashMap<>();
promises.forEach((name, p) -> {
boolean execParallel = false;
if (concurrencyLevel <= 0) {
// execute all health checks in parallel
execParallel = true;
} else if (concurrencyLevel == 1) {
// execute promise by promise, in sequence
execParallel = false;
} else {
// collect promises into group of concurrencyLevel size or into group of last promises to execute
toExecPromises.put(name, p);
if (toExecPromisesCountDown.decrementAndGet() > 0 && toExecPromises.size() < concurrencyLevel) {
return;
}
// execute promises in group in parallel
execParallel = true;
}
if (toExecPromises.size() > 0) {
// promise of immutable map of promises to execute, controls construction and their parallel execution while allows
// safe freeing of toExecPromises map.
context.promiseOf(ImmutableMap.<String, Promise<HealthCheck.Result>>copyOf(toExecPromises)).then(map -> {
// control finalization of parent promise
AtomicInteger groupOfPromisesCountDown = new AtomicInteger(map.size());
context.promise(f2 -> {
map.forEach((name2, p2) -> {
// execute promise p2 and check end condition: either last promise in group or last promise globally
execPromiseWithEndCondition(context, name2, p2, f2, hcheckResults, groupOfPromisesCountDown, executedPromisesCountDown);
});
}).then(finish -> {
if (finish == Boolean.TRUE) {
f.success(hcheckResults);
}
});
});
toExecPromises.clear();
} else {
if (execParallel) {
// execute promise and if last promise globally, return health check results
execPromiseWithEndResult(context, name, p, f, hcheckResults, executedPromisesCountDown);
} else {
context.promise(f2 -> {
// execute promise p and check end condition: if last promise globally
execPromiseWithEndCondition(context, name, p, f2, hcheckResults, null, executedPromisesCountDown);
}).then(finish -> {
if (finish == Boolean.TRUE) {
f.success(hcheckResults);
}
});
}
}
});
}).then(results -> {
context.render(new HealthCheckResults(ImmutableSortedMap.<String, HealthCheck.Result>copyOfSorted(hcheckResults)));
});
}
/**
* Execute promise and if last ({@code executedPromisesCountDown} is 0) return sorted map of health check results.
* @param context execution context
* @param name health check name
* @param promise health check promise with calculation to be run
* @param fulfiller fulfiller of an asynchronous promise
* @param hcheckResults sorted map of health check results
* @param executedPromisesCountDown counter of executed promises (counts down)
*/
private void execPromiseWithEndResult(
Context context,
String name,
Promise<HealthCheck.Result> promise,
Fulfiller<Object> fulfiller,
SortedMap<String, HealthCheck.Result> hcheckResults,
AtomicInteger executedPromisesCountDown) {
context.exec().onComplete(execution -> {
if (executedPromisesCountDown.decrementAndGet() == 0) {
fulfiller.success(hcheckResults);
}
}).onError(throwable -> {
hcheckResults.put(name, HealthCheck.Result.unhealthy(throwable));
if (executedPromisesCountDown.decrementAndGet() == 0) {
fulfiller.success(hcheckResults);
}
}).start(execution -> {
promise.then(r -> {
hcheckResults.put(name, r);
});
});
}
/**
* Execute promise and check end condition. Report Boolean as result.
* End condition: finish outer promise if {@code groupOfPromisesCountDown} is given and its decremented value is 0 or
* decremented {@code executedPromisesCountDown} is 0, return Boolean.TRUE if there is nothing to left to do, so if
* {@code executedPromisesCountDown} is 0.
* @param context execution context
* @param name health check name
* @param promise health check promise with calculation to be run
* @param fulfiller fulfiller of an asynchronous promise
* @param hcheckResults sorted map of health check results
* @param groupOfPromisesCountDown counter of executed promises in the given group (counts down)
* @param executedPromisesCountDown counter of executed promises (counts down)
*/
private void execPromiseWithEndCondition(
Context context,
String name,
Promise<HealthCheck.Result> promise,
Fulfiller<Object> fulfiller,
SortedMap<String, HealthCheck.Result> hcheckResults,
AtomicInteger groupOfPromisesCountDown, AtomicInteger executedPromisesCountDown) {
context.exec().onComplete(execution -> {
int i = executedPromisesCountDown != null ? executedPromisesCountDown.decrementAndGet() : 0;
if (groupOfPromisesCountDown != null) {
if (groupOfPromisesCountDown.decrementAndGet() == 0 || i == 0) {
fulfiller.success(i == 0 ? Boolean.TRUE : Boolean.FALSE);
}
}
else {
fulfiller.success(i == 0 ? Boolean.TRUE : Boolean.FALSE);
}
}).onError(throwable -> {
hcheckResults.put(name, HealthCheck.Result.unhealthy(throwable));
int i = executedPromisesCountDown != null ? executedPromisesCountDown.decrementAndGet() : 0;
if (groupOfPromisesCountDown != null) {
if (groupOfPromisesCountDown.decrementAndGet() == 0 || i == 0) {
fulfiller.success(i == 0 ? Boolean.TRUE : Boolean.FALSE);
}
}
else {
fulfiller.success(i == 0 ? Boolean.TRUE : Boolean.FALSE);
}
}).start(execution -> {
promise.then(r -> {
hcheckResults.put(name, r);
});
});
}
}