Skip to content

perf: fetch waypoints only for changed routes, in parallel - #2723

Open
cedricdhooge96 wants to merge 20 commits into
mainfrom
perf/waypoints-cache
Open

cedricdhooge96 wants to merge 20 commits into
mainfrom
perf/waypoints-cache

Conversation

@cedricdhooge96

Copy link
Copy Markdown

GET /v1/route-plans/{id}/waypoints refetched every route after each best solution, one map-service call at a time, under a lock shared by all runs. The map in the visualisations waits for this call.

Causes and fixes

Cause (2.7.0) Fix
Each best solution replaced all routes, so unchanged routes were fetched again A route is kept while its stops are equal; only changed routes are fetched
getWaypoints was synchronized for all runs and fetched one route at a time No global lock; missing routes are fetched in parallel on a dedicated pool of timefold.platform.map-service.waypoints-parallelism threads (default 8, must be > 0, checked at startup); two requests share one fetch per route
— For a run that was viewed (at least one GET), changed routes are fetched in the background after a best solution: at most one batch in flight per run, plus one trailing batch
Routes of every run stayed in memory until a restart A run is released after SaveWaypointsPostProcessor stored its waypoints, or when it fails; a later GET reads the stored waypoints; a bounded tombstone (1,024 ids) stops a late event from recreating a released run
SolverWorker.notifyOnFailure threw an NPE when the run metadata could not be read, so FAILED was never sent FAILED is sent with the run id (ItemFailed.withoutMetadata)

Measurements

Fake map service, 150 ms per call, 50 vehicles, a best solution that changes 5 routes:

Before After
GET after a best solution 7,829 ms, 50 calls 160 ms, 5 calls
First GET (all routes cold) 7,877 ms 1,106 ms
Repeated GET 0 ms 0 ms

Field service routing (FSR) dev mode, local OSRM (Georgia) through the platform maps service, 45 vehicles, about 175 routes, GET every 2 s while solving:

Before (FSR 2.7.0) After (this branch)
GET after a best solution 1.13–2.26 s 0.02–0.06 s (one 0.36 s)
GET on a finished run, filesystem storage (from memory) 14–51 ms (68 KB stored blob)

Local OSRM answers in about 9 ms per route; with network latency to the maps service, the gain is larger.

Behaviour changes

  • New property timefold.platform.map-service.waypoints-parallelism (default 8). The models document timefold.platform.map-service.* in their READMEs; the FSR README gets a row at the next solver bump.
  • A GET on a finished run reads the stored waypoints instead of memory: one object read per GET on cloud storage (not measured; local MinIO does not show cloud latency).
  • A GET for a failed run returns 404 instead of its last routes.
  • A GET before the first solution reads storage once, then returns 404, as before.
  • During a map-service outage, a viewed run retries its failed routes in each background batch (bounded by the coalescing and the pool size); before, only a GET retried.
  • At completion, SaveWaypointsPostProcessor now fetches up to waypoints-parallelism routes at the same time per pod, where it fetched one at a time; operators can lower the property.
  • If the store at completion fails, the run stays in memory, so GET keeps working until a restart.

Tests

WaypointsServiceImplTest (23), SaveWaypointsPostProcessorTest (3, new), VehicleRouteTest (1, new), SolverWorkerTest (2, new). The guards for the trailing batch, the release of the running flag after a failure, the release after store and the tombstone were each shown to fail with the guarded line removed.

Please squash-merge: several commits describe states that later commits replaced.

🤖 Generated with Claude Code

@timefold-automations

timefold-automations Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

The documentation drift reported earlier is gone: this pull request now documents timefold.platform.map-service.waypoints-parallelism, including its default and validation.

Generated by Solver Docs Drift Review · copilot · gpt56 · 11.1 AIC · ⌖ 3.52 AIC · ⊞ 25.2K · ◷

@cedricdhooge96

Copy link
Copy Markdown
Author

