Repository navigation
perf: fetch waypoints only for changed routes, in parallel - #2723
cedricdhooge96 wants to merge 20 commits into
Conversation
|
The documentation drift reported earlier is gone: this pull request now documents
|
|
Docs added in 1a6e557: a new page, |
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>
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>
f546edd to
1a4d8d2
Compare
winklerm
left a comment
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Done in c2b5b95: @NullMarked on the class, @Nullable on the field.
|
|
||
| if (waypoints != null && !waypoints.isEmpty()) { | ||
| storageService.storeWaypoints(id, waypoints); | ||
| waypointsService.releaseFinishedRun(id); |
There was a problem hiding this comment.
Q: Should this be in a finally clause to make sure it is released even when an exception is thrown?
| vehicleRouteMapReference.set(Collections.unmodifiableMap(nextVehicleRouteMap)); | ||
| } | ||
|
|
||
| List<VehicleRoute> getRouteList(Set<String> objectIdSet) { |
There was a problem hiding this comment.
PP: Could the objectIdSet be renamed to a more specific name?
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
PP: Naming - different models may have different entities and Vehicle might not be the case.
| try { | ||
| return waypointsFuture.join(); | ||
| } catch (CompletionException e) { | ||
| if (e.getCause() instanceof RuntimeException cause) { |
There was a problem hiding this comment.
Q: Why are we throwing the cause directly?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
Q: Why the if condition? Please add a comment explaining it.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
Please add javadoc explaining the purpose of this class.
|
|
||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
|
|
||
| class SolverWorkerTest { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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>
GET /v1/route-plans/{id}/waypointsrefetched 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
getWaypointswassynchronizedfor all runs and fetched one route at a timetimefold.platform.map-service.waypoints-parallelismthreads (default 8, must be > 0, checked at startup); two requests share one fetch per routeGET), changed routes are fetched in the background after a best solution: at most one batch in flight per run, plus one trailing batchSaveWaypointsPostProcessorstored its waypoints, or when it fails; a laterGETreads the stored waypoints; a bounded tombstone (1,024 ids) stops a late event from recreating a released runSolverWorker.notifyOnFailurethrew an NPE when the run metadata could not be read, soFAILEDwas never sentFAILEDis sent with the run id (ItemFailed.withoutMetadata)Measurements
Fake map service, 150 ms per call, 50 vehicles, a best solution that changes 5 routes:
GETafter a best solutionGET(all routes cold)GETField service routing (FSR) dev mode, local OSRM (Georgia) through the platform maps service, 45 vehicles, about 175 routes,
GETevery 2 s while solving:GETafter a best solutionGETon a finished run, filesystem storageLocal OSRM answers in about 9 ms per route; with network latency to the maps service, the gain is larger.
Behaviour changes
timefold.platform.map-service.waypoints-parallelism(default 8). The models documenttimefold.platform.map-service.*in their READMEs; the FSR README gets a row at the next solver bump.GETon a finished run reads the stored waypoints instead of memory: one object read perGETon cloud storage (not measured; local MinIO does not show cloud latency).GETfor a failed run returns 404 instead of its last routes.GETbefore the first solution reads storage once, then returns 404, as before.GETretried.SaveWaypointsPostProcessornow fetches up towaypoints-parallelismroutes at the same time per pod, where it fetched one at a time; operators can lower the property.GETkeeps 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