Skip to content

Running at Scale

Alexander Parent edited this page Jul 31, 2026 · 1 revision

Running at Scale

The worker pool

NavigationSystem owns one, but you can drive it directly.

try (var paths = new AsyncEntityPathfindingService(
        Runtime.getRuntime().availableProcessors(), 256)) {
    CompletableFuture<PathResult> future =
            paths.submitLatest(entity.getUuid(), request);
}

Cancelling the future cooperatively stops its active A* search. submitLatest cancels the previous unfinished request for that key, so a stale path can never beat a newer target. Closing cancels running and queued work, then waits for its workers.

Controllers created by NavigationSystem add a bounded admission layer ahead of the executor: newest intent per actor, at most maximumConcurrentControllerSearches(...) at once, and actors whose route is exhausted preferred over speculative replanning. Direct service submissions bypass all of it.

Overload

Submitting beyond capacity sheds the search. The future completes normally with PathResult.SHED — an empty route, not an exception.

PathResult result = navigation.submit(request).join();
if (result.shed()) return;          // keep the current route, retry later
if (result.found()) follow(result);

A shed replan is routine backpressure: the mob keeps the route it already has for another tick. One shared PathResult.SHED instance answers every shed, so sustained backpressure allocates nothing, and each shed still increments searches().rejected(). /nav shed in the demo shows the whole contract.

Metrics

NavigationMetricsSnapshot before = navigation.metricsSnapshot();
// ... one sampling interval later
NavigationMetricsSnapshot after = navigation.metricsSnapshot();

double seconds = after.elapsedSecondsSince(before);
long tailMicros = after.latency().since(before.latency()).p99Micros();
Field Contents
searches() submitted, completed, failed, cancelled, rejected, superseded, terminated(), inFlight()
latency() submission to result, for completed searches
queueWait() submission to execution start, for searches that ran
controllerOutcomes() outcomes plus stallRate() and travelStallRate()
adaptiveSources() one counter per SharedMeshSource, plus sharedHitRate()
stalePlans() plans refused because the entity moved past the start tolerance
queue() activeSearches, queuedSearches, queueCapacity, parallelism

Which stall rate to alarm on

Alarm on travelStallRate(), not stallRate().

Both have stuck as the numerator; the denominators differ. total() also counts a navigation issued to a mob already standing on its goal, which completes on its first tick without moving. That is not a rare edge: a mob that has arrived and replans once a second contributes one per replan, and in a representative soak they are over half of all outcomes. So stallRate() reads several times lower than the stalling actually observed.

travelStallRate() >= stallRate() always holds, so the travel-filtered rate can never hide a stall the lifetime rate would have shown.

unreachableTargets() is deliberately not part of total(). The controller stays PARTIAL and re-targetable, so a sustained rate there is a goal-selection problem, not a navigation failure.

Counters are LongAdders and never reset, so a rate is a difference between two snapshots. Latency buckets are powers of two microseconds and always round up, so a reported tail is never optimistic.

Clone this wiki locally