Docs added in 1a6e557: a new page, running-timefold-solver/service/maps-configuration.adoc, documents all timefold.platform.map-service.* properties, waypoints-parallelism included. The solver docs documented none of them before. Each default and behaviour comes from the service-client sources.

cedricdhooge96 and others added 8 commits October 13, 2026 15:03
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…global lock

A best solution now keeps each vehicle route whose stops are unchanged,
so only the changed routes go back to the map service.
getWaypoints is no longer synchronized: each route holds one shared fetch future,
missing routes are fetched in parallel, and a failed fetch is cleared so the next request retries.
The new timefold.platform.map-service.waypoints-parallelism (default 8) limits concurrent calls.

Executor: ManagedExecutor.builder().maxAsync(n), verified in
io.smallrye:smallrye-context-propagation 2.3.0 (Quarkus 3.40.1).
Builder.build() wraps the default executor in an org.jboss.threads 3.9.2 ViewExecutor
with maxSize = maxAsync and queueLimit = Integer.MAX_VALUE (maxQueued default -1),
so tasks above the limit queue instead of being rejected; shutdownNow closes only the view.
The tests show that the builder executor works in Quarkus with propagated(ThreadContext.NONE).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… startup

The fetches now run on a fixed pool of waypointsParallelism daemon threads
(waypoints-fetch-N) that the bean owns.
The ManagedExecutor builder ran them on the shared Quarkus worker pool,
where the GET threads block in join(): a pool full of waiting GETs left no thread
for the queued fetches, so every GET waited forever.
No context propagation is needed, because MapServiceOptionsSupplier reads only config.

@startup makes ArC create the bean at boot, so a waypointsParallelism below 1 fails at startup.
VehicleRoute completes and clears its future when the fetcher throws anything, an Error included,
so later requests start a new fetch instead of waiting forever.
A new test proves that @Retry intercepts calculateWaypoints for a retryable map-service failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After the first GET of a run, each best solution starts a fetch of the
changed routes. One batch is in flight per run; best solutions during a
batch coalesce into one trailing batch. The flags order (start: claim
running, then clear requested; finish: release running, then read
requested) prevents a lost request.

A batch that fails to start (a rejected submission after shutdown) logs
a WARN with the run id, releases the running flag and does not throw
into the solver event handler. It does not restart itself: the next best
solution or GET retries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The coalesce test waits on the call count, not on a GET, so a lost
  trailing batch fails it (the GET used to start the fetch itself).
- The failed-batch test sends one more best solution and expects a new
  background call, so a running flag that stays set fails it.
- A background failure logs one WARN line with its message; the stack
  trace goes to DEBUG. A route that keeps failing with an abortOn
  exception is fetched again by every batch.
- Rename the completion handler to onBackgroundFetchComplete.
- Each test waits until no map call is in flight before it resets the
  controls; beforeCall releases its in-flight count on any throw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
COMPLETED and FAILED remove the run from memory; a later GET reads the
stored waypoints, or reports 404 when none are stored. All Storage
implementations (in-memory; file system, S3, GCS, Azure at enterprise
2.7.0) return null for a missing sub-model, so null maps to
ItemNotFoundException.

INIT, BEST and FINAL_BEST still create the run: FSR's custom init phases
fire BEST before INIT. A bounded set of the last 1024 finished run ids
stops a late event from creating a finished run again; STARTED clears
the id, so a new solve of the same id works. A background batch of a
released run does not start a trailing batch.

The module tests get an in-memory Storage bean, which the storage
service needs and the module did not provide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When getMetadata returned null or threw, notifyOnFailure built
ItemFailed from null metadata, which threw a NullPointerException, so
FAILED was never sent and listeners kept the run.

ItemFailed gets a second constructor that takes the run id; the event
then has no metadata, and getMetadata documents that case. A constructor
next to the existing one keeps the event contract in one place, and the
FAILED listeners (AuditListener, WaypointsServiceImpl) read only the id
and the cause. Without metadata, the worker skips the metadata update
and FAILED_SOLUTION (which carries the metadata), and still runs the
post processors' processFailed.

SolverWorkerTest is new; UnresolvableInstance lets the test build the
worker without a CDI container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The COMPLETED listener released the run even when
SaveWaypointsPostProcessor failed, since the worker discards
post-processor errors. Storage then stayed empty, and each later GET
returned 404 until a restart. SaveWaypointsPostProcessor now releases
the run through WaypointsService.releaseFinishedRun, after a non-empty
list is stored. When the fetch or the store fails, or the run has no
routes, the run stays in memory and GET keeps working. FAILED still
releases through its listener. WaypointsService is in the impl
package, which revapi does not check.

The background-fetch guard is now in startBackgroundFetchIfIdle, so it
also covers a first batch for a run that was just released. The
not-found message says what to check. Test calls can wait on a gate
instead of a delay, and an empty model gives no routes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cedricdhooge96 and others added 6 commits October 13, 2026 15:03
ItemFailed.withoutMetadata replaces the overloaded constructor that took
a run id. The call site now says what it means, and new ItemFailed(null,
...) compiles again. The constructors of AbstractDatasetEvent and
SolverWorkerEvent that set no metadata are package-private, so only
ItemFailed can make an event without metadata. The events package is
not in the revapi scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Move the VehicleRoute test to its own file, release held calls before the
setup wait, and document releaseFinishedRun, ItemFailed.withoutMetadata,
getMetadata (now @nullable) and the RunRoutes flag order. The background
fetch WARN prints failure.toString(), as the message can be null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ArC rewrites a normal-scoped bean that has only an injection constructor:
it adds a no-args constructor for the client proxy. JaCoCo then cannot
match the rewritten class, so WaypointsServiceImpl and
SaveWaypointsPostProcessor showed 0% coverage in SonarCloud, also on main.
Both beans are created at startup and need no lazy proxy, so they are
@singleton now. Measured locally with -Prun-code-coverage:
WaypointsServiceImpl 0 -> 93 of 97 lines, SaveWaypointsPostProcessor
0 -> 9 of 10 lines.

Sonar findings:
- RunRoutes holds its route map in an AtomicReference (java:S3077).
- The background-fetch warning is built only when WARN is enabled, and
  the base waypoints are extracted only for a run that is kept.
- Tests: one call per assertThatThrownBy lambda, the 32-assertion test
  split in two, test utilities in application scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Answers java:S9068 on WaypointsServiceImpl and SaveWaypointsPostProcessor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docs did not document any timefold.platform.map-service.* property.
Defaults and behaviour are read from the service-client sources: url only
appears in the startup error, the REST client reads
quarkus.rest-client.map-service.url, and morning is the default timeframe
of StaticDaypartBucketing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Name the unset defaults, describe the fallback and the default timeframe
in active voice, and say "waypoint requests" for the parallelism limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@winklerm winklerm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving some early feedback, will check with a concrete model.

*
* @return the metadata; null only for an event made by {@link ItemFailed#withoutMetadata}
*/
public @Nullable Metadata getMetadata() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If adding @Nullable, the enclosing class should also get @NullMarked to state that the non-annotated elements are considered non-null. This might trigger larger changes, so maybe removing the @Nullable will make the change more isolated.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c2b5b95: @NullMarked on the class, @Nullable on the field.


if (waypoints != null && !waypoints.isEmpty()) {
storageService.storeWaypoints(id, waypoints);
waypointsService.releaseFinishedRun(id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: Should this be in a finally clause to make sure it is released even when an exception is thrown?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept outside finally on purpose: a run released before its waypoints are stored returns 404 until a restart (the bug 04cfcb5 fixed). The FAILED listener still releases on failure. A comment says so now (6f02387).

vehicleRouteMapReference.set(Collections.unmodifiableMap(nextVehicleRouteMap));
}

List<VehicleRoute> getRouteList(Set<String> objectIdSet) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PP: Could the objectIdSet be renamed to a more specific name?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to routeIdSet in c7200fb, in the interface and the impl too. The REST query parameter keeps its name.


import ai.timefold.solver.service.maps.api.model.Waypoints;

final class VehicleRoute {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PP: Naming - different models may have different entities and Vehicle might not be the case.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to Route in c7200fb.

try {
return waypointsFuture.join();
} catch (CompletionException e) {
if (e.getCause() instanceof RuntimeException cause) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: Why are we throwing the cause directly?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unwrapped cause keeps TimefoldRuntimeExceptionMapper in play with its code and id, as before the fetch moved to a pool. A CompletionException lands in UnknownExceptionMapper, which hides the message. MapServiceClientImpl unwraps the same way (line 233). A comment says so now (6f02387).

List.of(userModel.getLocations().get(index++), userModel.getLocations().get(index++))));
}
} else {
} else if (size == 1) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: Why the if condition? Please add a comment explaining it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment added in 6f02387: an empty model gives no routes.

import ai.timefold.solver.service.maps.service.client.impl.error.MapServiceIllegalArgumentException;

@ApplicationScoped
public class WaypointsCallControl {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add javadoc explaining the purpose of this class.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 6f02387.


import com.fasterxml.jackson.databind.ObjectMapper;

class SolverWorkerTest {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that if a test for the SolverWorker is introduced, it should cover also at least the basic solver worker use-cases (or at least be set up in a way to support them).

Since this test class is a bit over-fitted to check only the metadata issues, I would either extend it or remove it for now.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extended in b4239e7: start a stored run, start without input, terminate while solving, terminate an idle run, fail with metadata, plus the two FAILED paths. SolverWorkerBuilder builds the worker without the null arguments.

cedricdhooge96 and others added 5 commits October 13, 2026 15:23
The class got a @nullable getter without @NullMarked, so the other
members had unspecified nullness. @NullMarked on the class and
@nullable on the field make the contract explicit; the sealed
subclasses keep their own defaults.

Review: #2723, comment 4258543893.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The maps module serves any model, so VehicleRoute is now Route, and
the id set a caller filters on is routeIdSet, after the Waypoints ids
it holds; the REST query parameter keeps its name. The javadoc of the
same files breaks per sentence, as the constitution asks, and the
bean comments drop a prefix that belongs to another repository.

Review: #2723, comments 4258623534 and
4258678824.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The release after the store stays out of a finally block on purpose:
a run released before its waypoints are stored returns 404 until a
restart, which 04cfcb5 fixed. The unwrapped CompletionException
cause keeps TimefoldRuntimeExceptionMapper in play; a wrapped one
lands in UnknownExceptionMapper and hides the message. The dummy
extractor says why an empty model gives no routes, and the call
control gets class javadoc.

Review: #2723, comments 4258564531,
4258734790, 4258771532 and 4258781886.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
SolverWorkerTest only pinned the two FAILED paths. It now also covers
a start command for a stored run, a start command without a model
input, a terminate command while solving, a terminate command for an
idle run, and a failure with metadata. SolverWorkerBuilder replaces
the constructor call with nulls; the solver manager and the
termination service are Mockito mocks, so the module gains
mockito-core in test scope.

Review: #2723, comment 4258864546.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
After the rebase, DummyStorage extended an InMemoryStorage that moved
to the storage-in-memory module and lost its type parameter, so the
module's test sources did not compile. InMemoryStorage is a bean of
its own now, so the subclass goes and the module gets the storage
module in test scope.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
VerboseLoggingExecutionProfile builds the log file path with Path.of,
so on Windows the test's "/"-joined expectation did not match and the
Windows CI jobs of this branch failed. The test now builds its
expectation the same way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

This branch was successfully deployed

2 active deployments
documentation (preview) — 95111324 Deployed Oct 13, 2026 by cedricdhooge96 via Build Documentation #781
internal — 95111324 Deployed Oct 13, 2026 by cedricdhooge96 via approval_required #781
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants